From 831d96e56248a8da6208628cae0cb4b5a558e989 Mon Sep 17 00:00:00 2001 From: Kiyooru Date: Sun, 2 Aug 2026 17:05:27 +0200 Subject: [PATCH] Initial public source release Split reusable rendering and format support into vectorail-core and vectorail-gc. --- .gitignore | 61 + CHANGELOG.md | 18 + CMakeLists.txt | 83 + CONTRIBUTING.md | 13 + LICENSE | 21 + README.md | 79 + apps/desktop/CMakeLists.txt | 27 + .../openroller/desktop/AudioManager.hpp | 58 + .../openroller/desktop/CabinetBackend.hpp | 68 + .../openroller/desktop/LevelLoader.hpp | 105 + .../openroller/desktop/ServiceMenu.hpp | 12 + .../include/openroller/desktop/SongSelect.hpp | 12 + apps/desktop/openroller.cfg | 6 + apps/desktop/shaders/bg.frag | 39 + apps/desktop/shaders/bg.vert | 9 + apps/desktop/shaders/line.frag | 39 + apps/desktop/shaders/line.vert | 25 + apps/desktop/shaders/model.frag | 8 + apps/desktop/shaders/model.vert | 10 + apps/desktop/shaders/note.frag | 12 + apps/desktop/shaders/note.vert | 14 + apps/desktop/shaders/particle.frag | 9 + apps/desktop/shaders/particle.vert | 17 + apps/desktop/shaders/route.frag | 7 + apps/desktop/shaders/route.vert | 13 + apps/desktop/shaders/ui.frag | 19 + apps/desktop/shaders/ui.vert | 9 + apps/desktop/src/AudioManager.cpp | 233 ++ apps/desktop/src/CabinetBackend.cpp | 71 + apps/desktop/src/LevelLoader.cpp | 827 +++++ apps/desktop/src/ServiceMenu.cpp | 1120 ++++++ apps/desktop/src/SongSelect.cpp | 1207 +++++++ apps/desktop/src/main.cpp | 2352 +++++++++++++ docs/boot_pats/item.pat | 30 + docs/boot_pats/message.pat | 32 + docs/boot_pats/navigator.pat | 46 + docs/boot_pats/player.pat | 47 + docs/boot_pats/se.pat | 35 + docs/boot_pats/skin.pat | 31 + docs/boot_pats/stage_param.pat | 197 ++ docs/boot_pats/title.pat | 40 + docs/psp_port.md | 211 ++ docs/re_game471_notes.md | 537 +++ docs/re_gc_camera.md | 374 ++ docs/re_gc_catalog.md | 107 + docs/re_gc_menu_exe.md | 224 ++ docs/re_gc_song_select.md | 61 + docs/re_gc_switch.md | 154 + docs/re_gc_test_mode.md | 310 ++ docs/re_gc_track.md | 183 + docs/stage.pat | 332 ++ include/openroller/psp/SongCatalog.hpp | 49 + include/openroller/psp/StagePackage.hpp | 242 ++ psp/GNUmakefile | 36 + psp/assets/README.md | 11 + psp/include/AudioPlayer.hpp | 26 + psp/include/Gameplay.hpp | 63 + psp/include/SongMenu.hpp | 35 + psp/include/StageRuntime.hpp | 60 + psp/include/TateTransform.hpp | 43 + psp/src/AudioPlayer.cpp | 418 +++ psp/src/Gameplay.cpp | 344 ++ psp/src/SongMenu.cpp | 201 ++ psp/src/StageRuntime.cpp | 444 +++ psp/src/main.cpp | 2014 +++++++++++ src/main.cpp | 2997 +++++++++++++++++ src/psp_catalog.cpp | 218 ++ src/psp_pack.cpp | 600 ++++ src/psp_runtime_probe.cpp | 89 + tools/build_psp.sh | 12 + tools/dds_to_png.py | 176 + tools/ftd2xx_shim.c | 216 ++ tools/ftd2xx_shim.def | 21 + tools/gc_force_english.reg | 7 + tools/ghidra_scripts/DecompileAllSearch.java | 37 + tools/ghidra_scripts/DecompileByAddr.java | 65 + .../ghidra_scripts/DecompileCallContexts.java | 69 + tools/ghidra_scripts/DecompileSearch.java | 50 + tools/ghidra_scripts/DumpData.java | 24 + tools/ghidra_scripts/DumpInstructions.java | 38 + tools/ghidra_scripts/DumpPointerStrings.java | 42 + tools/ghidra_scripts/DumpPointers.java | 30 + tools/ghidra_scripts/DumpScalars.java | 26 + tools/ghidra_scripts/FindAddressRefs.java | 28 + .../FindD3DSetTransformSites.java | 87 + tools/ghidra_scripts/FindFunctionCallers.java | 63 + tools/ghidra_scripts/FindFunctions.java | 25 + tools/ghidra_scripts/FindScalarOps.java | 64 + tools/ghidra_scripts/FindScalarWindow.java | 76 + tools/ghidra_scripts/FindStringXrefs.java | 96 + tools/ghidra_scripts/FindSymbolRefs.java | 45 + .../ghidra_scripts/FindVirtualCallOffset.java | 78 + .../ghidra_scripts/FindVtableOffsetCalls.java | 62 + tools/ghidra_scripts/ListFunctionsRange.java | 28 + tools/ghidra_scripts/ReadScalars.java | 17 + tools/ghidra_scripts/RenameFunctions.java | 28 + tools/idmac_shim.c | 234 ++ tools/idmac_shim.def | 11 + tools/kernel32_min.def | 6 + tools/pack_psp_jacket.py | 39 + tools/patch_game471_winefix.py | 263 ++ tools/prepare_psp_demo.sh | 36 + tools/prepare_psp_library.sh | 74 + tools/re_game471.py | 326 ++ tools/run_gc_game471.sh | 37 + tools/run_stage_player.sh | 23 + tools/run_switch_stage.sh | 124 + tools/scan_gc_notes.py | 115 + tools/scan_stage_dir.py | 316 ++ 109 files changed, 20558 insertions(+) create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 CMakeLists.txt create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 apps/desktop/CMakeLists.txt create mode 100644 apps/desktop/include/openroller/desktop/AudioManager.hpp create mode 100644 apps/desktop/include/openroller/desktop/CabinetBackend.hpp create mode 100644 apps/desktop/include/openroller/desktop/LevelLoader.hpp create mode 100644 apps/desktop/include/openroller/desktop/ServiceMenu.hpp create mode 100644 apps/desktop/include/openroller/desktop/SongSelect.hpp create mode 100644 apps/desktop/openroller.cfg create mode 100644 apps/desktop/shaders/bg.frag create mode 100644 apps/desktop/shaders/bg.vert create mode 100644 apps/desktop/shaders/line.frag create mode 100644 apps/desktop/shaders/line.vert create mode 100644 apps/desktop/shaders/model.frag create mode 100644 apps/desktop/shaders/model.vert create mode 100644 apps/desktop/shaders/note.frag create mode 100644 apps/desktop/shaders/note.vert create mode 100644 apps/desktop/shaders/particle.frag create mode 100644 apps/desktop/shaders/particle.vert create mode 100644 apps/desktop/shaders/route.frag create mode 100644 apps/desktop/shaders/route.vert create mode 100644 apps/desktop/shaders/ui.frag create mode 100644 apps/desktop/shaders/ui.vert create mode 100644 apps/desktop/src/AudioManager.cpp create mode 100644 apps/desktop/src/CabinetBackend.cpp create mode 100644 apps/desktop/src/LevelLoader.cpp create mode 100644 apps/desktop/src/ServiceMenu.cpp create mode 100644 apps/desktop/src/SongSelect.cpp create mode 100644 apps/desktop/src/main.cpp create mode 100644 docs/boot_pats/item.pat create mode 100644 docs/boot_pats/message.pat create mode 100644 docs/boot_pats/navigator.pat create mode 100644 docs/boot_pats/player.pat create mode 100644 docs/boot_pats/se.pat create mode 100644 docs/boot_pats/skin.pat create mode 100644 docs/boot_pats/stage_param.pat create mode 100644 docs/boot_pats/title.pat create mode 100644 docs/psp_port.md create mode 100644 docs/re_game471_notes.md create mode 100644 docs/re_gc_camera.md create mode 100644 docs/re_gc_catalog.md create mode 100644 docs/re_gc_menu_exe.md create mode 100644 docs/re_gc_song_select.md create mode 100644 docs/re_gc_switch.md create mode 100644 docs/re_gc_test_mode.md create mode 100644 docs/re_gc_track.md create mode 100644 docs/stage.pat create mode 100644 include/openroller/psp/SongCatalog.hpp create mode 100644 include/openroller/psp/StagePackage.hpp create mode 100644 psp/GNUmakefile create mode 100644 psp/assets/README.md create mode 100644 psp/include/AudioPlayer.hpp create mode 100644 psp/include/Gameplay.hpp create mode 100644 psp/include/SongMenu.hpp create mode 100644 psp/include/StageRuntime.hpp create mode 100644 psp/include/TateTransform.hpp create mode 100644 psp/src/AudioPlayer.cpp create mode 100644 psp/src/Gameplay.cpp create mode 100644 psp/src/SongMenu.cpp create mode 100644 psp/src/StageRuntime.cpp create mode 100644 psp/src/main.cpp create mode 100644 src/main.cpp create mode 100644 src/psp_catalog.cpp create mode 100644 src/psp_pack.cpp create mode 100644 src/psp_runtime_probe.cpp create mode 100755 tools/build_psp.sh create mode 100644 tools/dds_to_png.py create mode 100644 tools/ftd2xx_shim.c create mode 100644 tools/ftd2xx_shim.def create mode 100644 tools/gc_force_english.reg create mode 100644 tools/ghidra_scripts/DecompileAllSearch.java create mode 100644 tools/ghidra_scripts/DecompileByAddr.java create mode 100644 tools/ghidra_scripts/DecompileCallContexts.java create mode 100644 tools/ghidra_scripts/DecompileSearch.java create mode 100644 tools/ghidra_scripts/DumpData.java create mode 100644 tools/ghidra_scripts/DumpInstructions.java create mode 100644 tools/ghidra_scripts/DumpPointerStrings.java create mode 100644 tools/ghidra_scripts/DumpPointers.java create mode 100644 tools/ghidra_scripts/DumpScalars.java create mode 100644 tools/ghidra_scripts/FindAddressRefs.java create mode 100644 tools/ghidra_scripts/FindD3DSetTransformSites.java create mode 100644 tools/ghidra_scripts/FindFunctionCallers.java create mode 100644 tools/ghidra_scripts/FindFunctions.java create mode 100644 tools/ghidra_scripts/FindScalarOps.java create mode 100644 tools/ghidra_scripts/FindScalarWindow.java create mode 100644 tools/ghidra_scripts/FindStringXrefs.java create mode 100644 tools/ghidra_scripts/FindSymbolRefs.java create mode 100644 tools/ghidra_scripts/FindVirtualCallOffset.java create mode 100644 tools/ghidra_scripts/FindVtableOffsetCalls.java create mode 100644 tools/ghidra_scripts/ListFunctionsRange.java create mode 100644 tools/ghidra_scripts/ReadScalars.java create mode 100644 tools/ghidra_scripts/RenameFunctions.java create mode 100644 tools/idmac_shim.c create mode 100644 tools/idmac_shim.def create mode 100644 tools/kernel32_min.def create mode 100755 tools/pack_psp_jacket.py create mode 100644 tools/patch_game471_winefix.py create mode 100755 tools/prepare_psp_demo.sh create mode 100755 tools/prepare_psp_library.sh create mode 100644 tools/re_game471.py create mode 100755 tools/run_gc_game471.sh create mode 100755 tools/run_stage_player.sh create mode 100755 tools/run_switch_stage.sh create mode 100755 tools/scan_gc_notes.py create mode 100755 tools/scan_stage_dir.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..da06136 --- /dev/null +++ b/.gitignore @@ -0,0 +1,61 @@ +# User-provided game data and decoded assets +/GC/ +/dds_png/ + +# Build and release output +/build/ +/build-*/ +/dist/ +CMakeFiles/ +CMakeCache.txt +cmake_install.cmake +compile_commands.json +Makefile +*.o +*.obj +*.a +*.lib +*.so +*.dylib +*.dll +*.exe +*.pdb +*.ilk +*.dSYM/ + +# PSPDEV output and optional private XMB media +/psp/EBOOT.PBP +/psp/PARAM.SFO +/psp/*.elf +/psp/*.prx +/psp/assets/*.PNG +/psp/assets/*.AT3 +!/psp/assets/README.md + +# Local reverse-engineering environments and scratch data +/.agents/ +/.codex/ +/.cache/ +/.ghidra_projects/ +/ghidra_projects/ +/.proton-gc/ +/.wine-gc/ +/.venv-re/ +/.env +/tools/__pycache__/ +*.pyc + +# External checkouts are separate repositories +/third_party/ + +# Local/generated experiments +/a.svg +/Makefile.in +/iDmacDrv32.spec +/iDmacDrv32_main.c + +# Editor and OS files +.DS_Store +.idea/ +.vscode/ +*~ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..1299b99 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,18 @@ +# Changelog + +## 0.1.5-publicTest + +- Added 15 tracks to the PSP test library. +- Added a PSP pause menu with Continue, Restart and Back to Menu actions. +- Rotated directional controls in Tate mode to match the screen orientation. +- Swapped PSP confirm/cancel defaults: Circle confirms and Cross cancels. +- Added additional note types, helpers and hit sound effects. +- Reworked real-hardware audio playback to avoid dual-stream crackling. +- Removed L/R gameplay bindings that interfered with normal play. +- Fixed notes being depth-tested behind the route and removed phantom notes. +- End stages from music completion and return to song select after the result + sequence instead of replaying the track. + +This release is still a beta. When reporting an issue, include the PSP model, +cIPL/CFW type and version, the selected song and difficulty, and the exact point +where the problem occurred. diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..a2217fd --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,83 @@ +cmake_minimum_required(VERSION 3.20) + +project(openroller VERSION 0.1.5 LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +option(OPENROLLER_BUILD_DESKTOP "Build the SDL/OpenGL desktop player" ON) +option(OPENROLLER_BUILD_TOOLS "Build host-side research and PSP packaging tools" ON) +option(OPENROLLER_BUILD_PSP_RUNTIME_PROBE "Build the host PSP runtime probe" ON) + +set(VECTORAIL_CORE_SOURCE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../vectorail-core" + CACHE PATH "Path to a vectorail-core source checkout") +set(VECTORAIL_GC_SOURCE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../vectorail-gc" + CACHE PATH "Path to a vectorail-gc source checkout") + +if(OPENROLLER_BUILD_DESKTOP AND NOT TARGET Vectorail::Core) + find_package(VectorailCore CONFIG QUIET) + if(NOT VectorailCore_FOUND) + if(EXISTS "${VECTORAIL_CORE_SOURCE_DIR}/CMakeLists.txt") + add_subdirectory("${VECTORAIL_CORE_SOURCE_DIR}" + "${CMAKE_CURRENT_BINARY_DIR}/_deps/vectorail-core") + else() + message(FATAL_ERROR + "vectorail-core was not found. Install VectorailCore or set " + "VECTORAIL_CORE_SOURCE_DIR to its source checkout.") + endif() + endif() +endif() + +if(NOT TARGET Vectorail::GC) + find_package(VectorailGC CONFIG QUIET) + if(NOT VectorailGC_FOUND) + if(EXISTS "${VECTORAIL_GC_SOURCE_DIR}/CMakeLists.txt") + set(VECTORAIL_GC_BUILD_TOOLS OFF CACHE BOOL "" FORCE) + add_subdirectory("${VECTORAIL_GC_SOURCE_DIR}" + "${CMAKE_CURRENT_BINARY_DIR}/_deps/vectorail-gc") + else() + message(FATAL_ERROR + "vectorail-gc was not found. Install VectorailGC or set " + "VECTORAIL_GC_SOURCE_DIR to its source checkout.") + endif() + endif() +endif() + +function(openroller_enable_warnings target) + if(MSVC) + target_compile_options("${target}" PRIVATE /W4) + else() + target_compile_options("${target}" PRIVATE -Wall -Wextra -Wpedantic) + endif() +endfunction() + +if(OPENROLLER_BUILD_DESKTOP) + add_subdirectory(apps/desktop) +endif() + +if(OPENROLLER_BUILD_TOOLS) + add_executable(openroller-stage-probe src/main.cpp) + target_link_libraries(openroller-stage-probe PRIVATE Vectorail::GC) + openroller_enable_warnings(openroller-stage-probe) + + add_executable(openroller-psp-pack src/psp_pack.cpp) + target_include_directories(openroller-psp-pack PRIVATE include) + target_link_libraries(openroller-psp-pack PRIVATE Vectorail::GC) + openroller_enable_warnings(openroller-psp-pack) + + add_executable(openroller-psp-catalog src/psp_catalog.cpp) + target_include_directories(openroller-psp-catalog PRIVATE include) + target_link_libraries(openroller-psp-catalog PRIVATE Vectorail::GC) + openroller_enable_warnings(openroller-psp-catalog) +endif() + +if(OPENROLLER_BUILD_PSP_RUNTIME_PROBE) + add_executable(openroller-psp-runtime-probe + src/psp_runtime_probe.cpp + psp/src/StageRuntime.cpp + psp/src/Gameplay.cpp + ) + target_include_directories(openroller-psp-runtime-probe PRIVATE psp/include include) + openroller_enable_warnings(openroller-psp-runtime-probe) +endif() diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..7067d7c --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,13 @@ +# Contributing + +Bug reports and focused patches are welcome. For hardware-specific issues, +include the platform, firmware or CFW version, song and difficulty, and steps +that reproduce the problem. + +Do not submit copyrighted game assets, executables, keys, firmware dumps or +data extracted from a copy you are not authorized to inspect. Research notes +should describe observable formats and behavior without embedding original +assets. New code must be compatible with the repository's MIT license. + +Keep reusable rendering/platform primitives in `vectorail-core`, format-only +parsing in `vectorail-gc`, and game behavior in OpenRoller. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7969ff9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Kiyooru Takasaki + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..7b0a703 --- /dev/null +++ b/README.md @@ -0,0 +1,79 @@ +# OpenRoller + +OpenRoller is an experimental rhythm-game runtime for desktop Linux and Sony +PSP. It reconstructs compatible playback from independently researched stage +formats and user-provided data. + +The repository contains the gameplay, menus, platform code and tools that turn +parsed stage data into a playable game. Reusable code is split into two sibling +projects: + +- [vectorail-core](../vectorail-core): SDL, OpenGL, windowing and composition + primitives (BSD-3-Clause). +- [vectorail-gc](../vectorail-gc): parsers for DAT, TUMO, RVB, MTX and related + formats (MIT). +- **openroller**: gameplay, desktop integration, the PSP port and packaging + tools (MIT). + +## Data policy + +No game executables, decryption keys, music, charts, textures or other +copyrighted game data are distributed by this project. A local `GC/` directory +may be used with data you are legally entitled to access; it is ignored by Git. +Generated PSP libraries and optional XMB artwork/audio are ignored as well. + +OpenRoller is an independent interoperability project. It is not affiliated +with or endorsed by TAITO, Square Enix, Sony, Nintendo or Valve. Groove Coaster +and other names and marks belong to their respective owners. + +## Desktop build + +Place all three repositories next to each other: + +```text +workspace/ +├── vectorail-core/ +├── vectorail-gc/ +└── openroller/ +``` + +Install SDL3, OpenGL, libpng and GLM, then build: + +```sh +cmake -S openroller -B openroller/build -DCMAKE_BUILD_TYPE=Release +cmake --build openroller/build -j +openroller/tools/run_stage_player.sh +``` + +CMake first looks for installed `VectorailCore` and `VectorailGC` packages, +then falls back to sibling source checkouts. Their locations can be overridden +with `VECTORAIL_CORE_SOURCE_DIR` and `VECTORAIL_GC_SOURCE_DIR`. + +The raw stage inspection utility is built as `openroller-stage-probe`. + +## PSP build + +The PSP port is built with the `pspdev/pspdev` container: + +```sh +tools/build_psp.sh +``` + +This produces `psp/EBOOT.PBP`. Optional local XMB media can be placed in +`psp/assets`; see [psp/assets/README.md](psp/assets/README.md). Preparing a +playable song library requires user-provided data and FFmpeg: + +```sh +tools/prepare_psp_library.sh /path/to/GC /path/to/PSP/GAME/OpenRoller +``` + +## Status + +OpenRoller is reverse-engineering research and an early public test. Format and +runtime behavior are incomplete, and crashes on real PSP hardware are still +possible. See [CHANGELOG.md](CHANGELOG.md) and the notes under `docs/`. + +## License + +OpenRoller source code is available under the [MIT License](LICENSE). This +license does not apply to user-provided game data or media. diff --git a/apps/desktop/CMakeLists.txt b/apps/desktop/CMakeLists.txt new file mode 100644 index 0000000..957d686 --- /dev/null +++ b/apps/desktop/CMakeLists.txt @@ -0,0 +1,27 @@ +add_executable(openroller-desktop + src/AudioManager.cpp + src/CabinetBackend.cpp + src/LevelLoader.cpp + src/ServiceMenu.cpp + src/SongSelect.cpp + src/main.cpp +) + +set_target_properties(openroller-desktop PROPERTIES OUTPUT_NAME OpenRoller) + +target_include_directories(openroller-desktop PRIVATE include) +target_link_libraries(openroller-desktop + PRIVATE + Vectorail::Core + Vectorail::GC + Vectorail::GCEffects +) + +if(MSVC) + target_compile_options(openroller-desktop PRIVATE /W4) +else() + target_compile_options(openroller-desktop PRIVATE -Wall -Wextra -Wpedantic) +endif() + +configure_file(openroller.cfg openroller.cfg COPYONLY) +file(COPY shaders DESTINATION "${CMAKE_CURRENT_BINARY_DIR}") diff --git a/apps/desktop/include/openroller/desktop/AudioManager.hpp b/apps/desktop/include/openroller/desktop/AudioManager.hpp new file mode 100644 index 0000000..4e880c9 --- /dev/null +++ b/apps/desktop/include/openroller/desktop/AudioManager.hpp @@ -0,0 +1,58 @@ +#pragma once +#include +#include +#include +#include + +class AudioManager { +public: + enum class GameplaySound : size_t { + Adlib = 0, + Tap1 = 1, + Tap2 = 2, + }; + + AudioManager(); + ~AudioManager(); + + bool loadMusic(const std::string& path, float gain = 1.0f); + bool loadMusicPair(const std::string& bgmPath, const std::string& shotPath, + float bgmGain, float shotGain); + bool loadGameplaySounds(const std::array& paths, + const std::array& gains); + void playGameplaySound(GameplaySound sound); + void play(); + void pause(); + void resume(); + void setShotMuted(bool muted); + + double getTime() const; + double getDuration() const; // Длина песни в секундах + + bool isPlaying() const { return playing; } + +private: + struct GameplaySoundSlot { + std::vector data; + std::array voices{nullptr, nullptr}; + size_t nextVoice = 0; + }; + + void clear(); + void clearGameplaySounds(); + bool openStreams(const SDL_AudioSpec& bgmSpec, const Uint8* bgmData, Uint32 bgmLen, + float bgmGain, const SDL_AudioSpec* shotSpec = nullptr, + const Uint8* shotData = nullptr, Uint32 shotLen = 0, + float shotGain = 1.0f); + + SDL_AudioDeviceID device; + SDL_AudioStream* bgmStream; + SDL_AudioStream* shotStream; + std::array gameplaySounds; + double duration; // Предрассчитанная длительность + float shotBaseGain; + bool shotMuted; + bool playing; + Uint64 startTime; + Uint64 accumulatedTicks; +}; diff --git a/apps/desktop/include/openroller/desktop/CabinetBackend.hpp b/apps/desktop/include/openroller/desktop/CabinetBackend.hpp new file mode 100644 index 0000000..435db93 --- /dev/null +++ b/apps/desktop/include/openroller/desktop/CabinetBackend.hpp @@ -0,0 +1,68 @@ +#pragma once + +#include +#include +#include + +enum class CabinetInput : std::size_t { + Test, + Service, + Coin, + Select, + Enter, + LeftUp, + LeftDown, + LeftLeft, + LeftRight, + LeftButton, + RightUp, + RightDown, + RightLeft, + RightRight, + RightButton, + Count, +}; + +struct CabinetRgb { + std::uint8_t r = 0; + std::uint8_t g = 0; + std::uint8_t b = 0; +}; + +enum class CardReaderStatus { + Ready, + Disconnected, + Unformatted, + ReadError, + Timeout, +}; + +struct CardReaderState { + CardReaderStatus status = CardReaderStatus::Disconnected; + std::string cardId; +}; + +// Hardware boundary shared by gameplay and test mode. The software backend +// is deliberately useful on its own; an ACM implementation can replace it +// without teaching the test-mode forms about packets or device paths. +class CabinetBackend { +public: + virtual ~CabinetBackend() = default; + + virtual void poll() = 0; + virtual bool input(CabinetInput input) const = 0; + + virtual void setLed(std::size_t logicalIndex, CabinetRgb color) = 0; + virtual void clearLeds() = 0; + virtual void commitOutputs() = 0; + virtual const std::array& leds() const = 0; + + virtual int headphoneVolume() const { return 0; } + virtual bool headphoneConnected() const { return false; } + virtual bool grooveStageConnected() const { return false; } + virtual CardReaderState cardReader() const { return {}; } +}; + +// Keyboard + in-memory outputs. This keeps service mode fully testable before +// the cabinet ACM and RFID transports are attached. +CabinetBackend& defaultCabinetBackend(); diff --git a/apps/desktop/include/openroller/desktop/LevelLoader.hpp b/apps/desktop/include/openroller/desktop/LevelLoader.hpp new file mode 100644 index 0000000..422d84c --- /dev/null +++ b/apps/desktop/include/openroller/desktop/LevelLoader.hpp @@ -0,0 +1,105 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "gc/StagePattern.hpp" + +struct TrackPoint { + glm::vec3 position; + int type; // 0=Smooth, 1=Straight + bool visible = true; // NEW: Render rail or not + float timeMs = 0.0f; +}; + +struct Keyframe { + float t; + std::string param; + float value; +}; + +struct LevelNote { + float distance = 0.0f; + float timeMs = 0.0f; + float appearTimeMs = 0.0f; + float endTimeMs = 0.0f; + float endDistance = 0.0f; + unsigned int rawType = 0; + unsigned int effectiveType = 0; + // Wire +5 becomes runtime marker +8 in FUN_005ea800. Any non-zero value + // makes FUN_005e9410 select the ALB slot of the active gameplay SE set. + bool adlib = false; + // First signed 16-bit field at wire +6. DrawMark passes this value minus + // one as the UV-list selector for game effect 3. + int markEffectId = -1; + // game471 wire +55. The stage authors use this packed RRGGBBAA value for + // the path/body colour of duration targets. + uint32_t packedColor = 0xffffffffu; + uint32_t merryCount = 0; + // BuildTimingDataSub converts wire +25 (length) and +29/+33 (HPB angles) + // to this world-space vector. GameScene::buildGameData later projects it + // through the camera active at timeMs to obtain a fixed screen angle. + glm::vec3 directionVector{0.0f}; + // Runtime +0x20 and +0xc4 in game471's marker object. + float beatDurationMs = 500.0f; + float earlyTimingMs = 250.0f; + float lateTimingMs = 250.0f; + float missTimingMs = 250.0f; + float muteTimingMs = 0.0f; + float markerFadeEndTimeMs = 0.0f; + bool gcTiming = false; +}; + +// Exact 59-byte stage camera record after decoding from big endian. The +// original game expands this to a 0x44-byte runtime key by adding rotationA.z +// = 0; keeping the wire fields here lets the player reproduce aMode/fMode +// instead of flattening them into the legacy editor timeline. +struct GcCameraKey { + uint32_t timeMs = 0; + uint8_t aMode = 0; + uint8_t fMode = 0; + float dist = 0.0f; + glm::vec3 rotationA{0.0f}; + glm::vec3 originOff{0.0f}; + uint8_t projType = 0; + glm::vec3 fieldFar{0.0f}; + glm::vec3 fieldNear{0.0f}; + float rotationB = 0.0f; +}; + +struct LevelData { + std::string title; + std::string author; + std::string audioPath; + std::string audioShotPath; + float audioBgmGain = 1.0f; + float audioShotGain = 1.0f; + // game471 SE set slots: ALB, TP1 and TP2. The arcade runtime allocates + // two playback channels for each slot so repeated taps can overlap. + std::array gameplaySoundPaths; + std::array gameplaySoundGains{1.0f, 1.0f, 1.0f}; + std::string backgroundPath; + std::vector trackPoints; + std::vector notes; + std::vector gcCameraKeys; + // Optional arcade *_clip.dat table. It is object-major and contains one + // byte per 60 Hz frame; a zero byte suppresses that stage object for the + // frame. game471 consumes this before evaluating authored visibility. + uint32_t gcObjectClipFrameCount = 0; + std::vector gcObjectClipVisibility; + // Complete decoded stage background: particle/visualizer keys, exact + // gradient fade flags and the animated 3D object scene. + std::optional gcStage; + std::vector timeline; + std::map config; +}; + +class LevelLoader { +public: + static LevelData load(const std::string& path); +}; diff --git a/apps/desktop/include/openroller/desktop/ServiceMenu.hpp b/apps/desktop/include/openroller/desktop/ServiceMenu.hpp new file mode 100644 index 0000000..51e8c1a --- /dev/null +++ b/apps/desktop/include/openroller/desktop/ServiceMenu.hpp @@ -0,0 +1,12 @@ +#pragma once + +#include + +#include + +class CabinetBackend; + +// Runs the cabinet operator/test mode until Test/Escape returns to the caller. +// The existing OpenGL context remains owned by the caller. +void runServiceMenu(SDL_Window* window, CabinetBackend& cabinet, + const std::filesystem::path& contentPath = {}); diff --git a/apps/desktop/include/openroller/desktop/SongSelect.hpp b/apps/desktop/include/openroller/desktop/SongSelect.hpp new file mode 100644 index 0000000..6399140 --- /dev/null +++ b/apps/desktop/include/openroller/desktop/SongSelect.hpp @@ -0,0 +1,12 @@ +#pragma once + +#include + +#include +#include + +// Runs the Groove Coaster-style song and difficulty selector. Returns false +// when the window is closed/cancelled, otherwise writes the chosen stage .dat. +bool runSongSelect(SDL_Window* window, + const std::filesystem::path& gcRoot, + std::string* selectedStagePath); diff --git a/apps/desktop/openroller.cfg b/apps/desktop/openroller.cfg new file mode 100644 index 0000000..4cd5ce7 --- /dev/null +++ b/apps/desktop/openroller.cfg @@ -0,0 +1,6 @@ +# OpenRoller machine configuration. +# +# Mirrors HKLM\SOFTWARE\taito\typex\Country from the original Type X system. +# 0 = original eight coin/song presets +# other = extended 39-entry coin/song table +Country=0 diff --git a/apps/desktop/shaders/bg.frag b/apps/desktop/shaders/bg.frag new file mode 100644 index 0000000..37826f3 --- /dev/null +++ b/apps/desktop/shaders/bg.frag @@ -0,0 +1,39 @@ +#version 450 core +out vec4 FragColor; +in vec2 vUv; +uniform float uTime; +uniform vec3 uResolution; +uniform sampler2D uBackdrop; +uniform bool uHasBackdrop; +uniform float uBackdropAspect; +uniform vec3 uBgTopRight; +uniform vec3 uBgTopLeft; +uniform vec3 uBgBottomRight; +uniform vec3 uBgBottomLeft; + +void main() { + vec3 top = mix(uBgTopLeft, uBgTopRight, vUv.x); + vec3 bottom = mix(uBgBottomLeft, uBgBottomRight, vUv.x); + vec3 color = mix(bottom, top, vUv.y); + + if (uHasBackdrop) { + // The useful jacket cell occupies the left square of GC's 512x256 + // *_menu.dds atlas. Crop that cell and cover the portrait viewport. + float screenAspect = uResolution.x / uResolution.y; + vec2 imageUv = vUv - 0.5; + if (screenAspect < uBackdropAspect) { + imageUv.x *= screenAspect / uBackdropAspect; + } else { + imageUv.y *= uBackdropAspect / screenAspect; + } + imageUv += 0.5; + // Jacket cell bounds are 197x197 in the original 512x256 atlas. + vec2 atlasUv = vec2(imageUv.x * (197.0 / 512.0), + (1.0 - imageUv.y) * (197.0 / 256.0)); + vec4 backdrop = texture(uBackdrop, atlasUv); + float vignette = 1.0 - smoothstep(0.18, 0.82, length(vUv - 0.5)); + color = mix(color, backdrop.rgb * (0.08 + 0.05 * vignette), backdrop.a * 0.45); + } + + FragColor = vec4(color, 1.0); +} diff --git a/apps/desktop/shaders/bg.vert b/apps/desktop/shaders/bg.vert new file mode 100644 index 0000000..d64cedc --- /dev/null +++ b/apps/desktop/shaders/bg.vert @@ -0,0 +1,9 @@ +#version 450 core +layout (location = 0) in vec2 aPos; +out vec2 vUv; +uniform vec3 uGameplayFlip; + +void main() { + vUv = aPos * 0.5 + 0.5; + gl_Position = vec4(aPos * uGameplayFlip.xy, 0.0, 1.0); +} diff --git a/apps/desktop/shaders/line.frag b/apps/desktop/shaders/line.frag new file mode 100644 index 0000000..e161d4e --- /dev/null +++ b/apps/desktop/shaders/line.frag @@ -0,0 +1,39 @@ +#version 450 core +in float vSide; +in float vDist; +in float vTrackTimeMs; +out vec4 FragColor; + +uniform vec3 uColor; +uniform vec3 uBehindColor; +uniform bool uIsAvatar; +uniform bool uClipTrack; +uniform float uCurrentTimeMs; +uniform float uDrawBehindMs; +uniform float uDrawAheadMs; +uniform float uAlpha; + +void main() { + float dist = abs(vSide); + + // Мягкий туман: трасса видна очень далеко + float fog = exp(-vDist * 0.001); + + if (uClipTrack && + (vTrackTimeMs < uCurrentTimeMs - uDrawBehindMs || + vTrackTimeMs > uCurrentTimeMs + uDrawAheadMs)) { + discard; + } + + if (uIsAvatar) { + FragColor = vec4(1.0, 1.0, 1.0, 1.0); + } else { + float intensity = pow(1.0 - dist, 4.0); + float core = pow(1.0 - dist, 20.0); + vec3 railColor = (uClipTrack && vTrackTimeMs < uCurrentTimeMs) ? uBehindColor : uColor; + vec3 finalColor = mix(railColor, vec3(1.0), core); + + // Цвет затухает в темноту, а не просто обрезается + FragColor = vec4(finalColor * fog, intensity * fog * uAlpha); + } +} diff --git a/apps/desktop/shaders/line.vert b/apps/desktop/shaders/line.vert new file mode 100644 index 0000000..4726b3e --- /dev/null +++ b/apps/desktop/shaders/line.vert @@ -0,0 +1,25 @@ +#version 450 core +layout (location = 0) in vec3 aPos; +layout (location = 1) in float aSide; +layout (location = 2) in vec3 aRight; +layout (location = 3) in float aTimeMs; + +uniform mat4 uProjection; +uniform mat4 uView; +uniform float uWidth; + +out float vSide; +out float vDist; +out float vTrackTimeMs; + +void main() { + vSide = aSide; + vTrackTimeMs = aTimeMs; + vec3 finalPos = aPos + aRight * aSide * uWidth; + + // Считаем позицию в пространстве камеры + vec4 viewPos = uView * vec4(finalPos, 1.0); + vDist = -viewPos.z; // Глубина (дистанция от камеры) + + gl_Position = uProjection * viewPos; +} diff --git a/apps/desktop/shaders/model.frag b/apps/desktop/shaders/model.frag new file mode 100644 index 0000000..1f0e534 --- /dev/null +++ b/apps/desktop/shaders/model.frag @@ -0,0 +1,8 @@ +#version 450 core +out vec4 FragColor; + +uniform vec4 uColor; + +void main() { + FragColor = uColor; +} diff --git a/apps/desktop/shaders/model.vert b/apps/desktop/shaders/model.vert new file mode 100644 index 0000000..ea5748f --- /dev/null +++ b/apps/desktop/shaders/model.vert @@ -0,0 +1,10 @@ +#version 450 core +layout (location = 0) in vec3 aPos; + +uniform mat4 uProjection; +uniform mat4 uView; +uniform mat4 uModel; + +void main() { + gl_Position = uProjection * uView * uModel * vec4(aPos, 1.0); +} diff --git a/apps/desktop/shaders/note.frag b/apps/desktop/shaders/note.frag new file mode 100644 index 0000000..1db79ab --- /dev/null +++ b/apps/desktop/shaders/note.frag @@ -0,0 +1,12 @@ +#version 450 core +in vec2 vUv; +out vec4 FragColor; + +uniform sampler2D uTexture; +uniform vec4 uColor; + +void main() { + vec4 texel = texture(uTexture, vUv); + if (texel.a < 0.01) discard; + FragColor = vec4(texel.rgb * uColor.rgb, texel.a * uColor.a); +} diff --git a/apps/desktop/shaders/note.vert b/apps/desktop/shaders/note.vert new file mode 100644 index 0000000..d22b84d --- /dev/null +++ b/apps/desktop/shaders/note.vert @@ -0,0 +1,14 @@ +#version 450 core +layout (location = 0) in vec3 aPos; +layout (location = 1) in vec2 aUv; + +uniform mat4 uProjection; +uniform mat4 uView; +uniform vec4 uUvRect; + +out vec2 vUv; + +void main() { + vUv = uUvRect.xy + aUv * uUvRect.zw; + gl_Position = uProjection * uView * vec4(aPos, 1.0); +} diff --git a/apps/desktop/shaders/particle.frag b/apps/desktop/shaders/particle.frag new file mode 100644 index 0000000..8560dd5 --- /dev/null +++ b/apps/desktop/shaders/particle.frag @@ -0,0 +1,9 @@ +#version 450 core +in float vLife; +out vec4 FragColor; +uniform vec3 uColor; + +void main() { + // В кваде vLife достаточно для затухания + FragColor = vec4(uColor, vLife); +} diff --git a/apps/desktop/shaders/particle.vert b/apps/desktop/shaders/particle.vert new file mode 100644 index 0000000..be15ad1 --- /dev/null +++ b/apps/desktop/shaders/particle.vert @@ -0,0 +1,17 @@ +#version 450 core +layout (location = 0) in vec3 aPos; +layout (location = 1) in vec2 aOffset; // Смещение углов квадрата +layout (location = 2) in float aLife; + +uniform mat4 uProjection; +uniform mat4 uView; + +out float vLife; + +void main() { + vLife = aLife; + vec4 viewPos = uView * vec4(aPos, 1.0); + // Billboard: квадрат всегда смотрит на камеру + viewPos.xy += aOffset * aLife * 0.5; + gl_Position = uProjection * viewPos; +} diff --git a/apps/desktop/shaders/route.frag b/apps/desktop/shaders/route.frag new file mode 100644 index 0000000..5b7a50f --- /dev/null +++ b/apps/desktop/shaders/route.frag @@ -0,0 +1,7 @@ +#version 450 core +in vec4 vColor; +out vec4 FragColor; + +void main() { + FragColor = vColor; +} diff --git a/apps/desktop/shaders/route.vert b/apps/desktop/shaders/route.vert new file mode 100644 index 0000000..16b304a --- /dev/null +++ b/apps/desktop/shaders/route.vert @@ -0,0 +1,13 @@ +#version 450 core +layout (location = 0) in vec3 aPos; +layout (location = 1) in vec4 aColor; + +uniform mat4 uProjection; +uniform mat4 uView; + +out vec4 vColor; + +void main() { + vColor = aColor; + gl_Position = uProjection * uView * vec4(aPos, 1.0); +} diff --git a/apps/desktop/shaders/ui.frag b/apps/desktop/shaders/ui.frag new file mode 100644 index 0000000..bc23e9c --- /dev/null +++ b/apps/desktop/shaders/ui.frag @@ -0,0 +1,19 @@ +#version 450 core +in vec2 vUv; +out vec4 FragColor; +uniform sampler2D uTexture; +uniform bool uUseTexture; +uniform bool uUseGradient; +uniform vec4 uColor; +uniform vec4 uGradientTop; +uniform vec4 uGradientBottom; + +void main() { + if (uUseTexture) { + FragColor = texture(uTexture, vUv) * uColor; + } else if (uUseGradient) { + FragColor = mix(uGradientTop, uGradientBottom, vUv.y); + } else { + FragColor = uColor; + } +} diff --git a/apps/desktop/shaders/ui.vert b/apps/desktop/shaders/ui.vert new file mode 100644 index 0000000..fa63335 --- /dev/null +++ b/apps/desktop/shaders/ui.vert @@ -0,0 +1,9 @@ +#version 450 core +layout (location = 0) in vec2 aPos; +layout (location = 1) in vec2 aUv; +out vec2 vUv; + +void main() { + vUv = aUv; + gl_Position = vec4(aPos, 0.0, 1.0); +} diff --git a/apps/desktop/src/AudioManager.cpp b/apps/desktop/src/AudioManager.cpp new file mode 100644 index 0000000..aa613db --- /dev/null +++ b/apps/desktop/src/AudioManager.cpp @@ -0,0 +1,233 @@ +#include "openroller/desktop/AudioManager.hpp" +#include +#include + +AudioManager::AudioManager() + : device(0), bgmStream(nullptr), shotStream(nullptr), duration(0), + shotBaseGain(1.0f), shotMuted(false), playing(false), startTime(0), + accumulatedTicks(0) {} + +AudioManager::~AudioManager() { + clear(); +} + +void AudioManager::clear() { + clearGameplaySounds(); + if (bgmStream) SDL_DestroyAudioStream(bgmStream); + if (shotStream) SDL_DestroyAudioStream(shotStream); + if (device) SDL_CloseAudioDevice(device); + device = 0; + bgmStream = nullptr; + shotStream = nullptr; + duration = 0.0; + shotBaseGain = 1.0f; + shotMuted = false; + playing = false; + startTime = 0; + accumulatedTicks = 0; +} + +void AudioManager::clearGameplaySounds() { + for (GameplaySoundSlot& slot : gameplaySounds) { + for (SDL_AudioStream*& voice : slot.voices) { + if (voice) SDL_DestroyAudioStream(voice); + voice = nullptr; + } + slot.data.clear(); + slot.nextVoice = 0; + } +} + +bool AudioManager::openStreams(const SDL_AudioSpec& bgmSpec, const Uint8* bgmData, + Uint32 bgmLen, float bgmGain, + const SDL_AudioSpec* shotSpec, const Uint8* shotData, + Uint32 shotLen, float requestedShotGain) { + const int bytesPerSample = SDL_AUDIO_BITSIZE(bgmSpec.format) / 8; + if (bytesPerSample <= 0 || bgmSpec.channels <= 0 || bgmSpec.freq <= 0) return false; + duration = static_cast(bgmLen) / + (static_cast(bgmSpec.channels) * bytesPerSample * bgmSpec.freq); + + device = SDL_OpenAudioDevice(SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK, nullptr); + if (!device) { + std::cerr << "Audio device open error: " << SDL_GetError() << std::endl; + return false; + } + if (!SDL_PauseAudioDevice(device)) { + std::cerr << "Audio device pause error: " << SDL_GetError() << std::endl; + return false; + } + + bgmStream = SDL_CreateAudioStream(&bgmSpec, nullptr); + if (shotSpec) shotStream = SDL_CreateAudioStream(shotSpec, nullptr); + if (!bgmStream || (shotSpec && !shotStream)) { + std::cerr << "Audio stream create error: " << SDL_GetError() << std::endl; + return false; + } + SDL_AudioStream* streams[] = {bgmStream, shotStream}; + const int streamCount = shotStream ? 2 : 1; + if (!SDL_BindAudioStreams(device, streams, streamCount)) { + std::cerr << "Audio stream bind error: " << SDL_GetError() << std::endl; + return false; + } + + bgmGain = std::clamp(bgmGain, 0.0f, 1.0f); + shotBaseGain = std::clamp(requestedShotGain, 0.0f, 1.0f); + if (!SDL_SetAudioStreamGain(bgmStream, bgmGain) || + (shotStream && !SDL_SetAudioStreamGain(shotStream, shotBaseGain))) { + std::cerr << "Audio gain error: " << SDL_GetError() << std::endl; + return false; + } + if (!SDL_PutAudioStreamData(bgmStream, bgmData, static_cast(bgmLen)) || + (shotStream && !SDL_PutAudioStreamData( + shotStream, shotData, static_cast(shotLen)))) { + std::cerr << "Audio queue error: " << SDL_GetError() << std::endl; + return false; + } + shotMuted = false; + return true; +} + +bool AudioManager::loadMusic(const std::string& path, float gain) { + clear(); + SDL_AudioSpec loadedSpec{}; + Uint8* loadedBuf = nullptr; + Uint32 loadedLen = 0; + if (!SDL_LoadWAV(path.c_str(), &loadedSpec, &loadedBuf, &loadedLen)) { + std::cerr << "WAV Load Error: " << SDL_GetError() << std::endl; + return false; + } + const bool opened = openStreams(loadedSpec, loadedBuf, loadedLen, gain); + SDL_free(loadedBuf); + if (opened) return true; + clear(); + return false; +} + +bool AudioManager::loadMusicPair(const std::string& bgmPath, const std::string& shotPath, + float bgmGain, float shotGain) { + clear(); + + SDL_AudioSpec bgmSpec{}; + SDL_AudioSpec shotSpec{}; + Uint8* bgmBuf = nullptr; + Uint8* shotBuf = nullptr; + Uint32 bgmLen = 0; + Uint32 shotLen = 0; + if (!SDL_LoadWAV(bgmPath.c_str(), &bgmSpec, &bgmBuf, &bgmLen)) { + std::cerr << "BGM WAV load error: " << SDL_GetError() << std::endl; + return false; + } + if (!SDL_LoadWAV(shotPath.c_str(), &shotSpec, &shotBuf, &shotLen)) { + std::cerr << "SHOT WAV load error: " << SDL_GetError() + << "; playing BGM only" << std::endl; + SDL_free(bgmBuf); + return loadMusic(bgmPath, bgmGain); + } + + const bool opened = openStreams(bgmSpec, bgmBuf, bgmLen, bgmGain, + &shotSpec, shotBuf, shotLen, shotGain); + SDL_free(bgmBuf); + SDL_free(shotBuf); + if (opened) return true; + clear(); + return false; +} + +bool AudioManager::loadGameplaySounds(const std::array& paths, + const std::array& gains) { + clearGameplaySounds(); + if (!device) return false; + + std::array specs{}; + for (size_t i = 0; i < gameplaySounds.size(); ++i) { + Uint8* wavData = nullptr; + Uint32 wavLength = 0; + if (paths[i].empty() || + !SDL_LoadWAV(paths[i].c_str(), &specs[i], &wavData, &wavLength)) { + std::cerr << "Gameplay SE WAV load error: " << paths[i] << ": " + << SDL_GetError() << std::endl; + if (wavData) SDL_free(wavData); + clearGameplaySounds(); + return false; + } + gameplaySounds[i].data.assign(wavData, wavData + wavLength); + SDL_free(wavData); + } + + std::array voices{}; + size_t voiceIndex = 0; + for (size_t i = 0; i < gameplaySounds.size(); ++i) { + GameplaySoundSlot& slot = gameplaySounds[i]; + for (SDL_AudioStream*& voice : slot.voices) { + voice = SDL_CreateAudioStream(&specs[i], nullptr); + if (!voice || !SDL_SetAudioStreamGain(voice, std::clamp(gains[i], 0.0f, 1.0f))) { + std::cerr << "Gameplay SE stream error: " << SDL_GetError() << std::endl; + clearGameplaySounds(); + return false; + } + voices[voiceIndex++] = voice; + } + } + if (!SDL_BindAudioStreams(device, voices.data(), static_cast(voices.size()))) { + std::cerr << "Gameplay SE bind error: " << SDL_GetError() << std::endl; + clearGameplaySounds(); + return false; + } + return true; +} + +void AudioManager::playGameplaySound(GameplaySound sound) { + GameplaySoundSlot& slot = gameplaySounds[static_cast(sound)]; + if (slot.data.empty()) return; + + SDL_AudioStream* voice = slot.voices[slot.nextVoice]; + slot.nextVoice = (slot.nextVoice + 1) % slot.voices.size(); + if (!voice) return; + if (!SDL_ClearAudioStream(voice) || + !SDL_PutAudioStreamData(voice, slot.data.data(), static_cast(slot.data.size())) || + !SDL_FlushAudioStream(voice)) { + std::cerr << "Gameplay SE playback error: " << SDL_GetError() << std::endl; + } +} + +void AudioManager::play() { + if (device) { + SDL_ResumeAudioDevice(device); + playing = true; + startTime = SDL_GetTicks(); + accumulatedTicks = 0; + } +} + +void AudioManager::pause() { + if (!device || !playing) return; + const Uint64 now = SDL_GetTicks(); + accumulatedTicks += now - startTime; + SDL_PauseAudioDevice(device); + playing = false; +} + +void AudioManager::resume() { + if (!device || playing) return; + SDL_ResumeAudioDevice(device); + startTime = SDL_GetTicks(); + playing = true; +} + +void AudioManager::setShotMuted(bool muted) { + if (!shotStream || shotMuted == muted) return; + if (SDL_SetAudioStreamGain(shotStream, muted ? 0.0f : shotBaseGain)) { + shotMuted = muted; + } else { + std::cerr << "SHOT gain error: " << SDL_GetError() << std::endl; + } +} + +double AudioManager::getTime() const { + const Uint64 liveTicks = playing ? SDL_GetTicks() - startTime : 0; + return static_cast(accumulatedTicks + liveTicks) / 1000.0; +} + +double AudioManager::getDuration() const { + return duration; +} diff --git a/apps/desktop/src/CabinetBackend.cpp b/apps/desktop/src/CabinetBackend.cpp new file mode 100644 index 0000000..8434f45 --- /dev/null +++ b/apps/desktop/src/CabinetBackend.cpp @@ -0,0 +1,71 @@ +#include "openroller/desktop/CabinetBackend.hpp" + +#include + +#include + +namespace { + +class SoftwareCabinetBackend final : public CabinetBackend { +public: + void poll() override { + const bool* keys = SDL_GetKeyboardState(nullptr); + if (!keys) { + inputs_.fill(false); + return; + } + const auto down = [&](SDL_Scancode key) { return keys[key]; }; + set(CabinetInput::Test, down(SDL_SCANCODE_CAPSLOCK)); + set(CabinetInput::Service, down(SDL_SCANCODE_F1)); + set(CabinetInput::Coin, down(SDL_SCANCODE_F2)); + set(CabinetInput::Select, down(SDL_SCANCODE_F3)); + set(CabinetInput::Enter, + down(SDL_SCANCODE_RIGHTBRACKET) || down(SDL_SCANCODE_RETURN)); + set(CabinetInput::LeftUp, down(SDL_SCANCODE_Q)); + set(CabinetInput::LeftDown, down(SDL_SCANCODE_A)); + set(CabinetInput::LeftLeft, down(SDL_SCANCODE_LCTRL)); + set(CabinetInput::LeftRight, down(SDL_SCANCODE_S)); + set(CabinetInput::LeftButton, down(SDL_SCANCODE_LALT)); + set(CabinetInput::RightUp, down(SDL_SCANCODE_UP)); + set(CabinetInput::RightDown, down(SDL_SCANCODE_DOWN)); + set(CabinetInput::RightLeft, down(SDL_SCANCODE_LEFT)); + set(CabinetInput::RightRight, down(SDL_SCANCODE_RIGHT)); + set(CabinetInput::RightButton, down(SDL_SCANCODE_SPACE)); + } + + bool input(CabinetInput input) const override { + return inputs_[static_cast(input)]; + } + + void setLed(std::size_t logicalIndex, CabinetRgb color) override { + if (logicalIndex < leds_.size()) leds_[logicalIndex] = color; + } + + void clearLeds() override { + leds_.fill({}); + } + + void commitOutputs() override { + // The software backend intentionally retains the last committed frame + // so the LED test can render exactly what a hardware backend receives. + } + + const std::array& leds() const override { + return leds_; + } + +private: + void set(CabinetInput input, bool value) { + inputs_[static_cast(input)] = value; + } + + std::array(CabinetInput::Count)> inputs_{}; + std::array leds_{}; +}; + +} // namespace + +CabinetBackend& defaultCabinetBackend() { + static SoftwareCabinetBackend backend; + return backend; +} diff --git a/apps/desktop/src/LevelLoader.cpp b/apps/desktop/src/LevelLoader.cpp new file mode 100644 index 0000000..7f652ea --- /dev/null +++ b/apps/desktop/src/LevelLoader.cpp @@ -0,0 +1,827 @@ +#include "openroller/desktop/LevelLoader.hpp" +#include "gc/StageCatalog.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; + +namespace { + +struct GcTrackPiece { + uint32_t timeMs = 0; + glm::vec3 pos{0.0f}; +}; + +struct GcNote { + uint32_t timeMs = 0; + uint8_t rawType = 0; + uint8_t effectiveType = 0; + bool adlib = false; + int16_t markEffectId = -1; + float appearanceLeadBeats = 0.0f; + float durationBeats = 0.0f; + uint32_t packedColor = 0xffffffffu; + uint32_t merryCount = 0; + float merrySpacingBeats = 0.0f; + glm::vec3 directionVector{0.0f}; +}; + +struct GcTimingEntry { + uint32_t timeMs = 0; + uint32_t mode = 0; + float value = 0.0f; +}; + +struct GcSystemTimingConfig { + bool missMarkOverride = false; + float greatMinTimeMs = 32.0f; + std::array miss{236.0f, 202.0f, 168.0f, 168.0f}; + std::array unmute{202.0f, 168.0f, 134.0f, 134.0f}; + std::array limit{202.0f, 168.0f, 134.0f, 134.0f}; + std::array mute{0.0f, 0.0f, 0.0f, 0.0f}; + float scratchEnableTimeMs = 250.0f; + float beatEnableTimeMs = 200.0f; +}; + +// RotateHPB::ToVector_Deg converts (heading, pitch, bank) to a quaternion and +// transforms (0, 0, distance). Directional notes author bank as zero, but keep +// the complete original formula here. +glm::vec3 gcDirectionVector(float distance, float heading, float pitch, float bank = 0.0f) { + const float a = glm::radians(-pitch) * 0.5f; + const float b = glm::radians(heading) * 0.5f; + const float c = glm::radians(bank) * 0.5f; + const float ca = std::cos(a), cb = std::cos(b), cc = std::cos(c); + const float sa = std::sin(a), sb = std::sin(b), sc = std::sin(c); + + const float qw = sc * sa * sb + cc * ca * cb; + const float qx = sc * ca * sb + cc * sa * cb; + const float qy = cc * ca * sb - sc * sa * cb; + const float qz = cc * sa * sb - sc * ca * cb; + return distance * glm::vec3( + 2.0f * (qz * qx + qw * qy), + 2.0f * (qy * qz - qw * qx), + 1.0f - 2.0f * (qx * qx + qy * qy)); +} + +std::string trim(std::string value) { + const auto first = std::find_if_not(value.begin(), value.end(), + [](unsigned char c) { return std::isspace(c); }); + const auto last = std::find_if_not(value.rbegin(), value.rend(), + [](unsigned char c) { return std::isspace(c); }).base(); + return first < last ? std::string(first, last) : std::string{}; +} + +bool parseFloatTuple4(std::string value, std::array& out) { + for (char& c : value) { + if (c == '(' || c == ')' || c == ',') c = ' '; + } + std::stringstream values(value); + std::array parsed{}; + if (!(values >> parsed[0] >> parsed[1] >> parsed[2] >> parsed[3])) return false; + out = parsed; + return true; +} + +GcSystemTimingConfig loadGcSystemTimingConfig(const fs::path& stageFile) { + GcSystemTimingConfig config; + const fs::path systemCfg = stageFile.parent_path().parent_path() / "system.cfg"; + std::ifstream file(systemCfg); + if (!file.is_open()) return config; + + std::string line; + bool inBlockComment = false; + while (std::getline(file, line)) { + if (inBlockComment) { + const size_t end = line.find("*/"); + if (end == std::string::npos) continue; + line.erase(0, end + 2); + inBlockComment = false; + } + for (;;) { + const size_t begin = line.find("/*"); + if (begin == std::string::npos) break; + const size_t end = line.find("*/", begin + 2); + if (end == std::string::npos) { + line.resize(begin); + inBlockComment = true; + break; + } + line.erase(begin, end + 2 - begin); + } + const size_t comment = line.find("//"); + if (comment != std::string::npos) line.resize(comment); + const size_t equals = line.find('='); + if (equals == std::string::npos) continue; + const std::string key = trim(line.substr(0, equals)); + const std::string value = trim(line.substr(equals + 1)); + try { + if (key == "MissMarkOverride") config.missMarkOverride = std::stoi(value) != 0; + else if (key == "GreatMinTime") config.greatMinTimeMs = std::stof(value); + else if (key == "MissTimingOverride") parseFloatTuple4(value, config.miss); + else if (key == "UnmuteTimingOverride") parseFloatTuple4(value, config.unmute); + else if (key == "LimitTimingOverride") parseFloatTuple4(value, config.limit); + else if (key == "MuteTimingOverride") parseFloatTuple4(value, config.mute); + else if (key == "ScratchEnableTime") config.scratchEnableTimeMs = std::stof(value); + else if (key == "BeatEnableTime") config.beatEnableTimeMs = std::stof(value); + } catch (const std::exception&) { + // Preserve the shipped defaults if a local config line is malformed. + } + } + return config; +} + +size_t gcDifficultyIndex(const fs::path& stageFile) { + std::string stem = stageFile.stem().string(); + std::transform(stem.begin(), stem.end(), stem.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + if (stem.find("_normal") != std::string::npos) return 1; + if (stem.find("_hard") != std::string::npos) return 2; + if (stem.find("_extra") != std::string::npos || + (stem.size() >= 3 && stem.compare(stem.size() - 3, 3, "_ex") == 0) || + stem.find("_ex_") != std::string::npos) return 3; + return 0; // _easy and old charts without a suffix +} + +const char* gcNoteTypeName(uint8_t type) { + static constexpr const char* names[] = { + "NONE", "NORMAL", "FLICK", "HOLD", "SCRATCH", "BEAT", "MERRY GO ROUND", "HIDDEN", + "HIDDEN2", "CRITICAL", "SLIDE HOLD", "SLIDE COUNTER", "TURN", "SPIN", "FINISH", "DUAL HOLD", + }; + return type < (sizeof(names) / sizeof(names[0])) ? names[type] : "UNKNOWN"; +} + +uint16_t u16be(const std::vector& b, size_t off) { + return static_cast((static_cast(b[off]) << 8) | static_cast(b[off + 1])); +} + +uint32_t u32be(const std::vector& b, size_t off) { + return (static_cast(b[off + 0]) << 24) | + (static_cast(b[off + 1]) << 16) | + (static_cast(b[off + 2]) << 8) | + static_cast(b[off + 3]); +} + +float f32be(const std::vector& b, size_t off) { + const uint32_t u = u32be(b, off); + float f = 0.0f; + std::memcpy(&f, &u, sizeof(float)); + return f; +} + +bool readFile(const std::string& path, std::vector& out) { + std::ifstream file(path, std::ios::binary); + if (!file.is_open()) return false; + file.seekg(0, std::ios::end); + const std::streamoff size = file.tellg(); + if (size < 0) return false; + file.seekg(0, std::ios::beg); + out.assign(static_cast(size), 0); + if (!out.empty()) file.read(reinterpret_cast(out.data()), static_cast(out.size())); + return static_cast(file) || file.eof(); +} + +void loadGcObjectClipTable(const fs::path& stagePath, LevelData& data) { + if (!data.gcStage) return; + + const fs::path clipPath = + stagePath.parent_path() / (stagePath.stem().string() + "_clip.dat"); + std::vector bytes; + if (!readFile(clipPath.string(), bytes) || bytes.size() < 8) return; + + const uint32_t objectCount = u32be(bytes, 0); + const uint32_t frameCount = u32be(bytes, 4); + if (objectCount != data.gcStage->objects.size() || frameCount == 0) return; + if (objectCount > (bytes.size() - 8) / frameCount) return; + + const size_t payloadSize = static_cast(objectCount) * frameCount; + if (8 + payloadSize > bytes.size()) return; + data.gcObjectClipFrameCount = frameCount; + data.gcObjectClipVisibility.assign(bytes.begin() + 8, + bytes.begin() + 8 + payloadSize); +} + +bool saneFloat(float v, float limit = 1000000.0f) { + return std::isfinite(v) && std::fabs(v) <= limit; +} + +std::string readSizedString16(const std::vector& bytes, size_t& off, size_t end) { + if (off + 2 > end) return {}; + const uint16_t len = u16be(bytes, off); + off += 2; + if (off + len > end) return {}; + std::string s(reinterpret_cast(bytes.data() + off), len); + off += len; + while (!s.empty() && s.back() == '\0') s.pop_back(); + return s; +} + +std::vector decodeGcNotes(const std::vector& bytes, size_t start, size_t end) { + constexpr size_t recordSize = 99; + std::vector notes; + if (start + 8 > end || end > bytes.size()) return notes; + + size_t off = start; + const uint32_t nameCount = u32be(bytes, off); + off += 4; + for (uint32_t i = 0; i < nameCount; ++i) { + if (off >= end) return {}; + const size_t len = bytes[off++]; + if (len > end - off) return {}; + off += len; + } + if (off + 4 > end) return {}; + const uint32_t count = u32be(bytes, off); + off += 4; + const size_t payloadBytes = static_cast(count) * recordSize; + if (payloadBytes != end - off) return notes; + + notes.reserve(count); + for (uint32_t i = 0; i < count; ++i, off += recordSize) { + const uint8_t rawType = bytes[off + 4]; + uint8_t effectiveType = bytes[off + 5] ? 1 : rawType; + if (!bytes[off + 5]) { + if (rawType == 0x0b) effectiveType = 0x0a; + else if (rawType == 0x0c || rawType == 0x0e) effectiveType = 0x09; + else if (rawType == 0x0d) effectiveType = 0x04; + } + float directionLength = f32be(bytes, off + 25); + // LoadTuneMarkDataOne substitutes 1.0 for directional types whose + // authored vector length is zero (notably SLIDE HOLD charts). + if ((effectiveType == 2 || effectiveType == 10 || rawType == 0x10) && + directionLength <= 0.0f) { + directionLength = 1.0f; + } + notes.push_back({ + u32be(bytes, off), rawType, effectiveType, bytes[off + 5] != 0, + static_cast(u16be(bytes, off + 6)), + f32be(bytes, off + 39), + f32be(bytes, off + 51), + u32be(bytes, off + 55), + u32be(bytes, off + 71), + f32be(bytes, off + 75), + gcDirectionVector(directionLength, + f32be(bytes, off + 29), + f32be(bytes, off + 33)), + }); + } + return notes; +} + +float gcBeatDurationAt(const gc::StageConfig* config, uint32_t timeMs) { + uint32_t bpm = 120; + if (config) { + for (const gc::BpmChange& change : config->bpmChanges) { + if (change.timeMs > timeMs) break; + if (change.bpm != 0) bpm = change.bpm; + } + } + return 60000.0f / static_cast(std::max(1, bpm)); +} + +float gcTimingAt(const std::vector& entries, uint32_t timeMs, + float beatMs, float nextSpacingMs) { + if (entries.empty()) return beatMs * 0.5f; + const GcTimingEntry* active = &entries.front(); + for (const GcTimingEntry& entry : entries) { + if (entry.timeMs > timeMs) break; + active = &entry; + } + if (active->mode == 1) return active->value; + if (active->mode == 3) return nextSpacingMs; + return active->value * beatMs; +} + +float cumulativeDistanceAtTime(const std::vector& track, const std::vector& dists, float noteT, float minT, float maxT) { + if (track.empty() || dists.empty()) return 0.0f; + + const float lastMs = static_cast(std::max(1, track.back().timeMs)); + float targetMs = 0.0f; + if (maxT - minT > 0.001f && maxT * 1000.0f <= lastMs * 1.25f) { + targetMs = noteT * 1000.0f; + } else if (maxT - minT > 0.001f && maxT <= lastMs * 1.25f) { + targetMs = noteT; + } else { + const float u = (maxT > minT) ? ((noteT - minT) / (maxT - minT)) : 0.0f; + targetMs = u * lastMs; + } + + if (targetMs <= static_cast(track.front().timeMs)) return dists.front(); + if (targetMs >= lastMs) return dists.back(); + + for (size_t i = 0; i + 1 < track.size(); ++i) { + const float a = static_cast(track[i].timeMs); + const float b = static_cast(track[i + 1].timeMs); + if (targetMs >= a && targetMs <= b) { + const float span = b - a; + const float u = span > 0.001f ? (targetMs - a) / span : 0.0f; + return dists[i] + (dists[i + 1] - dists[i]) * u; + } + } + return dists.back(); +} + +float trackParamAtTimeMs(const std::vector& track, float timeMs) { + if (track.empty()) return 0.0f; + if (timeMs <= static_cast(track.front().timeMs)) return 0.0f; + if (timeMs >= static_cast(track.back().timeMs)) return static_cast(track.size() - 1); + for (size_t i = 0; i + 1 < track.size(); ++i) { + const float a = static_cast(track[i].timeMs); + const float b = static_cast(track[i + 1].timeMs); + if (timeMs >= a && timeMs <= b) { + const float span = b - a; + const float u = span > 0.001f ? (timeMs - a) / span : 0.0f; + return static_cast(i) + u; + } + } + return static_cast(track.size() - 1); +} + +std::string lower(std::string s) { + std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); + return s; +} + +std::string gcStageToken(const fs::path& stagePath, const std::string& chartName) { + std::string token = lower(chartName.empty() ? stagePath.stem().string() : chartName); + if (token.rfind("ac_", 0) == 0) token = token.substr(3); + for (const std::string suffix : {"_hard", "_normal", "_easy"}) { + if (token.size() > suffix.size() && token.compare(token.size() - suffix.size(), suffix.size(), suffix) == 0) { + token.resize(token.size() - suffix.size()); + } + } + return token; +} + +std::string inferGcBgmPath(const fs::path& stagePath, const std::string& chartName, const std::string& bgmName) { + const fs::path soundDir = stagePath.parent_path() / "sound"; + if (!fs::is_directory(soundDir)) return {}; + + const std::string token = gcStageToken(stagePath, chartName); + + const std::string bgm = lower(bgmName); + for (const auto& entry : fs::directory_iterator(soundDir)) { + if (!entry.is_regular_file()) continue; + const std::string name = lower(entry.path().filename().string()); + if (entry.path().extension() != ".wav") continue; + if (name.find("_bgm") == std::string::npos) continue; + if ((!token.empty() && name.find(token) != std::string::npos) || + (!bgm.empty() && name.find(bgm) != std::string::npos)) { + return entry.path().string(); + } + } + return {}; +} + +struct GcStageAudio { + std::string bgmPath; + std::string shotPath; + float bgmGain = 1.0f; + float shotGain = 1.0f; +}; + +GcStageAudio resolveGcStageAudio(const fs::path& stagePath, + const std::string& chartName, + const std::string& bgmName, + size_t difficultyIndex) { + GcStageAudio result; + const fs::path dataDir = stagePath.parent_path().parent_path(); + const fs::path stageParam = dataDir / "boot" / "stage_param.dat"; + std::vector catalogBytes; + std::vector entries; + std::string catalogError; + if (readFile(stageParam.string(), catalogBytes) && + gc::ParseStageCatalog(catalogBytes, &entries, &catalogError)) { + const std::string stageId = stagePath.stem().string(); + const gc::StageCatalogEntry* entry = gc::FindStageCatalogEntryByChart(entries, stageId); + if (!entry && !chartName.empty()) { + entry = gc::FindStageCatalogEntryByChart(entries, chartName); + } + if (entry) { + for (size_t i = 0; i < entry->chartIds.size(); ++i) { + if (entry->chartIds[i] == stageId || entry->chartIds[i] == chartName) { + difficultyIndex = i; + break; + } + } + difficultyIndex = std::min(difficultyIndex, entry->chartIds.size() - 1); + const fs::path soundDir = dataDir / "stage" / "sound"; + const fs::path bgm = soundDir / + (entry->bgmBase + entry->chartGroup0[difficultyIndex] + "_BGM.wav"); + const fs::path shot = soundDir / + (entry->bgmBase + entry->chartSuffixes[difficultyIndex] + "_SHOT.wav"); + result.bgmGain = static_cast(entry->bgmVolumes[difficultyIndex]) / 100.0f; + result.shotGain = static_cast(entry->shotVolumes[difficultyIndex]) / 100.0f; + + // LoadStageBGM requires the pair. Its compatibility path is the + // old one-file layout under data/sound. + if (fs::is_regular_file(bgm) && fs::is_regular_file(shot)) { + result.bgmPath = bgm.string(); + result.shotPath = shot.string(); + return result; + } + const fs::path legacy = dataDir / "sound" / (entry->bgmBase + ".wav"); + if (fs::is_regular_file(legacy)) { + result.bgmPath = legacy.string(); + return result; + } + // Keep a damaged/incomplete dump playable when its BGM survived. + if (fs::is_regular_file(bgm)) { + result.bgmPath = bgm.string(); + return result; + } + } + } + + result.bgmPath = inferGcBgmPath(stagePath, chartName, bgmName); + return result; +} + +std::string inferGcBackgroundPath(const fs::path& stagePath, const std::string& chartName) { + const fs::path image = stagePath.parent_path() / "2d" / (gcStageToken(stagePath, chartName) + "_menu.dds"); + return fs::is_regular_file(image) ? image.string() : std::string{}; +} + +LevelData loadGcStageDat(const std::string& stageFile) { + LevelData data; + std::vector bytes; + if (!readFile(stageFile, bytes) || bytes.size() < 52) return data; + + // Keep the complete clean-room decode alongside the compact player data. + // Rendering can consume sections incrementally without teaching this + // loader a second, diverging copy of every variable-length object record. + gc::StageDat stageDat; + gc::ParsedStagePattern parsedStage; + std::string stageError; + if (gc::StageDat::LoadFromFile(stageFile, stageDat, &stageError) && + gc::ParseStagePattern(stageDat, &parsedStage, &stageError)) { + data.gcStage = std::move(parsedStage); + } + + std::vector header; + header.reserve(13); + for (size_t off = 0; off < 52; off += 4) header.push_back(u32be(bytes, off)); + + const uint32_t cfgOff = header[0]; + const uint32_t trackOff = header[2]; + const uint32_t notesOff = header[3]; + const uint32_t cameraOff = header[4]; + if (cfgOff >= bytes.size() || trackOff >= bytes.size() || notesOff >= bytes.size() || cameraOff >= bytes.size()) return data; + + std::string chartName; + std::string bgmName; + float backwardsDrawDist = 10.0f; + float forwardDrawDist = 7.0f; + std::array trackAheadColor{0, 204, 255, 255}; + std::array trackBehindColor{0, 102, 160, 255}; + std::array, 4> noteTimings; + const GcSystemTimingConfig systemTiming = loadGcSystemTimingConfig(fs::path(stageFile)); + const size_t difficultyIndex = gcDifficultyIndex(fs::path(stageFile)); + { + const size_t cfgEnd = header[1]; + size_t off = cfgOff + 12; + if (off + 2 <= cfgEnd) { + const uint16_t bpmCount = u16be(bytes, off); + off += 2 + static_cast(bpmCount) * 8; + bool timingListsValid = off <= cfgEnd; + for (std::vector& list : noteTimings) { + if (!timingListsValid || off + 2 > cfgEnd) { + timingListsValid = false; + break; + } + const uint16_t count = u16be(bytes, off); + off += 2; + if (static_cast(count) > (cfgEnd - off) / 12) { + timingListsValid = false; + break; + } + list.reserve(count); + for (uint16_t i = 0; i < count; ++i, off += 12) { + list.push_back({u32be(bytes, off), u32be(bytes, off + 4), f32be(bytes, off + 8)}); + } + } + if (timingListsValid) { + chartName = readSizedString16(bytes, off, cfgEnd); + (void)readSizedString16(bytes, off, cfgEnd); + bgmName = readSizedString16(bytes, off, cfgEnd); + (void)readSizedString16(bytes, off, cfgEnd); + if (off + 16 <= cfgEnd) { + backwardsDrawDist = f32be(bytes, off); + forwardDrawDist = f32be(bytes, off + 4); + for (size_t i = 0; i < 4; ++i) trackAheadColor[i] = bytes[off + 8 + i]; + for (size_t i = 0; i < 4; ++i) trackBehindColor[i] = bytes[off + 12 + i]; + } + } + } + } + + data.title = chartName.empty() ? fs::path(stageFile).stem().string() : chartName; + data.author = "Groove Coaster"; + const GcStageAudio stageAudio = resolveGcStageAudio( + fs::path(stageFile), chartName, bgmName, difficultyIndex); + data.audioPath = stageAudio.bgmPath; + data.audioShotPath = stageAudio.shotPath; + data.audioBgmGain = stageAudio.bgmGain; + data.audioShotGain = stageAudio.shotGain; + const fs::path soundDir = fs::path(stageFile).parent_path().parent_path() / "sound"; + // The selected SE id lives in the arcade profile. OpenRoller currently + // starts with the shipped default (se0000, "Ver.3 Set") from se.dat. + data.gameplaySoundPaths = { + (soundDir / "SE_ARRANGE.wav").string(), + (soundDir / "TAP_SE1.wav").string(), + (soundDir / "TAP_SE2.wav").string(), + }; + // Exact entries in data/sound/SEList.csv for the three default slots. + data.gameplaySoundGains = {0.87f, 0.80f, 0.77f}; + data.backgroundPath = inferGcBackgroundPath(fs::path(stageFile), chartName); + data.config["speed"] = 1.0f; + data.config["gc_camera"] = 1.0f; + // game471.exe uses this fixed vertical FOV in its gameplay projection. + data.config["gc_fov"] = 75.0f; + data.config["gc_draw_behind"] = saneFloat(backwardsDrawDist) ? std::max(0.0f, backwardsDrawDist) : 10.0f; + data.config["gc_draw_ahead"] = saneFloat(forwardDrawDist) ? std::max(0.0f, forwardDrawDist) : 7.0f; + data.config["gc_track_ahead_r"] = trackAheadColor[0] / 255.0f; + data.config["gc_track_ahead_g"] = trackAheadColor[1] / 255.0f; + data.config["gc_track_ahead_b"] = trackAheadColor[2] / 255.0f; + data.config["gc_track_behind_r"] = trackBehindColor[0] / 255.0f; + data.config["gc_track_behind_g"] = trackBehindColor[1] / 255.0f; + data.config["gc_track_behind_b"] = trackBehindColor[2] / 255.0f; + data.config["gc_great_min_ms"] = std::max(0.0f, systemTiming.greatMinTimeMs); + data.config["gc_scratch_enable_ms"] = std::max(0.0f, systemTiming.scratchEnableTimeMs); + data.config["gc_beat_enable_ms"] = std::max(0.0f, systemTiming.beatEnableTimeMs); + + std::vector gcTrack; + { + const size_t trackEnd = notesOff; + if (trackOff + 4 > trackEnd) return data; + const uint32_t count = u32be(bytes, trackOff); + size_t off = trackOff + 4; + const size_t capacity = (trackEnd - off) / 16; + const size_t n = std::min(count, capacity); + gcTrack.reserve(n); + for (size_t i = 0; i < n; ++i, off += 16) { + GcTrackPiece p; + p.timeMs = u32be(bytes, off + 0); + p.pos.x = f32be(bytes, off + 4); + p.pos.y = f32be(bytes, off + 8); + p.pos.z = f32be(bytes, off + 12); + if (saneFloat(p.pos.x) && saneFloat(p.pos.y) && saneFloat(p.pos.z)) gcTrack.push_back(p); + } + } + + // game471's FUN_005e9690 interpolates adjacent track keys linearly by + // timestamp. Type 1 selects the matching straight-segment path here. + for (const auto& p : gcTrack) data.trackPoints.push_back({p.pos, 1, true, static_cast(p.timeMs)}); + if (gcTrack.size() < 2) return data; + data.config["gc_duration_ms"] = static_cast(std::max(1, gcTrack.back().timeMs)); + + { + const size_t drawOff = header[1]; + if (drawOff + 4 <= trackOff) { + const uint32_t count = u32be(bytes, drawOff); + const size_t capacity = (trackOff - drawOff - 4) / 8; + size_t off = drawOff + 4; + for (size_t i = 0; i < std::min(count, capacity); ++i, off += 8) { + const uint32_t timeMs = u32be(bytes, off); + const float distance = f32be(bytes, off + 4); + if (saneFloat(distance)) { + data.timeline.push_back({trackParamAtTimeMs(gcTrack, static_cast(timeMs)), + "gc_draw_ahead", std::max(0.0f, distance)}); + } + } + } + } + + std::vector cumulative; + cumulative.reserve(gcTrack.size()); + cumulative.push_back(0.0f); + for (size_t i = 1; i < gcTrack.size(); ++i) { + cumulative.push_back(cumulative.back() + glm::distance(gcTrack[i - 1].pos, gcTrack[i].pos)); + } + + { + const size_t cameraEnd = header[5]; + if (cameraOff + 4 <= cameraEnd) { + const uint32_t count = u32be(bytes, cameraOff); + size_t off = cameraOff + 4; + const size_t recordSize = 59; + const size_t capacity = (cameraEnd - off) / recordSize; + const size_t n = std::min(count, capacity); + data.gcCameraKeys.reserve(n); + for (size_t i = 0; i < n; ++i, off += recordSize) { + GcCameraKey key; + key.timeMs = u32be(bytes, off + 0); + key.aMode = bytes[off + 4]; + key.fMode = bytes[off + 5]; + key.dist = f32be(bytes, off + 6); + key.rotationA = {f32be(bytes, off + 10), f32be(bytes, off + 14), 0.0f}; + key.originOff = {f32be(bytes, off + 18), f32be(bytes, off + 22), f32be(bytes, off + 26)}; + key.projType = bytes[off + 30]; + key.fieldFar = {f32be(bytes, off + 31), f32be(bytes, off + 35), f32be(bytes, off + 39)}; + key.fieldNear = {f32be(bytes, off + 43), f32be(bytes, off + 47), f32be(bytes, off + 51)}; + key.rotationB = f32be(bytes, off + 55); + + // game471.exe FUN_005ed4c0 copies all 59 wire bytes into the + // 0x44-byte runtime key without finite-value filtering. The + // first camera in the three comet charts deliberately carries + // NaN rotations, so dropping the whole key changes their intro. + data.gcCameraKeys.push_back(key); + } + } + } + + // Full-screen stage background: four RGBA corners keyed by chart time. + // The two trailing bytes are fade flags; linear interpolation matches the + // common fade-enabled records and will be specialized once both modes are + // mapped from the renderer. + if (header.size() > 9) { + const size_t colorsOff = header[8]; + const size_t objectsOff = header[9]; + constexpr size_t colorRecordSize = 22; + if (colorsOff + 4 <= objectsOff && objectsOff <= bytes.size()) { + const uint32_t count = u32be(bytes, colorsOff); + const size_t capacity = (objectsOff - colorsOff - 4) / colorRecordSize; + size_t off = colorsOff + 4; + static constexpr const char* corners[] = {"tr", "tl", "br", "bl"}; + for (size_t i = 0; i < std::min(count, capacity); ++i, off += colorRecordSize) { + const uint32_t timeMs = u32be(bytes, off); + const float t = trackParamAtTimeMs(gcTrack, static_cast(timeMs)); + for (size_t corner = 0; corner < 4; ++corner) { + const size_t colorOff = off + 4 + corner * 4; + data.timeline.push_back({t, std::string("gc_bg_") + corners[corner] + "_r", bytes[colorOff + 0] / 255.0f}); + data.timeline.push_back({t, std::string("gc_bg_") + corners[corner] + "_g", bytes[colorOff + 1] / 255.0f}); + data.timeline.push_back({t, std::string("gc_bg_") + corners[corner] + "_b", bytes[colorOff + 2] / 255.0f}); + } + } + data.config["gc_background_keys"] = static_cast(std::min(count, capacity)); + } + } + + std::vector gcNotes = decodeGcNotes(bytes, notesOff, cameraOff); + if (!gcNotes.empty()) { + std::array typeCounts{}; + const float minT = static_cast(gcNotes.front().timeMs); + const float maxT = static_cast(gcNotes.back().timeMs); + for (size_t noteIndex = 0; noteIndex < gcNotes.size(); ++noteIndex) { + const GcNote& n = gcNotes[noteIndex]; + ++typeCounts[n.rawType]; + const float timeMs = static_cast(n.timeMs); + const float beatMs = gcBeatDurationAt(data.gcStage ? &data.gcStage->config : nullptr, n.timeMs); + const bool durationType = n.effectiveType == 3 || n.effectiveType == 4 || + n.effectiveType == 5 || n.effectiveType == 10 || + n.effectiveType == 15; + const float durationBeats = n.effectiveType == 6 + ? static_cast(n.merryCount) * std::max(0.0f, n.merrySpacingBeats) + : std::max(0.0f, n.durationBeats); + const float appearTimeMs = std::max(0.0f, timeMs - std::max(0.0f, n.appearanceLeadBeats) * beatMs); + const float endTimeMs = (durationType || n.effectiveType == 6) + ? std::max(timeMs, timeMs + durationBeats * beatMs) + : timeMs; + const float nextSpacingMs = noteIndex + 1 < gcNotes.size() + ? std::max(0.0f, static_cast(gcNotes[noteIndex + 1].timeMs) - timeMs) + : beatMs * 2.0f; + float missWindowMs = std::max(0.0f, gcTimingAt(noteTimings[0], n.timeMs, beatMs, nextSpacingMs)); + float earlyWindowMs = std::max(0.0f, gcTimingAt(noteTimings[1], n.timeMs, beatMs, nextSpacingMs)); + float lateWindowMs = std::max(0.0f, gcTimingAt(noteTimings[2], n.timeMs, beatMs, nextSpacingMs)); + float muteTimingMs = std::max(0.0f, gcTimingAt(noteTimings[3], n.timeMs, beatMs, nextSpacingMs)); + // FUN_005ed4c0 replaces the authored timing-list results with the + // four per-difficulty arrays from data/system.cfg when this flag is + // enabled. Runtime +0x98/+0x9c/+0xa0 are miss, early and late. + if (systemTiming.missMarkOverride) { + missWindowMs = std::max(0.0f, systemTiming.miss[difficultyIndex]); + earlyWindowMs = std::max(0.0f, systemTiming.unmute[difficultyIndex]); + lateWindowMs = std::max(0.0f, systemTiming.limit[difficultyIndex]); + muteTimingMs = std::max(0.0f, systemTiming.mute[difficultyIndex]); + } + // BuildTimingDataSub adds this literal for FLICK and the otherwise + // unclassified type 0x10 before constructing runtime +0xc4. + if (n.effectiveType == 2 || n.rawType == 0x10) { + earlyWindowMs += beatMs * 0.2f; + lateWindowMs += beatMs * 0.2f; + } + const float fadeAnchorMs = (durationType || n.effectiveType == 6) + ? endTimeMs + : timeMs + lateWindowMs; + const float markerFadeEndTimeMs = fadeAnchorMs + beatMs * 4.0f; + data.notes.push_back({ + cumulativeDistanceAtTime(gcTrack, cumulative, timeMs, minT, maxT), + timeMs, + appearTimeMs, + endTimeMs, + cumulativeDistanceAtTime(gcTrack, cumulative, endTimeMs, minT, maxT), + n.rawType, + n.effectiveType, + n.adlib, + n.markEffectId, + n.packedColor, + n.merryCount, + n.directionVector, + beatMs, + earlyWindowMs, + lateWindowMs, + missWindowMs, + muteTimingMs, + markerFadeEndTimeMs, + true, + }); + } + std::cout << "GC note types:"; + for (size_t type = 0; type < typeCounts.size(); ++type) { + if (typeCounts[type] == 0) continue; + std::cout << " 0x" << std::hex << type << std::dec << '/' << gcNoteTypeName(static_cast(type)) + << '=' << typeCounts[type]; + } + std::cout << std::endl; + } + + // FUN_005ed4c0 loads _clip.dat as an objectCount x frameCount byte + // matrix. FUN_006445b0 indexes object first and round(timeMs / (1000/60)) + // second, and skips the object when the stored byte is zero. + loadGcObjectClipTable(fs::path(stageFile), data); + + std::cout << "GC stage loaded: " << data.title << " track=" << data.trackPoints.size() + << " notes=" << data.notes.size() + << " cameras=" << data.gcCameraKeys.size() + << " clipFrames=" << data.gcObjectClipFrameCount + << " bgKeys=" << static_cast(data.config.count("gc_background_keys") + ? data.config.at("gc_background_keys") : 0.0f) + << (data.gcStage ? " particles=" + std::to_string(data.gcStage->particles.size()) + + " visualizers=" + std::to_string(data.gcStage->visualizer.size()) + + " models=" + std::to_string(data.gcStage->modelNames.size()) + + " objects=" + std::to_string(data.gcStage->objects.size()) + : " backgroundScene=") + << (data.audioPath.empty() ? " audio=" : " bgm=" + data.audioPath) + << (data.audioShotPath.empty() ? " shot=" : " shot=" + data.audioShotPath) + << " audioGain=" << data.audioBgmGain << '/' << data.audioShotGain + << (data.gameplaySoundPaths[1].empty() ? " tapSE=" : " tapSE=Ver.3") + << (data.backgroundPath.empty() ? " background=" : " background=" + data.backgroundPath) + << std::endl; + return data; +} + +LevelData loadLegacyFolder(const std::string& mapFolder) { + LevelData data; + std::string metaPath = mapFolder + "/map.txt"; + std::ifstream metaFile(metaPath); + if (!metaFile.is_open()) return data; + + std::string line, trackFile, notesFile, timelineFile; + while (std::getline(metaFile, line)) { + if (line.empty() || line[0] == '#') continue; + std::stringstream ss(line); + std::string key, value; ss >> key; std::getline(ss, value); + if (!value.empty() && value[0] == ' ') value.erase(0, 1); + if (key == "title") data.title = value; + else if (key == "author") data.author = value; + else if (key == "audio") data.audioPath = mapFolder + "/" + value; + else if (key == "track") trackFile = mapFolder + "/" + value; + else if (key == "notes") notesFile = mapFolder + "/" + value; + else if (key == "timeline") timelineFile = mapFolder + "/" + value; + else { try { data.config[key] = std::stof(value); } catch(...) {} } + } + + std::ifstream tFile(trackFile); + if (tFile.is_open()) { + while (std::getline(tFile, line)) { + std::stringstream ss(line); + float x, y, z; int type = 0; int visible = 1; + if (ss >> x >> y >> z) { + if (!(ss >> type)) type = 0; + if (!(ss >> visible)) visible = 1; + data.trackPoints.push_back({{x, y, z}, type, visible != 0}); + } + } + } + + std::ifstream nFile(notesFile); + if (nFile.is_open()) { + float distance = 0.0f; + while (nFile >> distance) data.notes.push_back({distance, 0.0f, 0, 0}); + } + + std::ifstream tlFile(timelineFile); + if (tlFile.is_open()) { + while (std::getline(tlFile, line)) { + if (line.empty() || line[0] == '#') continue; + std::stringstream ss(line); + float t, val; std::string param; + if (ss >> t >> param >> val) data.timeline.push_back({t, param, val}); + } + } + return data; +} + +} // namespace + +LevelData LevelLoader::load(const std::string& path) { + const fs::path p(path); + if (fs::is_regular_file(p) && p.extension() == ".dat") return loadGcStageDat(path); + return loadLegacyFolder(path); +} diff --git a/apps/desktop/src/ServiceMenu.cpp b/apps/desktop/src/ServiceMenu.cpp new file mode 100644 index 0000000..d501747 --- /dev/null +++ b/apps/desktop/src/ServiceMenu.cpp @@ -0,0 +1,1120 @@ +#include "openroller/desktop/ServiceMenu.hpp" + +#include "openroller/desktop/CabinetBackend.hpp" +#include "vectorail/core/Shader.hpp" +#include "vectorail/core/gl_loader.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace { + +constexpr float kWidth = 720.0f; +constexpr float kHeight = 1280.0f; + +const glm::vec4 kWhite{1.0f, 1.0f, 1.0f, 1.0f}; +const glm::vec4 kCyan{0.0f, 1.0f, 1.0f, 1.0f}; +const glm::vec4 kRed{1.0f, 0.0f, 0.0f, 1.0f}; +const glm::vec4 kGreen{0.1f, 1.0f, 0.2f, 1.0f}; + +struct Vertex { + float x; + float y; + float u; + float v; +}; + +std::filesystem::path serviceShaderPath(const char* name) { + namespace fs = std::filesystem; + std::vector probes{fs::current_path()}; + if (const char* base = SDL_GetBasePath()) probes.emplace_back(base); + for (fs::path probe : probes) { + for (int depth = 0; depth < 8 && !probe.empty(); ++depth) { + for (const fs::path& candidate : + {probe / "shaders" / name, + probe / "third_party" / "vectorail" / "shaders" / name}) { + if (fs::is_regular_file(candidate)) return candidate; + } + if (probe == probe.parent_path()) break; + probe = probe.parent_path(); + } + } + return fs::path("shaders") / name; +} + +class TestUi { +public: + explicit TestUi(const std::filesystem::path& contentPath) + : shader_(serviceShaderPath("ui.vert").string().c_str(), + serviceShaderPath("ui.frag").string().c_str()) { + glGenVertexArrays(1, &vao_); + glGenBuffers(1, &vbo_); + glBindVertexArray(vao_); + glBindBuffer(GL_ARRAY_BUFFER, vbo_); + glBufferData(GL_ARRAY_BUFFER, sizeof(Vertex) * 6 * 32768, nullptr, GL_DYNAMIC_DRAW); + glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, sizeof(Vertex), nullptr); + glEnableVertexAttribArray(0); + glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, sizeof(Vertex), + reinterpret_cast(sizeof(float) * 2)); + glEnableVertexAttribArray(1); + shader_.use(); + shader_.setInt("uTexture", 0); + findOriginalFont(contentPath); + } + + ~TestUi() { + for (const auto& [page, texture] : fontPages_) { + (void)page; + if (texture != 0) glDeleteTextures(1, &texture); + } + if (vbo_ != 0) glDeleteBuffers(1, &vbo_); + if (vao_ != 0) glDeleteVertexArrays(1, &vao_); + } + + void rect(float x, float y, float width, float height, const glm::vec4& color) { + if (color_ != color && !vertices_.empty()) flush(); + color_ = color; + appendQuad(x, y, width, height); + } + + void outline(float x, float y, float width, float height, float thickness, + const glm::vec4& color) { + rect(x, y, width, thickness, color); + rect(x, y + height - thickness, width, thickness, color); + rect(x, y, thickness, height, color); + rect(x + width - thickness, y, thickness, height, color); + } + + void text(float x, float y, float scale, std::string_view value, + const glm::vec4& color) { + (void)scale; + flush(); + std::vector glyphVertices; + unsigned int activeTexture = 0; + const auto drawRun = [&]() { + if (glyphVertices.empty() || activeTexture == 0) return; + shader_.use(); + shader_.setBool("uUseTexture", true); + shader_.setBool("uUseGradient", false); + shader_.setVec4("uColor", color); + glActiveTexture(GL_TEXTURE0); + glBindTexture(GL_TEXTURE_2D, activeTexture); + glBindVertexArray(vao_); + glBindBuffer(GL_ARRAY_BUFFER, vbo_); + glBufferSubData(GL_ARRAY_BUFFER, 0, glyphVertices.size() * sizeof(Vertex), + glyphVertices.data()); + glDrawArrays(GL_TRIANGLES, 0, static_cast(glyphVertices.size())); + glyphVertices.clear(); + }; + + std::size_t cursor = 0; + while (cursor < value.size()) { + const std::uint32_t codepoint = nextUtf8(value, cursor); + const Glyph glyphInfo = lookupGlyph(codepoint); + const bool halfWidth = codepoint < 0x80; + const float glyphWidth = halfWidth ? 8.0f : 16.0f; + const unsigned int texture = loadFontPage(glyphInfo.page); + if (texture != activeTexture) { + drawRun(); + activeTexture = texture; + } + if (texture != 0 && codepoint != ' ' && codepoint != '\t') { + const float u0 = static_cast(glyphInfo.cell & 0x0f) / 16.0f; + const float v0 = static_cast(glyphInfo.cell >> 4) / 16.0f; + const float u1 = u0 + (halfWidth ? 1.0f / 32.0f : 1.0f / 16.0f); + const float v1 = v0 + 1.0f / 16.0f; + appendQuad(glyphVertices, x, y, glyphWidth, 16.0f, u0, v0, u1, v1); + } + x += codepoint == '\t' ? 32.0f : glyphWidth; + } + drawRun(); + glBindTexture(GL_TEXTURE_2D, 0); + } + + void centered(float y, float scale, std::string_view value, const glm::vec4& color) { + (void)scale; + const float width = textWidth(value); + text((kWidth - width) * 0.5f, y, scale, value, color); + } + + void flush() { + if (vertices_.empty()) return; + shader_.use(); + shader_.setBool("uUseTexture", false); + shader_.setBool("uUseGradient", false); + shader_.setVec4("uColor", color_); + glBindTexture(GL_TEXTURE_2D, 0); + glBindVertexArray(vao_); + glBindBuffer(GL_ARRAY_BUFFER, vbo_); + glBufferSubData(GL_ARRAY_BUFFER, 0, vertices_.size() * sizeof(Vertex), vertices_.data()); + glDrawArrays(GL_TRIANGLES, 0, static_cast(vertices_.size())); + vertices_.clear(); + } + +private: + struct Glyph { + std::uint32_t page = 0; + std::uint8_t cell = 0; + }; + + static std::uint32_t readU32(const std::vector& bytes, std::size_t offset) { + if (offset + 4 > bytes.size()) return 0xffffffffu; + std::uint32_t value = 0; + std::memcpy(&value, bytes.data() + offset, sizeof(value)); + return value; + } + + static std::uint16_t readU16(const std::vector& bytes, std::size_t offset) { + if (offset + 2 > bytes.size()) return 0; + std::uint16_t value = 0; + std::memcpy(&value, bytes.data() + offset, sizeof(value)); + return value; + } + + static std::uint32_t nextUtf8(std::string_view text, std::size_t& offset) { + const auto first = static_cast(text[offset++]); + if ((first & 0x80u) == 0) return first; + int remaining = 0; + std::uint32_t value = 0; + if ((first & 0xe0u) == 0xc0u) { remaining = 1; value = first & 0x1fu; } + else if ((first & 0xf0u) == 0xe0u) { remaining = 2; value = first & 0x0fu; } + else if ((first & 0xf8u) == 0xf0u) { remaining = 3; value = first & 0x07u; } + else return 0xff1fu; + while (remaining-- > 0 && offset < text.size()) { + value = (value << 6) | (static_cast(text[offset++]) & 0x3fu); + } + return value; + } + + static float textWidth(std::string_view value) { + float width = 0.0f; + std::size_t offset = 0; + while (offset < value.size()) { + const std::uint32_t codepoint = nextUtf8(value, offset); + width += codepoint == '\t' ? 32.0f : (codepoint < 0x80 ? 8.0f : 16.0f); + } + return width; + } + + Glyph lookupGlyph(std::uint32_t codepoint) const { + if (fontMtf_.size() < 0x30 || std::memcmp(fontMtf_.data(), "MTF", 3) != 0) { + return {0, static_cast(codepoint >= 0x20 ? codepoint - 0x20 : 0)}; + } + const std::size_t header = readU32(fontMtf_, 8); + const std::size_t trie = header + readU16(fontMtf_, header); + const std::uint32_t fallback = readU16(fontMtf_, header + 2); + for (int attempt = 0; attempt < 2; ++attempt) { + std::uint32_t node = 0; + bool missing = false; + for (int shift = 28; shift >= 4; shift -= 4) { + const std::size_t slot = static_cast(node) * 16u + + ((codepoint >> shift) & 0x0fu); + node = readU32(fontMtf_, trie + slot * 4u); + if (node == 0xffffffffu) { + missing = true; + break; + } + } + if (!missing) { + return {node & 0x00ffffffu, + static_cast(((node >> 28) << 4) | (codepoint & 0x0f))}; + } + codepoint = fallback; + } + return {}; + } + + void findOriginalFont(const std::filesystem::path& contentPath) { + namespace fs = std::filesystem; + std::vector probes; + if (!contentPath.empty()) probes.push_back(fs::absolute(contentPath).parent_path()); + probes.push_back(fs::current_path()); + if (const char* base = SDL_GetBasePath()) probes.emplace_back(base); + for (fs::path probe : probes) { + for (int depth = 0; depth < 10 && !probe.empty(); ++depth) { + for (const fs::path& candidate : + {probe / "data" / "font", probe / "GC" / "data" / "font"}) { + if (fs::is_regular_file(candidate / "Font.mtf") && + fs::is_regular_file(candidate / "Font00000000.mfi")) { + fontDirectory_ = candidate; + std::ifstream stream(candidate / "Font.mtf", std::ios::binary); + fontMtf_.assign(std::istreambuf_iterator(stream), + std::istreambuf_iterator()); + std::cout << "Test mode font: " << candidate << '\n'; + return; + } + } + if (probe == probe.parent_path()) break; + probe = probe.parent_path(); + } + } + std::cerr << "Test mode: original data/font/Font.mtf was not found\n"; + } + + unsigned int loadFontPage(std::uint32_t page) { + if (const auto found = fontPages_.find(page); found != fontPages_.end()) { + return found->second; + } + if (fontDirectory_.empty()) return 0; + char name[32]{}; + std::snprintf(name, sizeof(name), "Font%08u.mfi", page); + std::ifstream stream(fontDirectory_ / name, std::ios::binary); + std::vector bytes(std::istreambuf_iterator(stream), {}); + if (bytes.size() < 32u || std::memcmp(bytes.data(), "MFI", 3) != 0) { + fontPages_[page] = 0; + return 0; + } + + // FUN_00485f90 creates image format 3. GWPCImage2D maps that format + // through DAT_006b6ea0[3] to D3DFMT_DXT5 and uploads the MFI payload + // verbatim. Decode its alpha block here; the font renderer uses the + // texture as a coverage mask and supplies the glyph colour separately. + const std::size_t header = readU32(bytes, 8); + const std::uint32_t width = readU16(bytes, header); + const std::uint32_t height = readU16(bytes, header + 2); + const std::size_t payload = header + readU32(bytes, header + 4); + const std::size_t blockWidth = (width + 3u) / 4u; + const std::size_t blockHeight = (height + 3u) / 4u; + const std::size_t compressedSize = blockWidth * blockHeight * 16u; + if (width == 0 || height == 0 || payload > bytes.size() || + compressedSize > bytes.size() - payload) { + fontPages_[page] = 0; + return 0; + } + + std::vector rgba( + static_cast(width) * height * 4u, 255u); + for (std::size_t blockY = 0; blockY < blockHeight; ++blockY) { + for (std::size_t blockX = 0; blockX < blockWidth; ++blockX) { + const std::uint8_t* block = + bytes.data() + payload + (blockY * blockWidth + blockX) * 16u; + std::array alpha{}; + alpha[0] = block[0]; + alpha[1] = block[1]; + if (alpha[0] > alpha[1]) { + for (int i = 1; i <= 6; ++i) { + alpha[i + 1] = static_cast( + ((7 - i) * alpha[0] + i * alpha[1]) / 7); + } + } else { + for (int i = 1; i <= 4; ++i) { + alpha[i + 1] = static_cast( + ((5 - i) * alpha[0] + i * alpha[1]) / 5); + } + alpha[6] = 0; + alpha[7] = 255; + } + + std::uint64_t indices = 0; + for (int i = 0; i < 6; ++i) { + indices |= static_cast(block[2 + i]) << (i * 8); + } + for (std::size_t py = 0; py < 4; ++py) { + for (std::size_t px = 0; px < 4; ++px) { + const std::size_t x = blockX * 4u + px; + const std::size_t y = blockY * 4u + py; + if (x >= width || y >= height) continue; + const std::size_t pixel = py * 4u + px; + rgba[(y * width + x) * 4u + 3u] = + alpha[(indices >> (pixel * 3u)) & 7u]; + } + } + } + } + + unsigned int texture = 0; + glGenTextures(1, &texture); + glBindTexture(GL_TEXTURE_2D, texture); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, static_cast(width), + static_cast(height), 0, GL_RGBA, + GL_UNSIGNED_BYTE, rgba.data()); + fontPages_[page] = texture; + return texture; + } + + static float ndcX(float x) { return x / (kWidth * 0.5f) - 1.0f; } + static float ndcY(float y) { return 1.0f - y / (kHeight * 0.5f); } + + void appendQuad(float x, float y, float width, float height) { + const float left = ndcX(x), right = ndcX(x + width); + const float top = ndcY(y), bottom = ndcY(y + height); + vertices_.insert(vertices_.end(), { + {left, top, 0, 0}, {right, top, 1, 0}, {left, bottom, 0, 1}, + {left, bottom, 0, 1}, {right, top, 1, 0}, {right, bottom, 1, 1}, + }); + } + + static void appendQuad(std::vector& vertices, float x, float y, + float width, float height, float u0, float v0, float u1, float v1) { + const float left = ndcX(x), right = ndcX(x + width); + const float top = ndcY(y), bottom = ndcY(y + height); + vertices.insert(vertices.end(), { + {left, top, u0, v0}, {right, top, u1, v0}, {left, bottom, u0, v1}, + {left, bottom, u0, v1}, {right, top, u1, v0}, {right, bottom, u1, v1}, + }); + } + + Shader shader_; + unsigned int vao_ = 0; + unsigned int vbo_ = 0; + std::vector vertices_; + glm::vec4 color_{1.0f}; + std::filesystem::path fontDirectory_; + std::vector fontMtf_; + std::unordered_map fontPages_; +}; + +enum class Page { + Main, + Monitor, + Io, + Led, + Card, + Audio, + Game, + Network, + Bookkeeping, + System, + Factory, +}; + +constexpr std::array kMainItems{ + "Monitor Test", + "Input/Output Test", + "LED Test", + "Card Test", + "Audio Settings", + "Game Settings ", + "Network Info", + "Bookkeeping", + "System Info", + "Restore factory settings", + "Exit Test Mode", +}; + +constexpr std::array kMainHelp{ + "Check that the monitor is functioning correctly.", + "Check that the game's switches are functioning correctly.", + "Check that the LEDs are functioning correctly.", + "Check that the card reader is functioning correctly.", + "Adjust volume and check that game audio is functioning correctly.", + "Set operating format, number of coins, etc.", + "Check or change NESYS network settings.", + "Check records of gameplay results.", + "Check system configuration.", + "Revert all settings to factory defaults.", + "Exit test mode.", +}; + +constexpr std::array kLedItems{ + "All Lights On", + "Selected Lights (Title)", + "Selected Lights (Left Side)", + "Selected Lights (Right Side)", + "Selected Lights (Left Booster)", + "Selected Lights (Right Booster)", + "Back", +}; + +constexpr std::array kAudioItems{ + "Test BGM", + "Master Volume", + "Headphone Volume", + "Demo Volume", + "Speaker", + "Groove Stage", + " Default vibration intensity", + " Vibration intensity during demo plays", + "Back", +}; + +struct OperatorSettings { + int masterVolume = 100; + int headphoneVolume = 100; + int demoVolume = 100; + int price = 0; + int country = 0; + bool scoreAttack = false; +}; + +std::string_view trimConfigValue(std::string_view value) { + const std::size_t first = value.find_first_not_of(" \t\r\n"); + if (first == std::string_view::npos) return {}; + const std::size_t last = value.find_last_not_of(" \t\r\n"); + return value.substr(first, last - first + 1); +} + +int loadCountryConfig() { + namespace fs = std::filesystem; + fs::path configPath = "openroller.cfg"; + if (const char* base = SDL_GetBasePath()) configPath = fs::path(base) / configPath; + + std::ifstream input(configPath); + if (!input) { + std::cerr << "OpenRoller config not found: " << configPath + << " (Country=0)\n"; + return 0; + } + + int country = 0; + std::string line; + while (std::getline(input, line)) { + const std::size_t comment = line.find_first_of("#;"); + if (comment != std::string::npos) line.resize(comment); + const std::size_t separator = line.find('='); + if (separator == std::string::npos) continue; + const std::string_view key = trimConfigValue( + std::string_view(line).substr(0, separator)); + if (key != "Country" && key != "country") continue; + const std::string_view value = trimConfigValue( + std::string_view(line).substr(separator + 1)); + try { + std::size_t consumed = 0; + const int parsed = std::stoi(std::string(value), &consumed, 0); + if (consumed == value.size()) country = parsed; + } catch (const std::exception&) { + std::cerr << "Invalid Country value in " << configPath << ": " + << value << '\n'; + } + } + std::cout << "OpenRoller config: " << configPath + << " Country=" << country << '\n'; + return country; +} + +int coinPresetCount(int country) { + return country == 0 ? 8 : 39; +} + +std::string coinPresetName(int country, int index) { + static constexpr std::array domestic{ + "1 coin, 2 songs", + "1 coin, 3 songs", + "2 coins, 2 songs", + "2 coins, 3 songs", + "Free play, 1 song", + "Free play, 2 songs", + "Free play, 3 songs", + "1 coin, 1 song", + }; + if (country == 0) return std::string(domestic[std::clamp(index, 0, 7)]); + + index = std::clamp(index, 0, 38); + if (index >= 36) { + const int songs = index - 35; + return "Free play, " + std::to_string(songs) + + (songs == 1 ? " song" : " songs"); + } + const int coins = index / 3 + 1; + const int songs = index % 3 + 1; + return std::to_string(coins) + (coins == 1 ? " coin, " : " coins, ") + + std::to_string(songs) + (songs == 1 ? " song" : " songs"); +} + +void header(TestUi& ui, std::string_view title) { + // CTestMode puts the three common title windows at normalized y + // -0.95/-0.925/-0.9, centered, with the global 16 px font height. + ui.centered(32.0f, 1.0f, "Test Mode", kWhite); + ui.centered(48.0f, 1.0f, title, kWhite); +} + +void footer(TestUi& ui, std::string_view help, bool main) { + (void)main; + // GWTestModeExplainForm uses anchor y=0.2 (768 px) and maps color 4 + // to the framework's red entry. The five-line information list is + // centered at y=0.4 (896 px). + ui.centered(768.0f, 1.0f, help, kRed); + ui.centered(896.0f, 1.0f, "OpenRoller", kWhite); + ui.centered(918.4f, 1.0f, __DATE__ " " __TIME__, kWhite); +} + +bool isUp(SDL_Keycode key) { + return key == SDLK_UP || key == SDLK_W || key == SDLK_Q; +} + +bool isDown(SDL_Keycode key) { + return key == SDLK_DOWN || key == SDLK_S || key == SDLK_A || key == SDLK_F3; +} + +bool isLeft(SDL_Keycode key) { + return key == SDLK_LEFT || key == SDLK_LCTRL; +} + +bool isRight(SDL_Keycode key) { + return key == SDLK_RIGHT || key == SDLK_D; +} + +bool isConfirm(SDL_Keycode key) { + return key == SDLK_RETURN || key == SDLK_SPACE || key == SDLK_RIGHTBRACKET || + key == SDLK_LALT || key == SDLK_RALT; +} + +bool isBack(SDL_Keycode key) { + return key == SDLK_ESCAPE || key == SDLK_CAPSLOCK; +} + +void moveCursor(int& cursor, int delta, int count) { + cursor = (cursor + delta) % count; + if (cursor < 0) cursor += count; +} + +CabinetRgb ledColor(int phase) { + switch (phase % 5) { + case 1: return {255, 255, 255}; + case 2: return {255, 0, 0}; + case 3: return {0, 255, 0}; + case 4: return {0, 0, 255}; + default: return {}; + } +} + +void updateLedTest(CabinetBackend& cabinet, int selection, int phase, Uint64 ticks) { + cabinet.clearLeds(); + if (selection == 6) { + cabinet.commitOutputs(); + return; + } + if (selection == 0) { + const CabinetRgb color = ledColor(phase); + for (std::size_t i = 0; i < cabinet.leds().size(); ++i) cabinet.setLed(i, color); + // game471 deliberately drives the two booster button channels as + // monochrome even while the surrounding LEDs use RGB. + const std::uint8_t mono = static_cast(color.r | color.g | color.b); + cabinet.setLed(0, {mono, mono, mono}); + cabinet.setLed(41, {mono, mono, mono}); + cabinet.commitOutputs(); + return; + } + + std::size_t first = 0; + std::size_t count = 0; + switch (selection) { + case 1: first = 82; count = 12; break; + case 2: first = 94; count = 12; break; + case 3: first = 106; count = 12; break; + case 4: first = 0; count = 41; break; + case 5: first = 41; count = 41; break; + default: break; + } + if (count != 0) { + // The original advances one address after a ten-update hold, fills + // the selected zone white, then clears it in the same order. + const std::size_t step = static_cast(ticks / 180) % (count * 2); + if (step < count) { + for (std::size_t i = 0; i <= step; ++i) cabinet.setLed(first + i, {255,255,255}); + } else { + const std::size_t cleared = step - count; + for (std::size_t i = cleared + 1; i < count; ++i) { + cabinet.setLed(first + i, {255,255,255}); + } + } + } + cabinet.commitOutputs(); +} + +void renderMain(TestUi& ui, int selection) { + header(ui, "Main Menu"); + float widest = 0.0f; + for (const std::string_view item : kMainItems) { + widest = std::max(widest, static_cast(item.size()) * 8.0f); + } + const float listX = (kWidth - widest) * 0.5f; + for (std::size_t i = 0; i < kMainItems.size(); ++i) { + // Main list anchor is normalized y=-0.725 => 176 px. WindowList + // advances by 16 px plus its 0.01 normalized row gap (6.4 px). + const float y = 176.0f + static_cast(i) * 22.4f; + if (static_cast(i) == selection) { + ui.text(listX - 20.0f, y, 1.0f, "→", kRed); + ui.text(listX, y, 1.0f, kMainItems[i], kRed); + } else { + ui.text(listX, y, 1.0f, kMainItems[i], kCyan); + } + } + footer(ui, kMainHelp[selection], true); +} + +void renderMonitor(TestUi& ui, int pattern) { + static constexpr std::array names{ + "Cross hatch", "Color Bar", "White ( 255,255,255 )", + "Red ( 255,0,0 )", "Green ( 0,255,0 )", "Blue ( 0,0,255 )", + }; + if (pattern == 0) { + ui.rect(0, 0, kWidth, kHeight, glm::vec4(0,0,0,1)); + for (int x = 0; x <= static_cast(kWidth); x += 64) { + ui.rect(static_cast(x), 0, 1, kHeight, kWhite); + } + for (int y = 32; y <= static_cast(kHeight); y += 64) { + ui.rect(0, static_cast(y), kWidth, 1, kWhite); + } + } else if (pattern == 1) { + static const std::array colors{ + kWhite, glm::vec4(1,1,0,1), glm::vec4(0,1,1,1), + glm::vec4(0,1,0,1), glm::vec4(1,0,1,1), + glm::vec4(1,0,0,1), glm::vec4(0,0,1,1), + }; + ui.rect(0, 0, kWidth, kHeight, glm::vec4(0,0,0,1)); + for (int column = 0; column < 16; ++column) { + const float factor = 1.0f - static_cast(column) / 15.0f; + for (int row = 0; row < 7; ++row) { + const float top = 128.0f + static_cast(row) * (640.0f / 6.0f); + ui.rect(static_cast(column) * 45.0f, top, 45.0f, + 640.0f / 6.0f, glm::vec4(glm::vec3(colors[row]) * factor, 1.0f)); + } + } + } else { + const std::array colors{kWhite, kRed, kGreen, glm::vec4(0,0,1,1)}; + ui.rect(0, 0, kWidth, kHeight, colors[pattern - 2]); + } + const std::array labelColors{ + glm::vec4(1,0,1,1), glm::vec4(1,0,1,1), + glm::vec4(0,0,0,1), glm::vec4(0,0,0,1), + glm::vec4(0,0,0,1), kWhite, + }; + ui.centered(640.0f, 1.0f, names[pattern], labelColors[pattern]); +} + +struct InputLabel { + std::string_view name; + CabinetInput input; +}; + +void renderIo(TestUi& ui, CabinetBackend& cabinet) { + static constexpr std::array labels{{ + {"Coin Switch", CabinetInput::Coin}, + {"Service Switch", CabinetInput::Service}, + {"Select Switch", CabinetInput::Select}, + {"Enter Switch", CabinetInput::Enter}, + {"Left Booster Up", CabinetInput::LeftUp}, + {"Left Booster Down", CabinetInput::LeftDown}, + {"Left Booster Left", CabinetInput::LeftLeft}, + {"Left Booster Right", CabinetInput::LeftRight}, + {"Left Booster Button", CabinetInput::LeftButton}, + {"Right Booster Up", CabinetInput::RightUp}, + {"Right Booster Down", CabinetInput::RightDown}, + {"Right Booster Left", CabinetInput::RightLeft}, + {"Right Booster Right", CabinetInput::RightRight}, + {"Right Booster Button", CabinetInput::RightButton}, + }}; + header(ui, "Input/Output Test"); + constexpr float kNameX = 220.0f; + constexpr float kValueX = 500.0f; + ui.text(kNameX, 176.0f, 1.0f, "Input Type", kWhite); + ui.text(kValueX, 176.0f, 1.0f, "ON/OFF", kWhite); + for (std::size_t i = 0; i < labels.size(); ++i) { + const float y = 198.4f + static_cast(i) * 22.4f; + const bool active = cabinet.input(labels[i].input); + ui.text(kNameX, y, 1.0f, labels[i].name, kWhite); + ui.text(kValueX, y, 1.0f, active ? "ON" : "OFF", kWhite); + } + float y = 198.4f + static_cast(labels.size()) * 22.4f; + ui.text(kNameX, y, 1.0f, "Headphone Volume", kWhite); + ui.text(kValueX, y, 1.0f, std::to_string(cabinet.headphoneVolume()), kWhite); + y += 22.4f; + ui.text(kNameX, y, 1.0f, "Headphone Jack", kWhite); + ui.text(kValueX, y, 1.0f, cabinet.headphoneConnected() ? "ON" : "OFF", kWhite); + y += 22.4f; + ui.text(kNameX, y, 1.0f, "Stage Connection", kWhite); + ui.text(kValueX, y, 1.0f, cabinet.grooveStageConnected() ? "ON" : "OFF", kWhite); + footer(ui, + "Pressing each switch will turn it on if it's functioning correctly. The booster", + false); + ui.centered(784.0f, 1.0f, + "buttons change the button lamps, and the coin switch changes lockout status.", kRed); +} + +void renderLed(TestUi& ui, CabinetBackend& cabinet, int selection, int phase) { + (void)cabinet; + (void)phase; + header(ui, "LED Test"); + float widest = 0.0f; + for (const std::string_view item : kLedItems) { + widest = std::max(widest, static_cast(item.size()) * 8.0f); + } + const float listX = (kWidth - widest) * 0.5f; + for (std::size_t i = 0; i < kLedItems.size(); ++i) { + const float y = 176.0f + static_cast(i) * 48.0f; + if (static_cast(i) == selection) { + ui.text(listX - 20.0f, y, 1.0f, "→", kRed); + } + ui.text(listX, y, 1.0f, kLedItems[i], + static_cast(i) == selection ? kRed : kCyan); + } + static constexpr std::array help{ + "Turn all LEDs on/off in the same color.", + "Turn the title panel on/off.", + "Turn the left side on/off.", + "Turn the right side on/off.", + "Turn the left booster on/off.", + "Turn the right booster on/off.", + "Return to main menu.", + }; + footer(ui, help[selection], false); +} + +std::string_view cardStatusName(CardReaderStatus status) { + switch (status) { + case CardReaderStatus::Ready: return "Terminated normally"; + case CardReaderStatus::Disconnected: return "No connection with the card reader"; + case CardReaderStatus::Unformatted: return "Unformatted card"; + case CardReaderStatus::ReadError: return "Couldn't read"; + case CardReaderStatus::Timeout: return "Time out"; + } + return "Unknown"; +} + +void renderCard(TestUi& ui, CabinetBackend& cabinet) { + const CardReaderState card = cabinet.cardReader(); + header(ui, "Card Test"); + ui.text(235.0f, 176.0f, 1.0f, "Status", kWhite); + ui.text(355.0f, 176.0f, 1.0f, cardStatusName(card.status), kWhite); + ui.text(235.0f, 198.4f, 1.0f, "Card ID", kWhite); + ui.text(355.0f, 198.4f, 1.0f, + card.cardId.empty() ? " " : card.cardId, kWhite); + ui.text(332.0f, 624.0f, 1.0f, "→", kRed); + ui.text(352.0f, 624.0f, 1.0f, "Back", kRed); + footer(ui, "Return to main menu.", false); +} + +void renderAudio(TestUi& ui, const OperatorSettings& settings, int selection) { + header(ui, "Audio Settings"); + const std::array values{ + "000", + std::to_string(settings.masterVolume) + "%", + std::to_string(settings.headphoneVolume) + "%", + std::to_string(settings.demoVolume) + "%", + "Left", + "", + "Medium", + "Medium", + "", + }; + float widestName = 0.0f; + float widestValue = 0.0f; + for (std::size_t i = 0; i < kAudioItems.size(); ++i) { + widestName = std::max(widestName, static_cast(kAudioItems[i].size()) * 8.0f); + widestValue = std::max(widestValue, static_cast(values[i].size()) * 8.0f); + } + const float nameX = (kWidth - widestName - 43.2f - widestValue) * 0.5f; + const float valueX = nameX + widestName + 43.2f; + for (std::size_t i = 0; i < kAudioItems.size(); ++i) { + const float y = 176.0f + static_cast(i) * 48.0f; + if (static_cast(i) == selection) { + ui.text(nameX - 20.0f, y, 1.0f, "→", kRed); + } + ui.text(nameX, y, 1.0f, kAudioItems[i], + static_cast(i) == selection ? kRed : kCyan); + ui.text(valueX, y, 1.0f, values[i], kWhite); + } + static constexpr std::array help{ + "Listen to background music.", + "Display master volume. Adjust volume via \"Volume\".", + "Display the headphone volume.", + "Set the demo volume.", + "Perform a speaker test.", + "Display the groove stage connection status.", + "Set the default vibration intensity of GROOVE STAGE during game plays.", + "Set the vibration intensity of GROOVE STAGE during demo plays.", + "Return to main menu.", + }; + footer(ui, help[selection], false); +} + +void renderGame(TestUi& ui, const OperatorSettings& settings, int selection) { + static constexpr std::array names{ + "Songs Played :", "Score attack mode :", "Back", + }; + header(ui, "Game Settings"); + for (std::size_t i = 0; i < names.size(); ++i) { + const float y = 190.0f + static_cast(i) * 100.0f; + ui.text(50, y, 3, static_cast(i) == selection ? ">" : " ", + static_cast(i) == selection ? kRed : kCyan); + ui.text(85, y, 3, names[i], static_cast(i) == selection ? kRed : kCyan); + } + ui.text(375, 190, 2, coinPresetName(settings.country, settings.price), kWhite); + ui.text(530, 290, 3, settings.scoreAttack ? "ON" : "OFF", kWhite); + footer(ui, selection == 0 + ? "Set the number of coins and songs that can be played." + : selection == 1 + ? "Set the score attack tournament mode." + : "Return to main menu.", + false); +} + +void renderInfo(TestUi& ui, Page page, Uint64 enteredAt, int selection) { + switch (page) { + case Page::Network: { + static constexpr std::array names{ + "Status", "Location Name", "Location Address", "Location IP Address", + "Machine IP Address", "Machine MAC Address", "NESYS Service Version", + "NESYS Library Version", "Relay Server", + }; + static constexpr std::array values{ + "Offline", "", "", "", "Not configured", "Not configured", "---", "---", "", + }; + header(ui, "Network Info"); + for (std::size_t i = 0; i < names.size(); ++i) { + const float y = 176.0f + static_cast(i) * 22.4f; + ui.text(180.0f, y, 1.0f, names[i], kWhite); + ui.text(420.0f, y, 1.0f, values[i], kWhite); + } + static constexpr std::array items{ + "Update Network Info", "Back", + }; + for (std::size_t i = 0; i < items.size(); ++i) { + const float y = 688.0f + static_cast(i) * 48.0f; + if (static_cast(i) == selection) ui.text(272.0f, y, 1.0f, "→", kRed); + ui.text(292.0f, y, 1.0f, items[i], + static_cast(i) == selection ? kRed : kCyan); + } + footer(ui, selection == 0 ? "Update network info." : "Return to main menu.", false); + break; + } + case Page::Bookkeeping: { + header(ui, "BOOKKEEPING"); + const Uint64 seconds = (SDL_GetTicks() - enteredAt) / 1000; + ui.text(48, 190, 2, "CURRENT TEST SESSION", kCyan); + ui.text(500, 190, 2, std::to_string(seconds) + " SEC", kGreen); + ui.text(48, 255, 2, "TOTAL PLAYS", kCyan); + ui.text(500, 255, 2, "0", kWhite); + ui.text(48, 320, 2, "SERVICE SWITCH COUNT", kCyan); + ui.text(500, 320, 2, "0", kWhite); + ui.text(48, 385, 2, "ERROR LOG (LAST 20)", kCyan); + ui.text(500, 385, 2, "EMPTY", kWhite); + footer(ui, "PERSISTENT OPERATOR LOG IS NOT ATTACHED YET.", false); + break; + } + case Page::System: { + static constexpr std::array names{ + "SYSTEM BIOS DATE", + "FAST IO HOST MINI R2 PCB", + "FAST IO UNIVERSAL PCB", + "6CH AMP4 PCB", + "FT232R", + "R5F21324C", + "IN_ADAU1701", + }; + header(ui, "System Info"); + for (std::size_t i = 0; i < names.size(); ++i) { + const float y = 176.0f + static_cast(i) * 22.4f; + ui.text(180.0f, y, 1.0f, names[i], kWhite); + ui.text(460.0f, y, 1.0f, i == 0 ? __DATE__ : "NOT CONNECTED", kWhite); + } + ui.text(332.0f, 624.0f, 1.0f, "→", kRed); + ui.text(352.0f, 624.0f, 1.0f, "Back", kRed); + footer(ui, "Return to main menu.", false); + break; + } + default: break; + } +} + +void renderFactory(TestUi& ui, int selection) { + header(ui, "Restore factory settings."); + ui.centered(384.0f, 1.0f, "Restore factory settings.", kWhite); + ui.centered(406.4f, 1.0f, "Are you sure?", kWhite); + constexpr std::array choices{"Yes", "No"}; + for (std::size_t i = 0; i < choices.size(); ++i) { + const float y = 640.0f + static_cast(i) * 22.4f; + if (static_cast(i) == selection) ui.text(328.0f, y, 1.0f, "→", kRed); + ui.text(348.0f, y, 1.0f, choices[i], + static_cast(i) == selection ? kRed : kCyan); + } + footer(ui, "Make a selection.", false); +} + +} // namespace + +void runServiceMenu(SDL_Window* window, CabinetBackend& cabinet, + const std::filesystem::path& contentPath) { + if (!window) return; + + SDL_SetWindowTitle(window, "OpenRoller - Test Mode"); + glDisable(GL_DEPTH_TEST); + glEnable(GL_BLEND); + glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); + TestUi ui(contentPath); + + Page page = Page::Main; + int mainSelection = 0; + int subSelection = 0; + int monitorPattern = 0; + int ledPhase = 0; + int ledActiveTest = -1; + Uint64 ledStartedAt = 0; + OperatorSettings settings; + settings.country = loadCountryConfig(); + bool running = true; + const Uint64 enteredAt = SDL_GetTicks(); + + while (running) { + cabinet.poll(); + SDL_Event event; + while (SDL_PollEvent(&event)) { + if (event.type == SDL_EVENT_QUIT) { + running = false; + continue; + } + if (event.type != SDL_EVENT_KEY_DOWN || event.key.repeat) continue; + const SDL_Keycode key = event.key.key; + + if (page == Page::Main) { + if (isBack(key)) { + running = false; + } else if (isUp(key)) { + moveCursor(mainSelection, -1, static_cast(kMainItems.size())); + } else if (isDown(key)) { + moveCursor(mainSelection, 1, static_cast(kMainItems.size())); + } else if (isConfirm(key)) { + subSelection = 0; + switch (mainSelection) { + case 0: page = Page::Monitor; break; + case 1: page = Page::Io; break; + case 2: + page = Page::Led; + ledActiveTest = -1; + ledPhase = 0; + break; + case 3: page = Page::Card; break; + case 4: page = Page::Audio; break; + case 5: page = Page::Game; break; + case 6: page = Page::Network; break; + case 7: page = Page::Bookkeeping; break; + case 8: page = Page::System; break; + case 9: page = Page::Factory; subSelection = 1; break; + case 10: running = false; break; + default: break; + } + } + continue; + } + + if (isBack(key)) { + cabinet.clearLeds(); + cabinet.commitOutputs(); + page = Page::Main; + continue; + } + + switch (page) { + case Page::Monitor: + if (isConfirm(key) || isRight(key) || isDown(key)) { + moveCursor(monitorPattern, 1, 6); + } else if (isLeft(key) || isUp(key)) { + moveCursor(monitorPattern, -1, 6); + } + break; + case Page::Led: + if (isUp(key)) moveCursor(subSelection, -1, static_cast(kLedItems.size())); + if (isDown(key)) moveCursor(subSelection, 1, static_cast(kLedItems.size())); + if (isConfirm(key) && subSelection == 0) { + ledActiveTest = 0; + moveCursor(ledPhase, 1, 5); + ledStartedAt = SDL_GetTicks(); + } else if (isConfirm(key) && subSelection >= 1 && subSelection <= 5) { + ledActiveTest = subSelection; + ledStartedAt = SDL_GetTicks(); + } + if (isConfirm(key) && subSelection == 6) { + cabinet.clearLeds(); + cabinet.commitOutputs(); + page = Page::Main; + } + break; + case Page::Audio: + if (isUp(key)) moveCursor(subSelection, -1, static_cast(kAudioItems.size())); + if (isDown(key)) moveCursor(subSelection, 1, static_cast(kAudioItems.size())); + if (subSelection >= 1 && subSelection <= 3 && (isLeft(key) || isRight(key))) { + int* value = subSelection == 1 ? &settings.masterVolume : + subSelection == 2 ? &settings.headphoneVolume : + &settings.demoVolume; + *value = std::clamp(*value + (isRight(key) ? 5 : -5), 0, 100); + } + if (subSelection == 8 && isConfirm(key)) page = Page::Main; + break; + case Page::Game: + if (isUp(key)) moveCursor(subSelection, -1, 3); + if (isDown(key)) moveCursor(subSelection, 1, 3); + if (subSelection == 0 && (isLeft(key) || isRight(key))) { + moveCursor(settings.price, isRight(key) ? 1 : -1, + coinPresetCount(settings.country)); + } + if (subSelection == 1 && (isLeft(key) || isRight(key) || isConfirm(key))) { + settings.scoreAttack = !settings.scoreAttack; + } + if (subSelection == 2 && isConfirm(key)) page = Page::Main; + break; + case Page::Factory: + if (isUp(key) || isDown(key) || isLeft(key) || isRight(key)) { + subSelection = 1 - subSelection; + } + if (isConfirm(key)) { + if (subSelection == 0) settings = {}; + page = Page::Main; + } + break; + case Page::Network: + if (isUp(key) || isDown(key)) subSelection = 1 - subSelection; + if (isConfirm(key) && subSelection == 1) page = Page::Main; + break; + default: + if (isConfirm(key)) page = Page::Main; + break; + } + } + + cabinet.poll(); + if (page == Page::Io) { + const bool left = cabinet.input(CabinetInput::LeftButton); + const bool right = cabinet.input(CabinetInput::RightButton); + cabinet.setLed(0, left ? CabinetRgb{255,255,255} : CabinetRgb{}); + cabinet.setLed(41, right ? CabinetRgb{255,255,255} : CabinetRgb{}); + cabinet.commitOutputs(); + } else if (page == Page::Led) { + updateLedTest(cabinet, ledActiveTest, ledPhase, SDL_GetTicks() - ledStartedAt); + } + + int pixelWidth = 0, pixelHeight = 0; + SDL_GetWindowSizeInPixels(window, &pixelWidth, &pixelHeight); + glViewport(0, 0, pixelWidth, pixelHeight); + glClearColor(0, 0, 0, 1); + glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); + + switch (page) { + case Page::Main: renderMain(ui, mainSelection); break; + case Page::Monitor: renderMonitor(ui, monitorPattern); break; + case Page::Io: renderIo(ui, cabinet); break; + case Page::Led: renderLed(ui, cabinet, subSelection, ledPhase); break; + case Page::Card: renderCard(ui, cabinet); break; + case Page::Audio: renderAudio(ui, settings, subSelection); break; + case Page::Game: renderGame(ui, settings, subSelection); break; + case Page::Network: + case Page::Bookkeeping: + case Page::System: renderInfo(ui, page, enteredAt, subSelection); break; + case Page::Factory: renderFactory(ui, subSelection); break; + } + ui.flush(); + SDL_GL_SwapWindow(window); + SDL_Delay(8); + } + + cabinet.clearLeds(); + cabinet.commitOutputs(); + SDL_SetWindowTitle(window, "OpenRoller"); + glEnable(GL_DEPTH_TEST); +} diff --git a/apps/desktop/src/SongSelect.cpp b/apps/desktop/src/SongSelect.cpp new file mode 100644 index 0000000..00d5b33 --- /dev/null +++ b/apps/desktop/src/SongSelect.cpp @@ -0,0 +1,1207 @@ +#include "openroller/desktop/SongSelect.hpp" + +#include "vectorail/core/DdsTexture.hpp" +#include "vectorail/core/Shader.hpp" +#include "openroller/desktop/ServiceMenu.hpp" +#include "openroller/desktop/CabinetBackend.hpp" +#include "gc/StageCatalog.hpp" +#include "gc/MtxArchive.hpp" +#include "gc/RvbLayout.hpp" +#include "gc/RvbScene.hpp" +#include "vectorail/core/gl_loader.hpp" +#include "gc/TumoModel.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; + +namespace { + +std::vector toGlmVertices(const std::vector& source) { + std::vector result; + result.reserve(source.size()); + for (const gc::TumoVertex& vertex : source) { + result.emplace_back(vertex.x, vertex.y, vertex.z); + } + return result; +} + +constexpr int kUiWidth = 720; +constexpr int kUiHeight = 1280; + +enum class SelectTask { + Music, + Difficulty, +}; + +struct Song { + gc::StageCatalogEntry catalog; + fs::path menuTexture; + std::array stages{}; +}; + +struct CarouselEntry { + bool category = false; + size_t songIndex = 0; + int genre = 0; +}; + +struct UiVertex { + float x; + float y; + float u; + float v; +}; + +class UiRenderer { +public: + UiRenderer() : shader_("shaders/ui.vert", "shaders/ui.frag") { + glGenVertexArrays(1, &vao_); + glGenBuffers(1, &vbo_); + glBindVertexArray(vao_); + glBindBuffer(GL_ARRAY_BUFFER, vbo_); + glBufferData(GL_ARRAY_BUFFER, sizeof(UiVertex) * 6 * 8192, nullptr, GL_DYNAMIC_DRAW); + glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, sizeof(UiVertex), nullptr); + glEnableVertexAttribArray(0); + glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, sizeof(UiVertex), + reinterpret_cast(sizeof(float) * 2)); + glEnableVertexAttribArray(1); + shader_.use(); + shader_.setBool("uTexture", false); + shader_.setBool("uUseGradient", false); + } + + void rect(float x, float y, float width, float height, const glm::vec4& color) { + if (solidColor_ != color && !solid_.empty()) flush(); + solidColor_ = color; + appendQuad(solid_, x, y, width, height, 0.0f, 0.0f, 1.0f, 1.0f); + } + + void outline(float x, float y, float width, float height, float thickness, + const glm::vec4& color) { + rect(x, y, width, thickness, color); + rect(x, y + height - thickness, width, thickness, color); + rect(x, y, thickness, height, color); + rect(x + width - thickness, y, thickness, height, color); + } + + void verticalGradient(float x, float y, float width, float height, + const glm::vec4& top, const glm::vec4& bottom) { + flush(); + std::vector vertices; + vertices.reserve(6); + appendQuad(vertices, x, y, width, height, 0.0f, 0.0f, 1.0f, 1.0f); + shader_.use(); + shader_.setBool("uUseTexture", false); + shader_.setBool("uUseGradient", true); + shader_.setVec4("uGradientTop", top); + shader_.setVec4("uGradientBottom", bottom); + glBindTexture(GL_TEXTURE_2D, 0); + uploadAndDraw(vertices); + shader_.setBool("uUseGradient", false); + } + + void texture(const DdsTexture& texture, float x, float y, float width, float height, + float sourceX = 0.0f, float sourceY = 0.0f, + float sourceWidth = -1.0f, float sourceHeight = -1.0f, + const glm::vec4& tint = glm::vec4(1.0f)) { + if (texture.id == 0 || texture.width <= 0 || texture.height <= 0) return; + flush(); + if (sourceWidth < 0.0f) sourceWidth = static_cast(texture.width); + if (sourceHeight < 0.0f) sourceHeight = static_cast(texture.height); + std::vector vertices; + vertices.reserve(6); + appendQuad(vertices, x, y, width, height, + sourceX / texture.width, sourceY / texture.height, + (sourceX + sourceWidth) / texture.width, + (sourceY + sourceHeight) / texture.height); + shader_.use(); + shader_.setBool("uUseTexture", true); + shader_.setBool("uUseGradient", false); + shader_.setVec4("uColor", tint); + glBindTexture(GL_TEXTURE_2D, texture.id); + uploadAndDraw(vertices); + glBindTexture(GL_TEXTURE_2D, 0); + } + + void textureQuad(const DdsTexture& texture, + const std::array, 4>& corners, + const glm::vec4& tint = glm::vec4(1.0f)) { + if (texture.id == 0) return; + flush(); + const float leftU = 0.0f, rightU = 1.0f, topV = 0.0f, bottomV = 1.0f; + const auto vertex = [&](size_t index, float u, float v) { + return UiVertex{ndcX(corners[index][0]), ndcY(corners[index][1]), u, v}; + }; + const std::vector vertices{ + vertex(0, leftU, topV), vertex(1, rightU, topV), vertex(2, leftU, bottomV), + vertex(2, leftU, bottomV), vertex(1, rightU, topV), vertex(3, rightU, bottomV) + }; + shader_.use(); + shader_.setBool("uUseTexture", true); + shader_.setBool("uUseGradient", false); + shader_.setVec4("uColor", tint); + glBindTexture(GL_TEXTURE_2D, texture.id); + uploadAndDraw(vertices); + glBindTexture(GL_TEXTURE_2D, 0); + } + + void text(float x, float y, float scale, const std::string& value, + const glm::vec4& color) { + const float advance = scale * 6.0f; + for (unsigned char raw : value) { + const char c = static_cast(std::toupper(raw)); + const std::array rows = glyph(c); + for (int row = 0; row < 7; ++row) { + for (int col = 0; col < 5; ++col) { + if ((rows[row] & (1u << (4 - col))) != 0) { + rect(x + col * scale, y + row * scale, scale, scale, color); + } + } + } + x += advance; + } + } + + void flush() { + if (solid_.empty()) return; + shader_.use(); + shader_.setBool("uUseTexture", false); + shader_.setBool("uUseGradient", false); + shader_.setVec4("uColor", solidColor_); + glBindTexture(GL_TEXTURE_2D, 0); + uploadAndDraw(solid_); + solid_.clear(); + } + +private: + static std::array glyph(char c) { + switch (c) { + case 'A': return {14,17,17,31,17,17,17}; case 'B': return {30,17,17,30,17,17,30}; + case 'C': return {14,17,16,16,16,17,14}; case 'D': return {30,17,17,17,17,17,30}; + case 'E': return {31,16,16,30,16,16,31}; case 'F': return {31,16,16,30,16,16,16}; + case 'G': return {14,17,16,23,17,17,15}; case 'H': return {17,17,17,31,17,17,17}; + case 'I': return {31,4,4,4,4,4,31}; case 'J': return {7,2,2,2,18,18,12}; + case 'K': return {17,18,20,24,20,18,17}; case 'L': return {16,16,16,16,16,16,31}; + case 'M': return {17,27,21,21,17,17,17}; case 'N': return {17,25,21,19,17,17,17}; + case 'O': return {14,17,17,17,17,17,14}; case 'P': return {30,17,17,30,16,16,16}; + case 'Q': return {14,17,17,17,21,18,13}; case 'R': return {30,17,17,30,20,18,17}; + case 'S': return {15,16,16,14,1,1,30}; case 'T': return {31,4,4,4,4,4,4}; + case 'U': return {17,17,17,17,17,17,14}; case 'V': return {17,17,17,17,17,10,4}; + case 'W': return {17,17,17,21,21,21,10}; case 'X': return {17,17,10,4,10,17,17}; + case 'Y': return {17,17,10,4,4,4,4}; case 'Z': return {31,1,2,4,8,16,31}; + case '0': return {14,17,19,21,25,17,14}; case '1': return {4,12,4,4,4,4,14}; + case '2': return {14,17,1,2,4,8,31}; case '3': return {30,1,1,14,1,1,30}; + case '4': return {2,6,10,18,31,2,2}; case '5': return {31,16,16,30,1,1,30}; + case '6': return {14,16,16,30,17,17,14}; case '7': return {31,1,2,4,8,8,8}; + case '8': return {14,17,17,14,17,17,14}; case '9': return {14,17,17,15,1,1,14}; + case '-': return {0,0,0,31,0,0,0}; case ':': return {0,4,4,0,4,4,0}; + case '/': return {1,2,2,4,8,8,16}; case '<': return {2,4,8,16,8,4,2}; + case '>': return {8,4,2,1,2,4,8}; case '.': return {0,0,0,0,0,12,12}; + case '[': return {14,8,8,8,8,8,14}; case ']': return {14,2,2,2,2,2,14}; + default: return {0,0,0,0,0,0,0}; + } + } + + static float ndcX(float x) { return x / (kUiWidth * 0.5f) - 1.0f; } + static float ndcY(float y) { return 1.0f - y / (kUiHeight * 0.5f); } + + static void appendQuad(std::vector& out, + float x, float y, float width, float height, + float u0, float v0, float u1, float v1) { + const float left = ndcX(x), right = ndcX(x + width); + const float top = ndcY(y), bottom = ndcY(y + height); + out.insert(out.end(), { + {left, top, u0, v0}, {right, top, u1, v0}, {left, bottom, u0, v1}, + {left, bottom, u0, v1}, {right, top, u1, v0}, {right, bottom, u1, v1} + }); + } + + void uploadAndDraw(const std::vector& vertices) { + glBindVertexArray(vao_); + glBindBuffer(GL_ARRAY_BUFFER, vbo_); + glBufferSubData(GL_ARRAY_BUFFER, 0, vertices.size() * sizeof(UiVertex), vertices.data()); + glDrawArrays(GL_TRIANGLES, 0, static_cast(vertices.size())); + } + + Shader shader_; + unsigned int vao_ = 0; + unsigned int vbo_ = 0; + std::vector solid_; + glm::vec4 solidColor_{1.0f}; +}; + +struct MenuGpuModel { + unsigned int vao = 0; + unsigned int vbo = 0; + GLsizei triangleCount = 0; + GLint lineFirst = 0; + GLsizei lineCount = 0; +}; + +class OriginalMenuModels { +public: + OriginalMenuModels() : shader_("shaders/model.vert", "shaders/model.frag") {} + + bool load(const fs::path& modelDir, std::string* error) { + return loadModel(modelDir / "menu_obj_05.tumo", decoration_, error) && + loadModel(modelDir / "obj_sphere06.tumo", sphere_, error); + } + + void render(float elapsedSeconds) const { + if (!ready()) return; + // CCommon3DCamera reset by FUN_0063de50: eye=(0,0,~2.25), + // target=(0,0,eye.z+1), up=(0,1,0), FOV=pi/3. The executable uses + // D3D's LH convention; GLM's LH/NO projection preserves that view + // while producing OpenGL clip depth. + const glm::mat4 projection = glm::perspectiveLH_NO( + glm::radians(60.0f), static_cast(kUiWidth) / kUiHeight, 0.1f, 1000.0f); + const glm::mat4 view = glm::lookAtLH(glm::vec3(0, 0, 2.25f), + glm::vec3(0, 0, 3.25f), + glm::vec3(0, 1, 0)); + shader_.use(); + shader_.setMat4("uProjection", projection); + shader_.setMat4("uView", view); + glEnable(GL_DEPTH_TEST); + glDepthMask(GL_FALSE); + + // FUN_00577fb0 draws menu_obj_05 twice at y=+/-13.75. The two + // material colours are read literally from 006fcdc4..006fcdc8 and + // 006fcdb8..006fcdc0. + shader_.setVec4("uColor", glm::vec4(0.3216f, 0.0f, 0.7020f, 0.18f)); + glBindVertexArray(decoration_.vao); + shader_.setMat4("uModel", glm::translate(glm::mat4(1.0f), glm::vec3(0, 13.75f, 0))); + glDrawArrays(GL_TRIANGLES, 0, decoration_.triangleCount); + shader_.setVec4("uColor", glm::vec4(0.8863f, 0.2118f, 0.5490f, 0.15f)); + shader_.setMat4("uModel", glm::translate(glm::mat4(1.0f), glm::vec3(0, -13.75f, 0))); + glDrawArrays(GL_TRIANGLES, 0, decoration_.triangleCount); + + // obj_sphere06: translation Z=100, scale=5, equal XYZ rotation at + // time*0.125. The small two-frequency Y drift is also present in + // FUN_00577fb0 (constants 0.221, 0.433, 2.5, 0.75). + const float y = (std::sin(elapsedSeconds * 0.221f) + + std::sin(elapsedSeconds * 0.433f) * 2.5f) * 0.75f; + glm::mat4 sphereModel = glm::translate(glm::mat4(1.0f), glm::vec3(0, y, 100)); + const float angle = elapsedSeconds * 0.125f; + sphereModel = glm::rotate(sphereModel, angle, glm::vec3(1, 0, 0)); + sphereModel = glm::rotate(sphereModel, angle, glm::vec3(0, 1, 0)); + sphereModel = glm::rotate(sphereModel, angle, glm::vec3(0, 0, 1)); + sphereModel = glm::scale(sphereModel, glm::vec3(5.0f)); + shader_.setMat4("uModel", sphereModel); + shader_.setVec4("uColor", glm::vec4(0.8157f, 0.7216f, 0.8235f, 0.48f)); + glBindVertexArray(sphere_.vao); + glLineWidth(1.0f); + glDrawArrays(GL_LINES, sphere_.lineFirst, sphere_.lineCount); + + glBindVertexArray(0); + glDepthMask(GL_TRUE); + glDisable(GL_DEPTH_TEST); + } + + bool ready() const { return decoration_.vao != 0 && sphere_.vao != 0; } + + void clear() { + clearModel(decoration_); + clearModel(sphere_); + } + +private: + static bool loadModel(const fs::path& path, MenuGpuModel& gpu, std::string* error) { + gc::TumoGeometry geometry; + if (!gc::LoadTumoGeometry(path.string(), &geometry, error)) return false; + std::vector vertices = toGlmVertices(geometry.triangles); + gpu.triangleCount = static_cast(vertices.size()); + gpu.lineFirst = static_cast(vertices.size()); + const std::vector solidLines = toGlmVertices(geometry.solidLines); + vertices.insert(vertices.end(), solidLines.begin(), solidLines.end()); + gpu.lineCount = static_cast(geometry.solidLines.size()); + glGenVertexArrays(1, &gpu.vao); + glGenBuffers(1, &gpu.vbo); + glBindVertexArray(gpu.vao); + glBindBuffer(GL_ARRAY_BUFFER, gpu.vbo); + glBufferData(GL_ARRAY_BUFFER, vertices.size() * sizeof(glm::vec3), + vertices.data(), GL_STATIC_DRAW); + glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(glm::vec3), nullptr); + glEnableVertexAttribArray(0); + return true; + } + + static void clearModel(MenuGpuModel& gpu) { + if (gpu.vbo != 0) glDeleteBuffers(1, &gpu.vbo); + if (gpu.vao != 0) glDeleteVertexArrays(1, &gpu.vao); + gpu = {}; + } + + Shader shader_; + MenuGpuModel decoration_; + MenuGpuModel sphere_; +}; + +bool readFile(const fs::path& path, std::vector* bytes) { + if (!bytes) return false; + std::ifstream file(path, std::ios::binary); + if (!file) return false; + file.seekg(0, std::ios::end); + const std::streamoff size = file.tellg(); + file.seekg(0, std::ios::beg); + if (size < 0) return false; + bytes->resize(static_cast(size)); + if (size > 0) file.read(reinterpret_cast(bytes->data()), size); + return file.good() || file.eof(); +} + +size_t imageTextureIndex(const std::string& symbol) { + if (symbol.rfind("Image", 0) != 0 || symbol.size() <= 5) return SIZE_MAX; + size_t number = 0; + for (size_t i = 5; i < symbol.size(); ++i) { + if (!std::isdigit(static_cast(symbol[i]))) return SIZE_MAX; + number = number * 10 + static_cast(symbol[i] - '0'); + } + return number == 0 ? SIZE_MAX : number - 1; +} + +class OriginalMenuLayer { +public: + bool load(const fs::path& rvbPath, const fs::path& mtxPath, + const gc::RvbSnapshotState& state, std::string* error) { + if (!readFile(rvbPath, &rvbBytes_) || !readFile(mtxPath, &mtxBytes_)) { + if (error) *error = "could not read original RVB/MTX scene"; + return false; + } + if (!gc::ParseRvbScene(rvbBytes_, &scene_, error) || + !gc::ParseMtxArchive(mtxBytes_, &archive_, error)) { + return false; + } + return setState(state, error); + } + + bool loadSymbol(const fs::path& rvbPath, const fs::path& mtxPath, + const std::string& symbol, const gc::RvbSnapshotState& state, + std::string* error) { + if (!readFile(rvbPath, &rvbBytes_) || !readFile(mtxPath, &mtxBytes_)) { + if (error) *error = "could not read original RVB/MTX scene"; + return false; + } + if (!gc::ParseRvbScene(rvbBytes_, &scene_, error) || + !gc::ParseMtxArchive(mtxBytes_, &archive_, error)) { + return false; + } + return setSymbolState(symbol, state, error); + } + + bool setState(const gc::RvbSnapshotState& state, std::string* error = nullptr) { + std::vector nextDraws; + if (!gc::BuildRvbSnapshot(rvbBytes_, scene_, state, &nextDraws, error)) return false; + return acceptDraws(std::move(nextDraws), error); + } + + bool setSymbolState(const std::string& symbol, const gc::RvbSnapshotState& state, + std::string* error = nullptr) { + std::vector nextDraws; + if (!gc::BuildRvbSymbolSnapshot(rvbBytes_, scene_, symbol, state, &nextDraws, error)) { + return false; + } + return acceptDraws(std::move(nextDraws), error); + } + + void render(UiRenderer& ui, float dx = 0.0f, float dy = 0.0f, + float alpha = 1.0f) const { + for (const gc::RvbImageDraw& draw : draws_) { + const auto found = textures_.find(draw.imageSymbol); + if (found == textures_.end()) continue; + auto corners = draw.corners; + for (auto& corner : corners) { + corner[0] += dx; + corner[1] += dy; + } + ui.textureQuad(found->second, corners, + glm::vec4(draw.color[0], draw.color[1], draw.color[2], + draw.alpha * alpha)); + } + } + + bool ready() const { return !textures_.empty(); } + + void translateSymbol(const std::string& symbol, float dx, float dy) { + for (gc::RvbImageDraw& draw : draws_) { + if (draw.imageSymbol != symbol) continue; + for (auto& corner : draw.corners) { + corner[0] += dx; + corner[1] += dy; + } + } + } + + void clear() { + for (auto& [_, texture] : textures_) { + if (texture.id != 0) glDeleteTextures(1, &texture.id); + } + textures_.clear(); + draws_.clear(); + rvbBytes_.clear(); + mtxBytes_.clear(); + scene_ = {}; + archive_ = {}; + } + +private: + bool acceptDraws(std::vector nextDraws, std::string* error) { + std::unordered_set required; + for (const gc::RvbImageDraw& draw : nextDraws) required.insert(draw.imageSymbol); + for (const std::string& symbol : required) { + if (textures_.contains(symbol)) continue; + const size_t index = imageTextureIndex(symbol); + if (index >= archive_.textures.size()) continue; + std::vector dds; + DdsTexture texture; + if (!gc::ExtractMtxTextureDds(mtxBytes_, archive_.textures[index], &dds, error) || + !loadDdsTextureBytes(dds, texture, error)) { + return false; + } + textures_.emplace(symbol, texture); + } + draws_ = std::move(nextDraws); + return !draws_.empty() && !textures_.empty(); + } + std::vector draws_; + std::unordered_map textures_; + std::vector rvbBytes_; + std::vector mtxBytes_; + gc::RvbScene scene_; + gc::MtxArchive archive_; +}; + +gc::RvbSnapshotState selectMusicRowState() { + gc::RvbSnapshotState state; + auto& frame = state.frameByPath; + frame["/"] = "jf_music_exoff"; + frame["/imc_tag_first_s/imc_music_ex"] = "jf_tag_ex_off"; + frame["/imc_tag_first_s/imc_music_new"] = "jf_tag_new_off"; + frame["/imc_tag_first_s/imc_music_no"] = "jf_tag_noxx"; + frame["/imc_tag_first_s"] = "jf_tag_first_off"; + frame["/imc_unlock_key_small"] = "jf_keysmall_off"; + frame["/imc_rate_smpl"] = "jf_rs_x"; + frame["/imc_rate_nrml"] = "jf_rs_x"; + frame["/imc_rate_hard"] = "jf_rs_x"; + return state; +} + +gc::RvbSnapshotState selectMusicSceneState(const Song* song = nullptr) { + gc::RvbSnapshotState state; + auto& frame = state.frameByPath; + frame["/"] = "jf_slmusic_start"; + frame["/imc_navi"] = "tg_navi_start"; + frame["/imc_navi/imc_tx"] = "jf_ope_tx_off"; + frame["/imc_title"] = "lf_title_selectmusic_start"; + frame["/imc_focus"] = "jf_focus_start"; + frame["/imc_focus/imc_fd_jacket_anim"] = "jf_fd_jacket_on"; + frame["/imc_focus/imc_fd_jacket_anim/imc_fd_jacket_off"] = "jf_jacket_first"; + frame["/imc_focus/imc_fd_jacket_anim/imc_fd_jacket_on"] = "jf_jacket_first"; + // FUN_005adbb0 only enables the key/total/ranking tags from persistent + // profile state. OpenRoller currently has no imported NESiCA profile. + frame["/imc_focus/imc_unlock_key_star"] = "jf_keystar_off"; + const bool extra = song && !song->stages[3].empty(); + frame["/imc_focus/imc_diff"] = extra ? "jf_diff_exon" : "jf_diff_exoff"; + static constexpr std::array paths{ + "/imc_focus/imc_diff/imc_n_smpl", "/imc_focus/imc_diff/imc_n_nrml", + "/imc_focus/imc_diff/imc_n_hard", "/imc_focus/imc_diff/imc_n_extra" + }; + static constexpr std::array visible{ + "jf_simple_on", "jf_normal_on", "jf_hard_on", "jf_extra_on" + }; + static constexpr std::array unavailable{ + "jf_simple_not", "jf_normal_not", "jf_hard_not", "jf_extra_not" + }; + for (size_t i = 0; i < paths.size(); ++i) { + frame[paths[i]] = !song || !song->stages[i].empty() ? visible[i] : unavailable[i]; + } + frame["/imc_focus/imc_total"] = "jf_total_off"; + frame["/imc_focus/imc_music_ex"] = extra ? "jf_tag_ex_on" : "jf_tag_ex_off"; + frame["/imc_focus/imc_tag_no"] = "jf_tag_noxx"; + frame["/imc_focus/imc_tag_new"] = "jf_tag_new_on"; + frame["/imc_focus/imc_tri_btm"] = "lf_tri_off"; + frame["/imc_focus/imc_tri_top"] = "lf_tri_off"; + // CSelectMusicTask stores sort kinds in executable order, not their + // left-to-right tab order. Internal kind 0 (genre) maps through the + // "34621857" table to visual tab 3. + frame["/imc_sort"] = "jf_sort3_ini"; + // Child clips carry each tab's enabled label independently of the active + // chevron. The yellow 40x18 NEW marker is a separate root child authored + // into both jf_sort3_ini and jf_sort3, so it is intentionally preserved. + frame["/imc_sort/imc_sort1"] = "jf_sort1_on"; + frame["/imc_sort/imc_sort2"] = "jf_sort2_on"; + frame["/imc_sort/imc_sort3"] = "jf_sort3_on"; + frame["/imc_sort/imc_sort4"] = "jf_sort4_on"; + frame["/imc_sort/imc_sort5"] = "jf_sort5_on"; + frame["/imc_sort/imc_sort6"] = "jf_sort6_on"; + frame["/imc_sort/imc_sort7"] = "jf_sort7_on"; + frame["/imc_sort/imc_sort8"] = "jf_sort8_on"; + return state; +} + +gc::RvbSnapshotState selectModeSceneState(const Song* song = nullptr, int difficulty = 0) { + gc::RvbSnapshotState state; + auto& frame = state.frameByPath; + frame["/"] = "jf_slmode_start"; + frame["/imc_navi"] = "tg_navi_start"; + frame["/imc_navi/UNIQUE_155"] = "jf_tx_mode"; + frame["/imc_title"] = "jf_title_mode"; + frame["/imc_slmode"] = "jf_mode_fi"; + const bool extra = song && !song->stages[3].empty(); + frame["/imc_slmode/imc_mode"] = extra ? "jf_mode_exon" : "jf_mode_exoff"; + static constexpr std::array paths{ + "/imc_slmode/imc_mode/imc_m_smpl", "/imc_slmode/imc_mode/imc_m_nrml", + "/imc_slmode/imc_mode/imc_m_hard", "/imc_slmode/imc_mode/imc_m_extra" + }; + static constexpr std::array selected{ + "jf_m_simple_ini", "jf_m_normal_ini", "jf_m_hard_ini", "jf_m_extra_ini" + }; + static constexpr std::array visible{ + "jf_m_simple_on", "jf_m_normal_on", "jf_m_hard_on", "jf_m_extra_on" + }; + static constexpr std::array unavailable{ + "jf_m_simple_off", "jf_m_normal_off", "jf_m_hard_off", "jf_m_extra_off" + }; + for (size_t i = 0; i < paths.size(); ++i) { + const bool exists = !song || !song->stages[i].empty(); + frame[paths[i]] = static_cast(i) == difficulty && exists + ? selected[i] : (exists ? visible[i] : unavailable[i]); + } + static constexpr std::array labels{ + "lf_simple", "lf_normal", "lf_hard", "lf_extra" + }; + frame[extra ? "/imc_slmode/imc_mode/imc_tri_set_exon" + : "/imc_slmode/imc_mode/imc_tri_set_exoff"] = labels[difficulty]; + frame["/imc_tab"] = "lf_tab_mode"; + static constexpr std::array marks{ + "jf_mk_simple", "jf_mk_normal", "jf_mk_hard", "jf_mk_extra" + }; + frame["/imc_tab/imc_info_m_tab"] = marks[difficulty]; + return state; +} + +gc::RvbSnapshotState commonSelectSceneState() { + gc::RvbSnapshotState state; + auto& frame = state.frameByPath; + frame["/"] = "jf_com_all"; + frame["/imc_ctrl_anim"] = "jf_ctrl_start"; + frame["/imc_ctrl_anim/imc_ctrl"] = "jf_ctrl_3"; + frame["/imc_ctrl_anim/imc_ctrl/imc_ctrl_label1"] = "jf_ctrl_tx02"; + frame["/imc_ctrl_anim/imc_ctrl/imc_ctrl_label2"] = "jf_ctrl_tx05"; + frame["/imc_ctrl_anim/imc_ctrl/imc_ctrl_label3"] = "jf_ctrl_tx08"; + frame["/imc_head"] = "jf_head_fi"; + frame["/imc_head/imc_head_back"] = "jf_hback_black"; + frame["/imc_head/UNIQUE_225"] = "jf_line_on"; + frame["/imc_head/imc_head_time"] = "jf_time_on"; + frame["/imc_head/imc_ico_l"] = "jf_local_on"; + frame["/imc_head/imc_ico_n"] = "jf_nesys_on"; + frame["/imc_foot"] = "jf_foot_fi"; + return state; +} + +gc::RvbSnapshotState navigatorSelectSceneState() { + gc::RvbSnapshotState state; + auto& frame = state.frameByPath; + frame["/"] = "jf_ope_start"; + frame["/imc_ope"] = "jf_ope00"; + frame["/imc_ope/imc_ope_mouth"] = "jf_ope_mouth_stay"; + frame["/imc_ope/imc_ope_mouth/imc_ope_mouth_anim"] = "lf_mouth_bc"; + return state; +} + +const char* genreName(int genre) { + switch (genre) { + case 1: return "ANIME AND POPS"; + case 2: return "VOCALOID"; + case 3: return "RHYTHM GAME"; + case 4: return "GAME"; + case 5: return "VARIETY"; + case 6: return "ORIGINAL"; + case 7: return "TOUHOU"; + default: return "ALL SONGS"; + } +} + +int genreLabelRow(int genre) { + // s_j[_eng].dds is the executable-owned 256x256 genre-label atlas. + // Each pseudo song ID 50000+n selects one 256x32 row. + switch (genre) { + case 1: return 1; // Anime & Pops + case 2: return 2; // VOCALOID + case 7: return 3; // Touhou arrangements + case 3: return 4; // Rhythm Game + case 4: return 5; // Game + case 5: return 6; // Variety + case 6: return 7; // Original + default: return 0; // Recommended for beginners + } +} + +void captureFrameForReverse(const char* prefix, const char* suffix, + int width, int height) { + if (!prefix || !*prefix || width <= 0 || height <= 0) return; + std::vector pixels(static_cast(width) * height * 3); + glPixelStorei(GL_PACK_ALIGNMENT, 1); + glReadPixels(0, 0, width, height, GL_RGB, GL_UNSIGNED_BYTE, pixels.data()); + std::ofstream file(std::string(prefix) + "-" + suffix + ".ppm", std::ios::binary); + if (!file) return; + file << "P6\n" << width << ' ' << height << "\n255\n"; + const size_t rowBytes = static_cast(width) * 3; + for (int row = height - 1; row >= 0; --row) { + file.write(reinterpret_cast(pixels.data() + row * rowBytes), + static_cast(rowBytes)); + } +} + +glm::vec4 genreColor(int genre) { + switch (genre) { + case 1: return {0.95f, 0.42f, 0.50f, 1.0f}; + case 2: return {0.15f, 0.70f, 0.86f, 1.0f}; + case 3: return {0.94f, 0.72f, 0.08f, 1.0f}; + case 4: return {0.43f, 0.72f, 0.05f, 1.0f}; + case 5: return {0.13f, 0.49f, 0.72f, 1.0f}; + case 6: return {0.64f, 0.32f, 0.62f, 1.0f}; + case 7: return {0.25f, 0.58f, 0.36f, 1.0f}; + default: return {0.96f, 0.34f, 0.16f, 1.0f}; + } +} + +int firstPlayableDifficulty(const Song& song, int preferred = 2) { + if (preferred >= 0 && preferred < 4 && !song.stages[preferred].empty()) return preferred; + for (int distance = 1; distance < 4; ++distance) { + const int lower = preferred - distance; + const int upper = preferred + distance; + if (lower >= 0 && !song.stages[lower].empty()) return lower; + if (upper < 4 && !song.stages[upper].empty()) return upper; + } + return 0; +} + +} // namespace + +bool runSongSelect(SDL_Window* window, const fs::path& gcRoot, std::string* selectedStagePath) { + if (!window || !selectedStagePath) return false; + const fs::path stageParam = gcRoot / "data" / "boot" / "stage_param.dat"; + const fs::path stageDir = gcRoot / "data" / "stage"; + std::vector bytes; + std::vector catalog; + std::string error; + if (!readFile(stageParam, &bytes) || !gc::ParseStageCatalog(bytes, &catalog, &error)) { + std::cerr << "Song select: could not load " << stageParam << ": " << error << std::endl; + return false; + } + + std::vector songs; + for (gc::StageCatalogEntry& entry : catalog) { + const fs::path menu = stageDir / "2d" / (entry.imageKey + "_menu.dds"); + if (entry.imageKey.empty() || !fs::is_regular_file(menu)) continue; + Song song; + song.catalog = std::move(entry); + song.menuTexture = menu; + bool playable = false; + for (size_t difficulty = 0; difficulty < 4; ++difficulty) { + std::string chart = song.catalog.chartIds[difficulty]; + if (chart.empty()) chart = song.catalog.chartGroup0[difficulty]; + if (chart.empty()) continue; + const fs::path stage = stageDir / (chart + ".dat"); + if (fs::is_regular_file(stage)) { + song.stages[difficulty] = stage; + playable = true; + } + } + if (playable) songs.push_back(std::move(song)); + } + if (songs.empty()) { + std::cerr << "Song select: catalog has no playable local charts" << std::endl; + return false; + } + + UiRenderer ui; + OriginalMenuModels originalMenuModels; + OriginalMenuLayer originalMusic; + OriginalMenuLayer originalMusicRow; + OriginalMenuLayer originalMusicIndex; + OriginalMenuLayer originalDifficulty; + OriginalMenuLayer originalCommon; + OriginalMenuLayer originalNavigator; + std::string originalError; + if (!originalMenuModels.load(gcRoot / "data" / "model", &originalError)) { + std::cerr << "Song select: original 3D menu models unavailable: " + << originalError << std::endl; + } + if (!originalMusic.load(gcRoot / "data" / "2d_boost" / "selectmusic2_eng.rvb", + gcRoot / "data" / "2d_boost" / "selectmusic2_eng.mtx", + selectMusicSceneState(), + &originalError)) { + std::cerr << "Song select: original music scene unavailable: " << originalError << std::endl; + } + if (!originalMusicRow.loadSymbol( + gcRoot / "data" / "2d_boost" / "selectmusic2_eng.rvb", + gcRoot / "data" / "2d_boost" / "selectmusic2_eng.mtx", + "mc_music_link", selectMusicRowState(), &originalError)) { + std::cerr << "Song select: original carousel row unavailable: " + << originalError << std::endl; + } + if (!originalMusicIndex.loadSymbol( + gcRoot / "data" / "2d_boost" / "selectmusic2_eng.rvb", + gcRoot / "data" / "2d_boost" / "selectmusic2_eng.mtx", + "mc_index_link", {}, &originalError)) { + std::cerr << "Song select: original category row unavailable: " + << originalError << std::endl; + } + if (!originalDifficulty.load(gcRoot / "data" / "2d_boost" / "selectmode2_eng.rvb", + gcRoot / "data" / "2d_boost" / "selectmode2_eng.mtx", + selectModeSceneState(), + &originalError)) { + std::cerr << "Song select: original difficulty scene unavailable: " << originalError << std::endl; + } + if (!originalCommon.load(gcRoot / "data" / "2d_boost" / "common_eng.rvb", + gcRoot / "data" / "2d_boost" / "common_eng.mtx", + commonSelectSceneState(), &originalError)) { + std::cerr << "Song select: original common scene unavailable: " << originalError << std::endl; + } else { + // The common controller clip reuses alternate label frames authored + // on the button centreline. When composing those normally separate + // task states into the three-button selector, align their captions to + // the baseline used by the first slot. + originalCommon.translateSymbol("Image8", 0.0f, 59.0f); + originalCommon.translateSymbol("Image11", 0.0f, 59.0f); + } + if (!originalNavigator.load( + gcRoot / "data" / "2d_boost" / "navigator" / "navi_001_yume.rvb", + gcRoot / "data" / "2d_boost" / "navigator" / "navi_001_yume.mtx", + navigatorSelectSceneState(), &originalError)) { + std::cerr << "Song select: original navigator scene unavailable: " << originalError << std::endl; + } + DdsTexture navigator; + DdsTexture menuBalloon; + DdsTexture genreLabels; + std::string textureError; + loadDdsTexture((gcRoot / "data" / "2d_boost" / "navigator" / "001_yume" / "base.dds").string(), + navigator, &textureError); + if (!loadDdsTexture((gcRoot / "data" / "2d_boost" / "menu" / "balloon.dds").string(), + menuBalloon, &textureError)) { + std::cerr << "Song select: original balloon unavailable: " << textureError << std::endl; + } + if (!loadDdsTexture((gcRoot / "data" / "2d_boost" / "menu" / "s_j_eng.dds").string(), + genreLabels, &textureError)) { + std::cerr << "Song select: original genre labels unavailable: " + << textureError << std::endl; + } + std::unordered_map textures; + + const std::array genres{-1, 1, 2, 7, 3, 4, 5, 6}; + size_t genreSlot = 0; + std::vector visible; + std::vector carousel; + auto rebuildVisible = [&] { + visible.clear(); + carousel.clear(); + const int genre = genres[genreSlot]; + for (size_t i = 0; i < songs.size(); ++i) { + if (genre < 0 || songs[i].catalog.genre == genre) visible.push_back(i); + } + int previousGenre = -1; + for (const size_t songIndex : visible) { + const int songGenre = songs[songIndex].catalog.genre; + if (songGenre != previousGenre) { + carousel.push_back({true, 0, songGenre}); + previousGenre = songGenre; + } + carousel.push_back({false, songIndex, songGenre}); + } + }; + rebuildVisible(); + + size_t selection = 0; + int difficulty = firstPlayableDifficulty(songs[visible[selection]]); + auto refreshOriginalScenes = [&] { + if (visible.empty()) return; + const Song& song = songs[visible[selection]]; + if (originalMusic.ready()) originalMusic.setState(selectMusicSceneState(&song)); + if (originalDifficulty.ready()) { + originalDifficulty.setState(selectModeSceneState(&song, difficulty)); + } + }; + refreshOriginalScenes(); + auto changeSong = [&](int delta) { + if (visible.empty()) return; + const int count = static_cast(visible.size()); + int next = (static_cast(selection) + delta) % count; + if (next < 0) next += count; + selection = static_cast(next); + difficulty = firstPlayableDifficulty(songs[visible[selection]], difficulty); + refreshOriginalScenes(); + }; + auto changeGenre = [&](int delta) { + int next = (static_cast(genreSlot) + delta) % static_cast(genres.size()); + if (next < 0) next += static_cast(genres.size()); + genreSlot = static_cast(next); + selection = 0; + rebuildVisible(); + if (!visible.empty()) difficulty = firstPlayableDifficulty(songs[visible[selection]]); + refreshOriginalScenes(); + }; + auto changeDifficulty = [&](int delta) { + const Song& song = songs[visible[selection]]; + for (int step = 1; step <= 4; ++step) { + int next = (difficulty + delta * step) % 4; + if (next < 0) next += 4; + if (!song.stages[next].empty()) { + difficulty = next; + refreshOriginalScenes(); + return; + } + } + }; + + bool running = true; + bool confirmed = false; + SelectTask task = SelectTask::Music; + bool capturedMusic = false; + bool capturedDifficulty = false; + const char* capturePrefix = std::getenv("OPENROLLER_CAPTURE_PREFIX"); + const bool captureBoth = std::getenv("OPENROLLER_CAPTURE_BOTH") != nullptr; + const Uint64 menuStartTicks = SDL_GetTicks(); + while (running) { + SDL_Event event; + while (SDL_PollEvent(&event)) { + if (event.type == SDL_EVENT_QUIT) running = false; + if (event.type != SDL_EVENT_KEY_DOWN || event.key.repeat) continue; + if (event.key.key == SDLK_CAPSLOCK) { + runServiceMenu(window, defaultCabinetBackend()); + SDL_SetWindowTitle(window, "OpenRoller"); + continue; + } + if (task == SelectTask::Music) { + switch (event.key.key) { + case SDLK_ESCAPE: running = false; break; + case SDLK_UP: case SDLK_W: changeSong(-1); break; + case SDLK_DOWN: case SDLK_S: changeSong(1); break; + case SDLK_PAGEUP: changeSong(-8); break; + case SDLK_PAGEDOWN: changeSong(8); break; + case SDLK_Q: changeGenre(-1); break; + case SDLK_E: case SDLK_TAB: changeGenre(1); break; + case SDLK_RETURN: case SDLK_SPACE: + task = SelectTask::Difficulty; + break; + default: break; + } + } else { + switch (event.key.key) { + case SDLK_ESCAPE: + task = SelectTask::Music; + break; + case SDLK_UP: case SDLK_W: case SDLK_LEFT: case SDLK_A: + changeDifficulty(-1); + break; + case SDLK_DOWN: case SDLK_S: case SDLK_RIGHT: case SDLK_D: + changeDifficulty(1); + break; + case SDLK_RETURN: case SDLK_SPACE: + confirmed = true; + running = false; + break; + default: break; + } + } + } + if (visible.empty()) continue; + + const Song& selected = songs[visible[selection]]; + SDL_SetWindowTitle(window, ("OpenRoller - " + selected.catalog.imageKey).c_str()); + int pixelWidth = 0, pixelHeight = 0; + SDL_GetWindowSizeInPixels(window, &pixelWidth, &pixelHeight); + glViewport(0, 0, pixelWidth, pixelHeight); + glDisable(GL_DEPTH_TEST); + glClearColor(0.1882353f, 0.1882353f, 0.6078432f, 1.0f); + glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); + + // CMenuBackgroundTask::FUN_00577fb0 emits a full-screen four-vertex + // D3D strip. Its top pair is ARGB FF30309B and its bottom pair is + // ARGB FFE57386. This pass sits behind every RVB menu scene. + ui.verticalGradient(0, 0, kUiWidth, kUiHeight, + glm::vec4(0x30 / 255.0f, 0x30 / 255.0f, 0x9b / 255.0f, 1.0f), + glm::vec4(0xe5 / 255.0f, 0x73 / 255.0f, 0x86 / 255.0f, 1.0f)); + originalMenuModels.render((SDL_GetTicks() - menuStartTicks) / 1000.0f); + + // FUN_005b73a0 reconstructs the first 768x256 texels of balloon.dds + // as a 12x4 grid of 64px quads at y=1000. Drawing the same source + // rectangle once is pixel-equivalent (the final 48px are clipped). + if (menuBalloon.id != 0) { + ui.texture(menuBalloon, 0, 1000, 768, 256, 0, 0, 768, 256); + } + + if (task == SelectTask::Music && originalMusic.ready()) originalMusic.render(ui); + if (task == SelectTask::Difficulty && originalDifficulty.ready()) originalDifficulty.render(ui); + size_t selectedCarouselEntry = 0; + if (task == SelectTask::Music && !carousel.empty()) { + const size_t selectedSongIndex = visible[selection]; + const auto selectedEntry = std::find_if( + carousel.begin(), carousel.end(), [selectedSongIndex](const CarouselEntry& entry) { + return !entry.category && entry.songIndex == selectedSongIndex; + }); + if (selectedEntry != carousel.end()) { + selectedCarouselEntry = static_cast( + std::distance(carousel.begin(), selectedEntry)); + } + } + const auto carouselAtOffset = [&](int offset) -> const CarouselEntry& { + int logical = static_cast(selectedCarouselEntry) + offset; + const int count = static_cast(carousel.size()); + logical %= count; + if (logical < 0) logical += count; + return carousel[static_cast(logical)]; + }; + if (task == SelectTask::Music && + (originalMusicRow.ready() || originalMusicIndex.ready())) { + // FUN_00447170 supplies the raw MovieClip translation, while + // FUN_00447620 is applied as opacity to these fixed-width rows. + // FUN_00446cb0 switches each slot between mc_music_link and + // mc_index_link for pseudo IDs >= 50000. + static constexpr std::array offsets{ + -5, -4, -3, -2, -1, 0, 0, 1, 2, 3, 4, 5 + }; + static constexpr std::array rowY{ + 175, 228, 281, 334, 387, 440, 701, 754, 807, 860, 913, 966 + }; + static constexpr std::array indexY{ + 189, 242, 295, 348, 401, 454, 715, 768, 821, 874, 927, 980 + }; + static constexpr std::array rowAlpha{ + 0.0f, 0.7f, 0.8f, 0.9f, 1.0f, 0.0f, + 0.0f, 1.0f, 0.9f, 0.8f, 0.7f, 0.0f + }; + for (size_t slot = 0; slot < rowY.size(); ++slot) { + if (rowAlpha[slot] <= 0.0f || carousel.empty()) continue; + const CarouselEntry& entry = carouselAtOffset(offsets[slot]); + if (!entry.category && originalMusicRow.ready()) { + originalMusicRow.render(ui, 8.0f, rowY[slot], rowAlpha[slot]); + } else if (entry.category && originalMusicIndex.ready()) { + originalMusicIndex.render(ui, 88.0f, indexY[slot], rowAlpha[slot]); + if (genreLabels.id != 0) { + const float sourceY = 32.0f * genreLabelRow(entry.genre); + // FUN_005aca40: index X/Y plus 53 and 2; FUN_005b3fc0 + // copies a 256x32 cell from s_j_eng.dds. + ui.texture(genreLabels, 141.0f, indexY[slot] + 2.0f, + 256.0f, 32.0f, 0.0f, sourceY, 256.0f, 32.0f, + glm::vec4(1.0f, 1.0f, 1.0f, rowAlpha[slot])); + } + } + } + } + + const bool usingOriginal = task == SelectTask::Music + ? originalMusic.ready() : originalDifficulty.ready(); + const size_t selectedSongIndex = visible[selection]; + auto selectedTexture = textures.find(selectedSongIndex); + if (selectedTexture == textures.end()) { + DdsTexture loaded; + if (loadDdsTexture(selected.menuTexture.string(), loaded, nullptr)) { + selectedTexture = textures.emplace(selectedSongIndex, loaded).first; + } + } + + if (!usingOriginal) { + const glm::vec4 orange{1.0f, 0.31f, 0.08f, 1.0f}; + const glm::vec4 dark{0.08f, 0.12f, 0.14f, 1.0f}; + const glm::vec4 paleBlue{0.76f, 0.91f, 0.95f, 0.94f}; + ui.rect(0, 0, 720, 18, orange); + ui.rect(0, 18, 720, 100, glm::vec4(1.0f, 0.78f, 0.67f, 1.0f)); + ui.rect(0, 118, 720, 5, orange); + ui.text(28, 40, 6, + task == SelectTask::Music ? "SELECT MUSIC" : "SELECT DIFFICULTY", dark); + ui.text(550, 32, 3, "LOCAL", dark); + ui.text(550, 62, 5, std::to_string(selection + 1) + "/" + std::to_string(visible.size()), dark); + + const int activeGenre = genres[genreSlot]; + const glm::vec4 activeColor = genreColor(activeGenre); + ui.rect(18, 136, 684, 48, activeColor); + ui.text(38, 149, 3, "Q < " + std::string(genreName(activeGenre)) + " > E", glm::vec4(1.0f)); + + constexpr int rows = 8; + constexpr float rowY = 200.0f; + constexpr float rowHeight = 49.0f; + const int half = rows / 2; + for (int row = 0; row < rows; ++row) { + int logical = static_cast(selection) + row - half; + while (logical < 0) logical += static_cast(visible.size()); + logical %= static_cast(visible.size()); + const size_t songIndex = visible[static_cast(logical)]; + const bool isSelected = logical == static_cast(selection); + const float y = rowY + row * rowHeight; + ui.rect(34, y, isSelected ? 615.0f : 535.0f, rowHeight - 5.0f, + isSelected ? glm::vec4(1.0f, 0.67f, 0.56f, 0.98f) : paleBlue); + ui.rect(34, y, 9, rowHeight - 5.0f, + isSelected ? orange : genreColor(songs[songIndex].catalog.genre)); + auto found = textures.find(songIndex); + if (found == textures.end()) { + DdsTexture loaded; + if (loadDdsTexture(songs[songIndex].menuTexture.string(), loaded, nullptr)) { + found = textures.emplace(songIndex, loaded).first; + } + } + if (found != textures.end()) { + // Every *_menu.dds already contains a rendered title in its + // upper-right atlas cell; use it exactly as the arcade does. + ui.texture(found->second, 55, y + 4, 455, 35, 250, 0, 262, 48); + } + if (isSelected) ui.outline(29, y - 3, 625, rowHeight, 3, orange); + } + + // The navigator is an original local asset and occupies the same + // lower-right visual layer as the arcade selection screen. + if (navigator.id != 0) ui.texture(navigator, 345, 750, 390, 390); + ui.rect(34, 610, 640, 455, glm::vec4(0.90f, 0.97f, 0.91f, 0.91f)); + ui.outline(34, 610, 640, 455, 3, orange); + + if (selectedTexture != textures.end()) { + // Native atlas positioning preserves jacket, title, source and + // artist fragments without synthesizing localized glyphs. + ui.texture(selectedTexture->second, 54, 632, 512, 256); + } + + static constexpr const char* names[] = {"SIMPLE", "NORMAL", "HARD", "EXTRA"}; + static const glm::vec4 colors[] = { + {0.15f, 0.74f, 0.91f, 1.0f}, {0.93f, 0.70f, 0.08f, 1.0f}, + {0.93f, 0.24f, 0.53f, 1.0f}, {0.54f, 0.26f, 0.77f, 1.0f} + }; + for (int i = 0; i < 4; ++i) { + const float x = 52.0f + i * 157.0f; + const bool exists = !selected.stages[i].empty(); + glm::vec4 color = colors[i]; + if (!exists) color *= glm::vec4(0.35f, 0.35f, 0.35f, 0.55f); + ui.rect(x, 920, 142, 84, color); + if (task == SelectTask::Difficulty && i == difficulty) { + ui.outline(x - 5, 915, 152, 94, 5, orange); + } + ui.text(x + 10, 933, 2, names[i], glm::vec4(1.0f)); + ui.text(x + 57, 963, 4, exists ? std::to_string(selected.catalog.difficultyRatings[i]) : "-", + glm::vec4(1.0f)); + } + ui.text(52, 1030, 2, "BPM " + selected.catalog.bpm + " TIME " + selected.catalog.duration, dark); + + ui.rect(0, 1165, 720, 115, dark); + if (task == SelectTask::Music) { + ui.text(28, 1183, 3, "UP DOWN: SONG Q E: GENRE", glm::vec4(1.0f)); + ui.text(28, 1225, 3, "ENTER: DIFFICULTY ESC: EXIT", glm::vec4(1.0f)); + } else { + ui.text(28, 1183, 3, "ARROWS: DIFFICULTY", glm::vec4(1.0f)); + ui.text(28, 1225, 3, "ENTER: PLAY ESC: BACK", glm::vec4(1.0f)); + } + } else if (selectedTexture != textures.end()) { + const DdsTexture& atlas = selectedTexture->second; + if (task == SelectTask::Music) { + // FUN_005aca40 + FUN_005b34f0: fixed focus fragments from the + // selected song's original 512x256 menu atlas. + ui.texture(atlas, 39, 497, 196, 196, 1, 1, 196, 196); + ui.texture(atlas, 251, 467, 374, 34, 0, 197, 374, 34); + ui.texture(atlas, 262, 500, 374, 24, 0, 232, 374, 24); + ui.texture(atlas, 262, 522, 314, 16, 198, 180, 314, 16); + + // FUN_00447170/FUN_00447620: the twelve real carousel slots. + static constexpr std::array offsets{ + -5, -4, -3, -2, -1, 0, 0, 1, 2, 3, 4, 5 + }; + static constexpr std::array centersY{ + 209, 262, 315, 368, 421, 474, 735, 788, 841, 894, 947, 1000 + }; + static constexpr std::array scales{ + 0.0f, 0.7f, 0.8f, 0.9f, 1.0f, 0.0f, + 0.0f, 1.0f, 0.9f, 0.8f, 0.7f, 0.0f + }; + for (size_t slot = 0; slot < offsets.size(); ++slot) { + const float scale = scales[slot]; + if (scale <= 0.0f || carousel.empty()) continue; + const CarouselEntry& entry = carouselAtOffset(offsets[slot]); + if (entry.category) continue; + const size_t songIndex = entry.songIndex; + auto texture = textures.find(songIndex); + if (texture == textures.end()) { + DdsTexture loaded; + if (loadDdsTexture(songs[songIndex].menuTexture.string(), loaded, nullptr)) { + texture = textures.emplace(songIndex, loaded).first; + } + } + if (texture == textures.end()) continue; + const float width = 374.0f * scale; + const float height = 34.0f * scale; + ui.texture(texture->second, 201.0f - width * 0.5f, + centersY[slot] - height * 0.5f, width, height, + 0, 197, 374, 34); + } + } else { + // Fixed selected-song fragments in CDifficultyTask's renderer + // (FUN_005be2d0), using its executable constants. + // FUN_005b33a0 treats the executable constants as sprite + // centres and subtracts half of the scaled source size. + ui.texture(atlas, 105, 167, 98, 98, 1, 1, 196, 196); + ui.texture(atlas, 209, 178, 374, 34, 0, 197, 374, 34); + ui.texture(atlas, 220, 214, 374, 24, 0, 232, 374, 24); + ui.texture(atlas, 220, 239, 314, 16, 198, 180, 314, 16); + } + } + // The navigator and common HUD are later compositing passes in + // CSelectMusicTask; the character must cover the lower carousel rows. + if (originalNavigator.ready()) originalNavigator.render(ui); + if (originalCommon.ready()) originalCommon.render(ui); + ui.flush(); + + if (capturePrefix && task == SelectTask::Music && !capturedMusic) { + captureFrameForReverse(capturePrefix, "music", pixelWidth, pixelHeight); + capturedMusic = true; + if (captureBoth) task = SelectTask::Difficulty; + } else if (capturePrefix && task == SelectTask::Difficulty && !capturedDifficulty) { + captureFrameForReverse(capturePrefix, "difficulty", pixelWidth, pixelHeight); + capturedDifficulty = true; + } + + SDL_GL_SwapWindow(window); + SDL_Delay(8); + + // Keep only a small moving texture window instead of uploading all + // ~900 jackets to VRAM. + if (textures.size() > 20) { + for (auto it = textures.begin(); it != textures.end();) { + const auto visibleIt = std::find(visible.begin(), visible.end(), it->first); + const int position = visibleIt == visible.end() + ? 100000 : static_cast(std::distance(visible.begin(), visibleIt)); + if (std::abs(position - static_cast(selection)) > 12) { + glDeleteTextures(1, &it->second.id); + it = textures.erase(it); + } else { + ++it; + } + } + } + } + + if (confirmed && !visible.empty()) { + const Song& song = songs[visible[selection]]; + if (!song.stages[difficulty].empty()) *selectedStagePath = song.stages[difficulty].string(); + } + for (auto& [_, texture] : textures) glDeleteTextures(1, &texture.id); + if (navigator.id != 0) glDeleteTextures(1, &navigator.id); + if (menuBalloon.id != 0) glDeleteTextures(1, &menuBalloon.id); + if (genreLabels.id != 0) glDeleteTextures(1, &genreLabels.id); + originalMenuModels.clear(); + originalMusic.clear(); + originalMusicRow.clear(); + originalMusicIndex.clear(); + originalDifficulty.clear(); + originalCommon.clear(); + originalNavigator.clear(); + SDL_SetWindowTitle(window, "OpenRoller"); + glEnable(GL_DEPTH_TEST); + return confirmed && !selectedStagePath->empty(); +} diff --git a/apps/desktop/src/main.cpp b/apps/desktop/src/main.cpp new file mode 100644 index 0000000..b99987b --- /dev/null +++ b/apps/desktop/src/main.cpp @@ -0,0 +1,2352 @@ +#include +#include "vectorail/core/gl_loader.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "vectorail/core/Spline.hpp" +#include "vectorail/core/Shader.hpp" +#include "openroller/desktop/LevelLoader.hpp" +#include "openroller/desktop/AudioManager.hpp" +#include "vectorail/core/DdsTexture.hpp" +#include "gc/GcTargetEffect.hpp" +#include "vectorail/core/PngTexture.hpp" +#include "openroller/desktop/SongSelect.hpp" +#include "openroller/desktop/ServiceMenu.hpp" +#include "openroller/desktop/CabinetBackend.hpp" +#include "gc/TumoModel.hpp" + +namespace fs = std::filesystem; + +constexpr int kGameWidth = 720; +constexpr int kGameHeight = 1280; + +std::vector toGlmVertices(const std::vector& source) { + std::vector result; + result.reserve(source.size()); + for (const gc::TumoVertex& vertex : source) { + result.emplace_back(vertex.x, vertex.y, vertex.z); + } + return result; +} + +// IDs are the first field of the records in data/boot/item.dat and are read +// by game471 through the gameplay state field at +0xcd8. +enum class GcGameplayItem : uint8_t { + None = 0, + Mirror = 4, + Reverse = 6, +}; + +const char* gcGameplayItemName(GcGameplayItem item) { + switch (item) { + case GcGameplayItem::Mirror: return "MIRROR"; + case GcGameplayItem::Reverse: return "REVERSE"; + default: return "NONE"; + } +} + +bool parseGcGameplayItem(std::string value, GcGameplayItem* item) { + std::transform(value.begin(), value.end(), value.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + if (value == "none" || value == "0") { + *item = GcGameplayItem::None; + return true; + } + if (value == "mirror" || value == "4") { + *item = GcGameplayItem::Mirror; + return true; + } + if (value == "reverse" || value == "6") { + *item = GcGameplayItem::Reverse; + return true; + } + return false; +} + +glm::vec2 gcGameplayItemScreenFlip(GcGameplayItem item) { + if (item == GcGameplayItem::Mirror) return {-1.0f, 1.0f}; + if (item == GcGameplayItem::Reverse) return {-1.0f, -1.0f}; + return {1.0f, 1.0f}; +} + +void gcApplyGameplayItemProjection(GcGameplayItem item, glm::mat4& projection) { + if (item != GcGameplayItem::Mirror && item != GcGameplayItem::Reverse) return; + + // FUN_0063ff90 multiplies the first column of the authored 3D projection + // by -1 for item 4 and 6. Item 6 also negates the second column. The view + // matrix at +0xd0 is deliberately not modified. + projection[0] *= -1.0f; + if (item == GcGameplayItem::Reverse) projection[1] *= -1.0f; +} + +void printUsage(const char* executable) { + std::cout + << "Usage:\n" + << " " << executable << " [stage.dat] [--item none|mirror|reverse]\n" + << " " << executable << " --menu [/path/to/GC] [--item none|mirror|reverse]\n"; +} + +struct RailVertex { glm::vec3 pos; float side; glm::vec3 right; float timeMs = 0.0f; }; +struct GcRouteVertex { glm::vec3 pos; glm::vec4 color; }; +struct Particle { glm::vec3 pos, vel; float life; }; +struct GpuTumoModel { + unsigned int vao = 0; + unsigned int vbo = 0; + GLsizei triangleCount = 0; + GLint solidLineFirst = 0; + GLsizei solidLineCount = 0; + GLint wireLineFirst = 0; + GLsizei wireLineCount = 0; +}; +struct Note { + float dist = 0.0f; + float timeMs = 0.0f; + float appearTimeMs = 0.0f; + float endTimeMs = 0.0f; + float endDist = 0.0f; + unsigned int rawType = 0; + unsigned int effectiveType = 0; + bool adlib = false; + int markEffectId = -1; + uint32_t packedColor = 0xffffffffu; + uint32_t merryCount = 0; + float directionDegrees = 0.0f; + float beatDurationMs = 500.0f; + float earlyTimingMs = 250.0f; + float lateTimingMs = 250.0f; + float missTimingMs = 250.0f; + float muteTimingMs = 0.0f; + float markerFadeEndTimeMs = 0.0f; + std::array durationFirst{0, 0}; + std::array durationCount{0, 0}; + int durationBands = 0; + float durationHalfWidth = 0.0f; + bool gcTiming = false; + bool hit = false; + bool holding = false; + bool shotMuteApplied = false; + float inputStartTimeMs = -1.0f; + float lastInputTimeMs = -1.0f; + uint32_t inputMask = 0; + uint32_t lastInputBit = 0; +}; + +enum class GcJudgment : int { + Miss = 1, + Good = 2, + Cool = 3, + Great = 4, +}; + +struct GcJudgmentEffect { + float dist = 0.0f; + float startTimeMs = 0.0f; + GcJudgment rank = GcJudgment::Miss; + float rotationDegrees = 0.0f; +}; + +bool gcTapTarget(unsigned int type) { + return type == 1 || type == 2; +} + +bool gcDualTapTarget(unsigned int type) { + return type == 9; +} + +bool gcHoldTarget(unsigned int type) { + return type == 3 || type == 15; +} + +bool gcRhythmLongTarget(unsigned int type) { + return type == 4 || type == 5; +} + +GcJudgment gcLongJudgment(const Note& note, float pressTimeMs, float releaseTimeMs) { + const float durationMs = std::max(1.0f, note.endTimeMs - note.timeMs); + const float heldStartMs = std::max(note.timeMs, pressTimeMs); + const float heldEndMs = std::min(note.endTimeMs, releaseTimeMs); + float heldMs = std::max(0.0f, heldEndMs - heldStartMs); + + // Arcade FUN_005d0d80 gives short charts the same four-frame tail margin: + // if the final 20% is below 66.666 ms, add the difference to held time. + const float finalTwentyPercentMs = durationMs * 0.20f; + if (finalTwentyPercentMs < 66.666664f) { + heldMs += 66.666664f - finalTwentyPercentMs; + } + const float heldPercent = heldMs * 100.0f / durationMs; + if (heldPercent > 80.0f) return GcJudgment::Great; + if (heldPercent > 60.0f) return GcJudgment::Cool; + if (heldPercent > 40.0f) return GcJudgment::Good; + return GcJudgment::Miss; +} + +GcJudgment gcTapJudgment(const Note& note, float currentMs, float greatMinTimeMs) { + const float error = std::fabs(currentMs - note.timeMs); + const float outer = std::max(0.0f, + currentMs > note.timeMs ? note.lateTimingMs : note.earlyTimingMs); + float great = outer * 0.25f; + float cool = outer * 0.50f; + if (great < greatMinTimeMs) { + great = greatMinTimeMs; + // FUN_005d1690 redistributes COOL between the clamped GREAT and GOOD + // boundaries with the original 50/(50+100) ratio. + cool = std::min(outer, great + (outer - great) / 3.0f); + } + if (error < great) return GcJudgment::Great; + if (error < cool) return GcJudgment::Cool; + if (error < outer) return GcJudgment::Good; + return GcJudgment::Miss; +} + +int gcJudgmentEffectId(GcJudgment rank) { + // setRankedEffect selects group-1 effect rank + 0x1c. + return static_cast(rank) + 28; +} + +uint32_t gcKeyboardInputBit(SDL_Keycode key) { + switch (key) { + case SDLK_LEFT: return 1u << 0; + case SDLK_RIGHT: return 1u << 1; + case SDLK_UP: return 1u << 2; + case SDLK_DOWN: return 1u << 3; + case SDLK_SPACE: return 1u << 4; + case SDLK_Z: return 1u << 5; + case SDLK_X: return 1u << 6; + case SDLK_C: return 1u << 7; + case SDLK_V: return 1u << 8; + default: return 0; + } +} + +uint32_t gcGamepadInputBit(Uint8 button) { + switch (button) { + case SDL_GAMEPAD_BUTTON_DPAD_LEFT: return 1u << 9; + case SDL_GAMEPAD_BUTTON_DPAD_RIGHT: return 1u << 10; + case SDL_GAMEPAD_BUTTON_DPAD_UP: return 1u << 11; + case SDL_GAMEPAD_BUTTON_DPAD_DOWN: return 1u << 12; + case SDL_GAMEPAD_BUTTON_SOUTH: return 1u << 13; + case SDL_GAMEPAD_BUTTON_EAST: return 1u << 14; + case SDL_GAMEPAD_BUTTON_WEST: return 1u << 15; + case SDL_GAMEPAD_BUTTON_NORTH: return 1u << 16; + default: return 0; + } +} + +AudioManager::GameplaySound gcTapSound(uint32_t inputBit) { + // Match the two physical arcade boosters. On a conventional pad the + // d-pad is booster 1 and the four face buttons are booster 2. + const uint32_t keyboardDpad = 0x0fu; + const uint32_t gamepadDpad = 0x0fu << 9; + return (inputBit & (keyboardDpad | gamepadDpad)) != 0 + ? AudioManager::GameplaySound::Tap1 + : AudioManager::GameplaySound::Tap2; +} + +glm::vec3 noteColor(unsigned int type) { + switch (type) { + case 1: return {1.00f, 0.95f, 0.05f}; // NORMAL, img4 cell 0 + case 2: return {0.05f, 0.90f, 1.00f}; // FLICK, img4 cell 3 + case 3: return {0.05f, 1.00f, 0.20f}; // HOLD, img4 cell 1 + case 4: return {1.00f, 0.05f, 0.95f}; // SCRATCH,img4 cell 4 + case 5: return {1.00f, 0.55f, 0.00f}; // BEAT, img4 cell 2 + case 6: return {1.00f, 0.95f, 0.05f}; // expanded by the original + case 9: return {1.00f, 1.00f, 0.45f}; // CRITICAL + case 10: return {0.05f, 0.90f, 1.00f}; + case 15: return {1.00f, 0.10f, 0.95f}; + default: return {0.85f, 0.85f, 0.85f}; + } +} + +bool gcDurationNote(unsigned int type) { + return type == 3 || type == 4 || type == 5 || type == 10 || type == 15; +} + +// game471.exe DAT_006ea040, consumed by FUN_00661680. Each valid entry is +// loaded into the lower-right control-helper slot 0xb1 + note type. +int gcHelperEffectId(unsigned int type) { + static constexpr int effectIds[] = { + -1, 61, 66, 62, 63, 64, 61, -1, + 67, 65, 69, -1, -1, -1, -1, 68, + }; + return type < (sizeof(effectIds) / sizeof(effectIds[0])) ? effectIds[type] : -1; +} + +glm::vec4 gcPackedColor(uint32_t rgba) { + return { + static_cast((rgba >> 24) & 0xffu) / 255.0f, + static_cast((rgba >> 16) & 0xffu) / 255.0f, + static_cast((rgba >> 8) & 0xffu) / 255.0f, + static_cast(rgba & 0xffu) / 255.0f, + }; +} + +float gcMarkerAlpha(float currentMs, float appearTimeMs, float fadeEndTimeMs, float beatDurationMs) { + const float fadeMs = std::max(1.0f, beatDurationMs * 0.5f); + if (currentMs < appearTimeMs || currentMs > fadeEndTimeMs) return 0.0f; + if (currentMs < appearTimeMs + fadeMs) { + return std::clamp((currentMs - appearTimeMs) / fadeMs, 0.0f, 1.0f); + } + if (currentMs > fadeEndTimeMs - fadeMs) { + return std::clamp((fadeEndTimeMs - currentMs) / fadeMs, 0.0f, 1.0f); + } + return 1.0f; +} + +float gcNoteAlpha(const Note& note, float currentMs) { + if (!note.gcTiming) return 1.0f; + return gcMarkerAlpha(currentMs, note.appearTimeMs, + note.markerFadeEndTimeMs, note.beatDurationMs); +} + +float gcMarkerEffectTick(const LevelData& level, float currentMs, int frameMax) { + uint32_t bpm = 120; + float bpmStartMs = 0.0f; + if (level.gcStage) { + for (const gc::BpmChange& change : level.gcStage->config.bpmChanges) { + if (static_cast(change.timeMs) > currentMs) break; + if (change.bpm != 0) { + bpm = change.bpm; + bpmStartMs = static_cast(change.timeMs); + } + } + } + const float beatMs = 60000.0f / static_cast(std::max(1, bpm)); + float beatPhase = std::fmod(std::max(0.0f, currentMs - bpmStartMs) / beatMs, 1.0f); + if (beatPhase < 0.0f) beatPhase += 1.0f; + return (1.0f - beatPhase) * static_cast(std::max(1, frameMax)); +} + +float getTimelineValue(const std::vector& timeline, float currentT, const std::string& param, float defaultValue) { + std::vector filtered; + for(const auto& k : timeline) if(k.param == param) filtered.push_back(k); + if(filtered.empty()) return defaultValue; + std::sort(filtered.begin(), filtered.end(), [](const Keyframe& a, const Keyframe& b){ return a.t < b.t; }); + if(currentT <= filtered.front().t) return filtered.front().value; + if(currentT >= filtered.back().t) return filtered.back().value; + for(size_t i=0; i= filtered[i].t && currentT <= filtered[i+1].t) { + float f = (currentT - filtered[i].t) / (filtered[i+1].t - filtered[i].t); + return filtered[i].value + (filtered[i+1].value - filtered[i].value) * f; + } + } + return defaultValue; +} + +float getTimelineStepValue(const std::vector& timeline, float currentT, + const std::string& param, float defaultValue) { + float value = defaultValue; + float activeTime = -std::numeric_limits::infinity(); + for (const Keyframe& key : timeline) { + if (key.param != param || key.t > currentT || key.t < activeTime) continue; + activeTime = key.t; + value = key.value; + } + return value; +} + +float getTrackParamAtMs(const std::vector& points, float timeMs) { + if (points.empty()) return 0.0f; + if (timeMs <= points.front().timeMs) return 0.0f; + if (timeMs >= points.back().timeMs) return (float)(points.size() - 1); + for (size_t i = 0; i + 1 < points.size(); ++i) { + float a = points[i].timeMs; + float b = points[i + 1].timeMs; + if (timeMs >= a && timeMs <= b) { + float span = b - a; + float u = span > 0.001f ? (timeMs - a) / span : 0.0f; + return (float)i + u; + } + } + return (float)(points.size() - 1); +} + +float getDistanceAtTrackParam(const Spline& track, float param, size_t pointCount) { + if (pointCount < 2) return 0.0f; + param = std::clamp(param, 0.0f, (float)(pointCount - 1)); + size_t idx = (size_t)floorf(param); + if (idx + 1 >= pointCount) return track.getDistanceAtIndex(pointCount - 1); + float u = param - (float)idx; + float d0 = track.getDistanceAtIndex(idx); + float d1 = track.getDistanceAtIndex(idx + 1); + return d0 + (d1 - d0) * u; +} + +struct GcCameraState { + glm::vec3 eye{0.0f}; + glm::vec3 target{0.0f}; + glm::vec3 up{0.0f, 1.0f, 0.0f}; + uint8_t projType = 0; + float projBlend = 0.0f; +}; + +glm::vec3 gcNormalize(const glm::vec3& value) { + const float magnitude = glm::length(value); + return magnitude > 0.0f ? value / magnitude : glm::vec3(0.0f); +} + +glm::vec3 gcTrackPositionAtMs(const std::vector& points, float timeMs) { + if (points.empty()) return glm::vec3(0.0f); + if (timeMs <= points.front().timeMs) return points.front().position; + if (timeMs >= points.back().timeMs) return points.back().position; + + const auto next = std::upper_bound(points.begin(), points.end(), timeMs, + [](float time, const TrackPoint& point) { return time < point.timeMs; }); + const size_t i = static_cast(std::distance(points.begin(), next) - 1); + const float span = points[i + 1].timeMs - points[i].timeMs; + const float u = span > 0.0f ? (timeMs - points[i].timeMs) / span : 0.0f; + return glm::mix(points[i].position, points[i + 1].position, u); +} + +// game471: Euler(deg) -> quaternion(-rotationA.y, rotationA.x, rotationA.z), +// then transform the vector (0, 0, dist). Writing out the third matrix column +// avoids depending on GLM's Euler ordering or handedness conventions. +glm::vec3 gcCameraOrbit(const glm::vec3& rotationA, float dist) { + const float a = glm::radians(-rotationA.y) * 0.5f; + const float b = glm::radians(rotationA.x) * 0.5f; + const float c = glm::radians(rotationA.z) * 0.5f; + const float ca = cosf(a), cb = cosf(b), cc = cosf(c); + const float sa = sinf(a), sb = sinf(b), sc = sinf(c); + + const float qw = sc * sa * sb + cc * ca * cb; + const float qx = sc * ca * sb + cc * sa * cb; + const float qy = cc * ca * sb - sc * sa * cb; + const float qz = cc * sa * sb - sc * ca * cb; + return dist * glm::vec3( + 2.0f * (qz * qx + qw * qy), + 2.0f * (qy * qz - qw * qx), + 1.0f - 2.0f * (qx * qx + qy * qy)); +} + +void gcAdjustCameraUp(GcCameraState& state, float rollDegrees) { + glm::vec3 view = state.target - state.eye; + if (glm::dot(view, view) < 1.0e-10f) { + state.up = {0.0f, 1.0f, 0.0f}; + return; + } + view = gcNormalize(view); + + glm::vec3 reference(0.0f, 1.0f, 0.0f); + glm::vec3 projected = reference - view * glm::dot(reference, view); + if (glm::dot(projected, projected) < 1.0e-8f) { + reference = {0.0f, 0.0f, 1.0f}; + projected = reference - view * glm::dot(reference, view); + } + state.up = gcNormalize(projected); + if (rollDegrees != 0.0f) { + state.up = glm::mat3(glm::rotate(glm::mat4(1.0f), glm::radians(rollDegrees), view)) * state.up; + } +} + +GcCameraKey gcMixCameraKey(const GcCameraKey& a, const GcCameraKey& b, float u) { + GcCameraKey out = a; // aMode, fMode and projType stay on the active key. + out.dist = glm::mix(a.dist, b.dist, u); + out.rotationA = glm::mix(a.rotationA, b.rotationA, u); + out.originOff = glm::mix(a.originOff, b.originOff, u); + out.fieldFar = glm::mix(a.fieldFar, b.fieldFar, u); + out.fieldNear = glm::mix(a.fieldNear, b.fieldNear, u); + out.rotationB = glm::mix(a.rotationB, b.rotationB, u); + return out; +} + +GcCameraState evaluateGcCamera(const LevelData& level, float timeMs, bool interpolate = true, int depth = 0) { + GcCameraState state; + if (level.gcCameraKeys.empty() || depth > 8) { + state.target = gcTrackPositionAtMs(level.trackPoints, timeMs); + state.eye = state.target + glm::vec3(0.0f, 0.0f, 10.0f); + return state; + } + + const auto next = std::upper_bound(level.gcCameraKeys.begin(), level.gcCameraKeys.end(), timeMs, + [](float time, const GcCameraKey& key) { return time < static_cast(key.timeMs); }); + size_t index = next == level.gcCameraKeys.begin() + ? 0 + : static_cast(std::distance(level.gcCameraKeys.begin(), next) - 1); + const GcCameraKey* key = &level.gcCameraKeys[index]; + GcCameraKey mixedKey; + + const bool betweenKeys = interpolate && key->fMode != 0 && + timeMs > static_cast(key->timeMs) && index + 1 < level.gcCameraKeys.size() && + timeMs < static_cast(level.gcCameraKeys[index + 1].timeMs); + if (betweenKeys) { + const GcCameraKey& following = level.gcCameraKeys[index + 1]; + const float u = (timeMs - key->timeMs) / static_cast(following.timeMs - key->timeMs); + if (key->fMode == 1) { + GcCameraState from = evaluateGcCamera(level, static_cast(key->timeMs), true, depth + 1); + GcCameraState to = evaluateGcCamera(level, static_cast(following.timeMs), true, depth + 1); + // FUN_005e0ad0(..., 0) rebuilds the unrolled endpoint up vectors. + gcAdjustCameraUp(from, 0.0f); + gcAdjustCameraUp(to, 0.0f); + state.eye = glm::mix(from.eye, to.eye, u); + state.target = glm::mix(from.target, to.target, u); + state.up = gcNormalize(glm::mix(from.up, to.up, u)); + state.projType = from.projType; + state.projBlend = glm::mix(from.projBlend, to.projBlend, u); + + // fMode 1 applies the interpolated rotationB after position/up lerp. + const float roll = glm::mix(key->rotationB, following.rotationB, u); + if (roll != 0.0f && glm::dot(state.target - state.eye, state.target - state.eye) > 1.0e-10f) { + const glm::vec3 axis = gcNormalize(state.target - state.eye); + state.up = glm::mat3(glm::rotate(glm::mat4(1.0f), glm::radians(roll), axis)) * state.up; + } + return state; + } + if (key->fMode == 2) { + mixedKey = gcMixCameraKey(*key, following, u); + key = &mixedKey; + } + } + + const glm::vec3 orbit = gcCameraOrbit(key->rotationA, key->dist); + const glm::vec3 currentTrack = gcTrackPositionAtMs(level.trackPoints, timeMs); + switch (key->aMode) { + case 0: + state.target = currentTrack + key->originOff; + state.eye = state.target + orbit; + break; + case 1: + state.target = gcTrackPositionAtMs(level.trackPoints, static_cast(key->timeMs)) + key->originOff; + state.eye = state.target + orbit; + break; + case 2: + if (index > 1) { + return evaluateGcCamera(level, static_cast(level.gcCameraKeys[index].timeMs) - 1.0f, + false, depth + 1); + } + state.target = currentTrack + key->originOff; + state.eye = state.target + orbit; + break; + case 3: + state.target = currentTrack + key->originOff; + state.eye = gcTrackPositionAtMs(level.trackPoints, static_cast(key->timeMs)) + + key->originOff + orbit; + break; + case 4: + state.target = key->fieldFar; + state.eye = key->fieldNear; + break; + case 5: + state.target = currentTrack + key->originOff; + state.eye = key->fieldNear; + break; + case 6: + state.target = key->fieldFar; + state.eye = currentTrack + orbit; + break; + default: + state.target = currentTrack + key->originOff; + state.eye = state.target + orbit; + break; + } + + gcAdjustCameraUp(state, key->rotationB); + state.projType = key->projType; + state.projBlend = key->projType ? 1.0f : 0.0f; + return state; +} + +// game471 FUN_0063fba0/FUN_0063fc10/FUN_0063fd60. Projection type 1 is +// the regular 75 degree perspective matrix. Type 0 is an orthographic +// matrix whose extents match that perspective matrix at the look-at target, +// so switching projection does not introduce a size jump at the target. +// During fMode=1 the game blends the matrix elements with projBlend^3. +glm::mat4 gcProjection(const GcCameraState& camera, float fovDegrees, float aspect) { + constexpr float nearPlane = 1.0f; + constexpr float farPlane = 1000.0f; + // The arcade helper is left-handed. LH_NO keeps the same handedness and + // screen-space result while adapting D3D's depth convention to OpenGL. + const glm::mat4 perspective = glm::perspectiveLH_NO( + glm::radians(fovDegrees), aspect, nearPlane, farPlane); + + const float focusDistance = glm::length(camera.target - camera.eye); + const float halfHeight = focusDistance * tanf(glm::radians(fovDegrees) * 0.5f); + const float halfWidth = halfHeight * aspect; + const glm::mat4 orthographic = glm::orthoLH_NO( + -halfWidth, halfWidth, -halfHeight, halfHeight, nearPlane, farPlane); + + if (camera.projBlend <= 0.0f) return orthographic; + if (camera.projBlend >= 1.0f) return perspective; + const float blend = std::clamp(camera.projBlend * camera.projBlend * camera.projBlend, 0.0f, 1.0f); + return orthographic + (perspective - orthographic) * blend; +} + +// GameScene::buildGameData computes directional marker rotation once, before +// gameplay: BuildTimingDataSub turns wire +25/+29/+33 into a world vector, +// then the game projects its endpoints with the camera at the note timestamp. +// RotateHPB::SetVector finally evaluates atan2(screen_dx, screen_dy_down). +// Keeping that result fixed makes the prompt announce the eventual input +// direction even while the authored camera turns on the way to the note. +float gcDirectionScreenDegrees(const LevelData& level, const LevelNote& note) { + if (glm::dot(note.directionVector, note.directionVector) < 1.0e-10f) return 0.0f; + + const GcCameraState camera = evaluateGcCamera(level, note.timeMs); + glm::vec3 viewDir = camera.target - camera.eye; + if (glm::dot(viewDir, viewDir) < 1.0e-10f) return 0.0f; + viewDir = glm::normalize(viewDir); + glm::vec3 cameraUp = camera.up; + if (std::fabs(glm::dot(viewDir, cameraUp)) > 0.96f) { + cameraUp = glm::vec3(1.0f, 0.0f, 0.0f); + } + const glm::mat4 view = glm::lookAtLH(camera.eye, camera.target, cameraUp); + const float fov = level.config.count("gc_fov") ? level.config.at("gc_fov") : 75.0f; + const glm::mat4 projection = gcProjection( + camera, fov, static_cast(kGameWidth) / static_cast(kGameHeight)); + + const glm::vec3 origin = gcTrackPositionAtMs(level.trackPoints, note.timeMs); + const auto projectToScreen = [&](const glm::vec3& point) { + const glm::vec4 clip = projection * view * glm::vec4(point, 1.0f); + // GameScene::UnProject divides by abs(w), then maps to the 720x1280 + // drawing surface. Its returned vector uses downward-positive screen Y. + const float divisor = std::max(1.0e-6f, std::fabs(clip.w)); + const glm::vec2 ndc(clip.x / divisor, clip.y / divisor); + return glm::vec2((ndc.x + 1.0f) * (0.5f * kGameWidth), + (1.0f - ndc.y) * (0.5f * kGameHeight)); + }; + const glm::vec2 delta = projectToScreen(origin + note.directionVector) - + projectToScreen(origin); + if (glm::dot(delta, delta) < 1.0e-8f) return 0.0f; + return glm::degrees(std::atan2(delta.x, delta.y)); +} + +glm::vec3 gcTrackPositionBetween(const TrackPoint& a, const TrackPoint& b, float timeMs) { + const float span = b.timeMs - a.timeMs; + const float u = span > 0.0f + ? std::clamp((timeMs - a.timeMs) / span, 0.0f, 1.0f) + : 0.0f; + return glm::mix(a.position, b.position, u); +} + +// Android GameScene::DrawWay, which retains its original symbol, submits the +// authored track as a line strip. It clips the first and last segment at the +// exact requested timestamps and linearly grades the endpoint colours. There +// is no camera-facing ribbon, physical-distance resampling or triangle cull. +void gcAppendDrawWay(const std::vector& track, float firstTimeMs, + float lastTimeMs, const glm::vec4& firstColor, + const glm::vec4& lastColor, + std::vector& out) { + if (track.size() < 2 || lastTimeMs <= firstTimeMs || + lastTimeMs < track.front().timeMs || firstTimeMs > track.back().timeMs) { + return; + } + firstTimeMs = std::max(firstTimeMs, track.front().timeMs); + lastTimeMs = std::min(lastTimeMs, track.back().timeMs); + + auto colorAt = [&](float timeMs) { + const float u = std::clamp((timeMs - firstTimeMs) / + std::max(1.0f, lastTimeMs - firstTimeMs), + 0.0f, 1.0f); + return glm::mix(firstColor, lastColor, u); + }; + + size_t segment = 0; + while (segment + 1 < track.size() && track[segment + 1].timeMs < firstTimeMs) { + ++segment; + } + bool firstVertex = true; + for (; segment + 1 < track.size() && track[segment].timeMs < lastTimeMs; ++segment) { + const TrackPoint& a = track[segment]; + const TrackPoint& b = track[segment + 1]; + if (b.timeMs < firstTimeMs || b.timeMs <= a.timeMs) continue; + const float clippedA = std::max(firstTimeMs, a.timeMs); + const float clippedB = std::min(lastTimeMs, b.timeMs); + if (clippedB < clippedA) continue; + if (firstVertex) { + out.push_back({gcTrackPositionBetween(a, b, clippedA), colorAt(clippedA)}); + firstVertex = false; + } + out.push_back({gcTrackPositionBetween(a, b, clippedB), colorAt(clippedB)}); + if (clippedB >= lastTimeMs) break; + } +} + +struct GcKeySample { + int index = -1; + float blendToNext = 0.0f; + float sampledTimeMs = 0.0f; +}; + +bool gcLoopFlag(const gc::TransformPoint& key) { return key.tweenTowards; } +bool gcLoopFlag(const gc::ObjectColorPoint& key) { return key.tweenTowards; } +bool gcLoopFlag(const gc::VisibilityPoint& key) { return key.fadeOut; } +bool gcInterpolateFlag(const gc::TransformPoint& key) { return key.tweenAway; } +bool gcInterpolateFlag(const gc::ObjectColorPoint& key) { return key.tweenAway; } +bool gcInterpolateFlag(const gc::VisibilityPoint& key) { return key.fadeIn; } + +// FUN_005e9100 is shared by all five object-animation channels. The first +// flag marks a repeat block; the second enables interpolation to the next key. +template +GcKeySample gcSampleObjectKeys(const std::vector& keys, float timeMs) { + if (keys.empty()) return {-1, 0.0f, timeMs}; + + const auto activeAt = [&](float t) { + int active = -1; + for (size_t i = 0; i < keys.size(); ++i) { + if (static_cast(keys[i].timeMs) > t) break; + active = static_cast(i); + } + return active; + }; + + float sampleTime = timeMs; + int active = activeAt(sampleTime); + if (active < 0) return {-1, 0.0f, sampleTime}; + + // The game repeats only a contiguous run whose adjacent keys both have + // byte +5 set (the first flag in the on-disk record). + if (active > 0 && gcLoopFlag(keys[active]) && gcLoopFlag(keys[active - 1])) { + int first = active - 1; + while (first > 0 && gcLoopFlag(keys[first - 1])) --first; + int last = active; + while (last + 1 < static_cast(keys.size()) && gcLoopFlag(keys[last + 1])) ++last; + const float begin = static_cast(keys[first].timeMs); + const float duration = static_cast(keys[last].timeMs) - begin; + if (duration > 0.0f && sampleTime >= begin) { + sampleTime = begin + std::fmod(sampleTime - begin, duration); + active = activeAt(sampleTime); + } + } + + float blend = 0.0f; + if (active >= 0 && active + 1 < static_cast(keys.size()) && gcInterpolateFlag(keys[active])) { + const float begin = static_cast(keys[active].timeMs); + const float end = static_cast(keys[active + 1].timeMs); + if (end > begin) blend = std::clamp((sampleTime - begin) / (end - begin), 0.0f, 1.0f); + } + return {active, blend, sampleTime}; +} + +// _DAT_006fcae8 in game471.exe is 250.0f; object visibility transitions are +// always this many milliseconds long, independently of the key spacing. +constexpr float kGcVisibilityFadeMs = 250.0f; + +struct GcVisibilityState { + bool draw = true; + float alpha = 1.0f; +}; + +struct GcBackgroundColors { + glm::vec3 topRight{0.0f, 0.01f, 0.02f}; + glm::vec3 topLeft{0.0f, 0.01f, 0.02f}; + glm::vec3 bottomRight{0.0f, 0.01f, 0.02f}; + glm::vec3 bottomLeft{0.0f, 0.01f, 0.02f}; +}; + +GcBackgroundColors gcBackgroundColorsAt(const LevelData& level, float timeMs) { + GcBackgroundColors result; + if (!level.gcStage || level.gcStage->backgroundColors.empty()) return result; + + const auto& keys = level.gcStage->backgroundColors; + const auto next = std::upper_bound( + keys.begin(), keys.end(), timeMs, + [](float time, const gc::BackgroundColorPoint& key) { + return time < static_cast(key.timeMs); + }); + const size_t index = next == keys.begin() + ? 0 + : static_cast(std::distance(keys.begin(), next) - 1); + const auto rgb = [](const gc::Color& color) { + return glm::vec3(color.r, color.g, color.b) / 255.0f; + }; + const auto read = [&](const gc::BackgroundColorPoint& key) { + return GcBackgroundColors{ + rgb(key.topRight), rgb(key.topLeft), + rgb(key.bottomRight), rgb(key.bottomLeft), + }; + }; + result = read(keys[index]); + + // FUN_00642390: the first trailing byte enables interpolation from the + // active color record to the next one. The second byte enables an + // audio-reactive HSV/value modulation; with no recovered analyser value, + // its neutral factor is 1 and the authored colors remain unchanged. + if (keys[index].interpolateToNext && index + 1 < keys.size()) { + const float begin = static_cast(keys[index].timeMs); + const float end = static_cast(keys[index + 1].timeMs); + const float blend = end > begin + ? std::clamp((timeMs - begin) / (end - begin), 0.0f, 1.0f) + : 0.0f; + const GcBackgroundColors following = read(keys[index + 1]); + result.topRight = glm::mix(result.topRight, following.topRight, blend); + result.topLeft = glm::mix(result.topLeft, following.topLeft, blend); + result.bottomRight = glm::mix(result.bottomRight, following.bottomRight, blend); + result.bottomLeft = glm::mix(result.bottomLeft, following.bottomLeft, blend); + } + return result; +} + +GcVisibilityState gcObjectVisibility(const gc::StageObject& object, float timeMs) { + if (object.visibility.empty()) return {}; + const auto& keys = object.visibility; + const GcKeySample sample = gcSampleObjectKeys(keys, timeMs); + const int active = sample.index; + if (active < 0) return {true, 1.0f}; + + bool visible = keys[active].visible; + float alpha = visible ? 1.0f : 0.0f; + if (active + 1 < static_cast(keys.size())) { + const auto& next = keys[active + 1]; + const float untilNext = static_cast(next.timeMs) - sample.sampledTimeMs; + if (next.visible != visible && next.fadeIn && + untilNext >= 0.0f && untilNext < kGcVisibilityFadeMs) { + const float remaining = std::clamp(untilNext / kGcVisibilityFadeMs, 0.0f, 1.0f); + alpha = next.visible ? 1.0f - remaining : remaining; + visible = true; + } + } + return {visible && alpha > 0.0f, alpha}; +} + +glm::vec3 gcPointValue(const gc::TransformPoint& point) { + return {point.value[0], point.value[1], point.value[2]}; +} + +bool gcSampleTransform(const std::vector& keys, float timeMs, + glm::vec3* value, float* blend, glm::vec3* nextValue) { + const GcKeySample sample = gcSampleObjectKeys(keys, timeMs); + if (sample.index < 0) return false; + *value = gcPointValue(keys[sample.index]); + *blend = sample.blendToNext; + *nextValue = *value; + if (sample.blendToNext > 0.0f && sample.index + 1 < static_cast(keys.size())) { + *nextValue = gcPointValue(keys[sample.index + 1]); + } + return true; +} + +glm::mat4 gcEulerRotation(const glm::vec3& degrees) { + // Exact FUN_005e0330 -> FUN_005e0220 convention. The stage stores XYZ + // degrees, but the quaternion builder receives (-Y, X, Z). Its quaternion + // is laid out WXYZ and FUN_005df790 emits the same column-major matrix as + // glm::mat4_cast. + const float a = glm::radians(-degrees.y) * 0.5f; + const float b = glm::radians(degrees.x) * 0.5f; + const float c = glm::radians(degrees.z) * 0.5f; + const float ca = std::cos(a), sa = std::sin(a); + const float cb = std::cos(b), sb = std::sin(b); + const float cc = std::cos(c), sc = std::sin(c); + const glm::quat q( + sc * sa * sb + cc * ca * cb, + sc * ca * sb + cc * sa * cb, + cc * ca * sb - sc * sa * cb, + sc * ca * cb - cc * sa * sb); + return glm::mat4_cast(q); +} + +glm::mat4 gcObjectTransform(const gc::StageObject& object, float timeMs) { + const glm::vec3 basePosition(object.position[0], object.position[1], object.position[2]); + const glm::vec3 baseRotation(object.rotation[0], object.rotation[1], object.rotation[2]); + const glm::vec3 baseScale(object.scale[0], object.scale[1], object.scale[2]); + glm::mat4 model = glm::translate(glm::mat4(1.0f), basePosition); + + glm::vec3 value(0.0f), nextValue(0.0f); + float blend = 0.0f; + if (gcSampleTransform(object.movement, timeMs, &value, &blend, &nextValue)) { + model = glm::translate(model, glm::mix(value, nextValue, blend)); + } + if (baseRotation.x != 0.0f || baseRotation.y != 0.0f || baseRotation.z != 0.0f) { + model *= gcEulerRotation(baseRotation); + } + if (gcSampleTransform(object.rotations, timeMs, &value, &blend, &nextValue)) { + // FUN_00643b20 interpolates the stored Euler vec3 first; it does not + // slerp quaternions despite converting the final value to one. + model *= gcEulerRotation(glm::mix(value, nextValue, blend)); + } + // In FUN_00643b20 an all-zero base scale means "not specified", not a + // collapsed object. + if (baseScale.x != 0.0f || baseScale.y != 0.0f || baseScale.z != 0.0f) { + model = glm::scale(model, baseScale); + } + if (gcSampleTransform(object.scaling, timeMs, &value, &blend, &nextValue)) { + model = glm::scale(model, glm::mix(value, nextValue, blend)); + } + return model; +} + +glm::vec4 gcObjectColor(const gc::StageObject& object, float timeMs) { + glm::vec4 color(object.color[0], object.color[1], object.color[2], object.color[3]); + const GcKeySample sample = gcSampleObjectKeys(object.colorChanges, timeMs); + if (sample.index < 0) return color; + const auto rgba = [](const gc::Color& c) { + return glm::vec4(c.r, c.g, c.b, c.a) / 255.0f; + }; + color = rgba(object.colorChanges[sample.index].color); + if (sample.blendToNext > 0.0f && sample.index + 1 < static_cast(object.colorChanges.size())) { + color = glm::mix(color, rgba(object.colorChanges[sample.index + 1].color), sample.blendToNext); + } + return color; +} + +int main(int argc, char* argv[]) { + // A direct stage path remains useful for reverse/debug work. With no path + // (or --menu), use the arcade-style catalog selector. + std::string selectedPath; + fs::path requestedGcRoot; + GcGameplayItem gameplayItem = GcGameplayItem::None; + bool menuRequested = false; + for (int i = 1; i < argc; ++i) { + const std::string argument = argv[i]; + if (argument == "--help" || argument == "-h") { + printUsage(argv[0]); + return 0; + } + if (argument == "--menu") { + if (!selectedPath.empty()) { + std::cerr << "--menu cannot be combined with a direct stage path.\n"; + printUsage(argv[0]); + return 1; + } + menuRequested = true; + if (i + 1 < argc && std::string_view(argv[i + 1]).rfind("--", 0) != 0) { + requestedGcRoot = fs::path(argv[++i]); + } + continue; + } + std::string itemValue; + if (argument == "--item") { + if (i + 1 >= argc) { + std::cerr << "--item requires none, mirror, or reverse.\n"; + return 1; + } + itemValue = argv[++i]; + } else if (argument.rfind("--item=", 0) == 0) { + itemValue = argument.substr(7); + } + if (!itemValue.empty()) { + if (!parseGcGameplayItem(itemValue, &gameplayItem)) { + std::cerr << "Unknown gameplay item: " << itemValue << '\n'; + printUsage(argv[0]); + return 1; + } + continue; + } + if (argument.rfind("--", 0) == 0) { + std::cerr << "Unknown option: " << argument << '\n'; + printUsage(argv[0]); + return 1; + } + if (menuRequested) { + if (!requestedGcRoot.empty()) { + std::cerr << "Only one GC root may follow --menu.\n"; + return 1; + } + requestedGcRoot = fs::path(argument); + } else if (selectedPath.empty()) { + selectedPath = argument; + } else { + std::cerr << "Unexpected argument: " << argument << '\n'; + printUsage(argv[0]); + return 1; + } + } + const bool selectFromCatalog = menuRequested || selectedPath.empty(); + + // Engine/window init has to precede the graphical selector. + if (!SDL_Init(SDL_INIT_VIDEO | SDL_INIT_AUDIO | SDL_INIT_GAMEPAD)) { + std::cerr << "SDL_Init Error: " << SDL_GetError() << std::endl; + return 1; + } + // game471 reads the same fixed portrait size from data/system.cfg. + SDL_Window* window = SDL_CreateWindow("OpenRoller", kGameWidth, kGameHeight, SDL_WINDOW_OPENGL); + if (!window) { + std::cerr << "SDL_CreateWindow Error: " << SDL_GetError() << std::endl; + return 1; + } + SDL_GLContext gl_context = SDL_GL_CreateContext(window); + if (!gl_context) { + std::cerr << "SDL_GL_CreateContext Error: " << SDL_GetError() << std::endl; + return 1; + } + load_gl_functions(); + + int initialWindowW = 0; + int initialWindowH = 0; + int initialPixelW = 0; + int initialPixelH = 0; + SDL_GetWindowSize(window, &initialWindowW, &initialWindowH); + SDL_GetWindowSizeInPixels(window, &initialPixelW, &initialPixelH); + std::cout << "Window: " << initialWindowW << 'x' << initialWindowH + << " drawable=" << initialPixelW << 'x' << initialPixelH << std::endl; + + glEnable(GL_BLEND); glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); + glEnable(GL_DEPTH_TEST); + + fs::path gcRoot = requestedGcRoot; + if (gcRoot.empty() && !selectedPath.empty()) { + const fs::path candidate = + fs::path(selectedPath).parent_path().parent_path().parent_path(); + if (fs::is_regular_file(candidate / "data" / "boot" / "stage_param.dat")) { + gcRoot = candidate; + } + } + if (gcRoot.empty()) { + fs::path probe = fs::current_path(); + for (int depth = 0; depth < 8 && !probe.empty(); ++depth) { + if (fs::is_regular_file(probe / "GC" / "data" / "boot" / "stage_param.dat")) { + gcRoot = probe / "GC"; + break; + } + if (fs::is_regular_file(probe / "data" / "boot" / "stage_param.dat")) { + gcRoot = probe; + break; + } + if (probe == probe.parent_path()) break; + probe = probe.parent_path(); + } + } + if (selectFromCatalog) { + if (gcRoot.empty()) { + std::cerr << "Could not locate GC/data/boot/stage_param.dat; use --menu /path/to/GC" << std::endl; + return 1; + } + if (!runSongSelect(window, gcRoot, &selectedPath)) { + SDL_GL_DestroyContext(gl_context); + SDL_DestroyWindow(window); + SDL_Quit(); + return 0; + } + } + + std::cout << "Loading: " << selectedPath << "..." << std::endl; + std::cout << "Gameplay item: " << gcGameplayItemName(gameplayItem) + << " (id " << static_cast(gameplayItem) << ")" << std::endl; + + // Resolve renderer assets independently of the caller's working directory. + // The development binary and a configured shader copy live below build/. + // Resolve from the executable as well so launching OpenRoller from the + // repository root cannot compile empty programs and show a blank stage. + fs::path shaderDir = fs::current_path() / "shaders"; + if (!fs::is_regular_file(shaderDir / "bg.vert")) { + fs::path probe = fs::path(SDL_GetBasePath()); + for (int depth = 0; depth < 4 && !probe.empty(); ++depth) { + const fs::path candidate = probe / "shaders"; + if (fs::is_regular_file(candidate / "bg.vert")) { + shaderDir = candidate; + break; + } + if (probe == probe.parent_path()) break; + probe = probe.parent_path(); + } + } + if (!fs::is_regular_file(shaderDir / "bg.vert")) { + std::cerr << "Renderer shaders not found (looked from cwd and executable path)." + << std::endl; + return 1; + } + const auto shaderFile = [&](const char* name) { return (shaderDir / name).string(); }; + const std::string bgVert = shaderFile("bg.vert"), bgFrag = shaderFile("bg.frag"); + const std::string lineVert = shaderFile("line.vert"), lineFrag = shaderFile("line.frag"); + const std::string routeVert = shaderFile("route.vert"), routeFrag = shaderFile("route.frag"); + const std::string noteVert = shaderFile("note.vert"), noteFrag = shaderFile("note.frag"); + const std::string particleVert = shaderFile("particle.vert"), particleFrag = shaderFile("particle.frag"); + const std::string modelVert = shaderFile("model.vert"), modelFrag = shaderFile("model.frag"); + Shader bgShader(bgVert.c_str(), bgFrag.c_str()); + Shader lineShader(lineVert.c_str(), lineFrag.c_str()); + Shader routeShader(routeVert.c_str(), routeFrag.c_str()); + Shader noteShader(noteVert.c_str(), noteFrag.c_str()); + Shader partShader(particleVert.c_str(), particleFrag.c_str()); + Shader modelShader(modelVert.c_str(), modelFrag.c_str()); + + LevelData level = LevelLoader::load(selectedPath); + if (level.trackPoints.size() < 2) { + std::cerr << "Level load failed or track is too short: " << selectedPath << std::endl; + return 1; + } + + DdsTexture backdrop; + // *_menu.dds is UI artwork, not the gameplay background. Do not even load + // it during normal startup; B remains a lazy debug comparison path. + + AudioManager audio; + bool audioOk = false; + if (!level.audioPath.empty()) { + if (!level.audioShotPath.empty()) { + audioOk = audio.loadMusicPair(level.audioPath, level.audioShotPath, + level.audioBgmGain, level.audioShotGain); + } else { + audioOk = audio.loadMusic(level.audioPath, level.audioBgmGain); + } + if (audioOk && !level.gameplaySoundPaths[0].empty()) { + if (audio.loadGameplaySounds(level.gameplaySoundPaths, + level.gameplaySoundGains)) { + std::cout << "Gameplay SE loaded: Ver.3 Set (ALB/TP1/TP2, 2 voices each)" + << std::endl; + } else { + std::cerr << "Gameplay SE unavailable; continuing with stage audio." + << std::endl; + } + } + } + if (!audioOk) { + std::cerr << "Audio unavailable; using wall-clock playback." << std::endl; + } + + std::vector backgroundModels; + if (level.gcStage) { + backgroundModels.resize(level.gcStage->modelNames.size()); + const fs::path modelDir = fs::path(selectedPath).parent_path().parent_path() / "model"; + size_t loadedModels = 0; + for (size_t i = 0; i < level.gcStage->modelNames.size(); ++i) { + gc::TumoGeometry geometry; + std::string modelError; + const fs::path path = modelDir / (level.gcStage->modelNames[i] + ".tumo"); + if (!gc::LoadTumoGeometry(path.string(), &geometry, &modelError)) { + std::cerr << "TUMO geometry unavailable: " << level.gcStage->modelNames[i] + << ": " << modelError << std::endl; + continue; + } + auto& gpu = backgroundModels[i]; + glGenVertexArrays(1, &gpu.vao); + glGenBuffers(1, &gpu.vbo); + glBindVertexArray(gpu.vao); + glBindBuffer(GL_ARRAY_BUFFER, gpu.vbo); + std::vector vertices = toGlmVertices(geometry.triangles); + gpu.triangleCount = static_cast(vertices.size()); + gpu.solidLineFirst = static_cast(vertices.size()); + const std::vector solidLines = toGlmVertices(geometry.solidLines); + vertices.insert(vertices.end(), solidLines.begin(), solidLines.end()); + gpu.solidLineCount = static_cast(geometry.solidLines.size()); + gpu.wireLineFirst = static_cast(vertices.size()); + const std::vector wireframeLines = + toGlmVertices(geometry.wireframeLines); + vertices.insert(vertices.end(), wireframeLines.begin(), wireframeLines.end()); + gpu.wireLineCount = static_cast(geometry.wireframeLines.size()); + glBufferData(GL_ARRAY_BUFFER, vertices.size() * sizeof(glm::vec3), + vertices.data(), GL_STATIC_DRAW); + glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(glm::vec3), nullptr); + glEnableVertexAttribArray(0); + ++loadedModels; + } + std::cout << "TUMO geometry: " << loadedModels << '/' << backgroundModels.size() + << " models loaded" << std::endl; + } + + Spline track; + for (const auto& p : level.trackPoints) track.addPoint(p.position, p.type); + track.rebuildLUT(); + + std::vector notes; + for (const LevelNote& note : level.notes) { + notes.push_back({ + note.distance, note.timeMs, note.appearTimeMs, note.endTimeMs, note.endDistance, + note.rawType, note.effectiveType, note.adlib, note.markEffectId, + note.packedColor, note.merryCount, + gcDirectionScreenDegrees(level, note), note.beatDurationMs, note.earlyTimingMs, + note.lateTimingMs, note.missTimingMs, note.muteTimingMs, + note.markerFadeEndTimeMs, + {0, 0}, {0, 0}, 0, 0.0f, note.gcTiming, false, + }); + } + + float totalD = track.getTotalLength(); + if (totalD <= 0.1f) { + std::cerr << "Level track length is too small." << std::endl; + return 1; + } + std::vector routeVertices; + routeVertices.reserve(level.trackPoints.size() + 4); + unsigned int routeVAO = 0, routeVBO = 0; + glGenVertexArrays(1, &routeVAO); glGenBuffers(1, &routeVBO); + glBindVertexArray(routeVAO); glBindBuffer(GL_ARRAY_BUFFER, routeVBO); + glBufferData(GL_ARRAY_BUFFER, + (level.trackPoints.size() + 4) * sizeof(GcRouteVertex), + nullptr, GL_DYNAMIC_DRAW); + glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(GcRouteVertex), (void*)0); + glEnableVertexAttribArray(0); + glVertexAttribPointer(1, 4, GL_FLOAT, GL_FALSE, sizeof(GcRouteVertex), + (void*)offsetof(GcRouteVertex, color)); + glEnableVertexAttribArray(1); + + std::vector durationMesh; + for (Note& note : notes) { + // game471 builds duration bodies in four different paths. BEAT has no + // continuous body at all; it is expanded into targets further below. + if (!gcDurationNote(note.effectiveType) || note.effectiveType == 5) continue; + const float span = std::max(0.0f, note.endDist - note.dist); + const float step = note.effectiveType == 4 ? 0.15f : 0.20f; + const int samples = std::max(2, static_cast(std::ceil(span / step)) + 1); + + auto appendBand = [&](int band, float centerOffset, bool scratchHelix) { + note.durationFirst[band] = static_cast(durationMesh.size()); + for (int i = 0; i < samples; ++i) { + const float u = static_cast(i) / static_cast(samples - 1); + const float d = glm::mix(note.dist, note.endDist, u); + glm::vec3 tangent = track.getTangentAtDistance(d); + glm::vec3 right = glm::cross(tangent, glm::vec3(0.0f, 1.0f, 0.0f)); + if (glm::length(right) < 0.001f) right = glm::vec3(1.0f, 0.0f, 0.0f); + else right = glm::normalize(right); + glm::vec3 localUp = glm::cross(right, tangent); + if (glm::length(localUp) < 0.001f) localUp = glm::vec3(0.0f, 1.0f, 0.0f); + else localUp = glm::normalize(localUp); + + glm::vec3 center = track.getPositionAtDistance(d) + glm::vec3(0.0f, 0.05f, 0.0f); + if (scratchHelix) { + // FUN_005ebaa0 samples SCRATCH every 0.15 world units and + // derives two opposing paths from radius 0.2, rotating the + // offset by 45 degrees for every sample. + const float phase = glm::radians(static_cast(i) * 45.0f); + const float sign = band == 0 ? 1.0f : -1.0f; + center += sign * 0.20f * (std::cos(phase) * right + std::sin(phase) * localUp); + } else { + center += centerOffset * right; + } + const float timeMs = glm::mix(note.timeMs, note.endTimeMs, u); + durationMesh.push_back({center, -1.0f, right, timeMs}); + durationMesh.push_back({center, 1.0f, right, timeMs}); + } + note.durationCount[band] = static_cast(durationMesh.size()) - note.durationFirst[band]; + }; + + if (note.effectiveType == 15) { + // FUN_00646d70 uses the literal inner/outer offsets 0.2 and 0.5, + // producing two bands centred at +/-0.35 with half-width 0.15. + note.durationBands = 2; + note.durationHalfWidth = 0.15f; + appendBand(0, -0.35f, false); + appendBand(1, 0.35f, false); + } else if (note.effectiveType == 4) { + note.durationBands = 2; + note.durationHalfWidth = 0.035f; + appendBand(0, 0.0f, true); + appendBand(1, 0.0f, true); + } else { + note.durationBands = 1; + note.durationHalfWidth = 0.10f; + appendBand(0, 0.0f, false); + } + } + unsigned int durationVAO = 0, durationVBO = 0; + glGenVertexArrays(1, &durationVAO); glGenBuffers(1, &durationVBO); + glBindVertexArray(durationVAO); glBindBuffer(GL_ARRAY_BUFFER, durationVBO); + glBufferData(GL_ARRAY_BUFFER, durationMesh.size() * sizeof(RailVertex), durationMesh.data(), GL_STATIC_DRAW); + glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(RailVertex), (void*)0); glEnableVertexAttribArray(0); + glVertexAttribPointer(1, 1, GL_FLOAT, GL_FALSE, sizeof(RailVertex), (void*)offsetof(RailVertex, side)); glEnableVertexAttribArray(1); + glVertexAttribPointer(2, 3, GL_FLOAT, GL_FALSE, sizeof(RailVertex), (void*)offsetof(RailVertex, right)); glEnableVertexAttribArray(2); + glVertexAttribPointer(3, 1, GL_FLOAT, GL_FALSE, sizeof(RailVertex), (void*)offsetof(RailVertex, timeMs)); glEnableVertexAttribArray(3); + + std::vector noteCircle; + for(int i=0; i<=16; ++i) { + float a=i/16.0f*6.28318530718f; + noteCircle.push_back({{cosf(a)*0.5f, sinf(a)*0.5f, 0}, 0, {0,0,0}}); + } + unsigned int noteVAO, noteVBO; + glGenVertexArrays(1, ¬eVAO); glGenBuffers(1, ¬eVBO); + glBindVertexArray(noteVAO); glBindBuffer(GL_ARRAY_BUFFER, noteVBO); + glBufferData(GL_ARRAY_BUFFER, noteCircle.size()*sizeof(RailVertex), noteCircle.data(), GL_STATIC_DRAW); + glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(RailVertex), (void*)0); glEnableVertexAttribArray(0); + glVertexAttribPointer(1, 1, GL_FLOAT, GL_FALSE, sizeof(RailVertex), (void*)offsetof(RailVertex, side)); glEnableVertexAttribArray(1); + glVertexAttribPointer(2, 3, GL_FLOAT, GL_FALSE, sizeof(RailVertex), (void*)offsetof(RailVertex, right)); glEnableVertexAttribArray(2); + glVertexAttribPointer(3, 1, GL_FLOAT, GL_FALSE, sizeof(RailVertex), (void*)offsetof(RailVertex, timeMs)); glEnableVertexAttribArray(3); + + lineShader.use(); + lineShader.setFloat("uAlpha", 1.0f); + + struct NoteSpriteVertex { glm::vec3 pos; glm::vec2 uv; }; + const NoteSpriteVertex noteQuad[] = { + {{-0.5f, -0.5f, 0.002f}, {0.0f, 1.0f}}, {{0.5f, -0.5f, 0.002f}, {1.0f, 1.0f}}, + {{-0.5f, 0.5f, 0.002f}, {0.0f, 0.0f}}, {{-0.5f, 0.5f, 0.002f}, {0.0f, 0.0f}}, + {{ 0.5f, -0.5f, 0.002f}, {1.0f, 1.0f}}, {{0.5f, 0.5f, 0.002f}, {1.0f, 0.0f}}, + }; + unsigned int noteSpriteVAO = 0, noteSpriteVBO = 0; + glGenVertexArrays(1, ¬eSpriteVAO); glGenBuffers(1, ¬eSpriteVBO); + glBindVertexArray(noteSpriteVAO); glBindBuffer(GL_ARRAY_BUFFER, noteSpriteVBO); + glBufferData(GL_ARRAY_BUFFER, sizeof(noteQuad), noteQuad, GL_STATIC_DRAW); + glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(NoteSpriteVertex), (void*)0); glEnableVertexAttribArray(0); + glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, sizeof(NoteSpriteVertex), (void*)offsetof(NoteSpriteVertex, uv)); glEnableVertexAttribArray(1); + + const fs::path dataDir = fs::path(selectedPath).parent_path().parent_path(); + const fs::path effectDir = dataDir / "effect" / "game"; + const fs::path skinDir = dataDir / "skin" / "skin1"; + const fs::path skinCommonDir = dataDir / "skin" / "common"; + GcTargetEffectBank noteEffects; + std::vector noteTextures; + std::vector skinTextures; + std::string noteEffectError; + const bool noteEffectsLoaded = + noteEffects.load((effectDir / "effect.dat").string(), (skinDir / "uv.dat").string(), ¬eEffectError) && + loadPngTextureList((skinDir / "img.dat").string(), skinTextures, ¬eEffectError) && + (noteTextures.resize(std::max(2, skinTextures.size())), + loadPngTexture((skinCommonDir / "common.png").string(), noteTextures[0], ¬eEffectError)) && + loadPngTexture((skinCommonDir / "common2.png").string(), noteTextures[1], ¬eEffectError); + if (noteEffectsLoaded) { + for (size_t i = 0; i < skinTextures.size(); ++i) { + if (skinTextures[i].id != 0) noteTextures[i] = skinTextures[i]; + } + } + if (noteEffectsLoaded) { + size_t textureCount = 0; + for (const DdsTexture& texture : noteTextures) textureCount += texture.id != 0; + std::cout << "GC note effects: effect/game/effect.dat + skin/skin1 (" + << textureCount << " textures)" << std::endl; + } else { + std::cerr << "GC note effects unavailable: " << noteEffectError << std::endl; + } + + GcTargetEffectBank helperEffects; + DdsTexture helperAtlas; + std::string helperEffectError; + const bool helperEffectsLoaded = + helperEffects.load((effectDir / "efcdata.dat").string(), (effectDir / "uvdata.dat").string(), &helperEffectError) && + loadPngTexture((effectDir / "img13.bin").string(), helperAtlas, &helperEffectError); + if (helperEffectsLoaded) { + std::cout << "GC control helpers: efcdata.dat + img13.bin " + << helperAtlas.width << 'x' << helperAtlas.height << std::endl; + } else { + std::cerr << "GC control helpers unavailable: " << helperEffectError << std::endl; + } + + std::vector avatarMesh = { + {{0, 0.4f, 0}, 0, {0,0,0}}, {{-0.25f, 0, 0.25f}, 0, {0,0,0}}, {{0.25f, 0, 0.25f}, 0, {0,0,0}}, + {{0, 0.4f, 0}, 0, {0,0,0}}, {{0.25f, 0, 0.25f}, 0, {0,0,0}}, {{0.25f, 0, -0.25f}, 0, {0,0,0}}, + {{0, 0.4f, 0}, 0, {0,0,0}}, {{0.25f, 0, -0.25f}, 0, {0,0,0}}, {{-0.25f, 0, -0.25f}, 0, {0,0,0}}, + {{0, 0.4f, 0}, 0, {0,0,0}}, {{-0.25f, 0, -0.25f}, 0, {0,0,0}}, {{-0.25f, 0, 0.25f}, 0, {0,0,0}}, + {{0, -0.4f, 0}, 0, {0,0,0}}, {{0.25f, 0, 0.25f}, 0, {0,0,0}}, {{-0.25f, 0, 0.25f}, 0, {0,0,0}}, + {{0, -0.4f, 0}, 0, {0,0,0}}, {{0.25f, 0, -0.25f}, 0, {0,0,0}}, {{0.25f, 0, 0.25f}, 0, {0,0,0}}, + {{0, -0.4f, 0}, 0, {0,0,0}}, {{-0.25f, 0, -0.25f}, 0, {0,0,0}}, {{0.25f, 0, -0.25f}, 0, {0,0,0}}, + {{0, -0.4f, 0}, 0, {0,0,0}}, {{-0.25f, 0, 0.25f}, 0, {0,0,0}}, {{-0.25f, 0, -0.25f}, 0, {0,0,0}} + }; + unsigned int avVAO, avVBO; + glGenVertexArrays(1, &avVAO); glGenBuffers(1, &avVBO); + glBindVertexArray(avVAO); glBindBuffer(GL_ARRAY_BUFFER, avVBO); + glBufferData(GL_ARRAY_BUFFER, avatarMesh.size()*sizeof(RailVertex), avatarMesh.data(), GL_STATIC_DRAW); + glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(RailVertex), (void*)0); glEnableVertexAttribArray(0); + glVertexAttribPointer(1, 1, GL_FLOAT, GL_FALSE, sizeof(RailVertex), (void*)offsetof(RailVertex, side)); glEnableVertexAttribArray(1); + glVertexAttribPointer(2, 3, GL_FLOAT, GL_FALSE, sizeof(RailVertex), (void*)offsetof(RailVertex, right)); glEnableVertexAttribArray(2); + glVertexAttribPointer(3, 1, GL_FLOAT, GL_FALSE, sizeof(RailVertex), (void*)offsetof(RailVertex, timeMs)); glEnableVertexAttribArray(3); + + unsigned int partVAO, partVBO; + glGenVertexArrays(1, &partVAO); glGenBuffers(1, &partVBO); + glBindVertexArray(partVAO); glBindBuffer(GL_ARRAY_BUFFER, partVBO); + glBufferData(GL_ARRAY_BUFFER, 1000 * 6 * 6 * sizeof(float), NULL, GL_DYNAMIC_DRAW); + glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)0); glEnableVertexAttribArray(0); + glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)(3 * sizeof(float))); glEnableVertexAttribArray(1); + glVertexAttribPointer(2, 1, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)(5 * sizeof(float))); glEnableVertexAttribArray(2); + + unsigned int bgVAO, bgVBO; + glGenVertexArrays(1, &bgVAO); glGenBuffers(1, &bgVBO); + glBindVertexArray(bgVAO); glBindBuffer(GL_ARRAY_BUFFER, bgVBO); + float quad[] = { -1, -1, 1, -1, -1, 1, 1, 1 }; + glBufferData(GL_ARRAY_BUFFER, sizeof(quad), quad, GL_STATIC_DRAW); + glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 2 * sizeof(float), (void*)0); glEnableVertexAttribArray(0); + + std::vector particles; + std::vector judgmentEffects; + std::vector gamepads; + bool running = true; + bool chartCompleted = false; + // The menu jacket is a debug comparison overlay, not a gameplay layer. + // Keep it opt-in via B instead of enabling it for every chart. + bool backdropEnabled = false; + bool stageObjectsEnabled = true; + // Do not start either clock until every model, mesh, effect bank and + // texture is resident. Previously audio.play() ran before TUMO/effect + // loading, so the first rendered frame could already be several seconds + // into a chart and its opening notes were immediately judged as misses. + if (audioOk) audio.play(); + const Uint64 fallbackStartTicks = SDL_GetTicks(); + Uint64 lastTicks = fallbackStartTicks; + glm::vec3 sCP(0), sLA(0); bool first = true; + float previousCurrentMs = -1.0f; + float previousProgressD = -1.0f; + uint32_t pressedInputs = 0; + + while (running) { + Uint64 curTicks = SDL_GetTicks(); + float dt = (curTicks - lastTicks) / 1000.0f; lastTicks = curTicks; + SDL_Event e; + float aTime = audioOk ? (float)audio.getTime() : (float)(SDL_GetTicks() - fallbackStartTicks) / 1000.0f; + float speedP = level.config.count("speed") ? level.config.at("speed") : 1.0f; + bool useGcCamera = level.config.count("gc_camera") && level.config.at("gc_camera") > 0.5f && + !level.gcCameraKeys.empty(); + float curT = 0.0f; + float progressD = 0.0f; + // The original evaluator FUN_005e9e20 receives the gameplay clock as + // an integer millisecond. SDL ticks take a float round-trip through + // AudioManager, so snap back to that same clock domain here. + float currentMs = std::round(aTime * 1000.0f); + // The DAT timeline describes authored gameplay data, not the lifetime + // of the stage. Some charts deliberately stop emitting track/camera + // keys before the backing track ends. Keep evaluating their final + // state until the music itself reaches EOF; only use the DAT duration + // as a fallback when no audio could be loaded. + const float datDurationMs = level.config.count("gc_duration_ms") + ? level.config.at("gc_duration_ms") + : level.trackPoints.back().timeMs; + const bool playbackFinished = audioOk + ? (audio.getDuration() > 0.0 && + static_cast(aTime) >= audio.getDuration()) + : (datDurationMs > 1.0f && currentMs >= datDurationMs); + if (playbackFinished) { + chartCompleted = true; + running = false; + break; + } + if (useGcCamera) { + curT = getTrackParamAtMs(level.trackPoints, currentMs); + progressD = getDistanceAtTrackParam(track, curT, level.trackPoints.size()); + } else { + progressD = fmodf(aTime * speedP * 30.0f, totalD - 0.1f); + curT = (progressD / totalD) * (float)(level.trackPoints.size() - 1); + } + float fov = useGcCamera + ? (level.config.count("gc_fov") ? level.config.at("gc_fov") : 75.0f) + : getTimelineValue(level.timeline, curT, "fov", 90.0f); + float cy = getTimelineValue(level.timeline, curT, "cam_y", 1.0f); + float cz = getTimelineValue(level.timeline, curT, "cam_z", 3.5f); + const float drawBehind = level.config.count("gc_draw_behind") ? level.config.at("gc_draw_behind") : totalD; + const float defaultDrawAhead = level.config.count("gc_draw_ahead") ? level.config.at("gc_draw_ahead") : totalD; + // TrackDrawDist is an event list, not a tween curve. Before its first + // event the StageConfig forward distance remains active; each event + // replaces it. A lone end-of-song zero (Knight Rider) must therefore + // not erase the complete upcoming route from frame zero. + const float drawAhead = getTimelineStepValue( + level.timeline, curT, "gc_draw_ahead", defaultDrawAhead); + + const bool chartLooped = useGcCamera + ? previousCurrentMs >= 0.0f && currentMs < previousCurrentMs + : previousProgressD >= 0.0f && progressD < previousProgressD; + if (chartLooped) { + for (Note& note : notes) { + note.hit = false; + note.holding = false; + note.shotMuteApplied = false; + note.inputStartTimeMs = -1.0f; + note.lastInputTimeMs = -1.0f; + note.inputMask = 0; + note.lastInputBit = 0; + } + judgmentEffects.clear(); + pressedInputs = 0; + if (audioOk) audio.setShotMuted(false); + } + previousCurrentMs = currentMs; + previousProgressD = progressD; + + const auto finishGcNote = [&](Note& note, GcJudgment rank) { + note.hit = true; + note.holding = false; + note.shotMuteApplied = rank == GcJudgment::Miss; + note.inputStartTimeMs = -1.0f; + note.lastInputTimeMs = -1.0f; + note.inputMask = 0; + note.lastInputBit = 0; + judgmentEffects.push_back({ + progressD, currentMs, rank, static_cast(rand() % 360), + }); + // game471's GameTotalTask applies IsMute to channel 13 every + // frame: MISS -> 0.0, any successful rank -> stage SHOT volume. + if (audioOk) audio.setShotMuted(rank == GcJudgment::Miss); + if (audioOk && rank != GcJudgment::Miss && note.adlib) { + audio.playGameplaySound(AudioManager::GameplaySound::Adlib); + } + }; + + const auto performPress = [&](uint32_t inputBit) { + if (level.gcStage && audioOk) { + // game471 triggers TP1/TP2 from the booster edge, before note + // judgement; a mistimed press therefore still makes a tap SE. + audio.playGameplaySound(gcTapSound(inputBit)); + } + if (!level.gcStage) { + for (Note& note : notes) { + if (note.hit || std::fabs(note.dist - progressD) >= 3.0f) continue; + note.hit = true; + const glm::vec3 hitPos = track.getPositionAtDistance(note.dist); + for (int i = 0; i < 40; ++i) { + particles.push_back({hitPos, + {((float)rand() / RAND_MAX - 0.5f) * 20, + ((float)rand() / RAND_MAX - 0.5f) * 20, + ((float)rand() / RAND_MAX - 0.5f) * 20}, 1.0f}); + } + break; + } + return; + } + + for (Note& note : notes) { + if (note.hit || !note.holding || !gcRhythmLongTarget(note.effectiveType)) continue; + if (note.effectiveType == 5 || inputBit != note.lastInputBit) { + note.lastInputTimeMs = currentMs; + note.lastInputBit = inputBit; + } + return; + } + + // Mobile checkHitMarkDualTap and arcade's type-9 input aggregator + // retain the first independent press and judge on the second. + // Dual HOLD similarly begins its measured duration at press two. + for (Note& note : notes) { + if (note.hit || note.inputMask == 0 || (note.inputMask & inputBit) != 0 || + !(gcDualTapTarget(note.effectiveType) || + (note.effectiveType == 15 && !note.holding))) continue; + const float error = currentMs - note.timeMs; + if (error < -note.earlyTimingMs || error > note.lateTimingMs) continue; + note.inputMask |= inputBit; + if (gcDualTapTarget(note.effectiveType)) { + const float greatMin = level.config.count("gc_great_min_ms") + ? level.config.at("gc_great_min_ms") : 32.0f; + finishGcNote(note, gcTapJudgment(note, note.inputStartTimeMs, greatMin)); + } else { + note.holding = true; + note.inputStartTimeMs = currentMs; + if (audioOk) audio.setShotMuted(false); + } + return; + } + + Note* candidate = nullptr; + float candidateError = 0.0f; + for (Note& note : notes) { + if (note.hit || note.inputMask != 0 || + !(gcTapTarget(note.effectiveType) || gcDualTapTarget(note.effectiveType) || + gcHoldTarget(note.effectiveType) || + gcRhythmLongTarget(note.effectiveType))) continue; + const float error = currentMs - note.timeMs; + if (error < -note.earlyTimingMs || error > note.lateTimingMs) continue; + if (!candidate || std::fabs(error) < candidateError) { + candidate = ¬e; + candidateError = std::fabs(error); + } + } + if (!candidate) return; + + if (gcTapTarget(candidate->effectiveType)) { + const float greatMin = level.config.count("gc_great_min_ms") + ? level.config.at("gc_great_min_ms") : 32.0f; + finishGcNote(*candidate, gcTapJudgment(*candidate, currentMs, greatMin)); + } else { + candidate->inputMask = inputBit; + candidate->inputStartTimeMs = currentMs; + candidate->lastInputTimeMs = currentMs; + candidate->lastInputBit = inputBit; + candidate->holding = candidate->effectiveType == 3 || + gcRhythmLongTarget(candidate->effectiveType); + if (candidate->holding && audioOk) audio.setShotMuted(false); + } + }; + + const auto performRelease = [&](uint32_t inputBit) { + if (!level.gcStage) return; + for (Note& note : notes) { + if (note.hit || !gcHoldTarget(note.effectiveType) || + (note.inputMask & inputBit) == 0) continue; + if (note.holding) { + finishGcNote(note, gcLongJudgment(note, note.inputStartTimeMs, currentMs)); + } else { + // First half of DUAL HOLD must still be down when the + // second independent input arrives. + note.inputMask &= ~inputBit; + if (note.inputMask == 0) note.inputStartTimeMs = -1.0f; + } + return; + } + }; + + while (SDL_PollEvent(&e)) { + if (e.type == SDL_EVENT_QUIT) running = false; + if (e.type == SDL_EVENT_KEY_DOWN && !e.key.repeat && + e.key.key == SDLK_CAPSLOCK) { + if (audioOk) audio.pause(); + runServiceMenu(window, defaultCabinetBackend(), selectedPath); + if (audioOk) audio.resume(); + lastTicks = SDL_GetTicks(); + continue; + } + if (e.type == SDL_EVENT_GAMEPAD_ADDED) { + if (SDL_Gamepad* gamepad = SDL_OpenGamepad(e.gdevice.which)) { + gamepads.push_back(gamepad); + std::cout << "Gamepad opened: " << SDL_GetGamepadName(gamepad) << std::endl; + } + } + if (e.type == SDL_EVENT_GAMEPAD_REMOVED) { + const auto removed = std::remove_if(gamepads.begin(), gamepads.end(), + [&](SDL_Gamepad* gamepad) { + if (SDL_GetGamepadID(gamepad) != e.gdevice.which) return false; + SDL_CloseGamepad(gamepad); + return true; + }); + gamepads.erase(removed, gamepads.end()); + } + if (e.type == SDL_EVENT_KEY_DOWN && e.key.key == SDLK_B) { + if (backdrop.id == 0 && !level.backgroundPath.empty()) { + std::string textureError; + if (loadDdsTexture(level.backgroundPath, backdrop, &textureError)) { + std::cout << "Backdrop loaded for debug comparison: " << level.backgroundPath << ' ' + << backdrop.width << 'x' << backdrop.height << std::endl; + } else { + std::cerr << "Backdrop unavailable: " << level.backgroundPath + << ": " << textureError << std::endl; + } + } + if (backdrop.id != 0) { + backdropEnabled = !backdropEnabled; + std::cout << "Backdrop " << (backdropEnabled ? "enabled" : "disabled") << std::endl; + } + } + if (e.type == SDL_EVENT_KEY_DOWN && e.key.key == SDLK_O && level.gcStage) { + stageObjectsEnabled = !stageObjectsEnabled; + std::cout << "Stage objects " << (stageObjectsEnabled ? "enabled" : "disabled") << std::endl; + } + if (e.type == SDL_EVENT_KEY_DOWN && !e.key.repeat) { + const uint32_t inputBit = gcKeyboardInputBit(e.key.key); + if (inputBit != 0 && (pressedInputs & inputBit) == 0) { + pressedInputs |= inputBit; + performPress(inputBit); + } + } + if (e.type == SDL_EVENT_KEY_UP) { + const uint32_t inputBit = gcKeyboardInputBit(e.key.key); + if (inputBit != 0 && (pressedInputs & inputBit) != 0) { + performRelease(inputBit); + pressedInputs &= ~inputBit; + } + } + if (e.type == SDL_EVENT_GAMEPAD_BUTTON_DOWN) { + const uint32_t inputBit = gcGamepadInputBit(e.gbutton.button); + if (inputBit != 0 && (pressedInputs & inputBit) == 0) { + pressedInputs |= inputBit; + performPress(inputBit); + } + } + if (e.type == SDL_EVENT_GAMEPAD_BUTTON_UP) { + const uint32_t inputBit = gcGamepadInputBit(e.gbutton.button); + if (inputBit != 0 && (pressedInputs & inputBit) != 0) { + performRelease(inputBit); + pressedInputs &= ~inputBit; + } + } + } + + if (level.gcStage) { + // The note remains judgeable until LimitTiming, but the original + // closes the SHOT gate earlier at note time + MuteTiming. A late + // successful input opens it again immediately. + for (Note& note : notes) { + const bool supported = gcTapTarget(note.effectiveType) || + gcDualTapTarget(note.effectiveType) || gcHoldTarget(note.effectiveType) || + gcRhythmLongTarget(note.effectiveType); + if (!supported || note.hit || note.holding || note.shotMuteApplied) continue; + if (currentMs >= note.timeMs + note.muteTimingMs) { + note.shotMuteApplied = true; + if (audioOk) audio.setShotMuted(true); + } + } + for (Note& note : notes) { + if (note.hit) continue; + if (gcRhythmLongTarget(note.effectiveType) && note.holding) { + const float enableMs = note.effectiveType == 4 + ? (level.config.count("gc_scratch_enable_ms") + ? level.config.at("gc_scratch_enable_ms") : 250.0f) + : (level.config.count("gc_beat_enable_ms") + ? level.config.at("gc_beat_enable_ms") : 200.0f); + const bool bodyEnded = currentMs >= note.endTimeMs; + const bool inputExpired = currentMs - note.lastInputTimeMs > enableMs; + if (bodyEnded || inputExpired) { + float coveredEndMs = note.lastInputTimeMs; + if (bodyEnded && note.endTimeMs - note.lastInputTimeMs < 1000.0f / 60.0f) { + coveredEndMs = note.endTimeMs; + } + finishGcNote(note, + gcLongJudgment(note, note.inputStartTimeMs, coveredEndMs)); + } + continue; + } + if (gcHoldTarget(note.effectiveType) && note.holding && + currentMs >= note.endTimeMs) { + finishGcNote(note, + gcLongJudgment(note, note.inputStartTimeMs, note.endTimeMs)); + continue; + } + const bool supported = gcTapTarget(note.effectiveType) || + gcDualTapTarget(note.effectiveType) || gcHoldTarget(note.effectiveType) || + gcRhythmLongTarget(note.effectiveType); + if (supported && !note.holding && + currentMs > note.timeMs + note.lateTimingMs) { + finishGcNote(note, GcJudgment::Miss); + } + } + } + for(int i=0; i<(int)particles.size(); ++i) { + particles[i].pos += particles[i].vel * dt; particles[i].life -= dt * 2.0f; + if(particles[i].life <= 0) { particles.erase(particles.begin() + i); i--; } + } + judgmentEffects.erase( + std::remove_if(judgmentEffects.begin(), judgmentEffects.end(), + [&](const GcJudgmentEffect& effect) { + const int effectId = gcJudgmentEffectId(effect.rank); + const int frames = noteEffectsLoaded ? noteEffects.lifetime(effectId) : 20; + return currentMs - effect.startTimeMs >= + std::max(1, frames) * (1000.0f / 60.0f); + }), + judgmentEffects.end()); + + glm::vec3 pP = track.getPositionAtDistance(progressD); + glm::vec3 tan = track.getTangentAtDistance(progressD); + glm::vec3 worldUp = glm::vec3(0, 1, 0); + glm::vec3 right = glm::cross(tan, worldUp); + if (glm::length(right) < 0.001f) right = glm::vec3(1, 0, 0); + else right = glm::normalize(right); + + GcCameraState gcCamera; + glm::vec3 tCP; + glm::vec3 tLA; + glm::vec3 tUp = worldUp; + if (useGcCamera) { + gcCamera = evaluateGcCamera(level, currentMs); + tCP = gcCamera.eye; + tLA = gcCamera.target; + tUp = gcCamera.up; + } else { + tCP = pP + worldUp * cy + tan * -cz; + tLA = pP + tan * 10.0f; + } + + float cL = 1.0f - powf(0.01f, dt); + if (first || useGcCamera) { sCP = tCP; sLA = tLA; first = false; } + else { sCP = glm::mix(sCP, tCP, cL); sLA = glm::mix(sLA, tLA, cL); } + + int drawableW = 0; + int drawableH = 0; + SDL_GetWindowSizeInPixels(window, &drawableW, &drawableH); + if (drawableW <= 0 || drawableH <= 0) continue; + glViewport(0, 0, drawableW, drawableH); + + glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); + bgShader.use(); + bgShader.setFloat("uTime", aTime); + bgShader.setVec3("uResolution", glm::vec3((float)drawableW, (float)drawableH, 0.0f)); + const glm::vec2 screenFlip = gcGameplayItemScreenFlip(gameplayItem); + bgShader.setVec3("uGameplayFlip", glm::vec3(screenFlip, 0.0f)); + bgShader.setBool("uHasBackdrop", backdropEnabled); + bgShader.setFloat("uBackdropAspect", backdrop.height > 0 ? (backdrop.width * 0.5f) / backdrop.height : 1.0f); + bgShader.setBool("uBackdrop", false); + const GcBackgroundColors backgroundColors = gcBackgroundColorsAt(level, currentMs); + bgShader.setVec3("uBgTopRight", backgroundColors.topRight); + bgShader.setVec3("uBgTopLeft", backgroundColors.topLeft); + bgShader.setVec3("uBgBottomRight", backgroundColors.bottomRight); + bgShader.setVec3("uBgBottomLeft", backgroundColors.bottomLeft); + glBindTexture(GL_TEXTURE_2D, backdrop.id); + glDisable(GL_DEPTH_TEST); glBindVertexArray(bgVAO); glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); glEnable(GL_DEPTH_TEST); + glBindTexture(GL_TEXTURE_2D, 0); + + float aspect = (float)drawableW / (float)drawableH; + glm::mat4 proj = useGcCamera + ? gcProjection(gcCamera, fov, aspect) + : glm::perspective(glm::radians(fov), aspect, 1.0f, 30000.0f); + glm::vec3 viewDir = glm::normalize(sLA - sCP); + glm::vec3 cameraUp = tUp; + if (glm::length(viewDir) < 0.001f) viewDir = tan; + if (fabsf(glm::dot(viewDir, cameraUp)) > 0.96f) cameraUp = right; + glm::mat4 view = useGcCamera + ? glm::lookAtLH(sCP, sLA, cameraUp) + : glm::lookAt(sCP, sLA, cameraUp); + gcApplyGameplayItemProjection(gameplayItem, proj); + + if (stageObjectsEnabled && level.gcStage && !backgroundModels.empty()) { + // FUN_00648c30 enables D3DRS_ZENABLE (7) and + // D3DRS_ZWRITEENABLE (14) for the complete object pass. + glEnable(GL_DEPTH_TEST); + glDepthMask(GL_TRUE); + modelShader.use(); + modelShader.setMat4("uProjection", proj); + modelShader.setMat4("uView", view); + struct ObjectDraw { + const gc::StageObject* object = nullptr; + const GpuTumoModel* gpu = nullptr; + glm::mat4 transform{1.0f}; + glm::vec4 color{1.0f}; + }; + std::vector objectDraws; + const auto& objects = level.gcStage->objects; + for (size_t objectIndex = 0; objectIndex < objects.size(); ++objectIndex) { + const gc::StageObject& object = objects[objectIndex]; + if (level.gcObjectClipFrameCount != 0 && + !level.gcObjectClipVisibility.empty()) { + // game471 FUN_006445b0 uses ROUND rather than floor and + // the renderer's fixed 60 Hz (16.666... ms) interval. + const uint32_t clipFrame = static_cast( + std::max(0.0f, std::round(currentMs * 60.0f / 1000.0f))); + if (clipFrame < level.gcObjectClipFrameCount) { + const size_t clipIndex = + objectIndex * level.gcObjectClipFrameCount + clipFrame; + if (clipIndex >= level.gcObjectClipVisibility.size() || + level.gcObjectClipVisibility[clipIndex] == 0) { + continue; + } + } + } + const GcVisibilityState visibility = gcObjectVisibility(object, currentMs); + if (object.model >= backgroundModels.size() || !visibility.draw) continue; + const GpuTumoModel& gpu = backgroundModels[object.model]; + if (gpu.triangleCount <= 0 && gpu.solidLineCount <= 0 && gpu.wireLineCount <= 0) continue; + glm::mat4 objectTransform = gcObjectTransform(object, currentMs); + glm::vec4 color = gcObjectColor(object, currentMs); + if (object.parentIndex >= 0 && + static_cast(object.parentIndex) < objects.size() && + static_cast(object.parentIndex) != objectIndex) { + const gc::StageObject& parent = objects[static_cast(object.parentIndex)]; + // FUN_006445b0 evaluates one parent level, then composes + // parent * child matrices and component-wise RGBA. + objectTransform = gcObjectTransform(parent, currentMs) * objectTransform; + color *= gcObjectColor(parent, currentMs); + } + color.a *= visibility.alpha; + objectDraws.push_back({&object, &gpu, objectTransform, color}); + } + + const auto drawObject = [&](const ObjectDraw& draw) { + modelShader.setMat4("uModel", draw.transform); + modelShader.setVec4("uColor", draw.color); + glBindVertexArray(draw.gpu->vao); + // game471 selects exactly one path here: FUN_005dd8e0 draws + // polygon parts, while wireframe objects use FUN_005dd7f0 and + // the TUMO explicit edge buffer. Drawing both adds fake layers. + if (!draw.object->wireframe && draw.gpu->triangleCount > 0) { + glDrawArrays(GL_TRIANGLES, 0, draw.gpu->triangleCount); + } + if (!draw.object->wireframe && draw.gpu->solidLineCount > 0) { + glDrawArrays(GL_LINES, draw.gpu->solidLineFirst, draw.gpu->solidLineCount); + } else if (draw.object->wireframe && draw.gpu->wireLineCount > 0) { + glDrawArrays(GL_LINES, draw.gpu->wireLineFirst, draw.gpu->wireLineCount); + } + }; + // FUN_006445b0 submits the array exactly in file order. Alpha + // objects are not extracted, distance-sorted, or exempted from Z. + for (const ObjectDraw& draw : objectDraws) drawObject(draw); + } + + // FUN_00648c30 disables both Z modes immediately after the background + // object loop. The rail and notes are therefore always presented over + // the background while retaining their own submission order. + glDisable(GL_DEPTH_TEST); + glDepthMask(GL_FALSE); + + const glm::vec3 aheadColor( + level.config.count("gc_track_ahead_r") ? level.config.at("gc_track_ahead_r") : 0.0f, + level.config.count("gc_track_ahead_g") ? level.config.at("gc_track_ahead_g") : 0.8f, + level.config.count("gc_track_ahead_b") ? level.config.at("gc_track_ahead_b") : 1.0f); + const glm::vec3 behindColor( + level.config.count("gc_track_behind_r") ? level.config.at("gc_track_behind_r") : 0.0f, + level.config.count("gc_track_behind_g") ? level.config.at("gc_track_behind_g") : 0.35f, + level.config.count("gc_track_behind_b") ? level.config.at("gc_track_behind_b") : 0.55f); + + // Original DrawWay is an authored-point GL_LINE_STRIP. Build its two + // independently graded intervals: travelled -> current and current -> + // upcoming. Keeping them separate also avoids joining the far ends + // when one interval is empty. + glDepthMask(GL_FALSE); + routeShader.use(); + routeShader.setMat4("uProjection", proj); + routeShader.setMat4("uView", view); + glBindVertexArray(routeVAO); + glBindBuffer(GL_ARRAY_BUFFER, routeVBO); + glLineWidth(2.0f); + const auto drawWay = [&](float firstMs, float lastMs, + const glm::vec4& firstColor, + const glm::vec4& lastColor) { + routeVertices.clear(); + gcAppendDrawWay(level.trackPoints, firstMs, lastMs, + firstColor, lastColor, routeVertices); + if (routeVertices.size() < 2) return; + glBufferSubData(GL_ARRAY_BUFFER, 0, + routeVertices.size() * sizeof(GcRouteVertex), + routeVertices.data()); + glDrawArrays(GL_LINE_STRIP, 0, static_cast(routeVertices.size())); + }; + if (useGcCamera) { + drawWay(currentMs - drawBehind * 1000.0f, currentMs, + glm::vec4(behindColor, 0.0f), glm::vec4(behindColor, 1.0f)); + drawWay(currentMs, currentMs + drawAhead * 1000.0f, + glm::vec4(aheadColor, 1.0f), glm::vec4(aheadColor, 0.0f)); + } else { + drawWay(level.trackPoints.front().timeMs, level.trackPoints.back().timeMs, + glm::vec4(aheadColor, 1.0f), glm::vec4(aheadColor, 1.0f)); + } + glLineWidth(1.0f); + + lineShader.use(); + lineShader.setMat4("uProjection", proj); + lineShader.setMat4("uView", view); + lineShader.setBool("uIsAvatar", false); + lineShader.setBool("uClipTrack", false); + // Duration targets are sampled along the same authored 3D track. In + // game471, FUN_005e9ca0 builds these arrays for 3/4/5/10/15 and the + // renderer stops them at runtime +0xac (the BPM-derived end time). + lineShader.setMat4("uView", view); + glBindVertexArray(durationVAO); + for (const Note& n : notes) { + if (n.hit || gcHelperEffectId(n.effectiveType) < 0 || n.durationBands == 0 || + currentMs >= n.endTimeMs || gcNoteAlpha(n, currentMs) <= 0.0f) continue; + if (useGcCamera && + (n.endTimeMs < currentMs - drawBehind * 1000.0f || + n.timeMs > currentMs + drawAhead * 1000.0f)) continue; + glm::vec4 authored = gcPackedColor(n.packedColor); + lineShader.setVec3("uColor", glm::vec3(authored)); + lineShader.setFloat("uWidth", n.durationHalfWidth); + for (int band = 0; band < n.durationBands; ++band) { + GLint firstVertex = n.durationFirst[band]; + GLsizei count = n.durationCount[band]; + if (currentMs > n.timeMs && n.endTimeMs > n.timeMs) { + const float consumed = std::clamp( + (currentMs - n.timeMs) / (n.endTimeMs - n.timeMs), 0.0f, 1.0f); + const GLint sampleCount = count / 2; + const GLint skipSamples = std::min( + sampleCount - 2, + static_cast(consumed * static_cast(sampleCount - 1))); + firstVertex += skipSamples * 2; + count -= skipSamples * 2; + } + if (count >= 4) glDrawArrays(GL_TRIANGLE_STRIP, firstVertex, count); + } + } + + struct MarkerDraw { + float dist; + unsigned int type; + int effectId; + int uvRecordBase; + uint32_t packedColor; + float alpha; + float timeMs; + float endTimeMs; + float directionDegrees; + float frameOffsetTicks; + }; + struct ApproachCircleDraw { + float dist; + float timeMs; + float progress; + float alpha; + }; + std::vector markerDraws; + std::vector approachCircleDraws; + // Long markers can contribute a head, an end cap and (for SLIDE + // HOLD) a directional overlay at the end. + markerDraws.reserve(notes.size() * 3); + approachCircleDraws.reserve(notes.size()); + const glm::mat4 markerBillboard = glm::inverse(glm::mat4(glm::mat3(view))); + for (const Note& n : notes) { + if (n.hit || gcHelperEffectId(n.effectiveType) < 0) continue; + if (useGcCamera && + (n.markerFadeEndTimeMs < currentMs - drawBehind * 1000.0f || + n.timeMs > currentMs + drawAhead * 1000.0f)) continue; + if (n.effectiveType == 6 && n.merryCount > 0 && n.endTimeMs > n.timeMs) { + // game471 computes local_3e4 once from the parent runtime + // +0xbc/+0xc4 pair, then applies it to every generated MERRY + // effect. Their individual time offsets only affect position + // and the approach cue. + const float alpha = gcNoteAlpha(n, currentMs); + if (alpha <= 0.0f) continue; + for (uint32_t i = 0; i < n.merryCount; ++i) { + const float u = static_cast(i) / static_cast(n.merryCount); + const float markerTime = glm::mix(n.timeMs, n.endTimeMs, u); + const float approachSpan = 2.0f * std::max(1.0f, n.earlyTimingMs); + const float approach = std::min(1.0f, (markerTime - currentMs) / approachSpan); + if (currentMs < markerTime && approach > 0.0f) { + approachCircleDraws.push_back( + {glm::mix(n.dist, n.endDist, u), markerTime, approach, alpha}); + } + markerDraws.push_back({glm::mix(n.dist, n.endDist, u), 1, 3, 0, + n.packedColor, alpha, + markerTime, markerTime, 0.0f, 0.0f}); + } + continue; + } + if (n.effectiveType == 5 && n.endDist > n.dist && n.endTimeMs > n.timeMs) { + // Original FUN_005e9ca0 uses a literal 0.55-unit step for BEAT + // and case 5 of FUN_0064ab80 draws an effect at every sampled + // point instead of a hold ribbon. + const float span = n.endDist - n.dist; + const int samples = std::max(2, static_cast(std::ceil(span / 0.55f)) + 1); + for (int i = 0; i < samples; ++i) { + const float u = static_cast(i) / static_cast(samples - 1); + const float markerTime = glm::mix(n.timeMs, n.endTimeMs, u); + // case 5 of FUN_0064ab80 only draws samples whose authored + // point time has not passed. It advances effect frame +8 + // by two per point while retaining the authored selector. + if (currentMs < n.appearTimeMs || markerTime < currentMs) continue; + markerDraws.push_back({glm::mix(n.dist, n.endDist, u), n.effectiveType, + 3, n.markEffectId - 1, n.packedColor, 1.0f, + markerTime, markerTime, 0.0f, + static_cast(i * 2)}); + } + continue; + } + const float alpha = gcNoteAlpha(n, currentMs); + if (alpha > 0.0f) { + const float earlyBoundary = n.timeMs - n.earlyTimingMs; + const float lateBoundary = n.timeMs + n.lateTimingMs; + const float timingSpan = 2.0f * std::max(1.0f, lateBoundary - earlyBoundary); + const float approach = std::min(1.0f, (lateBoundary - currentMs) / timingSpan); + if (currentMs < n.timeMs && approach > 0.0f) { + approachCircleDraws.push_back({n.dist, n.timeMs, approach, alpha}); + } + markerDraws.push_back({n.dist, n.effectiveType, 3, n.markEffectId - 1, + n.packedColor, alpha, n.timeMs, n.endTimeMs, + 0.0f, 0.0f}); + + // DrawMark layers effect 39 over directional heads. Its + // 128x32 sprite supplies the arrows extending to both sides + // of the marker; the root angle comes from wire +29/+0x1c. + if (n.effectiveType == 2 || n.effectiveType == 10) { + markerDraws.push_back({n.dist, n.effectiveType, 39, + n.effectiveType == 10 ? 10 : 0, + n.packedColor, alpha, + n.timeMs, n.timeMs, + n.directionDegrees, 0.0f}); + } + + // GameScene::drawLongShotEffect renders a second marker at + // the authored endpoint. These are fixed selectors passed to + // effect 3, not copies of the chart's starting selector. + int endUvRecordBase = -1; + switch (n.effectiveType) { + case 3: endUvRecordBase = 29; break; // HOLD + case 4: endUvRecordBase = 30; break; // SCRATCH + case 10: endUvRecordBase = 41; break; // SLIDE HOLD + case 15: endUvRecordBase = 42; break; // DUAL HOLD + default: break; + } + if (endUvRecordBase >= 0 && n.endDist > n.dist && + n.endTimeMs > n.timeMs) { + markerDraws.push_back({n.endDist, n.effectiveType, 3, + endUvRecordBase, n.packedColor, alpha, + n.endTimeMs, n.endTimeMs, 0.0f, 0.0f}); + if (n.effectiveType == 10) { + // SLIDE HOLD adds effect 39/selector 10 on top of the + // endpoint and rotates its root by the authored angle. + markerDraws.push_back({n.endDist, n.effectiveType, 39, 10, + n.packedColor, alpha, + n.endTimeMs, n.endTimeMs, + n.directionDegrees, 0.0f}); + } + } + } + } + + // GameScene::drawHitTimingCircle: 16 segments, radius + // 0.2 + progress*0.5, white at alpha*128, additive blend. It is drawn + // immediately before the textured marker head. + if (!approachCircleDraws.empty()) { + lineShader.use(); + lineShader.setMat4("uProjection", proj); + lineShader.setBool("uClipTrack", false); + lineShader.setBool("uIsAvatar", false); + lineShader.setFloat("uWidth", 0.0f); + glBindVertexArray(noteVAO); + glBlendFunc(GL_SRC_ALPHA, GL_ONE); + glLineWidth(1.0f); + for (const ApproachCircleDraw& circle : approachCircleDraws) { + const float radius = 0.2f + circle.progress * 0.5f; + // BuildTimingDataSub stores GetWayPosition(authored_time) + // directly. Do not round-trip marker positions through the + // physical-distance LUT: even a tiny corner error accumulates. + glm::mat4 circleModel = + glm::translate(glm::mat4(1.0f), + useGcCamera + ? gcTrackPositionAtMs(level.trackPoints, circle.timeMs) + : track.getPositionAtDistance(circle.dist)) * + markerBillboard * + glm::scale(glm::mat4(1.0f), glm::vec3(radius * 2.0f)); + lineShader.setMat4("uView", view * circleModel); + lineShader.setVec3("uColor", glm::vec3(1.0f)); + lineShader.setFloat("uAlpha", circle.alpha * 0.5f); + glDrawArrays(GL_LINE_STRIP, 0, 17); + } + lineShader.setFloat("uAlpha", 1.0f); + glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); + } + + // DrawMark follows DrawWay with both Z test and Z writes disabled, + // matching FUN_00648c30's state teardown. + if (noteEffectsLoaded && !markerDraws.empty()) { + noteShader.use(); + noteShader.setMat4("uProjection", proj); + noteShader.setInt("uTexture", 0); + glActiveTexture(GL_TEXTURE0); + glBindVertexArray(noteSpriteVAO); + for (const MarkerDraw& marker : markerDraws) { + if (marker.effectId <= 0 || marker.uvRecordBase < 0) continue; + // SetCommonParam derives this descending 1..0 phase from the + // BPM segment. Use each effect's own lifetime: the SLIDE end + // overlay is effect 39, while marker heads/caps are effect 3. + const float noteTick = gcMarkerEffectTick( + level, currentMs, noteEffects.lifetime(marker.effectId)); + // Original runtime +0xe0 is the direct GetWayPosition result + // for this marker's timestamp (endpoint markers use end time). + glm::mat4 markerRoot = + glm::translate(glm::mat4(1.0f), + useGcCamera + ? gcTrackPositionAtMs(level.trackPoints, marker.timeMs) + : track.getPositionAtDistance(marker.dist)) * + markerBillboard; + if (marker.directionDegrees != 0.0f) { + markerRoot = glm::rotate(markerRoot, + glm::radians(marker.directionDegrees), + glm::vec3(0, 0, 1)); + } + const glm::vec4 authored = gcPackedColor(marker.packedColor); + for (const GcEffectSprite& sprite : + noteEffects.evaluate(marker.effectId, + noteTick + marker.frameOffsetTicks, + marker.uvRecordBase)) { + const GcUvCell* cell = noteEffects.uvCell(sprite.uvRecord, sprite.frame); + const int textureIndex = noteEffects.uvTexture(sprite.uvRecord); + if (!cell || cell->width == 0 || cell->height == 0 || + textureIndex < 0 || static_cast(textureIndex) >= noteTextures.size() || + noteTextures[textureIndex].id == 0) continue; + const DdsTexture& texture = noteTextures[textureIndex]; + glm::mat4 spriteModel = markerRoot; + spriteModel = glm::translate( + spriteModel, + glm::vec3(sprite.offsetPixels.x * 0.025f, + sprite.offsetPixels.y * 0.025f, + 0.002f + sprite.offsetPixels.z / 4096.0f)); + spriteModel = glm::rotate( + spriteModel, glm::radians(sprite.rotationDegrees), glm::vec3(0, 0, 1)); + spriteModel = glm::scale( + spriteModel, + glm::vec3(sprite.scale.x * cell->width * 0.025f, + sprite.scale.y * cell->height * 0.025f, 1.0f)); + noteShader.setMat4("uView", view * spriteModel); + noteShader.setVec4( + "uUvRect", + {static_cast(cell->x) / texture.width, + static_cast(cell->y + cell->height) / texture.height, + static_cast(cell->width) / texture.width, + -static_cast(cell->height) / texture.height}); + noteShader.setVec4( + "uColor", + {sprite.color.r * authored.r, + sprite.color.g * authored.g, + sprite.color.b * authored.b, + sprite.color.a * authored.a * marker.alpha}); + glBindTexture(GL_TEXTURE_2D, texture.id); + glDrawArrays(GL_TRIANGLES, 0, 6); + } + } + glBindTexture(GL_TEXTURE_2D, 0); + } else { + // Text levels and incomplete dumps keep a geometry fallback. + lineShader.use(); + lineShader.setMat4("uProjection", proj); + glBindVertexArray(noteVAO); + for (const MarkerDraw& marker : markerDraws) { + // Effect 39 is only the textured directional overlay; drawing + // another geometry ring would make the fallback misleading. + if (marker.effectId != 3) continue; + glm::mat4 nModel = glm::translate( + glm::mat4(1.0f), + useGcCamera + ? gcTrackPositionAtMs(level.trackPoints, marker.timeMs) + : track.getPositionAtDistance(marker.dist)) * + markerBillboard; + lineShader.setMat4("uView", view * nModel); + lineShader.setVec3("uColor", noteColor(marker.type) * marker.alpha); + glLineWidth(3.0f); + glDrawArrays(GL_LINE_STRIP, 0, 17); + } + } + + // DrawGameStageCharacter presents the ranked sprite after DrawMark. + // The effect stays at the player's hit position, faces the camera, + // advances at 60 Hz, and uses group-1 effects 29..32. + if (noteEffectsLoaded && !judgmentEffects.empty()) { + noteShader.use(); + noteShader.setMat4("uProjection", proj); + noteShader.setInt("uTexture", 0); + glActiveTexture(GL_TEXTURE0); + glBindVertexArray(noteSpriteVAO); + glDepthMask(GL_FALSE); + glBlendFunc(GL_SRC_ALPHA, GL_ONE); + for (const GcJudgmentEffect& judgment : judgmentEffects) { + const int effectId = gcJudgmentEffectId(judgment.rank); + const float tick = (currentMs - judgment.startTimeMs) * 60.0f / 1000.0f; + glm::mat4 effectRoot = + glm::translate(glm::mat4(1.0f), + track.getPositionAtDistance(judgment.dist)) * + markerBillboard; + effectRoot = glm::rotate( + effectRoot, glm::radians(judgment.rotationDegrees), glm::vec3(0, 0, 1)); + for (const GcEffectSprite& sprite : noteEffects.evaluate(effectId, tick, 0)) { + const GcUvCell* cell = noteEffects.uvCell(sprite.uvRecord, sprite.frame); + const int textureIndex = noteEffects.uvTexture(sprite.uvRecord); + if (!cell || cell->width == 0 || cell->height == 0 || + textureIndex < 0 || static_cast(textureIndex) >= noteTextures.size() || + noteTextures[textureIndex].id == 0) continue; + const DdsTexture& texture = noteTextures[textureIndex]; + glm::mat4 spriteModel = glm::translate( + effectRoot, + glm::vec3(sprite.offsetPixels.x * 0.03f, + sprite.offsetPixels.y * 0.03f, + 0.004f + sprite.offsetPixels.z / 4096.0f)); + spriteModel = glm::rotate( + spriteModel, glm::radians(sprite.rotationDegrees), glm::vec3(0, 0, 1)); + spriteModel = glm::scale( + spriteModel, + glm::vec3(sprite.scale.x * cell->width * 0.03f, + sprite.scale.y * cell->height * 0.03f, 1.0f)); + noteShader.setMat4("uView", view * spriteModel); + noteShader.setVec4( + "uUvRect", + {static_cast(cell->x) / texture.width, + static_cast(cell->y + cell->height) / texture.height, + static_cast(cell->width) / texture.width, + -static_cast(cell->height) / texture.height}); + noteShader.setVec4("uColor", sprite.color); + glBindTexture(GL_TEXTURE_2D, texture.id); + glDrawArrays(GL_TRIANGLES, 0, 6); + } + } + glBindTexture(GL_TEXTURE_2D, 0); + glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); + } + glDepthMask(GL_TRUE); + + // These are not world-space note sprites. game471 FUN_006492f0 writes + // the literal screen position (580, 788, 0) to the selected common + // effect instance, applies scale 1.3, and presents one control helper + // for the current note in the lower-right HUD. Its child at Y=68 is the + // instruction caption. Attaching the composite to every rail marker is + // what previously put hands/text inside notes. + const Note* helperNote = nullptr; + float helperTick = 0.0f; + if (helperEffectsLoaded) { + for (const Note& note : notes) { + if (note.hit || gcHelperEffectId(note.effectiveType) < 0) continue; + const float until = note.timeMs - currentMs; + if (until > 1000.0f) break; + const float tick = std::max(0.0f, std::floor(-until * 60.0f / 1000.0f)); + const bool alive = gcDurationNote(note.effectiveType) + ? currentMs < note.endTimeMs + : tick < static_cast(helperEffects.lifetime(gcHelperEffectId(note.effectiveType))); + if (!alive) continue; + helperNote = ¬e; + helperTick = tick; + break; + } + } + + if (helperNote) { + glDisable(GL_DEPTH_TEST); + glDepthMask(GL_FALSE); + noteShader.use(); + noteShader.setMat4( + "uProjection", + glm::ortho(0.0f, static_cast(kGameWidth), + static_cast(kGameHeight), 0.0f, -1.0f, 1.0f)); + noteShader.setInt("uTexture", 0); + glActiveTexture(GL_TEXTURE0); + glBindTexture(GL_TEXTURE_2D, helperAtlas.id); + glBindVertexArray(noteSpriteVAO); + const glm::mat4 helperRoot = glm::scale( + glm::translate(glm::mat4(1.0f), glm::vec3(580.0f, 788.0f, 0.0f)), + glm::vec3(1.3f, 1.3f, 1.0f)); + + auto drawHelperEffect = [&](int id, float wholeRotationDegrees) { + for (const GcEffectSprite& sprite : helperEffects.evaluate(id, helperTick)) { + const GcUvCell* cell = helperEffects.uvCell(sprite.uvRecord, sprite.frame); + if (!cell || cell->width == 0 || cell->height == 0) continue; + glm::mat4 spriteModel = helperRoot; + spriteModel = glm::rotate( + spriteModel, glm::radians(wholeRotationDegrees), glm::vec3(0, 0, 1)); + spriteModel = glm::translate( + spriteModel, + glm::vec3(sprite.offsetPixels.x, sprite.offsetPixels.y, + sprite.offsetPixels.z / 4096.0f)); + spriteModel = glm::rotate( + spriteModel, glm::radians(sprite.rotationDegrees), glm::vec3(0, 0, 1)); + spriteModel = glm::scale( + spriteModel, + glm::vec3(sprite.scale.x * cell->width, + sprite.scale.y * cell->height, 1.0f)); + noteShader.setMat4("uView", spriteModel); + noteShader.setVec4( + "uUvRect", + {static_cast(cell->x) / helperAtlas.width, + static_cast(cell->y + cell->height) / helperAtlas.height, + static_cast(cell->width) / helperAtlas.width, + -static_cast(cell->height) / helperAtlas.height}); + noteShader.setVec4("uColor", sprite.color); + glDrawArrays(GL_TRIANGLES, 0, 6); + } + }; + + const int effectId = gcHelperEffectId(helperNote->effectiveType); + drawHelperEffect(effectId, 0.0f); + if (helperNote->effectiveType == 2 || helperNote->effectiveType == 10) { + drawHelperEffect(67, helperNote->directionDegrees); + } + glBindTexture(GL_TEXTURE_2D, 0); + glDepthMask(GL_TRUE); + glDisable(GL_DEPTH_TEST); + } + + lineShader.use(); + lineShader.setMat4("uProjection", proj); + + lineShader.setBool("uIsAvatar", true); + glm::mat4 model = glm::translate(glm::mat4(1.0f), pP + worldUp * 0.4f); + model = model * glm::inverse(glm::lookAt(glm::vec3(0), tan, worldUp)); + lineShader.setMat4("uView", view * model); + glBindVertexArray(avVAO); + glDrawArrays(GL_TRIANGLES, 0, (GLsizei)avatarMesh.size()); + + if(!particles.empty()) { + glDepthMask(GL_FALSE); glBlendFunc(GL_SRC_ALPHA, GL_ONE); partShader.use(); + partShader.setMat4("uProjection", proj); partShader.setMat4("uView", view); + partShader.setVec3("uColor", glm::vec3(1, 0.8, 0.2)); + std::vector pd; + for(const auto& p : particles) { + float offs[12] = {-1,-1, 1,-1, -1,1, -1,1, 1,-1, 1,1}; + for(int j=0; j<6; ++j) { + pd.push_back(p.pos.x); pd.push_back(p.pos.y); pd.push_back(p.pos.z); + pd.push_back(offs[j*2]); pd.push_back(offs[j*2+1]); pd.push_back(p.life); + } + } + glBindVertexArray(partVAO); glBindBuffer(GL_ARRAY_BUFFER, partVBO); + glBufferSubData(GL_ARRAY_BUFFER, 0, pd.size()*sizeof(float), pd.data()); + glDrawArrays(GL_TRIANGLES, 0, (GLsizei)particles.size()*6); + glDepthMask(GL_TRUE); glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); + } + + SDL_GL_SwapWindow(window); + } + for (SDL_Gamepad* gamepad : gamepads) SDL_CloseGamepad(gamepad); + if (chartCompleted && !gcRoot.empty()) { + char executable[PATH_MAX]{}; + const ssize_t length = readlink( + "/proc/self/exe", executable, sizeof(executable) - 1); + if (length > 0) { + executable[length] = '\0'; + const std::string root = gcRoot.string(); + if (gameplayItem == GcGameplayItem::Mirror) { + execl(executable, executable, "--item", "mirror", + "--menu", root.c_str(), static_cast(nullptr)); + } else if (gameplayItem == GcGameplayItem::Reverse) { + execl(executable, executable, "--item", "reverse", + "--menu", root.c_str(), static_cast(nullptr)); + } else { + execl(executable, executable, "--menu", root.c_str(), + static_cast(nullptr)); + } + std::cerr << "Could not return to song menu: " + << std::strerror(errno) << std::endl; + } + } + return 0; +} diff --git a/docs/boot_pats/item.pat b/docs/boot_pats/item.pat new file mode 100644 index 0000000..0c84d83 --- /dev/null +++ b/docs/boot_pats/item.pat @@ -0,0 +1,30 @@ +#pragma endian big +import std.io; +import std.string; + +using string = std::string::SizedString [[format("string_formatter")]]; +fn string_formatter(ref string s) { + return std::format("\"{:s}\"", s); +}; + +enum Version : u8 { + GC1 = 1, + GC1EX = 2, + GC2 = 3, + GC3 = 4, + GC4 = 5, +}; + +Version PARSE_VER = Version::GC4; + +struct Item { + u32 id; + string texture; + string unk2; // Name? + u8 unk3; + string descriptionJP; + string descriptionEN; +}; + +u16 itemCount @ 0x00; +Item items[itemCount] @ 0x02 [[inline]]; diff --git a/docs/boot_pats/message.pat b/docs/boot_pats/message.pat new file mode 100644 index 0000000..5485af8 --- /dev/null +++ b/docs/boot_pats/message.pat @@ -0,0 +1,32 @@ +#pragma endian big +import std.io; +import std.string; + +using string = std::string::SizedString [[format("string_formatter")]]; +fn string_formatter(ref string s) { + return std::format("\"{:s}\"", s); +}; + +enum Version : u8 { + GC1 = 1, + GC1EX = 2, + GC2 = 3, + GC3 = 4, + GC4 = 5, +}; + +Version PARSE_VER = Version::GC4; + +struct Message { + u32 id; + string texture; + string unk1; + u8 unk2; + string unk3; // Contents + string unk4; // Contents + bool unk5; + bool unk6; +}; + +u16 messageCount @ 0x00; +Message messages[messageCount] @ 0x02 [[inline]]; diff --git a/docs/boot_pats/navigator.pat b/docs/boot_pats/navigator.pat new file mode 100644 index 0000000..b8239ae --- /dev/null +++ b/docs/boot_pats/navigator.pat @@ -0,0 +1,46 @@ +#pragma endian big +import std.io; +import std.string; + +using string = std::string::SizedString [[format("string_formatter")]]; +fn string_formatter(ref string s) { + return std::format("\"{:s}\"", s); +}; + +enum Version : u8 { + GC1 = 1, + GC1EX = 2, + GC2 = 3, + GC3 = 4, + GC4 = 5, +}; + +Version PARSE_VER = Version::GC4; + +struct Navigator { + u32 id; + string texture; // /data/2d_boost/navigator/.dds + string unk2; + string unk3; + string unk4; + string unk5; + string unk6; + string unk7; + string unk8; + string unk9; + string unk10; + u8 unk11; + u8 unk12; + u8 unk13; + u8 unk14; + u32 unk15; + u32 unk16; + u8 unk17; + u8 unk18; + u8 unk19; + string descriptionJP; + string descriptionEN; +}; + +u16 navigatorCount @ 0x00; +Navigator navigators[navigatorCount] @ 0x02 [[inline]]; diff --git a/docs/boot_pats/player.pat b/docs/boot_pats/player.pat new file mode 100644 index 0000000..cf948f3 --- /dev/null +++ b/docs/boot_pats/player.pat @@ -0,0 +1,47 @@ +#pragma endian big +import std.io; +import std.string; + +using string = std::string::SizedString [[format("string_formatter")]]; +fn string_formatter(ref string s) { + return std::format("\"{:s}\"", s); +}; + +enum Version : u8 { + GC1 = 1, + GC1EX = 2, + GC2 = 3, + GC3 = 4, + GC4 = 5, +}; + +Version PARSE_VER = Version::GC4; + +struct Player { + u32 id; + string texture; // /data/2d_boost/avatar/.dds + string unk2; + string unk3; + string unk4; + string unk5; + string unk6; + string unk7; + u8 unk8; + string unk9; // /data/model/ - pngs only + string unk10; // /data/model/ - uvb/tumo files + string unk11; // /data/model/ - tusc/efcb2 files + u32 unk12; + u32 unk13; + u8 unk14; + u32 unk15; + u32 unk16; + u32 unk17; + u32 unk18; + u8 unk19; + string descriptionJP; + string descriptionEN; + u32 unk22; +}; + +u16 playerCount @ 0x00; +Player players[playerCount] @ 0x02 [[inline]]; diff --git a/docs/boot_pats/se.pat b/docs/boot_pats/se.pat new file mode 100644 index 0000000..84466d0 --- /dev/null +++ b/docs/boot_pats/se.pat @@ -0,0 +1,35 @@ +#pragma endian big +import std.io; +import std.string; + +using string = std::string::SizedString [[format("string_formatter")]]; +fn string_formatter(ref string s) { + return std::format("\"{:s}\"", s); +}; + +enum Version : u8 { + GC1 = 1, + GC1EX = 2, + GC2 = 3, + GC3 = 4, + GC4 = 5, +}; + +Version PARSE_VER = Version::GC4; + +struct SE { + u32 id; + string texture; + string unk2; // Name long? + string unk3; // Name short? + string unk4; + string se_name1; // /data/sound/.wav + string se_name2; + string se_name3; + u8 unk8; + string descriptionJP; + string descriptionEN; +}; + +u16 seCount @ 0x00; +SE ses[seCount] @ 0x02 [[inline]]; diff --git a/docs/boot_pats/skin.pat b/docs/boot_pats/skin.pat new file mode 100644 index 0000000..30201e9 --- /dev/null +++ b/docs/boot_pats/skin.pat @@ -0,0 +1,31 @@ +#pragma endian big +import std.io; +import std.string; + +using string = std::string::SizedString [[format("string_formatter")]]; +fn string_formatter(ref string s) { + return std::format("\"{:s}\"", s); +}; + +enum Version : u8 { + GC1 = 1, + GC1EX = 2, + GC2 = 3, + GC3 = 4, + GC4 = 5, +}; + +Version PARSE_VER = Version::GC4; + +struct Skin { + u32 id; + string texture; + string unk2; + string skinFolder; + u8 unk4; + string descriptionJP; + string descriptionEN; +}; + +u16 skinCount @ 0x00; +Skin skins[skinCount] @ 0x02 [[inline]]; diff --git a/docs/boot_pats/stage_param.pat b/docs/boot_pats/stage_param.pat new file mode 100644 index 0000000..7a6166d --- /dev/null +++ b/docs/boot_pats/stage_param.pat @@ -0,0 +1,197 @@ +#pragma endian big +import std.io; +import std.string; + +using string = std::string::SizedString [[format("string_formatter")]]; +fn string_formatter(ref string s) { + return std::format("\"{:s}\"", s); +}; + +enum Version : u8 { + GC1 = 1, + GC1EX = 2, + GC2 = 3, + GC3 = 4, + GC4 = 5, +}; + +enum Genre_New : u8 { + GENRE_UNKNOWN = 0, + GENRE_ANIME_POP, + GENRE_VOCALOID, + GENRE_OTOGE, + GENRE_GAME, + GENRE_VARIETY, + GENRE_ORIGINAL, + GENRE_TOUHOU +}; + +enum Genre_GC2 : u8 { + GENRE_GC2_UNKNOWN = 0, + GENRE_GC2_POPS, + GENRE_GC2_VOCALOID, + GENRE_GC2_GAME, + GENRE_GC2_VARIETY, + GENRE_GC2_ORIGINAL, + GENRE_GC2_ANIME, + GENRE_GC2_TOUHOU +}; + +enum Genre_GC1 : u8 { + GENRE_GC1_UNKNOWN = 0, + GENRE_GC1_ANIME_POP, + GENRE_GC1_VOCALOID, + GENRE_GC1_GAME, // Rhythm games and touhou are merged into this in GC1 + GENRE_GC1_VARIETY, + GENRE_GC1_ORIGINAL +}; + +enum SKIN : u8 { + SKIN_NONE = 0, + SKIN_BASIC, + SKIN_FLOWER, + SKIN_FIREWORKS, + SKIN_MARBLES, + SKIN_MIDCENTURY, + SKIN_INFINITYGENE, + SKIN_MOLECULE, + SKIN_BUTTERFLY, + SKIN_PAISLEY, + SKIN_8BIT, + SKIN_BRIGHTNESS, + SKIN_HOLOGRAM, + SKIN_HEAVEN, + SKIN_SNOW, + SKIN_FEATHER, + SKIN_NOTE, + SKIN_STAR, + SKIN_HORROR, + SKIN_HEART, + SKIN_SYNCHRONICA +}; + +enum SongFlag : u8 { + NONE = 0, + TUTORIAL = 1, + GC1_CREDITS = 2, + SHOW_CATEGORY = 3, + GC2_CREDITS = 4, + TUTORIAL_JP = 5, + TUTORIAL_EN_RV = 6, + TUTORIAL_EN_GC = 7, + GC3_CREDITS = 8, + GC3EX_CREDITS = 9, + GC4_CREDITS = 10, +}; + +struct Song { + u32 id; + string name; + string ident; + string artist; + + if (PARSE_VER >= Version::GC2) + string extraInfo; + + string yomigana; + + if (PARSE_VER <= Version::GC1EX) + Genre_GC1 genre; + else if (PARSE_VER == Version::GC2) + Genre_GC2 genre; + else + Genre_New genre; + + string duration; + u8 diffSimple; + u8 diffNormal; + u8 diffHard; + if (PARSE_VER > Version::GC1) u8 diffExtra; + + string bpm; + + if (PARSE_VER == Version::GC1) { + u32 unk1; // TODO: Maybe look what this is? + } else if (PARSE_VER == Version::GC1EX) { + // TODO: for 1EX one of these needs to have [4] otherise shit gets fucked + u8 bgmVolume[4]; + u8 shotVolume; + } else { + u8 bgmVolume[4]; + u8 shotVolume[4]; + } + + if (PARSE_VER <= Version::GC1EX) u32 unk2; + + if (PARSE_VER >= Version::GC2) { + u16 unk3; + u16 unk4; + } + + u32 previewSeekStartMillis; + u32 previewSeekEndMillis; + + SKIN defaultSkin; + SongFlag specialFlags; // TODO: Implement the enum + + string bgmFilePrefix; + + // TODO: Rework this a bit + if (PARSE_VER == Version::GC1) { + string stageFilenameSimple; + string stageFilenameNormal; + string stageFilenameHard; + string bgmFilenameSuffixSimple; + string bgmFilenameSuffixNormal; + string bgmFilenameSuffixHard; + } else if (PARSE_VER == Version::GC1EX) { + if (id != 999) { + string shotFilenameSuffixSimple; + string shotFilenameSuffixNormal; + string shotFilenameSuffixHard; + string shotFilenameSuffixExtra; + } + string stageFilenameSimple; + string stageFilenameNormal; + string stageFilenameHard; + string stageFilenameExtra; + string bgmFilenameSuffixSimple; + string bgmFilenameSuffixNormal; + string bgmFilenameSuffixHard; + string bgmFilenameSuffixExtra; + } else { + string bgmFilenameSuffixSimple; + string bgmFilenameSuffixNormal; + string bgmFilenameSuffixHard; + string bgmFilenameSuffixExtra; + string shotFilenameSuffixSimple; + string shotFilenameSuffixNormal; + string shotFilenameSuffixHard; + string shotFilenameSuffixExtra; + string stageFilenameSimple; + string stageFilenameNormal; + string stageFilenameHard; + string stageFilenameExtra; + } + + if (PARSE_VER >= Version::GC2) { + string stageRegionSuffix; + u32 judgeDelay; // TODO: Confirm + } + + if (PARSE_VER == Version::GC1EX) u8 musicPanelCost; + + u8 unlockable; // definitely a bool, seems to dictate whether server unlocks work + u8 songEnabled; + u8 unk7; + + if (PARSE_VER == Version::GC4) u8 isRecommended; +} [[format("song_formatter")]]; +fn song_formatter(ref Song s) { + return std::format("{} {}: ({})", s.id, s.ident, s.name); +}; + +Version PARSE_VER = Version::GC1EX; + +u16 songCount @ 0x00; +Song songs[songCount] @ 0x02 [[inline]]; diff --git a/docs/boot_pats/title.pat b/docs/boot_pats/title.pat new file mode 100644 index 0000000..d98d440 --- /dev/null +++ b/docs/boot_pats/title.pat @@ -0,0 +1,40 @@ +#pragma endian big +import std.io; +import std.string; + +using string = std::string::SizedString [[format("string_formatter")]]; +fn string_formatter(ref string s) { + return std::format("\"{:s}\"", s); +}; + +enum Version : u8 { + GC1 = 1, + GC1EX = 2, + GC2 = 3, + GC3 = 4, + GC4 = 5, +}; + +Version PARSE_VER = Version::GC4; + +struct Title { + u32 id; + string texture; + string textureSuffixEn; + string textJP; + string textEN; + u8 unk5; + string descriptionJP; + string descriptionEN; + string unk6; // Also descriptions, maybe for next title? + string unk7; // ^ + u8 unk8; + u8 unk9; + u8 unk10; + u8 unk11; + u8 unk12; + u8 unk13; +}; + +u16 titleCount @ 0x00; +Title titles[titleCount] @ 0x02 [[inline]]; diff --git a/docs/psp_port.md b/docs/psp_port.md new file mode 100644 index 0000000..d25fc06 --- /dev/null +++ b/docs/psp_port.md @@ -0,0 +1,211 @@ +# OpenRoller PSP port + +The first hardware target is a PSP-1004 (PSP-1000/Fat). The runtime must fit +the original model's memory limits and must not depend on the extra application +memory available on later PSP models. + +## Display contract + +The arcade game uses a 720x1280 portrait canvas. OpenRoller keeps those logical +coordinates on PSP and rotates the final scene into the physical 480x272 panel: + +- scale: 3/8; +- resulting image: 270x480; +- physical border: one pixel on each long side; +- `SELECT`: swap clockwise/counter-clockwise tate orientation; +- `START`: pause/resume once a stage is loaded. + +This transform must remain above both the gameplay renderer and the song-select +renderer. Content code should not contain PSP screen coordinates. + +Gameplay geometry is submitted to the native PSP Geometry Engine as +`GU_TRANSFORM_3D` vertices. The 720x1280 portrait projection is preserved in +tate mode by swapping its horizontal/vertical projection extents and rolling +the camera basis by 90 degrees. The GU performs perspective division, +near/far clipping, and depth testing; CPU projection is not part of the runtime +renderer except for the original screen-relative directional-note arrow and +lower-right helper overlays. + +## Memory rules for PSP-1000 + +- Use RGB565 double framebuffers and a 16-bit depth buffer. Together they use + 0xcc000 bytes of VRAM, leaving about 1.2 MiB of VRAM for resident textures. +- Never load a whole song into RAM. Audio is decoded from a bounded streaming + ring and the decoded sample count is the gameplay clock. +- Keep only the current jacket resident; replace it when selection moves. +- Convert DDS/MTX textures on the host to RGB565, RGBA4444, or indexed T8 data. +- Parse arcade formats on the host. PSP consumes little-endian, aligned binary + arrays and never instantiates the reverse-engineering object graph. +- Generate/copy only the visible rail window and visible notes each frame. +- Background meshes require culling and a fixed resident-memory budget. + +## Build + +The local machine does not need a permanent PSPDEV installation. The build +script uses the official PSPDEV Docker image: + +```sh +tools/build_psp.sh +``` + +The result is `psp/EBOOT.PBP`. Copy the runtime directory to: + +```text +ms0:/PSP/GAME/OpenRoller/ +``` + +When `catalog.orpc` is present next to `EBOOT.PBP`, the runtime opens the +portrait song-select screen and lazily loads the selected jacket, chart and +audio stream. It renders the real track, camera, notes, draw window, four-corner color +background, and the stage-authored `.tumo` objects. Object translation, scale, +rotation, color and visibility channels are evaluated on PSP; one-level parent +composition, wireframe selection, the fixed 250 ms visibility fade and sorted +alpha rendering follow the recovered `game471.exe` path. + +Particle repeat/lifetime, spawn shape, direction, velocity and opacity are now +evaluated against the authored timeline and current BPM. The sprite resource +lookup is not ported yet, so particle instances use a small geometric marker. +Visualizer type, color and timeline are also carried by `.orps`; the current GE +renderer maps those types to rings, rays, bars and scanlines, but still uses a +deterministic pulse rather than the original audio analyser/FFT path. TUMO +geometry is position/color only at this stage; its material textures remain the +next background fidelity layer. + +Without a catalog, the legacy `stage.orps`/`audio.mp3` pair still works. Without +either format, the executable falls back to the synthetic smoke scene. + +The host preparation script applies both per-difficulty volumes from +`stage_param.dat`, pre-mixes the authored BGM and SHOT WAVs with a +latency-compensated limiter, and emits one CBR 128 kbit/s, 44.1 kHz stereo MP3 +per chart. The PSP runtime decodes that stream incrementally through one handle +of the native MP3 module and submits it through one SRC channel. This is +deliberately single-stream: simultaneous MP3 handles stutter on PSP-1000 while +the 3D stage is active. Reactive SHOT muting is therefore temporarily disabled +in the PSP build. The number of samples accepted by audio output is the gameplay +clock; rendering framerate does not advance song time. `SE_ARRANGE`, `TAP_SE1` and +`TAP_SE2` are host-converted to small 44.1 kHz PCM effects mixed by the same +thread. + +The host-side chart normalizer is built with the regular desktop CMake build: + +```sh +cmake -S . -B build +cmake --build build --target openroller-psp-pack +build/openroller-psp-pack GC/data/stage/ac_example_easy.dat stage.orps +``` + +The version-4 `.orps` file is a little-endian, 16-byte-aligned snapshot containing the +track, notes, camera, draw distance, background colors, pre-triangulated TUMO +vertices, stage-object records, flattened animation keys, particles, +visualizer keys and BPM changes. Each note also carries the effective runtime +type, appearance/end times, directional vector, per-note early/late/miss/mute +windows, colour and effect selector produced by the recovered +`BuildTimingDataSub` path. It deliberately +retains unknown note fields so discoveries made later in the reverse do not +invalidate imported charts. For `ac_10pt8tion_easy.dat` the full package is +about 493 KiB; the 36 source models contribute about 356 KiB of position-only +vertices and are submitted to GU directly from the loaded package. + +For a ready-to-copy Memory Stick directory using the default test chart: + +```sh +tools/prepare_psp_demo.sh +``` + +This creates `dist/PSP/GAME/OpenRoller/`. Copy that `OpenRoller` directory to +`ms0:/PSP/GAME/`. Runtime controls are: + +For the 30-song library build used by the PSP-1004 port: + +```sh +tools/prepare_psp_library.sh +``` + +This produces 97 difficulty packages for `10pt8ion`, `Oshama Scramble!`, +`Bonetrousle`, `Shadow`, `Planet connection`, `Departure`, `Journey`, +`SPACE ARCADIAN`, `Altale`, `Analysis Division`, `Thrash Beat`, `Agent Angels`, +`FLOWER`, `ADRENA`, and `7 days a week`, plus `Satisfiction` and fourteen +VOCALOID tracks: `MikuMiku`, `The Disappearance of Hatsune Miku`, +`World's End Dancehall`, `Rolling Girl`, `Uraomote Lovers`, +`Unknown Mother-Goose`, `Redial`, `Tell Your World`, `Umiyuri`, +`Karakuri Pierrot`, `Dappo Rock`, `ECHO`, `Vampire`, and +`PaIII.SENSATION`. Audio is CBR 128 kbit/s MP3 and jackets are 128x128 +RGBA4444. Only the selected song's resources are resident. + +The song selector has a continuously scrolling public-test notice. Its build +identifier is compiled as `tsuki@kagebaito-yyyymmdd-hhmmss` by +`tools/build_psp.sh`; each release build therefore exposes its exact timestamp +on screen. + +Song-select controls are: + +- D-pad up/down: previous/next song; +- `L` / `R`: move five songs; +- `CIRCLE`: enter difficulty selection, then start the selected chart; +- D-pad in difficulty selection: choose an available mode; +- `CROSS`: back (or exit from the song list); +- `SELECT`: switch tate side. + +The physical D-pad is rotated into the portrait coordinate system. In the +default clockwise orientation physical left/up/right/down becomes logical +down/left/up/right; counter-clockwise tate uses the inverse mapping. + +Gameplay controls are: + +- D-pad or `CROSS` / `CIRCLE` / `TRIANGLE` / `SQUARE`: tap; +- `SELECT`: switch tate side; +- `START`: open the pause menu; +- `L` / `R`: seek backward/forward five seconds; +- `START` + `SELECT`: exit (the PSP `HOME` menu also remains available). + +The pause menu contains `CONTINUE`, `RESTART`, and `BACK TO MENU`; `CIRCLE` +confirms and `CROSS` cancels. At chart end the current song is unloaded and the +runtime returns to song select automatically. The Linux runtime performs the +same transition after the authored track duration and reopens its selector. + +Every newly pressed D-pad or face-button bit is retained as an independent +input, so simultaneous buttons can judge CRITICAL and start DUAL HOLD. +Releases terminate HOLD/DUAL HOLD, while alternating/repeated presses keep +SCRATCH and BEAT active. The recovered `game471.exe` judge routine uses +25% / 50% / 100% of the full early or late input-window width for GREAT / COOL / +GOOD, with `GreatMinTime = 32` ms as the lower GREAT bound. Windows come from +the chart timing lists or the per-difficulty `system.cfg` override, including +the recovered FLICK extension. Long-note ranks use the original held-percentage +thresholds and short-tail correction. SLIDE HOLD and MERRY GO ROUND rendering +data is packaged, but their final input state machines remain unresolved in +the desktop reverse as well. + +Notes fade in/out on authored beat-relative times and are submitted after the +rail without depth testing. HOLD/SCRATCH/BEAT/SLIDE/DUAL bodies and matching end +markers are generated as PSP GE geometry; directional arrows are fixed in +screen orientation using the camera at the note timestamp, and the control +helper stays in the lower-right HUD slot. The original note/effect atlases are +not yet converted for PSP, so these marks remain geometric rather than using +the arcade textures. + +## Runtime package + +```text +OpenRoller/ + EBOOT.PBP + catalog.orpc + songs/ + / + easy.orps + normal.orps + hard.orps + extra.orps # when present + easy_bgm.mp3 + normal_bgm.mp3 + ... + jacket.orpj + sounds/ + adlib.pcm + tap1.pcm + tap2.pcm +``` + +`catalog.orpc` is a fixed-record index generated from the original +`stage_param.dat`; it binds display title, artist, BPM, duration, genre, +difficulty ratings and availability to a stable song key. The menu uses that +same key to resolve the chart package, per-difficulty mix and jacket paths. diff --git a/docs/re_game471_notes.md b/docs/re_game471_notes.md new file mode 100644 index 0000000..139acf3 --- /dev/null +++ b/docs/re_game471_notes.md @@ -0,0 +1,537 @@ +# game471.exe: Notes (Static Analysis) + +Goal: understand file formats and runtime behavior for a clean-room reimplementation. + +This is based on static inspection (strings + disassembly). No patching, no runtime hooking. + +## Quick Map + +- Stage file path templates referenced in code: + - `data/stage/%s.dat` + - `data/stage/%s_ext.dat` + - `data/stage/%s_clip.dat` + +- The game uses a background file reader (a small work queue + thread) that: + 1. Opens a file path stored in a request object. + 2. Reads the file into a buffer in 64 KiB chunks. + 3. Calls a callback function provided when the queue was created. + +## Background File Reader + +### Worker thread proc + +- `0x004ca320` is used as the thread entry function (passed as a function pointer). +- It receives a small argument struct, extracts two pointers from it, then calls `0x004ca070`. + +### File read loop + +- `0x004ca070` implements: + - open file (`CreateFileA`), then `ReadFile` in a loop + - destination buffer is `request->buf` at offset `+0x204` + - expected size is `request->size` at offset `+0x208` + +At several points it calls a callback: + +- `queue->callback_fn` stored at offset `queue + 0x120` +- `queue->callback_ctx` stored at offset `queue + 0x124` (passed as last argument) + +### Queue initialization + +- `0x004ca5f0` looks like the queue constructor/initializer. +- It stores its arguments into `this+0x120` and `this+0x124`. + +So, to find the actual parsing logic for a specific file type, you typically follow the callback function pointer passed into the queue creation. + +## Stage Loader Entry + +There is code that formats the stage file path strings and attempts to open: + +- `data/stage/.dat` +- `data/stage/_ext.dat` +- `data/stage/_clip.dat` + +This is a good anchor when looking for where the game requests reads for stage containers. + +Static addresses in the 4.71 executable: + +- `0x0063ea70`: formats all three stage paths and requests each resource. +- `0x00427730`: resource request wrapper. +- `0x004279d0`: creates the 0x4c-byte resource object (Ghidra mislabels it as an MFC `CreateObject`). +- `0x0063ecc0`: completion/cleanup callback which unwraps three resources before passing the result onward. + +## Main stage header + +The file is big-endian. The initial words are not all section offsets. Confirmed section slots are: + +| Index | Meaning | +| ---: | --- | +| 0 | stage config | +| 1 | track draw distances | +| 2 | track points | +| 3 | notes | +| 4 | camera | +| 5 | particles | +| 6 | visualizer | +| 7 | unknown section | +| 8 | first color table | +| 9 | objects | +| 10 | scalar/unknown; often `0x30`, not an offset | +| 11 | second color table | +| 12 | scalar/unknown; can accidentally look like an in-file offset | + +This matters because treating every plausible header word as an offset can split the notes or camera section at a false boundary. + +Older 11-word stage headers instead store the second color-table offset in +slot 10. The scalar/offset/scalar arrangement in slots 10..12 belongs to the +newer 13-word format used by the 4.71-era charts. + +## Note array: confirmed wire layout + +The note section starts at header word 3 and ends exactly at header word 4. Its layout is: + +```text +u32be name_count // observed: 0 in all 2,968 dumped main charts +repeat name_count times: + string8 name // u8 length followed by bytes +u32be count +repeat count times: + u32be time_ms + u8 raw_type + u8 type_override + s16be params16[9] + u8 flag24 + f32be params25[3] + u8 flag37 + u8 flag38 + f32be params39[4] + u32be params55[3] + f32be param67 + u32be param71 + f32be params75[5] + u32be param95 +``` + +Thus each note record is exactly 99 bytes and the invariant is: + +```text +camera_offset - notes_offset == 8 + encoded_names_size + count * 99 +``` + +The invariant holds on tested charts of very different sizes. For example, +`ac_10pt8tion_hard.dat` has 669 records from 6162 through 125568 ms. +Its raw-type histogram is `01:366, 02:231, 03:1, 04:3, 05:3, 09:63, 0f:2`. + +Observed raw types over the parsed corpus are `00, 01, 02, 03, 04, 05, 06, 09, 0a, 0f`. +The payload field meanings remain unnamed until backed by code references or +controlled runtime observations. + +The exact read order above comes from the game's stage constructor `0x005ed4c0` +and its note-section parser `0x005ea800`. The latter allocates a 0x11c-byte +runtime object for every compact 99-byte wire record. It also proves one behavior: +if `type_override` is non-zero, the runtime replaces `raw_type` with type 1. + +The post-load pass at `0x005ebaa0` additionally proves that runtime types 11, +12, 13 and 14 are compatibility aliases: 11 becomes 10, 12/14 become 9, and +13 becomes 4. Other game modes can deliberately simplify types (for example +2 to 1, and 4/5/10/15 to 3), so a clean player should preserve both the raw +wire type and the effective runtime type. + +Full-dump validation with `tools/scan_gc_notes.py GC/data/stage` covers 2,968 +main stage files and 1,080,259 notes with zero size mismatches. All of those +files currently have zero note-name strings. The aggregate raw-type counts are: + +```text +00:6 01:719892 02:163462 03:67753 04:8651 +05:5115 06:193 09:102699 0a:6360 0f:6128 +``` + +`type_override` is non-zero in 52,506 records, so it cannot be discarded. + +## Confirmed type names + +The executable contains a direct pointer table at `0x0077d480`; its array index +is the numeric note type. This yields the complete original enum: + +| Value | Original name | +| ---: | --- | +| 0 | NONE | +| 1 | NORMAL | +| 2 | FLICK | +| 3 | HOLD | +| 4 | SCRATCH | +| 5 | BEAT | +| 6 | MERRY GO ROUND | +| 7 | HIDDEN | +| 8 | HIDDEN2 | +| 9 | CRITICAL | +| 10 | SLIDE HOLD | +| 11 | SLIDE COUNTER | +| 12 | TURN | +| 13 | SPIN | +| 14 | FINISH | +| 15 | DUAL HOLD | + +The same 16-entry sequence is repeated in several embedded reflection/debug +tables, which independently confirms the ordering. + +## Confirmed runtime classifications + +With the exact names assigned, several behavioral groups are also explicit in code: + +- `0x005e9480` classifies types 3, 4, 5, 10 and 15 as duration/path notes. +- `0x005ebaa0` generates sampled paths for those types. Type 4 additionally + builds two derived point arrays; types 3/10/15 share one path branch. +- `0x005e94c0` singles out type 6. During gameplay setup it expands that note + into several runtime judge objects using the runtime field originating from + wire offset `+71`. +- Types 1, 2 and 9 follow the non-duration branches in the preview/gameplay + render path at `0x006492f0`. + +The enum names are now exact. The remaining reverse target is the meaning of +the compact payload fields and the type-specific hold/slide behavior. + +## Appearance, duration and control-helper effects + +The post-load pass at `0x005ebaa0` converts several compact fields using the +BPM active at the note timestamp. With `beat_ms = 60000 / bpm`: + +| Wire field | Runtime meaning | +| ---: | --- | +| `+6` | signed marker-effect/UV selector; the note-head draw passes `value - 1` to effect 3 | +| `+39` | appearance lead in beats; runtime `+0xbc = max(time - value * beat_ms, 0)` | +| `+51` | duration in beats for types 3/4/5/10/15; runtime `+0x58` and `+0xac = time + value * beat_ms` | +| `+55` | packed authored `RRGGBBAA` colour used by duration-target geometry | +| `+71` | number of generated MERRY GO ROUND targets | +| `+75` | MERRY GO ROUND spacing in beats | + +`0x005efe40` confirms that MERRY's span is +`count * spacing * beat_ms`. Gameplay setup allocates three visual handles per +generated target, which explains why treating type 6 as one marker loses most +of the authored pattern. + +The four blocks following the BPM table in `StageConfig` are variable-length +timing lists, not four fixed records. Each list is `u16be count`, followed by +`count` entries of `u32be time_ms, u32be mode, f32be value`. +`TuneTimingData::GetTime` selects the last entry at or before the note: mode 1 +returns `value` as milliseconds, mode 3 returns the spacing to the next note, +and the other modes return `value * beat_ms`. The third list supplies the late +boundary used as runtime `+0xac` for ordinary targets. FLICK (and raw type +`0x10`) receives an additional literal `0.2 * beat_ms`. + +Marker opacity is independent of the fixed judgment-window overrides below. +`0x0063ff90` stores `beat_ms` at runtime `+0x20`; `0x0064ab80` divides it by +the literal `2.0`, while `0x005ebaa0` sets runtime `+0xc4` to +`+0xac + 4 * beat_ms`. With `start = +0xbc`, `finish = +0xc4`, and +`fade = beat_ms / 2`, the exact unclamped shape is: + +```text +time < start + fade : alpha = (time - start) / fade +time <= finish - fade : alpha = 1 +otherwise : alpha = (finish - time) / fade +``` + +The renderer clamps this to `[0, 1]`. Duration targets use their authored end +as `+0xac`; MERRY GO ROUND first adds its generated-target span. The Linux +player now carries the per-note BPM and `+0xc4` equivalent instead of using +the former guessed 236 ms marker lifetime. + +The lower-right control helpers are not note-head effects. `0x00661680` reads the 16-entry +table at `0x006ea040`, loads the indicated `efcdata.dat` record, and stores it +in runtime slot `0xb1 + type`: + +```text +type: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 +effect id: -1 61 66 62 63 64 61 -1 67 65 69 -1 -1 -1 -1 68 +``` + +Thus NONE and HIDDEN have no helper resource. FLICK and SLIDE HOLD also draw +runtime slot `0xb9` as a direction overlay in `0x006492f0`. The leading words +of effect records 61--69 give animation lengths of 16, 38, 34, 13, 16, 14, +14, 38 and 38 update ticks respectively. + +The common-effect interpreter is now mapped far enough to reproduce those +records exactly. `0x005f2800` dispatches the child nodes, while +`0x005f2030`, `0x005f2250` and `0x005f23f0` implement looping, key lookup and +step/linear interpolation. Every child has five relative track offsets. A +track begins with `u16be key_count, loop_start, loop_end`, followed by keys of +`u16be time, u8 interpolation, f32be values[]`. For a sprite child (type 2), +the tracks are: + +| Track | Values | +| ---: | --- | +| 0 | UV frame plus six unused/secondary values | +| 1 | XYZ position | +| 2 | alpha, red, green, blue | +| 3 | XY scale | +| 4 | rotation in degrees | + +Type 0 is a parent rotation node in radians. A sprite whose child flag at +`+4` is set inherits that rotation, including the rotated XY offset. Effects +61--69 use UV record 13 from `uvdata.dat`, whose image is `img13.bin`: target +rings, balls, hand prompts, arrows, action bursts and the type-specific help +caption are separate animated layers. `0x006492f0` advances the records with +an integer 60 Hz tick, places the selected composite at the literal virtual- +screen coordinate `(580, 788, 0)` and applies the literal global XY scale +`1.3`. `0x006e9ee4` supplies a 1000 ms look-ahead, so only the current helper +is selected; the effect is not cloned onto every world-space note. The Linux +player implements this format +in `vectorail-gc/src/GcTargetEffect.cpp`; it does not substitute a +single guessed icon for the composite any more. + +## Cross-version confirmation of note-head rendering + +The Android ARM64 build preserves C++ symbols which are stripped from the +arcade executable. Its `GameScene::DrawGameStageCharacter` establishes the +same ordering seen in game471: it disables depth writes, calls `DrawWay`, +then calls `DrawMark` without disabling the depth test. The route therefore +cannot punch holes through marker sprites drawn afterwards. + +`TuneGameData::BuildTimingDataSub` (`0x00196a74`) fixes the note head's world +position before gameplay by calling `GetWayPosition(note_time)` directly and +storing the returned XYZ at runtime `+0xe0`. Long-note endpoints likewise use +`GetWayPosition(end_time)` at `+0xf8`. There is no conversion through physical +track distance and no note-time lead added in this path. `DrawMark` consumes +these stored positions. The separate one-frame `-16.6667 ms` lookup in +`GameScene::SetCommonParam` is used for the moving player segment, not for note +placement. + +The Linux player now performs the same direct timestamp lookup for heads, +endpoint heads, generated BEAT/MERRY targets, and approach circles. Its shared +physical-distance LUT is still needed for duration-body sampling; it now +samples each authored segment independently and includes every exact corner. +The previous cumulative `t += 0.02` loop could jump across a segment boundary, +measure a shortcut chord through the corner, and progressively place later +distance-based geometry ahead of the authored route position. + +`GameScene::DrawMark` creates each head with effect manager group 1, +effect 3, and the final selector `wire_mark_effect_id - 1`. Group 1 is bound +by `GameScene::LoadSkinData` to the selected skin's `uv.dat` and its +texture list. The first signed word at stage-note wire offset `+6` contains +the selector source. Examples from `ac_10pt8tion_hard.dat` are: + +| Note | Wire `+6` | Resolved UV record | +| --- | ---: | ---: | +| NORMAL | 1 | 1 | +| HOLD | 9 | 9 | +| FLICK | 10 | 10 | +| SCRATCH | 11 | 11 | +| BEAT | 12 | 12 | +| CRITICAL | 32 | 32 | +| DUAL HOLD | 37 | 37 | + +Effect 3 has a 15-tick loop. Records 1--12 use 32x32 cells from the common +skin atlas, while records 32 and 37 use 64x64 cells from the second common +atlas. The effect instance uses the literal XY scale `0.025`; consequently +the authored 32px and 64px cells occupy 0.8 and 1.6 route-world units. + +Directional heads have a second world-space layer. `DrawMark` plays effect +39 over FLICK with UV base 0 and over SLIDE HOLD with UV base 10. The final +root angle is not wire `+29` used directly: wire `+25` is a vector length and +`+29/+33` are HPB angles. `BuildTimingDataSub` converts these through +`RotateHPB::ToVector_Deg`; `GameScene::buildGameData` projects the route point +and the displaced point with the camera evaluated at the note timestamp, then +uses `RotateHPB::SetVector` (`atan2(screen_dx, screen_dy_down)`) and stores the +resulting screen angle. It is computed before gameplay and remains fixed while +the live camera moves. Effect 39 resolves to a 128x32 animated strip whose +arrows extend to the sides of the 32x32 head; it uses the same `0.025` scale, +colour and marker alpha. This is independent of the lower-right control +helper, even though that helper also has a direction overlay. + +The marker animation clock is beat-synchronized, not a free-running 60 Hz +counter. Android `GameScene::SetCommonParam` computes +`phase = 1 - (((time - bpm_change_time) / beat_ms * 2000) % 2000) / 2000` +and `DrawMark` writes `phase * EffectData::GetFrameMax()` to effect field +`+8`. Thus effect 3 runs backwards from frame 15 toward frame 0 once per beat +and resets at every beat; BPM changes also reset the phase origin. BEAT adds +`sample_index * 2` to this base frame. + +The approach ring is geometry, not another atlas layer. +`GameScene::drawHitTimingCircle` draws a white 16-segment circle with additive +blending, `radius = 0.2 + progress * 0.5`, and alpha `marker_alpha * 128/255`. +For ordinary notes `progress = (late_boundary - time) / (2 * (late_boundary - early_boundary))`, +capped at 1 and only shown before +the target time. The arcade MERRY branch instead evaluates the analogous +progress separately at each generated target's shifted timestamp. + +Both that circle and the effect-3 head are camera-facing billboards. The +mobile renderer calls `SetTransposeMatrix(world_position, camera_matrix)` for +the circle and leaves the effect sprite in billboard mode; neither is laid in +the route tangent plane. Duration bodies remain authored 3D route geometry. + +The Linux player now decodes this selector, interprets effect 3, loads +`skin/common/common.png`, `skin/common/common2.png`, the selected +`skinN/img.dat` list and `skinN/uv.dat`, and renders the selected animated +UV record. The former guessed `effect/game/img4.bin` glyph mapping and +synthetic pulsing rings are no longer used for decoded GC stages. + +Duration bodies are also type-specific in the executable: + +- HOLD calls `0x00647cf0` and SLIDE HOLD calls `0x00641fd0`; both emit ribbon + triangles rather than an OpenGL-style line. +- SCRATCH is sampled every `0.15` world units. `0x005ebaa0` derives two + opposing paths with radius `0.2` and rotates the offset by 45 degrees per + sample; `0x00641d50` emits six vertices per segment for each path. +- BEAT is sampled every `0.55` world units and `0x0064ab80` creates a separate + effect at every point. There is no continuous BEAT body. Its case-5 loop + draws only points whose sampled timestamp has not passed, and writes + `base_frame + sample_index * 2` into effect field `+8`. Android's symbolized + `EffectData` access confirms this field is the animation frame; the authored + UV-list selector remains unchanged. +- MERRY GO ROUND computes the `+0xbc…+0xc4` alpha once outside its generated- + target loop and multiplies every copied effect by that same parent alpha. + Individual offsets from `0x005efe40` change target time and position, not + the fade interval. +- DUAL HOLD uses `0x00646d70` to build two ribbon bands. Its literal inner and + outer offsets are `0.2` and `0.5`. +- Android `GameScene::drawLongShotEffect` (matching arcade `0x0064ab80`) + draws an effect-3 marker at the authored endpoint as well as the starting + marker. The fixed endpoint UV bases are 29 for HOLD, 30 for SCRATCH, 41 for + SLIDE HOLD, and 42 for DUAL HOLD. SLIDE HOLD additionally layers effect 39 + with UV base 10 at the endpoint and rotates its root by the precomputed + screen direction. Both endpoint layers inherit the long note's colour, fade alpha, + billboard transform, beat-synchronised animation and `0.025` sprite scale. + +## Tap judgment timing + +The system-config loader at `0x00635c90` lays out the timing overrides in four +per-difficulty arrays. The stage loader applies them in `0x005ed4c0`, and the +post-load pass at `0x005ebaa0` derives these runtime note boundaries: + +| Runtime field | Boundary | Easy override | +| ---: | --- | ---: | +| `+0x98` | note time minus `MissTimingOverride` | 236 ms | +| `+0x9c` | note time minus `UnmuteTimingOverride` | 202 ms | +| `+0xa0` | note time plus `LimitTimingOverride` | 202 ms | +| `+0xa4` | note time plus `MuteTimingOverride` | 0 ms | + +`0x005d1690` is the tap-grade function. It selects `+0x9c` for an early tap or +`+0xa0` for a late tap and computes the absolute distance `W` from the note +time to that boundary. It then builds thresholds using the literal table +`{100000, 100, 50, 25}` at `0x006e8bc0`, divided by 100. The grade loop checks +indices 3 down to 1, which matches the adjacent original enum/string order +`MISS, GOOD, COOL, GREAT`. + +For an error magnitude `e` and the selected outer input distance `W`, the +unclamped comparisons are therefore: + +```text +GREAT: e < 0.25 * W +COOL: e < 0.50 * W +GOOD: e < 1.00 * W +``` + +`GreatMinTime` is copied into the gameplay object at `+0x104`; if the computed +GREAT threshold is below that value, it is clamped and COOL is redistributed +by the same routine to `great + (good - great) / 3`, capped at GOOD. With the +shipped Easy `W = 202`, the absolute-error thresholds are 50.5 ms for GREAT, +101 ms for COOL, and 202 ms for GOOD. HARD/EXTRA use `W = 134`, producing +33.5/67/134 ms. The shipped `GreatMinTime = 32` therefore does not clamp these +four standard difficulty windows, but the branch matters for custom configs. + +`MissMarkOverride=1` in the shipped `data/system.cfg` makes the stage loader +replace the timing-list results with these four Simple/Normal/Hard/Extra arrays: + +```text +MissTimingOverride = (236,202,168,168) +UnmuteTimingOverride = (202,168,134,134) +LimitTimingOverride = (202,168,134,134) +MuteTimingOverride = (0,0,0,0) +``` + +## SHOT miss gating + +The two stage WAVs remain sample-synchronized, but they are not mixed at a +fixed level. `FUN_00640790` reads each player's `IsMute` state after the +judgment update. For player zero, `FUN_00611920`/`FUN_00611940` pass `1.0` or +`0.0` through `FUN_00611800` to the third volume multiplier of channel 13 +(SHOT); channel 12 (BGM) is untouched. The write is immediate rather than a +timed SoundManager fade. + +An unresolved target closes the SHOT gate at runtime `+0xa4`, which is note +time plus `MuteTimingOverride` (zero in the shipped configuration). The note +remains judgeable through `+0xa0`; a successful late judgment opens SHOT again +immediately. This is why a miss removes the chart-performance layer without +stopping or seeking either WAV. + +## Gameplay tap and AD-LIB sound effects + +The hit sounds are a separate three-WAV sound set; they are not baked into +either stage stem. `FUN_00613430` resolves the selected entry from +`data/boot/se.dat` and loads its three names from record fields `+0x14`, +`+0x18`, and `+0x1c`. The default `se0000` ("Ver.3 Set") maps them to: + +| Slot | WAV | `SEList.csv` volume | Playback channels | +| ---: | --- | ---: | --- | +| 0 | `SE_ARRANGE.wav` | 87 | 6, 7 | +| 1 | `TAP_SE1.wav` | 80 | 10, 11 | +| 2 | `TAP_SE2.wav` | 77 | 8, 9 | + +Every slot is deliberately double-buffered. `FUN_00611fc0` round-robins slot +0 over channels 6/7. `FUN_00611e70` round-robins the two booster sounds over +10/11 and 8/9. Consequently consecutive taps overlap instead of cutting off +the previous sample. Gameplay update `FUN_00640790` consumes the two booster +edge flags at player fields `+0xed/+0xee` and the separate AD-LIB flag at +`+0xaa`; the booster edge is what starts TP1/TP2, before the resulting note +rank is known. A mistimed press still produces its tap sound. The AD-LIB flag +selects slot 0 independently. + +The other `se.dat` entries preserve the same three-slot layout, for example +`ALB_06/TP1_06/TP2_06` for the Scratch set. The chosen set id comes from the +player customization state (`Afx` state `+0xce0` in the gameplay loader), not +from the stage chart. + +The Linux player currently loads the default Ver.3 set at the exact CSV gains +and reproduces the original two voices per slot. It decodes serialized marker +byte `+5` into the runtime `+8` AD-LIB flag and starts ALB after a successful +rank on such a marker. D-pad inputs are treated as booster 1 and face-button +inputs as booster 2; this also matches the PSP-style control grouping. + +## Ranked hit effects + +`setRankedEffect` records the rank and gameplay timestamp. The later +`DrawGameStageCharacter` pass selects group-1 effect `rank + 0x1c`, so the +effect IDs are 29 MISS, 30 GOOD, 31 COOL, and 32 GREAT. Their decoded lifetimes +are respectively 20, 12, 12, and 12 frames. The effect advances from the hit +timestamp at 60 Hz, uses additive blending, a global XY scale of `0.03`, a +camera-facing matrix, and a stored random root rotation from `rand() % 360`. + +## Hit-type dispatch and long-shot ranks + +The arcade dispatcher at `0x005d5fb0` selects the handler from the effective +marker type: 1 TAP, 2 FLICK, 3 HOLD, 4 SCRATCH, 5 BEAT, 6 MERRY, 9 CRITICAL, +10 SLIDE HOLD, and 15 DUAL HOLD. Android's symbolized +`GameScene::checkHitMark` independently confirms the same mapping and names +type 9 `checkHitMarkDualTap`. + +Dual tap stores the timestamp of its first independent input, consumes a +second independent input, then grades using the first timestamp. DUAL HOLD +also waits for two independent inputs, but begins its measured hold duration +when the second arrives; releasing either side ends it. + +Arcade `FUN_005d0d80` grades HOLD and DUAL HOLD by the percentage of the +authored body actually covered after clamping press/release to its start/end: + +```text +GREAT: held > 80% +COOL: held > 60% +GOOD: held > 40% +MISS: otherwise +``` + +The comparisons are strict. Before computing the percentage it also applies +the original short-tail compensation: if 20% of the body is below +`66.666664 ms`, that difference is added to covered time. `FUN_005d0eb0` +finalizes a still-pressed hold at the authored end; an early release grades it +immediately. + +BEAT (`FUN_005d41b0`) uses the same long-shot percentage grader, but records +the first tap and continually replaces the covered endpoint with each later +tap. If no fresh trigger arrives for `BeatEnableTime` (200 ms in the shipped +config), it finalizes at the last trigger. Thus it measures how far through the +body the repeated taps continue, rather than merely counting taps. + +SCRATCH (`FUN_005d44f0`) listens to four directional triggers. The first one +starts coverage; each later trigger extends it only when its direction differs +from the previous accepted direction. It finalizes after `ScratchEnableTime` +(250 ms) without a direction change or at the body end, then uses the same +40/60/80-percent long-shot grader. diff --git a/docs/re_gc_camera.md b/docs/re_gc_camera.md new file mode 100644 index 0000000..3e609b2 --- /dev/null +++ b/docs/re_gc_camera.md @@ -0,0 +1,374 @@ +# GC Camera Reverse Notes + +Status: work in progress. This file records facts extracted from `game471.exe` +and `docs/stage.pat` so the Vectorail camera port does not keep accumulating +guesswork. + +## Stage Camera Records + +`docs/stage.pat` defines a 59-byte camera key: + +```c +struct Camera { + u32 timeMs; + u8 aMode; + u8 fMode; + float dist; + float rotationA[2]; + float originOff[3]; + u8 projType; + float fieldFar[3]; + float fieldNear[3]; + float rotationB[1]; +}; +``` + +Observed across stage files: + +- `fieldFar[]` and `fieldNear[]` are almost always zero. +- Therefore `fieldNear[0]` is not the gameplay FOV. The current Vectorail GC + loader uses it as `fov`; that is almost certainly wrong. +- Useful camera fields are `dist`, `rotationA[0]`, `rotationA[1]`, + `originOff[0..2]`, `rotationB`, and the mode bytes. +- Common defaults seen in real stages: + - `projType` is usually `1`. + - `fMode` is usually `1` or `2`, with some `0`. + - `aMode` is usually `0`, but `1..6` also appear. + +Example from `ac_10pt8tion_hard.dat`: + +```text +time=0 aMode=0 fMode=0 dist=22 rotA=(0,90) originOff=(0,0,9) projType=1 rotB=0 +``` + +## Runtime Matrix Path + +The apparent gameplay matrix slots in the tune object are not where the camera +is calculated: + +- `tune + 0x150`: saved D3D `VIEW` matrix. +- `tune + 0x110`: saved D3D `PROJECTION` matrix. +- `tune + 0x0d0`: alternate saved D3D `VIEW` matrix. +- `tune + 0x090`: alternate saved D3D `PROJECTION` matrix. + +Functions confirmed: + +- `FUN_006449f0` calls `GetTransform(2, tune+0x150)` and + `GetTransform(3, tune+0x110)`. +- `FUN_00645e00` does the same after setting a 2D ortho projection. +- `FUN_00648d40` also snapshots `VIEW/PROJECTION` at render start. +- `FUN_0064da90` snapshots `VIEW/PROJECTION` into `+0xd0/+0x90`. + +So these offsets are consumers/snapshots, not the camera mechanics. + +The D3D device calls use the normal Direct3D 9 transform ids: + +```text +0xb0 on IDirect3DDevice9 vtable = SetTransform ++0xe4 on IDirect3DDevice9 vtable = GetRenderState ++0xb4 on IDirect3DDevice9 vtable = GetTransform +2 = D3DTS_VIEW +3 = D3DTS_PROJECTION +0x100 = D3DTS_WORLD +``` + +`FUN_004e72c0` / `FUN_004e5af0` are a central D3D state cache, not camera +math. The cache fields are: + +```text ++0x3a8 cached WORLD ++0x3e8 cached VIEW ++0x428 cached PROJECTION ++0x390 cached viewport +``` + +Wrapper setters identified: + +```text +FUN_004e3c10 = set view matrix, copies 16 floats to +0x310 and calls SetTransform(2) +FUN_004e3bc0 = set projection matrix, copies 16 floats to +0x350 and calls SetTransform(3) +FUN_004e3c60 = copy 16-float matrix to +0x180, no direct D3D SetTransform +``` + +These are useful for tracing the render path, but current xrefs show most +gameplay code uses the global D3D device helpers and snapshots rather than +directly calling these wrappers. + +## Common 3D Camera + +`CCommon3DCamera` is identified around `0063d820..0063d9d0`. + +Struct layout: + +```text ++0x04 eye vec3 ++0x10 target vec3 ++0x1c up vec3 ++0x28 base fov, radians ++0x2c camera mode +``` + +Projection: + +```c +D3DXMatrixPerspectiveFovLH( + out, + camera->fovRad * fovMul, + viewportWidth / viewportHeight, + nearZ, + farZ); +``` + +Important difference from Vectorail: the original uses the runtime viewport +aspect, not a hardcoded `1280.0 / 720.0`. + +View: + +```c +D3DXMatrixLookAtLH(out, eye, target, up); +``` + +when camera mode is `0`. + +Known refs: + +```text +FUN_0063d820 projection, caller FUN_00577840 +FUN_0063d9d0 view/look-at, caller FUN_00577840 +FUN_0063d8a0 constructor, caller FUN_00578570 +``` + +Only one `D3DXMatrixPerspectiveFovLH` call is present in the binary, and it is +in `FUN_0063d820`. Only two `D3DXMatrixLookAtLH` calls are present: +`FUN_0063d9d0` and `FUN_0065b1f0`. This makes `CCommon3DCamera` the strongest +candidate for the gameplay camera path, but its stage-game caller has not been +found yet. + +## Stage Object Transform Detour (Corrected) + +`FUN_00643570` is not a camera or stage-object evaluator. It belongs to the +temporary rail-strip construction path. The actual scene-object loop is +`FUN_006445b0`; it iterates `0xdc`-byte runtime objects from +`stageResource + 0x140` and calls `FUN_00643b20` for their transforms and +`FUN_00643fd0` for their colors. + +Inside `FUN_00643b20`: + +```text ++0xa4/+0xa8/+0xac count/times/vec3 track-like stream ++0xb0/+0xb4/+0xb8 count/times/vec3 stream ++0xbc/+0xc0/+0xc4 count/times/vec3 stream +``` + +It samples every keyframe array through `FUN_005e9100`. Movement, scale, and +rotation are linearly interpolated vec3 values. The final Euler rotation is +then converted to a quaternion using the engine's `(-Y, X, Z)` convention and +converted to a matrix; there is no quaternion slerp. Base and +animated translations, rotations and scales are separate matrices which are +multiplied in this exact sequence: + +```text +T(base) * T(move) * R(base) * R(animated) * S(base) * S(animated) +``` + +The color evaluator selects a keyed RGBA value in +place of the base color and linearly interpolates it when enabled. + +The recovered helper operations include: + +```text +FUN_005df6a0 = vec3 subtract +FUN_005df660 = vec3 scale +FUN_005df6f0 = vec3 add +``` + +and builds matrices through the local matrix stack: + +```text +FUN_005e0650 = translation matrix +FUN_005e0610 = scale matrix +FUN_005e0330 = Euler-degrees-to-rotation-matrix entry +FUN_005e0220 = quaternion from the (-Y, X, Z) half angles +FUN_005df790 = quaternion-to-column-major-matrix +FUN_005e0680 = in-place right matrix multiply +``` + +The first boolean in transform/color key records becomes runtime metadata byte +`+5` and identifies repeating key ranges. The second becomes byte `+4` and +enables interpolation from the active key to the next. Visibility uses a +separate fixed fade window: `_DAT_006fcae8` is `250.0f` milliseconds. + +The renderer also checks runtime object `+0xd4`, populated from the newer stage +supplemental parent-index stream. When present, it evaluates the parent and +child independently, multiplies their matrices, and multiplies their RGBA via +`FUN_0043a540`. It does not recursively walk more than one parent level. + +## Stage Camera Evaluator (Confirmed) + +`FUN_005e9e20(stage, result, timeMs, interpolate)` is the stage-camera +evaluator. The output is 44 bytes: + +```text ++0x00 eye vec3 ++0x0c target vec3 ++0x18 up vec3 ++0x24 projType byte ++0x28 projBlend float (0 or 1 outside an fMode=1 transition) +``` + +`FUN_005e0ca0` passes those first three vectors to `FUN_005e0800`, the game's +look-at matrix builder. This proves the vector order and bypasses the earlier +`CCommon3DCamera` false lead. + +The 59-byte wire key becomes this 0x44-byte runtime key: + +```text ++0x00 aMode (int) ++0x04 fMode (int) ++0x08 dist ++0x0c rotationA.x ++0x10 rotationA.y ++0x14 rotationA.z = 0 ++0x18 originOff vec3 ++0x24 projType byte ++0x28 fieldFar vec3 ++0x34 fieldNear vec3 ++0x40 rotationB +``` + +`FUN_005ed4c0` reads every serialized field directly and does not reject +non-finite floats. This matters for `ac_comet_{easy,normal,hard}.dat`: their +first key at 0 ms intentionally has NaN in both `rotationA` components, and the +next key starts at 1316 ms. The Linux loader must retain that first key; dropping +it activates the second camera too early and changes the intro. + +Let `T(t)` be the linearly interpolated track position and `R` be the orbit +vector calculated from `rotationA` and `dist`. The seven `aMode` branches are: + +```text +aMode 0: target = T(t) + originOff; eye = target + R +aMode 1: target = T(keyTime) + originOff; eye = target + R +aMode 2: for camera index > 1, return camera(keyTime - 1ms, no interpolation); + otherwise the same as mode 0 +aMode 3: target = T(t) + originOff; + eye = T(keyTime) + originOff + R +aMode 4: target = fieldFar; eye = fieldNear +aMode 5: target = T(t) + originOff; eye = fieldNear +aMode 6: target = fieldFar; eye = T(t) + R +``` + +This confirms that `originOff` is a world-space addition, not a rail-local +offset. + +### Orbit axis convention + +`FUN_005dfaa0` creates a quaternion from the Euler tuple +`(-rotationA.y, rotationA.x, rotationA.z)` in degrees. `FUN_005e01d0` then +transforms `(0, 0, dist)` by its matrix. In particular, the first 10pt8tion +key `rotationA=(0,90), dist=22` produces `R=(0,22,0)`: the camera is directly +above the rail, not behind it. + +### Interpolation modes + +Interpolation only happens strictly between the active key and the following +key: + +```text +fMode 0: evaluate the active key directly +fMode 1: evaluate camera states at both key timestamps, remove endpoint roll, + linearly interpolate eye/target/up, normalize up, then apply the + linearly interpolated rotationB +fMode 2: linearly interpolate all raw float/vector fields, retain the active + key's aMode/fMode/projType, then evaluate that mixed key +``` + +There is no shortest-angle interpolation in `fMode=2`; the rotation floats are +mixed as ordinary scalars. + +### Up vector and roll + +`FUN_005e0ad0` rebuilds `up` by projecting world-up `(0,1,0)` onto the plane +normal to `target-eye`. If the two directions are collinear it falls back to +`(0,0,1)`. `rotationB` then rotates that up vector around the normalized view +axis. + +### Gameplay projection + +`FUN_0063fd60(cameraState, fovMul)` consumes the complete 44-byte result of +the stage-camera evaluator. It selects or blends two projections: + +```text +projBlend <= 0: orthographic +projBlend >= 1: perspective +0 < projBlend < 1: + ortho + (perspective - ortho) * clamp(projBlend^3, 0, 1) +``` + +The perspective matrix uses vertical FOV `75 * fovMul`, the live viewport +aspect, near `1` and far `1000`. The orthographic half-height is +`distance(eye,target) * tan(FOV/2)` and its half-width is that value times the +viewport aspect. Consequently objects on the target plane keep the same +screen size while the projection changes, but depth shrinking disappears in +orthographic sections. + +The distance is not clamped to the near plane. A zero-length camera therefore +also produces zero orthographic extents in the original. Likewise, its vector +normalizer returns `(0,0,0)` for a zero-length input instead of propagating a +NaN as `glm::normalize` does. The Linux port mirrors both edge cases and uses +explicit left-handed view/projection builders; the `NO` depth variant is the +OpenGL backend adaptation of the original D3D left-handed matrices. + +The evaluator sets `projBlend` to `0` for `projType=0` and `1` for +`projType=1`. An `fMode=1` transition linearly interpolates the endpoint +blend values before the projection builder applies the cubic curve. + +The gameplay owner initializes its `fovMul` field at `+0x238` to `1.0` and the +normal update path passes it unchanged to `FUN_0063fd60`; therefore the normal +stage camera uses a 75-degree vertical FOV. This scalar is runtime state rather +than part of the 59-byte camera record. The same is true of viewport aspect and +the fixed near/far planes. + +### Gameplay item projection modifiers + +The value checked at global gameplay-state offset `+0xcd8` is the selected item +ID from `data/boot/item.dat`, not a camera or screen-mode enum: + +- `4`, `MIRROR`: flip the stage horizontally; +- `6`, `REVERSE`: flip the stage horizontally and vertically. + +At the end of `FUN_0063ff90`, both items negate the first column of the 3D +projection at gameplay-camera offset `+0x90` and the 2D orthographic projection +at `+0x110`. `REVERSE` also negates their second columns. The view matrix at +`+0xd0` is not changed. Course geometry switches from `D3DCULL_CCW` to +`D3DCULL_CW` only for `MIRROR`; the two-axis `REVERSE` transform preserves +triangle winding. + +## Vectorail Port Status + +The player now imports the raw camera keys (including non-finite sentinel +values) and ports the confirmed `aMode`, +`fMode`, orbit, up/roll, projection type/blend, FOV and clipping-plane +behavior. GC cameras are evaluated directly without the legacy follow-camera +smoothing. Remaining camera work is validation against captured original +frames. + +## Switch and Android cross-version validation + +The base Switch executable retains `CTuneGameData` RTTI and the original GC +source filenames. Its stripped `CTuneGameData::GetWayPosition` and +`CTuneGameData::GetCameraData` implementations were matched to the named +Android ARM64 functions by class layout, control flow, and camera-mode +behavior. + +Switch stores camera timestamps separately from a 0x44-byte runtime payload, +where Android uses an interleaved 0x48-byte record. The evaluator itself still +implements the same `aMode` branches `0..6`, `fMode` 1/2 interpolation, up and +roll construction, projection type/blend, and the mode-2 one-millisecond +hold. This provides an independent confirmation of the arcade reconstruction +above. + +FOV is platform policy rather than a stage field. Android chooses between +`60.0`, `68.5`, and `75.0` degrees for its 3:2, 16:9, and iPhone-X layout +modes. That mobile-only aspect switch does not override the arcade +executable's confirmed 75-degree gameplay FOV used by the Linux arcade target. +See `re_gc_switch.md` for the Switch addresses and asset inventory. diff --git a/docs/re_gc_catalog.md b/docs/re_gc_catalog.md new file mode 100644 index 0000000..83d0bdf --- /dev/null +++ b/docs/re_gc_catalog.md @@ -0,0 +1,107 @@ +# GC 4.71 song catalog reverse notes + +The Windows build loads its master song catalog from +`data/boot/stage_param.dat`. `LevelList.dat` is a different fixed-record table: +its 9002 bytes are a big-endian `u16` count of 1000 followed by 1000 records of +9 bytes, and it is not the song/asset relation table. + +## Confirmed loader + +`FUN_005e66a0` opens `data/boot/stage_param.dat`. It reads a big-endian `u16` +record count, allocates `count * 0xb4` bytes, and deserializes every variable-size +disk record into one `0xb4`-byte runtime entry. The dumped file contains 924 +records. IDs are explicit and can have gaps; they are not array indices. + +The game's primitives used by this loader are: + +- `FUN_005d8bc0`: big-endian `u32`; +- `FUN_005d8cc0`: `u8`; +- `FUN_005d97d0`: `u8 byteLength` followed by that many string bytes; +- `FUN_005d96d0`: CP932-to-current-Windows-codepage conversion, used for display + strings but not asset identifiers. + +The recovered runtime entry is: + +```text +offset type confirmed/current meaning +0x00 u32 numeric song ID +0x04 string* display title +0x08 string* image/asset key +0x0c string* artist +0x10 string* source/subtitle +0x14 string* normalized sort key +0x18 u8 genre ID (1 anime, 2 Vocaloid, 3 rhythm game, 4 game, + 5 variety, 6 original, 7 Touhou) +0x1c string* duration, e.g. "2:03" +0x20 u8[4] EASY/NORMAL/HARD/EXTRA ratings +0x30 string* BPM text +0x34 u8[4] per-difficulty BGM volume percentage +0x44 u8[4] per-difficulty SHOT volume percentage +0x54 u32[3] timing values (exact roles not yet named) +0x60 u8[2] not yet named +0x68 string* BGM base name +0x6c string*[4] alternate chart group (mostly empty in current songs) +0x7c string*[4] auxiliary chart suffixes +0x8c string*[4] EASY/NORMAL/HARD/EXTRA chart IDs +0x9c string* not yet named +0xa0 u32 not yet named +0xa4 u8[2] not yet named +0xac string* not yet named +0xb0 u8 not yet named +``` + +The clean-room implementation is in `src/gc/StageCatalog.cpp`; the old +nearby-string search used by `--track-info` has been replaced by this exact +record parser. + +## Asset relation + +For `Oshama Scramble!`, the catalog record contains: + +```text +title Oshama Scramble! +image key oshama +artist t+pazolite +ratings 1, 7, 13, 0 +BGM base bgm_b-879_oshama +charts ac_oshama_easy + ac_oshama_normal + ac_oshama_hard +``` + +The executable derives paths rather than storing full paths in the catalog: + +```text +chart ID -> data/stage/.dat + data/stage/_ext.dat + data/stage/_clip.dat + +BGM base -> data/stage/sound/_BGM.wav + data/stage/sound/_SHOT.wav + data/stage/sound/_VIB.csv + +image key -> data/stage/2d/_menu.dds + data/stage/2d/_start.dds +``` + +`FUN_0063ea70` confirms the three chart formats. `FUN_005b3980` and +`FUN_005b3850` build the `_menu.dds` path from runtime offset `+0x08`. +For English UI they first try `data/stage/2d/eng/_menu.dds` and fall back to +the non-language directory. `_start.dds` follows the same rule. + +`FUN_00613710` prefixes stage audio with `data/stage/sound/` and appends the +suffixes. If the stage-specific BGM cannot be found, it also has a legacy +fallback under `data/sound/`. + +The stage `.dat` then supplies the playable geometry, notes, camera, authored +background color table, particles, visualizer data and object/model scene. The +`*_menu.dds` image is selection/game UI artwork, not the gameplay background. + +## Inspecting a record + +```sh +./build/openroller GC/data/boot/stage_param.dat --track-info ac_oshama_hard +``` + +This prints metadata, all difficulty chart IDs, and every derived stage, sound, +menu and start-image path with a missing-file marker where applicable. diff --git a/docs/re_gc_menu_exe.md b/docs/re_gc_menu_exe.md new file mode 100644 index 0000000..c36954a --- /dev/null +++ b/docs/re_gc_menu_exe.md @@ -0,0 +1,224 @@ +# Menu reverse notes: game471.exe as source of truth + +This work deliberately does not derive menu layout or behavior from web +screenshots. The authoritative inputs are `GC/game_patched.exe` and the +matching files in `GC/data/2d_boost` and `GC/data/task_cfg`. + +## Task flow + +The global state machine at `FUN_00651440` constructs these tasks in order: + +| state | task | constructor | scheduler id | +|---|---|---:|---:| +| `0x10` | `CSelectMusicTask` | `FUN_005aa900` | `0x260` | +| `0x11` | `CDifficultyTask` | `FUN_005bc750` | `0x261` | +| `0x12` | `CGameMainTask` | gameplay constructor | — | + +This proves that selecting a song and selecting its difficulty are separate +screens. OpenRoller's earlier combined selector was structurally wrong. + +The main vtables recovered from RTTI are: + +- `CSelectMusicTask::vftable` at `0x006fbcdc`; +- `CDifficultyTask::vftable` at `0x006fb898`. + +Their render callbacks are `FUN_005aca40` and `FUN_005be2d0` respectively. + +## Scene files chosen by the executable + +`FUN_00446a10` chooses the language-specific select-music task config. The +English configs resolve to: + +```text +data/task_cfg/selectmusic_eng.cfg + data/2d_boost/selectmusic2_eng.rvb + data/2d_boost/selectmusic2_eng.mtx + +data/task_cfg/difficulty_eng.cfg + data/2d_boost/selectmode2_eng.rvb + data/2d_boost/selectmode2_eng.mtx +``` + +The `.rvb` MOVI header identifies both scenes as `720x1280 @ 60 fps`. Its two +dimension fields are stored height-first. This ordering is confirmed by the +root placements recovered from `TIME`: `imc_title=(360,112)`, +`imc_focus=(341,695)`, and `imc_navi=(238,1136)`. The UI is authored directly +for the cabinet's portrait display. + +The RVB top-level child layout is now parsed by `gc::ParseRvbScene`. For the +English music scene it is: + +```text +PREP @ 0x000033 size 0x0056cf (455 action/path bindings) +REPO @ 0x005702 size 0x0017b0 +DEFN @ 0x006eb2 size 0x03046e +EXPG @ 0x037320 size 0x000054 +TIME @ 0x037374 size 0x00177a +``` + +`PREP` exposes the real animation hierarchy, including +`/imc_focus/imc_fd_jacket_anim`, the four nodes below `/imc_focus/imc_diff`, +the eight `/imc_sort/imc_sort*` nodes, `tg_decision`, and the focus in/out +actions. The companion difficulty scene exposes its own `/imc_slmode` +hierarchy. + +Use the local probe to inspect all bindings: + +```sh +./build/openroller-rvb-probe GC/data/2d_boost/selectmusic2_eng.rvb +./build/openroller-rvb-probe GC/data/2d_boost/selectmode2_eng.rvb +``` + +The probe also accepts exact MovieClip states, for example: + +```sh +./build/openroller-rvb-probe --state /=jf_slmusic_start \ + --state /imc_focus=jf_focus_start \ + GC/data/2d_boost/selectmusic2_eng.rvb +``` + +## Recovered RVB/MTX runtime + +Every animation object is a recursive node with a four-byte tag, total size, +local-data size, local data, and child nodes. `DEFN` supplies named `MOVC` and +`SHAP` definitions; `TIME` contains labeled `FRAM` records. Frames are display +list deltas rather than complete scenes: + +- `PLC3` creates/replaces a definition at a depth or updates its transform; +- `RMOV` removes a depth; +- `TRN2` carries the 2D affine transform; +- `COLT` carries RGBA multiplication, including authored visibility fades; +- `ASRC` contains the exported `play();`, `stop();`, and target actions. + +`gc::BuildRvbSnapshot` now accumulates those deltas through a selected label +and advances `play()` entry frames to their following `stop()` frame. This is +why hidden templates and transition masks no longer appear together. + +MTX starts with `MTX\0`; each payload is a DDS whose first dword was replaced +by the container. Restoring `DDS ` yields a standard DDS. RVB `ImageN` maps to +MTX texture `N-1`; all 115 music resources and all 210 difficulty resources +match their declared dimensions. + +## Coordinates recovered from render code + +The select-music render callback walks exactly twelve neighboring song slots. +The associated catalog offsets stored at `0x006e0708` are: + +```text +-5 -4 -3 -2 -1 0 0 1 2 3 4 5 +``` + +`FUN_00447170` supplies the stable slot geometry. Its twelve vertical pairs +are: + +```text +175/189 228/242 281/295 334/348 387/401 440/454 +701/715 754/768 807/821 860/874 913/927 966/980 +``` + +The fixed components in `FUN_005aca40` include positions `(39,497)`, +`(251,467)`, `(262,500)`, `(262,522)`, and `(330,594)`. These are floats read +directly from the executable's `.rdata`, not measurements from a screenshot. + +`CDifficultyTask` passes sprite centres to `FUN_005b33a0`, which subtracts half +of the scaled source rectangle. The selected song fragments therefore resolve +to these destination rectangles: + +```text +jacket source (1,1,196,196) -> (105,167,98,98) +title source (0,197,374,34) -> (209,178,374,34) +source source (0,232,374,24) -> (220,214,374,24) +artist source (198,180,314,16) -> (220,239,314,16) +``` + +The difficulty callback centers variable-length groups using the executable's +actual formulas and spacings: + +- `(7 - count) * 0.5 * 68`, with the row anchored at `426 + 16`; +- `(9 - count) * 0.5 * 52`, with the row anchored at `433 + 16`. + +The remaining visual work is outside this recovered static scene snapshot: +the executable-owned player/status HUD in the blank upper band, continuous +timeline interpolation, and the exact transition timing between task states. + +## Common and navigator layers + +The select task is not visually self-contained. Two additional original +movies are composed with it: + +```text +data/2d_boost/common_eng.rvb/.mtx +data/2d_boost/navigator/navi_001_yume.rvb/.mtx +``` + +`common_eng` supplies the network/player icons and the three bottom controller +prompts. The select-music controller uses the `jf_ctrl_3` layout and the +`jf_ctrl_tx02`, `jf_ctrl_tx05`, and `jf_ctrl_tx08` label states (`Select`, +`Change song order`, `Confirm`). + +The navigator is itself a 720x1280 MovieClip scene, not a single positioned +DDS. Its recovered opening state draws the bottom backing plate at +`(0,1000)..(720,1232)`, Yume at `(416,830)..(720,1280)`, and a separate mouth +layer. Rendering only `navigator/001_yume/base.dds` was therefore structurally +incorrect. + +## Dynamically linked carousel rows + +The twelve list rows do not live in the root select-music timeline. The task +creates twelve instances of each exported linkage symbol below and attaches +them to `imc_scroll_dds`: + +```text +EXPG UNIQUE_71 -> mc_music_link +EXPG UNIQUE_74 -> mc_index_link +``` + +`gc::BuildRvbSymbolSnapshot` resolves those EXPG symbols back to their DEFN +MovieClips. `mc_music_link` contains the 520x34 row plate and its three score +cells. `FUN_00447170` places it at x=8 and the twelve y positions listed above; +`FUN_00447620` supplies row opacity (`0,.7,.8,.9,1,0,0,1,.9,.8,.7,0`), not a +geometric scale. The title atlas fragments do use the same values as scale. + +`mc_index_link` is the corresponding 356x36 category plate. Entries returned +by `FUN_005aa7c0` below 50000 select `mc_music_link`; pseudo entries at or above +50000 select `mc_index_link` and therefore consume a normal carousel slot. +For the Genre sort, `FUN_005b40f0` loads +`data/2d_boost/menu/s_j[_eng].dds`. `FUN_005b3fc0` selects one of its 256x32 +rows and `FUN_005aca40` draws it at the index clip position plus `(53,2)`: +with the static x=88 row position this gives label x=141. The English rows are +beginner, Anime & Pops, VOCALOID, Touhou, Rhythm Game, Game, Variety, Original. + +## Sort-tab indirection + +The integer stored in `DAT_007f3134` is not the left-to-right tab number. The +eight internal sort kinds map through the executable byte table `34621857`; +internal kind 0 (Genre and `s_j_eng.dds`) consequently drives visual frame +`jf_sort3_ini`. The visual tab order remains New, Monthly Theme Music, Genre, +Difficulty, Score Average, Title, Favorite, At random. Each `imc_sortN` child +also receives its independent `jf_sortN_on/off` availability frame. The small +40x18 NEW marker at `(44,181)` is a separate root child present in both +`jf_sort3_ini` and the stable `jf_sort3` frame; it is not part of `imc_sort1`. + +## Executable-owned menu background + +The background is a separate 3D task, not part of any RVB and not a guessed +flat colour. `FUN_00577fb0` first emits a full-screen four-vertex strip with +the exact D3D colours: + +```text +top: ARGB FF30309B +bottom: ARGB FFE57386 +``` + +It then draws two locally loaded TUMO resources. The loader table begins at +the literal `data/model/menu_obj_05.tumo`; its second 0x40-byte entry is +`data/model/obj_sphere06.tumo`. The latter is the 288-segment wire sphere seen +behind the list. Its recovered camera is LH, FOV 60 degrees, aspect 720/1280, +near/far 0.1/1000, looking along +Z. The sphere is translated to z=100, scaled +by 5, rotated equally about XYZ at `time*0.125`, and given the small authored +two-frequency vertical drift. + +Finally `FUN_005b73a0` draws `data/2d_boost/menu/balloon.dds` as a 12x4 grid +of 64px cells at y=1000. This reconstructs source rectangle `(0,0,768,256)`; +the last 48 pixels are clipped by the 720px cabinet viewport. This is the +dark controller backing visible behind the common HUD and navigator. diff --git a/docs/re_gc_song_select.md b/docs/re_gc_song_select.md new file mode 100644 index 0000000..ffb290b --- /dev/null +++ b/docs/re_gc_song_select.md @@ -0,0 +1,61 @@ +# GC-style song selection + +Running the stage player without a stage path opens the graphical song +selector backed by `data/boot/stage_param.dat`: + +```sh +./tools/run_stage_player.sh +``` + +Passing a stage remains the direct reverse/debug path: + +```sh +./tools/run_stage_player.sh GC/data/stage/ac_oshama_hard.dat +``` + +The selector renders the original English `selectmusic2` and `selectmode2` +RVB/MTX scenes. Exact task flow, coordinates, animation bindings, and the +container reverse are recorded in `docs/re_gc_menu_exe.md` from +`game471.exe` itself. + +What is already wired: + +- the original portrait 720x1280 `SELECT MUSIC` and `SELECT MODE` scenes; +- original MovieClip frame labels, display-list depth updates, transforms, + alpha fades, and RGB color transforms; +- the original `common_eng` controller HUD and animated Yume navigator scenes; +- the executable's purple-to-pink background strip, rotating wire sphere, + translucent menu geometry, and `balloon.dds` lower backing layer; +- genre filtering from the catalog genre byte; +- the twelve dynamically linked `mc_music_link`/`mc_index_link` carousel + slots with their executable positions and opacity table, including original + `s_j_eng.dds` genre headings as real scrolling entries; +- title rows, jacket, source and artist cut directly from each original + `data/stage/2d/_menu.dds` atlas; +- SIMPLE/NORMAL/HARD/EXTRA availability and selected-state MovieClips from the + same catalog; +- separate select-music and difficulty states, matching the task transition + proven in the executable; +- launch through the selected difficulty's exact `ac_*` stage ID. + +Entries are included only when both their menu atlas and at least one local +stage `.dat` exist. This dump currently yields 887 playable songs from the 924 +master catalog records. Only the visible carousel window is uploaded to the +GPU, rather than all jackets at once. + +Controls: + +```text +Up / Down, W / S previous / next song +PageUp / PageDown jump by eight songs +Q / E or Tab genre +Enter or Space enter difficulty screen +Escape close selector + +Difficulty state: + +```text +Arrows, W / S, A / D difficulty +Enter or Space play +Escape back to music selection +``` diff --git a/docs/re_gc_switch.md b/docs/re_gc_switch.md new file mode 100644 index 0000000..20a75ae --- /dev/null +++ b/docs/re_gc_switch.md @@ -0,0 +1,154 @@ +# Groove Coaster Wai Wai Party Switch reverse notes + +Status: work in progress. These notes describe the base title +`0100EB500D92E000`; update and DLC content have not yet been merged into the +analysis corpus. + +## Executable and engine lineage + +The program NCA contains an AArch64 NSO (`main`). Converting it to ELF and +importing it into Ghidra exposes a stripped executable with relocations, RTTI, +and exception unwind data. + +This is the same custom GC engine lineage as the arcade and Android builds, +not a Unity rewrite. Direct evidence in `main` includes: + +- RTTI name `13CTuneGameData`; +- assertion/source paths under + `D:/project/GC/svn/latest/Program/Main/Src/GCMain/Tune/Functions/`; +- `TuneGameData.cpp`, `TuneGameManager.cpp`, and + `TuneGameManager_Draw.cpp` source names; +- the assertion label `LoadStageData`; +- stage paths `stage/data_gz/%s.dat.gz`, `%s_ext.dat.gz`, and + `%s_clip.dat.gz`. + +Initial function mapping in the base Switch executable (Ghidra image +addresses): + +```text +001456a0 CTuneGameData constructor +001457f0 CTuneGameData destructor +00145f60 CTuneGameData deleting destructor +0014a3d0 CTuneGameData::GetWayPosition +0014a560 CTuneGameData::GetCameraData +00134af0 asynchronous stage .dat/.ext/.clip loader +00155b50 TuneGameManager::LoadStageData owner/assert site +``` + +The names after the destructors are cross-matched against the symbol-bearing +Android ARM64 build and then checked from their decompiled behavior. + +## RomFS inventory + +The base RomFS contains 8,976 files (about 1.6 GiB). Important families are: + +```text +3421 .tumo scene models +2136 .gz compressed stage data +1790 .opus audio, normally named *.wav.opus +1052 .bntx Switch textures + 104 .tusc + 88 .bnvib + 85 .dat boot/catalog tables + 69 .efcb2 + 65 .uvb + 57 .rvb + 57 .mtx + 19 .bnsh shaders +``` + +Gameplay audio names explicitly distinguish tap, slide-hold, scratch, +critical, beat, and adlib hit effects. This makes the Switch assets useful for +validating the note-type-to-sound mapping even where the arcade catalog uses +numeric IDs. + +## Stage data compatibility + +Each chart still consists of the familiar three files, now gzip-compressed: + +```text +stage/data_gz/.dat.gz +stage/data_gz/_ext.dat.gz +stage/data_gz/_clip.dat.gz +``` + +After gzip decompression the normal `.dat` is accepted directly by +OpenRoller's arcade `StageDat` and `StagePattern` parsers. For example, +`sw_adr_hard_1.dat` decodes as: + +```text +track points: 240 +notes: 529 +camera keys: 67 +draw-distance keys: 21 +background models: 8 +background objects: 199 +``` + +The parser also recovers all expected note types, authored camera modes, +particle/visualizer keys, colors, and object animation. This is strong +structural confirmation that the arcade field interpretations are not merely +heuristics. + +The Switch `_clip.dat` can be much larger than the main chart because it +contains baked per-frame object visibility/animation data, just like the +arcade clip stream. + +All 712 main chart files in the extracted base-game RomFS parse successfully +with the current OpenRoller stage parser. A chart can be staged and launched +directly by ID: + +```sh +tools/run_switch_stage.sh /path/to/decoded/romfs sw_adr_hard_1 +``` + +List the available chart IDs with: + +```sh +tools/run_switch_stage.sh /path/to/decoded/romfs --list +``` + +The launcher decompresses the chart, extension, and clip streams into +`build/nsw_runtime`, links the common model directory, converts the matching +Nintendo Switch OPUS stream with `vgmstream-cli`, and starts the Linux player. +Set `VGMSTREAM_CLI=/path/to/vgmstream-cli` if it is not on `PATH` or in the +repository build directory. + +This currently provides chart, camera, note, clip, model geometry, and BGM +loading. The `.bntx` texture/material pipeline and the newer Switch song +catalog are not implemented yet, so a successfully running stage is not yet a +pixel-identical Switch presentation and there is no Switch-native song menu. + +The BGM resolver finds a shipped audio stream for 711 of the 712 chart files, +including charts whose internal authoring-time BGM name differs from the +release filename. The sole exception is `sw_shoukon_hard_1`: it is absent from +the base `stage_param.dat` catalog and the base RomFS contains no matching BGM, +so this orphan chart can only be launched silently from that data set. + +`boot/stage_param.dat` is a newer catalog revision. The current arcade catalog +parser does not consume it completely and must not be treated as compatible +until its extra fields are mapped from the Switch loader. + +## Camera cross-check + +The Switch `CTuneGameData::GetCameraData` stores key timestamps in a separate +array and uses a 0x44-byte runtime camera payload rather than Android's +interleaved 0x48-byte record. Despite that storage change, its behavior is the +same: + +- the active camera key is selected by timestamp; +- `fMode` 1 evaluates both endpoint cameras, rebuilds endpoint up vectors, + then interpolates the complete camera states; +- `fMode` 2 interpolates raw key fields before evaluation; +- `aMode` has the same seven branches `0..6`; +- projection type and projection blend are separate results; +- the mode-2 hold branch samples the state one millisecond before the key. + +This independently validates the camera evaluator currently documented in +`re_gc_camera.md` and implemented in the Linux player. + +The projection/FOV policy remains platform-specific. The Android build calls +`SwitchAspectValue(60.0, 68.5, 75.0)` and selects the value for its 3:2, +16:9, or iPhone-X layout. The arcade executable instead establishes a normal +75-degree gameplay FOV. Therefore Android's aspect presets must not replace +the arcade target behavior in the Linux player. diff --git a/docs/re_gc_test_mode.md b/docs/re_gc_test_mode.md new file mode 100644 index 0000000..3b79e12 --- /dev/null +++ b/docs/re_gc_test_mode.md @@ -0,0 +1,310 @@ +# game471 test mode + +This note describes the operator/test mode in the arcade `game471.exe` +(4.74.00ENG). The structural and layout observations below come from the +executable and its shipped resources, not from screenshots. + +## Live entry + +The game exposes twenty logical cabinet inputs through `FUN_00634060` +(`0x00634060`). Logical input 0 is the TEST switch. + +During normal operation, the main task at `FUN_006396f0` samples input 0 on +every update. Holding it for at least three updates starts the test-mode +transition. The game then: + +1. stops the normal game/audio/render tasks; +2. waits 45 updates; +3. constructs the test-mode text renderer, sprite renderer, input adapter, + SE adapter, and BGM adapter; +4. initializes the test-mode form system and opens the main form. + +The already generated `game471_bootskip.exe` can reach the original menu under +Wine. Once the `GameWare` window is active, press and briefly hold Caps Lock. +Caps Lock is the executable's built-in DirectInput fallback for the TEST +switch; no test-mode-specific patch is needed. + +The original executable's keyboard fallback table starts at `0x007840f0`: + +| Logical input | Cabinet control | FAST I/O mask | DirectInput fallback | +| --- | --- | ---: | --- | +| 0 | Test switch | `0x00000040` | `DIK_CAPITAL` (Caps Lock) | +| 1 | Service switch | `0x00000001` | `DIK_F1` | +| 2 | Coin switch | `0x00000004` | `DIK_F2` | +| 3 | Select switch | `0x00000010` | `DIK_F3` | +| 4 | Enter switch | `0x00000020` | `DIK_RBRACKET` (`]`) | +| 5 | Left booster up | `0x00000100` | `DIK_Q` | +| 6 | Left booster down | `0x00000200` | `DIK_A` | +| 7 | Left booster left | `0x00000400` | `DIK_LCONTROL` | +| 8 | Left booster right | `0x00000800` | `DIK_S` | +| 9 | Left booster button | `0x00100000` | `DIK_LMENU` (left Alt) | +| 10..14 | Right booster controls | `0x00010000` through `0x00200000` | raw scan codes `78,7d,7a,7b,6a` | + +`FUN_00633d00` obtains the FAST I/O bitfield and masks it with the first table +above. `FUN_00633de0` applies the per-input active-high/active-low table. +`FUN_00634060` ORs that result with the DirectInput fallback. + +## Test-mode input adapter + +`GWTestModeInput_GW` is constructed by `FUN_00569d00`. Its update method, +`FUN_00569d20`, translates cabinet inputs to the generic test-mode flags: + +- Test switch -> `0x20` (back/exit) +- Select switch -> `0x02` +- Enter switch -> `0x58` +- left booster up/down/left/right/button -> `0x01`, `0x02`, `0x84`, `0x48`, + `0x10` + +The language table describes the intended behavior: + +- booster up/down or Select moves the cursor; +- booster buttons or Enter confirms; +- booster left/right or Enter changes a setting; +- Test returns/backtracks. + +Short synthetic X11 key taps may be sampled for several game updates because +the original runs uncapped. For automated navigation, inject an explicit +cabinet state for one update instead of relying on `xdotool key`. + +## Resources + +Test mode does not use the normal RVB/MTX menu assets. Its strings come from: + +- `data/TestModeLaungage/Laungage_eng_sjis.csv` +- `data/TestModeLaungage/Laungage_jpn_sjis.csv` + +Both are CP932/Shift-JIS. `Laungage` is the spelling used by the shipped game. +The glyphs themselves come from `data/font/Font.mtf` and the +`data/font/FontXXXXXXXX.mfi` atlas pages. `GWTestModeRenderText_GW` +constructs a pool of 256 regular GameWare text objects backed by that font. +It does not use a test-mode-specific bitmap font or GDI text. + +`Font.mtf` starts with a 16-way Unicode radix table. A resolved entry stores +the MFI page in its low 24 bits and the high nibble of the atlas cell in bits +28..31; the low cell nibble comes from the Unicode codepoint. Each MFI contains +a 512x512 DXT5 atlas split into 16x16 cells. This is confirmed by +`FUN_00485f90`, which creates GameWare image format 3, and by +`GWPCImage2D::Create`, whose format table maps index 3 to `D3DFMT_DXT5`. +The font shader uses the DXT5 alpha channel as glyph coverage. The English +ASCII glyphs use page 0 and occupy the left half of each cell, producing 8x16 +glyphs. The selection marker is U+2192 (`→`), resolved by the MTF to page 27, +cell `0xb2`, and is 16x16. + +The renderer is a separate, mostly text-based framework represented by the +following RTTI classes: + +- `GWTestModeWindowText`, `GWTestModeWindow`, `GWTestModeWindowList` +- `GWTestModeForm`, `GWTestModeSelectForm` +- `GWTestModeForm_YesNo`, `GWTestModeForm_ProcYesNo` +- `GWTestModeInput_GW` +- `GWTestModeRenderText_GW`, `GWTestModeRenderSprite_GW` +- `GWTestModeBGM_GW`, `GWTestModeSE_GW` + +The framework screen is 720x1280 on black. `FUN_00577940` installs a logical +text size of 16x16, pure red `(1,0,0,1)` for the selected/help color, and pure +cyan `(0,1,1,1)` for normal selectable entries. The three centered common +title windows use normalized y coordinates `-0.95`, `-0.925`, and `-0.9`, +which map to pixel y positions 32, 48, and 64. + +The main list is anchored at normalized y `-0.725` (176 px). Its default row +gap is `0.01` of the 640-pixel half-height, so rows advance by +`16 + 6.4 = 22.4` pixels. The selected row is red and has a separately drawn +`→`; unselected rows are cyan. The help form is centered at normalized +y `0.2` (768 px), while the main five-line build/machine/time information list +is centered at y `0.4` (896 px). + +## Main menu in 4.74.00ENG + +The live build displays: + +1. Monitor Test +2. Input/Output Test +3. LED Test +4. Card Test +5. Audio Settings +6. Game Settings +7. Network Info +8. Bookkeeping +9. System Info +10. Restore factory settings +11. Exit Test Mode + +The CSV also contains `Check Input Count`, `System Settings`, `Delete High +Scores`, and `Machine Connection Test`, but prefixes those entries with `#`. +It contains `Update Online` without `#`, although that item is still filtered +out by this build's runtime conditions. + +## Forms and data exposed + +- Monitor Test: color bars, white, red, green, blue, and cross-hatch patterns. +- Input/Output Test: all cabinet switches, both five-input boosters, headphone + volume/jack state, and Groove Stage connection. Booster buttons drive their + lamps; the coin switch changes lockout. +- LED Test: all lights and individual title, side, and booster light groups. +- Card Test: card-reader status and card ID. +- Audio Settings: test BGM, master/headphone/demo levels, five speaker channels, + Groove Stage status, and normal/demo vibration intensity. +- Game Settings: coin/song price, per-day operating hours, and score-attack + mode. +- Network Info: location identity/address/IP, cabinet IP/MAC, NESYS versions, + and relay server. +- Bookkeeping: uptime/play totals, free plays, service-switch count, player + statistics, 30-week and hourly histograms, play logs, the last twenty errors, + and service-switch history. +- System Info: program/system configuration values. +- Factory Settings: confirmation form followed by persistent-data reset. + +Persistent test-mode data has separate RTTI classes for system settings, high +scores, play/weekly/daily/error/service-switch logs, and game-unique data. Log +paths embedded in the executable live under `TestModeFile\...\Log`. + +### Coin/song presets + +`CTestModeForm_GameSetting` chooses its coin/song table from the Type X +`HKLM\SOFTWARE\taito\typex\Country` registry value. `Country == 0` uses eight +entries, in this exact order: + +1. 1 coin, 2 songs +2. 1 coin, 3 songs +3. 2 coins, 2 songs +4. 2 coins, 3 songs +5. Free play, 1 song +6. Free play, 2 songs +7. Free play, 3 songs +8. 1 coin, 1 song + +For a non-zero country value it instead exposes 39 entries: every combination +of 1 through 12 coins with 1 through 3 songs, followed by the three free-play +variants. `FUN_00570a70` and `FUN_00570810` wrap the selected index forward and +backward respectively. + +OpenRoller mirrors the registry value with `Country` in `openroller.cfg` beside +the executable. CMake creates the file on the first build but leaves an +existing copy untouched. The value is reread whenever test mode is entered, so +the application does not need to be rebuilt after changing it. + +## Useful code addresses + +| Address | Role | +| ---: | --- | +| `0x006396f0` | top-level boot/game/test-mode state machine | +| `0x00634060` | logical cabinet input + DirectInput fallback | +| `0x00633d00` | mask current FAST I/O input bitfield | +| `0x00633de0` | apply active-level table | +| `0x00569d00` | construct `GWTestModeInput_GW` | +| `0x00569d20` | translate game inputs to generic test-mode input flags | +| `0x00569610` | construct `GWTestModeRenderText_GW` | +| `0x00569ad0` | construct `GWTestModeRenderSprite_GW` | +| `0x00569440` | construct `GWTestModeSE_GW` | +| `0x005693f0` | construct `GWTestModeBGM_GW` | +| `0x00577940` | initialize test-mode form/render state | +| `0x00634fd0` | credit/service-credit handling and `SERVICE-SW LOCKED` | + +`SERVICE-SW LOCKED` is unrelated to entering test mode. It is emitted by the +credit controller after repeated Service-switch credit pulses and holds the +service-credit lock for 180 updates. + +## LED output pipeline + +The LED test form's main methods are: + +| Address | Role | +| ---: | --- | +| `0x0056e160` | construct `CTestModeForm_LEDTest` | +| `0x0056e390` | update the selected LED test pattern | +| `0x0056e6c0` | handle the seven LED-test menu items | +| `0x0062b0e0` | set logical LED `index` to `R,G,B,alpha` | +| `0x0062d4d0` | construct all logical LED objects and their hardware mapping | +| `0x0062a4d0` | update one `CLedDevice` and place its value in the cabinet output buffers | +| `0x004b3b40` | write one RGB pixel into the shared board buffer | +| `0x004b4c50` | pack shared LED buffers into the FIO transfer blocks | +| `0x004b55f0` | exchange registers and transfer blocks with `iDmacDrv32` | +| `0x004b6700` | wrapper for `iDmacDrvRegisterBufferWrite` | + +### Logical groups + +There are 118 logical LED objects: + +| Logical indices | Test-mode group | Hardware coordinates | +| ---: | --- | --- | +| `0` | left booster button | simple output 4 | +| `1..8` | left booster RGB group 8 | board 0, group 8, positions 0..7 | +| `9..40` | left booster RGB groups 0..7 | board 0, groups 0..7, positions 0..3 | +| `41` | right booster button | simple output 5 | +| `42..49` | right booster RGB group 8 | board 1, group 8, positions 0..7 | +| `50..81` | right booster RGB groups 0..7 | board 1, groups 0..7, positions 0..3 | +| `82..93` | title | board 2, strips 0..1, pixels 0..5 | +| `94..105` | left side | board 2, strips 2..3, pixels 0..5 | +| `106..117` | right side | board 2, strips 4..5, pixels 0..5 | + +The menu implements the groups directly: + +- item 0 cycles all 118 LEDs through off, white, red, green, and blue; +- items 1, 2, and 3 fill the twelve title/left/right-side LEDs white one + address at a time, then clear them in the same order; +- items 4 and 5 perform the same fill/clear sequence across 41 LEDs on the + corresponding booster; +- item 6 starts the form-exit sequence. + +The LED screen itself is only the generic seven-row `GWTestModeWindowList`, +anchored at y `-0.725` with an explicit row gap of `0.05` (48 px row +advance). It does not draw a schematic of the cabinet or booster rings. + +The all-light loop special-cases logical LEDs 0 and 41 because the booster +button lamps are single-channel. For those two, it ORs `R|G|B` and writes the +same intensity as a simple lamp. + +The executable establishes 40 independently controlled RGB positions plus one +simple button lamp per booster. The `group/position` coordinates above describe +the software/FIO mapping only; they do not establish the physical arrangement +of those RGB positions inside the arcade booster's plastic assembly. + +### Color representation + +`FUN_004b3b40(board, strip, pixel, R, G, B)` stores each full-color LED as +three bytes in this order: + +```text +B, R, G +``` + +Each board reserves `9 * 8 * 3 = 0xd8` bytes even where fewer than eight +pixels are connected. Before placing a value in the hardware buffer, +`CLedDevice` applies the executable's integer gamma curve independently to +each channel: + +```text +wire_channel = min(channel * channel / 255, 255) +``` + +The alpha/intensity field is applied by the LED object before this final +conversion. Test mode always passes alpha `0xff`. + +### iDmac/FIO transfers for board type 0x825c + +The current shim reports FIO type `0x825c`; the game selects its `0x25c` +packing branch. Each hardware update eventually makes these calls: + +| Register-buffer address | Bytes | LED content | +| ---: | ---: | --- | +| `0x5000` | `0x1b0` | general FIO block; title bytes at offset `0x168`, button lamps at offsets 4 and 5 | +| `0x5200` | `0x1e0` | general FIO block; left/right-side bytes at offset `0x168` | +| `0x5400` | `0x0d8` | raw board-0 buffer: left booster | +| `0x5600` | `0x0d8` | raw board-1 buffer: right booster | + +The board-2 source buffer is split as follows: + +- its first `0x30` bytes (two 8-pixel strip slots) go to + `0x5000 + 0x168`; +- its following `0x60` bytes (four 8-pixel strip slots) go to + `0x5200 + 0x168`. + +The unused bytes in those strip slots remain zero. This was confirmed both in +the decompiled `GWInputDeviceXioFio_BOOST` update path and in the live +`idmac_shim.log`: normal steady-state writes have sizes `1b0`, `1e0`, `d8`, +and `d8` respectively. Initial device negotiation first sends four `0x200` +byte blocks, then switches to the exact steady-state lengths above. + +The game also contains a `0x23c` packing branch and generic transfer banks +through `0x5e00`, but those are not selected by the current emulated hardware +ID. diff --git a/docs/re_gc_track.md b/docs/re_gc_track.md new file mode 100644 index 0000000..a3ed40d --- /dev/null +++ b/docs/re_gc_track.md @@ -0,0 +1,183 @@ +# GC Track and Background Reverse Notes + +Status: linear track evaluation and the base background color table are +implemented in the Vectorail player. Camera mode bytes, fade modes and stage +objects still need deeper mapping. + +## Track wire data + +The main stage file stores track keys as big-endian records: + +```text +u32 count +repeat count times: + u32 time_ms + f32 x + f32 y + f32 z +``` + +The stage constructor `FUN_005ed4c0` loads these into 16-byte runtime entries. +The fourth float is not read from disk: the constructor computes the physical +length to the next point and stores it there. It also accumulates the complete +track length at stage-object offset `+0xb0`. + +## Confirmed evaluation + +`FUN_005e9690` evaluates a track position at an integer timestamp. It finds the +adjacent `time_ms` keys and computes: + +```text +u = (time_ms - key[i].time_ms) / (key[i+1].time_ms - key[i].time_ms) +position = key[i] + (key[i+1] - key[i]) * u +``` + +There is no Catmull-Rom or other spline interpolation in this path. + +The Android symbol `GameScene::DrawWay` (`0x001b3b68`) establishes how the +visible route itself is rendered. It clips the authored point list to the +requested first and last timestamps, inserts linearly interpolated points at +both exact boundaries, interpolates an RGBA color over that time interval, and +submits the result as primitive mode 3: a line strip. It does not generate a +camera-facing ribbon, resample by physical distance, or cull triangles. + +`FUN_005e9990` is a separate physical-distance sampler used while constructing +duration-note paths. It carries leftover distance between segments so those +generated samples remain uniform over corners; it is not the normal route +renderer. + +The player now mirrors `DrawWay`: it uploads the original authored points plus +the two exact timestamp intersections and draws separate behind/current and +current/ahead line strips with endpoint color gradients. The stage values +`backwardsDrawDist` and `forwardDrawDist` behave as seconds and are converted +to milliseconds before comparison with track and note timestamps. + +Dynamic `TrackDrawDist` records are step changes, not interpolation keys. The +StageConfig forward distance remains active until the timestamp of the first +matching record, then each record replaces it. This matters for Knight Rider: +its only record is `128400 ms: 0`, which hides the route at the end of the song; +using the first record before its timestamp incorrectly hid the entire future +route from the beginning. + +For `ac_10pt8tion_hard.dat`: + +```text +first track key: 0 ms, (0, 0, 0) +second track key: 6486 ms, (0, 0, 199.985) +draw behind: 10 s +draw ahead: 7 s +``` + +Treating 7/10 as world units collapses the visible rail to a tiny fraction of +the first segment; timestamp clipping produces the expected visible range. + +## Track colors + +The stage config stores two RGBA colors directly after the draw-range values. +They are used for the forward and already-travelled portions of the rail. For +`10pt8tion_hard` they are `(255,0,128)` ahead and `(255,255,255)` behind. + +## Base background + +The main `.dat` contains a `ColorTable` section. Each 22-byte record is: + +```text +u32 time_ms +rgba top_right +rgba top_left +rgba bottom_right +rgba bottom_left +u8 interpolate_to_next +u8 audio_reactive_color +``` + +`FUN_00642390` holds the active colors unless `interpolate_to_next` is set; in +that case it linearly interpolates all four RGBA values to the following key. +`audio_reactive_color` applies `FUN_005d9650` to each active color using the +runtime analyser value. The Linux player now implements the exact hold versus +interpolate selection and keeps the second mode at its neutral color factor +until the analyser feeding `stage renderer +0x24` is ported. + +`data/stage/2d/_menu.dds` is not the gameplay background. It is a +512x256 UI atlas whose top-left 197x197 cell is the song jacket. The player has +a small uncompressed DDS loader, but the jacket is disabled by default and is +only an explicit debug comparison layer (`B`). The inherited procedural +blue/black square grid has also been removed: the base layer is now only the +stage-authored color table before particles, visualizers and objects are drawn. + +The remaining original scene is produced by the stage `particles`, +`visualizer`, and `objects` sections (plus `.tumo` models), not by a single +background bitmap. These sections are now decoded completely by +`StagePattern`: particle records are 44 bytes, visualizer records are 12 bytes, +and every variable-length object record is consumed through its visibility, +movement, scaling, rotation and color-key arrays. Oshama contains 5 particle +keys, 19 visualizer keys, 46 model names and 316 object instances; 10pt8tion +contains 2, 28, 37 and 352 respectively. The Vectorail level data retains this +decoded scene for the model-rendering pass. + +The PSP package now retains all three timelines. The particle constructor at +`FUN_005f0940` creates a 64-instance pool for every configured particle key. +`FUN_005f0130` scales both `repeatMeasure` and `lifespanMeasure` by the active +beat duration. Recovered spawn layouts are: type 1, a deterministic/random +point; type 2, a screen/grid group using `groupShapeSize`; type 3, six points +at 60-degree intervals around a circle. The PSP implementation follows these +timing and layout rules, but substitutes geometry for the original particle +texture resource until that resource binding is mapped. + +The common `.tumo` container is also big-endian. Its outer count is followed, +for each mesh, by resource names, an XYZ vertex table, eight bound floats, +render parts, and polygon lists. A polygon corner is `u32 vertexIndex, f32 u, +f32 v`; a separate block contains explicit line-index pairs. The player now +triangulates polygon fans, retains line primitives, and renders stage objects +with the original five animation channels: visibility, movement, scaling, +linearly interpolated Euler rotation, and RGBA color. The final rotation uses +the recovered `(-Y, X, Z)` quaternion-builder convention. Base and animated transform +components are composed as separate matrices, matching `FUN_00643b20`; +visibility fades use the original fixed 250 ms window. All 46 unique Oshama +models and all 37 unique 10pt8tion models decode. `O` toggles this object layer +for comparison. + +For stage format versions newer than `0x29ce`, the supplemental table beginning +at the header's `ColorTable2` offset has a relative pointer at `base + 8`. Its +stream is `u32 objectCount` followed by one signed big-endian `int16` +`parentIndex` per object (`-1` means no parent). `FUN_006445b0` evaluates one +parent level and renders `parentTransform * childTransform`; parent and child +RGBA are multiplied component-wise. Oshama uses this on 80 of 316 objects and +10pt8tion on 87 of 352, so omitting it visibly loses grouped scale, placement, +and opacity. + +The object's `wireframe` byte also selects mutually exclusive model paths: +`FUN_005dd8e0` draws polygon parts, while `FUN_005dd7f0` draws the explicit TUMO +edge buffer. The player previously drew both. Polygon/edge selection now +matches the flag. `FUN_00648c30` enables `D3DRS_ZENABLE` and +`D3DRS_ZWRITEENABLE` for the complete authored object loop, then disables both +before the rail and markers are submitted. Objects therefore depth-test one +another in file order, but never hide gameplay geometry drawn afterwards. + +### Per-frame object clipping + +The stage loader also opens `data/stage/_clip.dat`. `FUN_005ed4c0` +decodes it as: + +```text +u32be object_count +u32be frame_count +u8 visible[object_count][frame_count] +``` + +The payload is object-major. `FUN_006445b0` selects frame +`round(time_ms / (1000 / 60))` and skips the object when the byte is zero; +when the table is absent or the requested frame is past its end, it falls +back to the normal authored-visibility path. Knight Rider has 178 objects and +7909 clip frames. Ignoring this table submits many objects which the original +never draws in that camera frame and turns its background into overlapping +full-screen geometry. The Linux player now applies the same test before object +animation and submission. + +`FUN_005dd8e0` itself supports two TUMO part types. Type `0` is a triangle +list; type `1` is a line list. Type-1 source polygons contain exactly two +corners. Oshama's models contain ten such parts, including one with 1498 line +vertices; treating them as polygon fans silently discarded all of them. The +player now keeps solid line parts separate from both triangle parts and the +wireframe edge buffer. All Oshama and 10pt8tion models end with a zero node +count, so an internal model hierarchy is not responsible for their transforms. diff --git a/docs/stage.pat b/docs/stage.pat new file mode 100644 index 0000000..9a5da6f --- /dev/null +++ b/docs/stage.pat @@ -0,0 +1,332 @@ +// Common +using string8 = std::string::SizedString [[format("string_formatter8")]]; +using string16 = std::string::SizedString [[format("string_formatter16")]]; +fn string_formatter8(ref string8 s) { + return std::format("\"{:s}\"", s); +}; +fn string_formatter16(ref string16 s) { + return std::format("\"{:s}\"", s); +}; +struct color { + u8 r; + u8 g; + u8 b; + u8 a; +} [[format("color_formatter")]]; +fn color_formatter(ref color c) { + return std::format("#{:02x}{:02x}{:02x}{:02x}", c.r, c.g, c.b, c.a); +}; +struct fcolor { + float r; + float g; + float b; + float a; +}; + +// Header +struct Header { + u32 stageCfg; + u32 trackDrawDist; + u32 track; + u32 notes; + u32 camera; + u32 particles; + u32 visualizer; + u32 unk1; + u32 colors; + u32 objects; + u32 unk2; + u32 colors2; + u32 unk3; +}; + +// Stage Config +struct BpmChange { + u32 timeMs; + u32 bpm; +} [[single_color]]; +struct NoteTimingEntry { + u32 timeMs; + u32 mode; + float value; +} [[single_color]]; +struct NoteTimingList { + u16 size; + NoteTimingEntry entries[size]; +} [[single_color]]; +struct StageConfig { + float endTime1; + float endTime2; + float outroTime; + u16 bpmChangeSz [[hidden]]; + BpmChange bpmChanges[bpmChangeSz]; + NoteTimingList noteTimings[4]; + + string16 chartName; + string16 chartName2; + string16 bgmName; + string16 shotName; + + float backwardsDrawDist; + float forwardDrawDist; + color trackAheadColor; + color trackBehindColor; + u8 audioOffset; + float visualOffset; + color unk; +}; + +// Track Draw Distance +struct DrawDistance { + u32 timeMs; + float distance; +} [[single_color]]; +struct TrackDrawDist { + u32 sz; + DrawDistance points[sz]; +}; + +// Track +struct TrackPiece { + u32 timeMs; + float x; + float y; + float z; +} [[single_color]]; +struct TrackPieceArray { + u32 sz; + TrackPiece pieces[sz]; +}; + +// Notes +enum NoteType : u8 { + NONE = 0, + NORMAL = 1, + FLICK = 2, + HOLD = 3, + SCRATCH = 4, + BEAT = 5, + MERRY_GO_ROUND = 6, + HIDDEN = 7, + HIDDEN2 = 8, + CRITICAL = 9, + SLIDE_HOLD = 10, + SLIDE_COUNTER = 11, + TURN = 12, + SPIN = 13, + FINISH = 14, + DUAL_HOLD = 15, +}; +struct Note { + u32 timeMs; + NoteType type; + u8 typeOverride; + s16 params16[9]; + u8 flag24; + float params25[3]; + u8 flag37; + u8 flag38; + float params39[4]; + u32 params55[3]; + float param67; + u32 param71; + float params75[5]; + u32 param95; +} [[single_color]]; +struct NoteArray { + u32 namesSz; + string8 names[namesSz]; + u32 sz; + Note entries[sz]; +}; + +// Camera +struct Camera { + u32 timeMs; + u8 aMode; + u8 fMode; + float dist; + float rotationA[2]; + float originOff[3]; + u8 projType; + float fieldFar[3]; + float fieldNear[3]; + float rotationB[1]; +} [[single_color]]; +struct CameraArray { + u32 sz; + Camera points[sz]; +}; + +// Particles +struct Particle { + u32 timeMs; + u32 unk; + u32 shape; + u32 texture; + color color; + float velocity[3]; + float repeatMeasure; + float lifespanMeasure; + u32 groupShapeSize; +} [[single_color]]; +struct ParticleArray { + u32 sz; + Particle particles[sz]; +}; + +// Visualizer +struct Visualizer { + u32 timeMs; + u32 type; + color color; +} [[same_color]]; +struct VisualizerArray { + u32 sz; + Visualizer entries[sz]; +}; + +// Color Table 1 +struct ColorTable { + u32 timeMs; + color topRight; + color topLeft; + color bottomRight; + color bottomLeft; + bool fadeOut; + bool fadeIn; +} [[same_color]]; +struct ColorTableArray { + u32 sz; + ColorTable entries[sz]; +}; + +// Objects +struct Visibility { + u32 timeMs; + bool fadeOut; + bool fadeIn; + bool visible; +}; +struct VisibilityArray { + u32 sz; + Visibility entries[sz]; +}; +struct Movement { + u32 timeMs; + bool tweenTowards; + bool tweenAway; + float pos[3]; +}; +struct MovementArray { + u32 sz; + Movement entries[sz]; +}; +struct Scaling { + u32 timeMs; + bool tweenTowards; + bool tweenAway; + float scale[3]; +}; +struct ScalingArray { + u32 sz; + Scaling entries[sz]; +}; +struct Rotation { + u32 timeMs; + bool tweenTowards; + bool tweenAway; + float rotation[3]; +}; +struct RotationArray { + u32 sz; + Rotation entries[sz]; +}; +struct ColorChange { + u32 timeMs; + bool tweenTowards; + bool tweenAway; + color new; +}; +struct ColorChangeArray { + u32 sz; + ColorChange entries[sz]; +}; +struct Object { + u32 model; + u32 fs; + bool wireframe; + bool flashing; + bool unk; + float position[3]; + float scale[3]; + float rotation[3]; + fcolor color; + float unk2[3]; + + VisibilityArray vis; + MovementArray mov; + ScalingArray scaling; + RotationArray rotations; + ColorChangeArray colorChanges; +} [[same_color]]; +struct ObjectArray { + u32 namesSz; + string8 names[namesSz]; + u32 names2Sz; + string8 names2[names2Sz]; + u32 sz; + Object entries[sz]; +}; + +// Color Table 2 +struct ColorTable2Entry1 { + color color; + u8 unk; +}; +struct ColorTable2Entry2 { + bool visible; + color center; + color top; + color bottom; + color left; + color right; +}; +struct ColorTable2Entry4 { + u8 unk; + color color; + float unk2; + u8 unk3; + u8 unk4; +}; +struct ColorTable2Array { + u32 off1; // after offsets + u32 off2; // after arr1 + u32 off3; // after arr2 + u32 off4; // after arr3 + u32 off5; // after arr4 + + u32 sz1; + ColorTable2Entry1 arr1[sz1]; + + u32 sz2; + ColorTable2Entry2 arr2[sz2]; + + u32 sz3; + u16 arr3[sz3]; + + u32 sz4; + ColorTable2Entry4 arr4[sz4]; +}; + +Header hdr @ 0x00; +StageConfig cfg @ hdr.stageCfg; +TrackDrawDist trackDrawDist @ hdr.trackDrawDist; +TrackPieceArray track @ hdr.track; +NoteArray notes @ hdr.notes; +CameraArray camera @ hdr.camera; +ParticleArray particles @ hdr.particles; +VisualizerArray visualizer @ hdr.visualizer; +ColorTableArray colorTable @ hdr.colors; +ObjectArray objects @ hdr.objects; +ColorTable2Array colorTable2 @ hdr.colors2; diff --git a/include/openroller/psp/SongCatalog.hpp b/include/openroller/psp/SongCatalog.hpp new file mode 100644 index 0000000..a8bc6b1 --- /dev/null +++ b/include/openroller/psp/SongCatalog.hpp @@ -0,0 +1,49 @@ +#pragma once + +#include + +namespace openroller::psp { + +constexpr char kSongCatalogMagic[4] = {'O', 'R', 'P', 'C'}; +constexpr std::uint16_t kSongCatalogVersion = 1; +constexpr std::uint32_t kMaximumCatalogSongs = 64; + +struct SongCatalogHeader { + char magic[4]{}; + std::uint16_t version = 0; + std::uint16_t headerSize = 0; + std::uint32_t fileSize = 0; + std::uint32_t songCount = 0; + std::uint32_t recordSize = 0; + std::uint32_t reserved[3]{}; +}; + +struct SongCatalogRecord { + char key[32]{}; + char title[64]{}; + char artist[48]{}; + char duration[8]{}; + char bpm[16]{}; + std::uint8_t genre = 0; + std::uint8_t availableMask = 0; + std::uint8_t ratings[4]{}; + std::uint8_t reserved[2]{}; +}; + +constexpr char kJacketMagic[4] = {'O', 'R', 'P', 'J'}; +constexpr std::uint16_t kJacketVersion = 1; + +struct JacketHeader { + char magic[4]{}; + std::uint16_t version = 0; + std::uint16_t width = 0; + std::uint16_t height = 0; + std::uint16_t pixelFormat = 0; // 0 = PSP GU_PSM_4444 + std::uint32_t dataSize = 0; +}; + +static_assert(sizeof(SongCatalogHeader) == 32); +static_assert(sizeof(SongCatalogRecord) == 176); +static_assert(sizeof(JacketHeader) == 16); + +} // namespace openroller::psp diff --git a/include/openroller/psp/StagePackage.hpp b/include/openroller/psp/StagePackage.hpp new file mode 100644 index 0000000..d0b1454 --- /dev/null +++ b/include/openroller/psp/StagePackage.hpp @@ -0,0 +1,242 @@ +#pragma once + +#include + +namespace openroller::psp { + +constexpr char kStagePackageMagic[4] = {'O', 'R', 'P', 'S'}; +constexpr std::uint16_t kStagePackageVersion = 4; + +enum class PackageDifficulty : std::uint32_t { + Easy = 0, + Normal = 1, + Hard = 2, + Extra = 3, +}; + +constexpr std::uint32_t kStageDifficultyMask = 0x3u; +constexpr std::uint32_t kMaximumPackageBackgroundObjects = 1024u; + +struct PackageSection { + std::uint32_t offset = 0; + std::uint32_t count = 0; +}; + +struct PackageRange { + std::uint32_t first = 0; + std::uint32_t count = 0; +}; + +// All package data is little-endian. Every section begins at a 16-byte +// boundary so it can be read directly into PSP-friendly arrays. +struct StagePackageHeader { + char magic[4]{}; + std::uint16_t version = 0; + std::uint16_t headerSize = 0; + std::uint32_t fileSize = 0; + std::uint32_t flags = 0; + std::uint32_t durationMs = 0; + std::uint32_t audioOffsetRaw = 0; + float visualOffset = 0.0f; + float greatMinimumTimeMs = 32.0f; + float scratchEnableTimeMs = 250.0f; + float beatEnableTimeMs = 200.0f; + float backwardsDrawDistance = 0.0f; + float forwardDrawDistance = 0.0f; + std::uint32_t trackAheadRgba = 0; + std::uint32_t trackBehindRgba = 0; + PackageSection track; + PackageSection notes; + PackageSection cameras; + PackageSection drawDistances; + PackageSection backgroundColors; + PackageSection backgroundModels; + PackageSection backgroundVertices; + PackageSection backgroundObjects; + PackageSection visibilityKeys; + PackageSection transformKeys; + PackageSection objectColorKeys; + PackageSection particles; + PackageSection visualizer; + PackageSection bpmChanges; +}; + +struct PackageTrackPoint { + std::uint32_t timeMs = 0; + float x = 0.0f; + float y = 0.0f; + float z = 0.0f; +}; + +enum PackageNoteFlags : std::uint8_t { + kNoteTypeOverride = 1u << 0, + kNoteFlag24 = 1u << 1, + kNoteFlag37 = 1u << 2, + kNoteFlag38 = 1u << 3, +}; + +// This is the original note payload normalized to little-endian and with its +// four single-byte flags packed into one byte. Unknown values are retained so +// later reverse-engineering does not require rebuilding the source parser. +struct PackageNote { + std::uint32_t timeMs = 0; + std::uint8_t type = 0; + std::uint8_t flags = 0; + std::int16_t params16[9]{}; + float params25[3]{}; + float params39[4]{}; + std::uint32_t params55[3]{}; + float param67 = 0.0f; + std::uint32_t param71 = 0; + float params75[5]{}; + std::uint32_t param95 = 0; + + // Values produced by the original BuildTimingDataSub path. Keeping these + // in the host-built package avoids duplicating chart/config resolution on + // the PSP and makes its runtime consume the same semantics as desktop. + std::uint8_t effectiveType = 0; + std::uint8_t reserved = 0; + std::int16_t markEffectId = -1; + float appearTimeMs = 0.0f; + float endTimeMs = 0.0f; + float directionVector[3]{}; + float beatDurationMs = 500.0f; + float earlyTimingMs = 250.0f; + float lateTimingMs = 250.0f; + float missTimingMs = 250.0f; + float muteTimingMs = 0.0f; + float markerFadeEndTimeMs = 0.0f; + std::uint32_t packedColor = 0xffffffffu; + std::uint32_t merryCount = 0; +}; + +struct PackageCameraPoint { + std::uint32_t timeMs = 0; + std::uint8_t aMode = 0; + std::uint8_t fMode = 0; + std::uint8_t projectionType = 0; + std::uint8_t reserved = 0; + float distance = 0.0f; + float rotationA[2]{}; + float originOffset[3]{}; + float fieldFar[3]{}; + float fieldNear[3]{}; + float rotationB = 0.0f; +}; + +struct PackageDrawDistancePoint { + std::uint32_t timeMs = 0; + float distance = 0.0f; +}; + +struct PackageBackgroundColorPoint { + std::uint32_t timeMs = 0; + std::uint32_t topRightRgba = 0; + std::uint32_t topLeftRgba = 0; + std::uint32_t bottomRightRgba = 0; + std::uint32_t bottomLeftRgba = 0; + std::uint32_t flags = 0; +}; + +struct PackageBackgroundModel { + PackageRange triangles; + PackageRange solidLines; + PackageRange wireframeLines; +}; + +struct PackageBackgroundVertex { + float x = 0.0f; + float y = 0.0f; + float z = 0.0f; +}; + +enum PackageBackgroundObjectFlags : std::uint32_t { + kBackgroundObjectWireframe = 1u << 0, + kBackgroundObjectFlashing = 1u << 1, + kBackgroundObjectUnknown = 1u << 2, +}; + +struct PackageBackgroundObject { + std::uint32_t model = 0; + std::int32_t parentIndex = -1; + std::uint32_t flags = 0; + std::uint32_t fragmentShader = 0; + float position[3]{}; + float scale[3]{1.0f, 1.0f, 1.0f}; + float rotation[3]{}; + float color[4]{1.0f, 1.0f, 1.0f, 1.0f}; + PackageRange visibility; + PackageRange movement; + PackageRange scaling; + PackageRange rotations; + PackageRange colorChanges; +}; + +enum PackageObjectKeyFlags : std::uint8_t { + kObjectKeyLoop = 1u << 0, + kObjectKeyInterpolate = 1u << 1, +}; + +struct PackageVisibilityKey { + std::uint32_t timeMs = 0; + std::uint8_t flags = 0; + std::uint8_t visible = 0; + std::uint16_t reserved = 0; +}; + +struct PackageTransformKey { + std::uint32_t timeMs = 0; + std::uint8_t flags = 0; + std::uint8_t reserved[3]{}; + float value[3]{}; +}; + +struct PackageObjectColorKey { + std::uint32_t timeMs = 0; + std::uint8_t flags = 0; + std::uint8_t reserved[3]{}; + std::uint32_t rgba = 0; +}; + +struct PackageParticlePoint { + std::uint32_t timeMs = 0; + std::uint32_t enabled = 0; + std::uint32_t shape = 0; + std::uint32_t texture = 0; + std::uint32_t rgba = 0; + float velocity[3]{}; + float repeatMeasure = 0.0f; + float lifespanMeasure = 0.0f; + std::uint32_t groupShapeSize = 0; +}; + +struct PackageVisualizerPoint { + std::uint32_t timeMs = 0; + std::uint32_t type = 0; + std::uint32_t rgba = 0; +}; + +struct PackageBpmPoint { + std::uint32_t timeMs = 0; + std::uint32_t bpm = 120; +}; + +static_assert(sizeof(PackageSection) == 8); +static_assert(sizeof(PackageRange) == 8); +static_assert(sizeof(StagePackageHeader) == 168); +static_assert(sizeof(PackageTrackPoint) == 16); +static_assert(sizeof(PackageNote) == 152); +static_assert(sizeof(PackageCameraPoint) == 60); +static_assert(sizeof(PackageDrawDistancePoint) == 8); +static_assert(sizeof(PackageBackgroundColorPoint) == 24); +static_assert(sizeof(PackageBackgroundModel) == 24); +static_assert(sizeof(PackageBackgroundVertex) == 12); +static_assert(sizeof(PackageBackgroundObject) == 108); +static_assert(sizeof(PackageVisibilityKey) == 8); +static_assert(sizeof(PackageTransformKey) == 20); +static_assert(sizeof(PackageObjectColorKey) == 12); +static_assert(sizeof(PackageParticlePoint) == 44); +static_assert(sizeof(PackageVisualizerPoint) == 12); +static_assert(sizeof(PackageBpmPoint) == 8); + +} // namespace openroller::psp diff --git a/psp/GNUmakefile b/psp/GNUmakefile new file mode 100644 index 0000000..ce7c1db --- /dev/null +++ b/psp/GNUmakefile @@ -0,0 +1,36 @@ +TARGET = openroller_psp +OBJS = src/main.o src/StageRuntime.o src/AudioPlayer.o src/Gameplay.o src/SongMenu.o + +INCDIR = include ../include +ifeq ($(DEBUG),1) +CFLAGS = -Og -g3 -G0 -Wall -Wextra +else +CFLAGS = -O2 -G0 -Wall -Wextra +endif +CXXFLAGS = $(CFLAGS) -std=gnu++17 -fno-exceptions -fno-rtti +CXXFLAGS += -DOPENROLLER_BUILD_TIMESTAMP=\"$(BUILD_TIMESTAMP)\" +ASFLAGS = $(CFLAGS) + +LIBDIR = +LDFLAGS = +LIBS = -lpspgum -lpspgu -lpspaudio -lpspmp3 + +BUILD_PRX = 1 + +EXTRA_TARGETS = EBOOT.PBP +PSP_EBOOT_TITLE = OpenRoller PSP + +# XMB media is optional so the public source tree can be built without +# redistributing project-specific artwork or music. +ifneq ($(wildcard assets/ICON0.PNG),) +PSP_EBOOT_ICON = assets/ICON0.PNG +endif +ifneq ($(wildcard assets/PIC1.PNG),) +PSP_EBOOT_PIC1 = assets/PIC1.PNG +endif +ifneq ($(wildcard assets/SND0.AT3),) +PSP_EBOOT_SND0 = assets/SND0.AT3 +endif + +PSPSDK := $(shell psp-config --pspsdk-path) +include $(PSPSDK)/lib/build.mak diff --git a/psp/assets/README.md b/psp/assets/README.md new file mode 100644 index 0000000..cf57481 --- /dev/null +++ b/psp/assets/README.md @@ -0,0 +1,11 @@ +# Optional XMB media + +The public source tree builds without any files in this directory. To customize +the XMB entry for a local build, provide any of these conventional PSP assets: + +- `ICON0.PNG` — application icon. +- `PIC1.PNG` — full-screen background image. +- `SND0.AT3` — short ATRAC3 background audio. + +The GNUmakefile detects each file independently. These media files are ignored +by Git and are not covered by the OpenRoller source license. diff --git a/psp/include/AudioPlayer.hpp b/psp/include/AudioPlayer.hpp new file mode 100644 index 0000000..69db1c6 --- /dev/null +++ b/psp/include/AudioPlayer.hpp @@ -0,0 +1,26 @@ +#pragma once + +#include + +namespace openroller::psp { + +enum class AudioEffect : std::uint8_t { + Adlib = 0, + Tap1 = 1, + Tap2 = 2, +}; + +bool startAudioPlayer( + const char* bgmPath, + const char* shotPath, + const char* effectDirectory); +void stopAudioPlayer(); +bool audioPlayerRunning(); +bool audioPlayerFinished(); +std::uint32_t audioPlayerTimeMs(); +void setAudioPlayerPaused(bool paused); +void seekAudioPlayer(std::uint32_t timeMs); +void setAudioPlayerShotMuted(bool muted); +void playAudioPlayerEffect(AudioEffect effect); + +} // namespace openroller::psp diff --git a/psp/include/Gameplay.hpp b/psp/include/Gameplay.hpp new file mode 100644 index 0000000..29f6951 --- /dev/null +++ b/psp/include/Gameplay.hpp @@ -0,0 +1,63 @@ +#pragma once + +#include "StageRuntime.hpp" + +#include + +namespace openroller::psp { + +enum class Judgment : std::uint8_t { + // Values 0..3 match the original game's recovered enum. + Miss = 0, + Good = 1, + Cool = 2, + Great = 3, + Pending = 0xff, +}; + +struct GameplayState { + struct NoteRuntime { + std::uint8_t judgment = static_cast(Judgment::Pending); + std::uint8_t holding = 0; + std::uint8_t shotMuteApplied = 0; + std::uint8_t reserved = 0; + float inputStartTimeMs = -1.0f; + float lastInputTimeMs = -1.0f; + std::uint32_t inputMask = 0; + std::uint32_t lastInputBit = 0; + }; + + NoteRuntime* notes = nullptr; + std::uint32_t noteCount = 0; + std::uint32_t nextPending = 0; + std::uint32_t combo = 0; + std::uint32_t maximumCombo = 0; + std::uint32_t greatCount = 0; + std::uint32_t coolCount = 0; + std::uint32_t goodCount = 0; + std::uint32_t missCount = 0; + float previousClockMs = 0.0f; + float lastJudgmentClockMs = -10000.0f; + std::int32_t lastJudgedNote = -1; + Judgment lastJudgment = Judgment::Pending; + bool shotMuted = false; +}; + +bool initializeGameplay(const StageView& stage, GameplayState* gameplay); +void destroyGameplay(GameplayState* gameplay); +void seekGameplay(const StageView& stage, GameplayState* gameplay, float clockMs); +void updateGameplay(const StageView& stage, GameplayState* gameplay, float clockMs); +Judgment tapGameplay(const StageView& stage, GameplayState* gameplay, float clockMs); +Judgment pressGameplay( + const StageView& stage, + GameplayState* gameplay, + float clockMs, + std::uint32_t inputBit); +Judgment releaseGameplay( + const StageView& stage, + GameplayState* gameplay, + float clockMs, + std::uint32_t inputBit); +Judgment noteJudgment(const GameplayState& gameplay, std::uint32_t noteIndex); + +} // namespace openroller::psp diff --git a/psp/include/SongMenu.hpp b/psp/include/SongMenu.hpp new file mode 100644 index 0000000..4d9a455 --- /dev/null +++ b/psp/include/SongMenu.hpp @@ -0,0 +1,35 @@ +#pragma once + +#include "openroller/psp/SongCatalog.hpp" + +#include +#include + +namespace openroller::psp { + +struct SongMenu { + void* catalogStorage = nullptr; + std::size_t catalogSize = 0; + const SongCatalogHeader* header = nullptr; + const SongCatalogRecord* songs = nullptr; + int selection = 0; + int difficulty = 0; + bool difficultyMode = false; + void* jacketStorage = nullptr; + std::size_t jacketSize = 0; + const std::uint16_t* jacketPixels = nullptr; + std::uint16_t jacketWidth = 0; + std::uint16_t jacketHeight = 0; + char rootPath[256]{}; +}; + +bool loadSongMenu(const char* catalogPath, SongMenu* menu, char* error, std::size_t errorCapacity); +void unloadSongMenu(SongMenu* menu); +bool moveSongSelection(SongMenu* menu, int delta); +bool moveDifficultySelection(SongMenu* menu, int delta); +const SongCatalogRecord* selectedSong(const SongMenu& menu); +bool selectedSongPath(const SongMenu& menu, char* output, std::size_t capacity); +bool selectedAudioPath(const SongMenu& menu, char* output, std::size_t capacity); +bool selectedShotAudioPath(const SongMenu& menu, char* output, std::size_t capacity); + +} // namespace openroller::psp diff --git a/psp/include/StageRuntime.hpp b/psp/include/StageRuntime.hpp new file mode 100644 index 0000000..f0bea5a --- /dev/null +++ b/psp/include/StageRuntime.hpp @@ -0,0 +1,60 @@ +#pragma once + +#include "openroller/psp/StagePackage.hpp" + +#include +#include + +namespace openroller::psp { + +struct Vec3 { + float x = 0.0f; + float y = 0.0f; + float z = 0.0f; +}; + +struct CameraState { + Vec3 eye; + Vec3 target; + Vec3 up{0.0f, 1.0f, 0.0f}; + float projectionBlend = 0.0f; +}; + +struct StageView { + void* storage = nullptr; + std::size_t storageSize = 0; + const StagePackageHeader* header = nullptr; + const PackageTrackPoint* track = nullptr; + const PackageNote* notes = nullptr; + const PackageCameraPoint* cameras = nullptr; + const PackageDrawDistancePoint* drawDistances = nullptr; + const PackageBackgroundColorPoint* backgroundColors = nullptr; + const PackageBackgroundModel* backgroundModels = nullptr; + const PackageBackgroundVertex* backgroundVertices = nullptr; + const PackageBackgroundObject* backgroundObjects = nullptr; + const PackageVisibilityKey* visibilityKeys = nullptr; + const PackageTransformKey* transformKeys = nullptr; + const PackageObjectColorKey* objectColorKeys = nullptr; + const PackageParticlePoint* particles = nullptr; + const PackageVisualizerPoint* visualizer = nullptr; + const PackageBpmPoint* bpmChanges = nullptr; +}; + +struct BackgroundColors { + std::uint32_t topRight = 0; + std::uint32_t topLeft = 0; + std::uint32_t bottomRight = 0; + std::uint32_t bottomLeft = 0; +}; + +bool loadStagePackage(const char* path, StageView* stage, char* error, std::size_t errorCapacity); +void unloadStagePackage(StageView* stage); + +Vec3 trackPositionAt(const StageView& stage, float timeMs); +Vec3 trackTangentAt(const StageView& stage, float timeMs); +CameraState evaluateCamera(const StageView& stage, float timeMs); +BackgroundColors evaluateBackground(const StageView& stage, float timeMs); +float evaluateDrawAhead(const StageView& stage, float timeMs); +float evaluateBeatDurationMs(const StageView& stage, float timeMs); + +} // namespace openroller::psp diff --git a/psp/include/TateTransform.hpp b/psp/include/TateTransform.hpp new file mode 100644 index 0000000..da65cd4 --- /dev/null +++ b/psp/include/TateTransform.hpp @@ -0,0 +1,43 @@ +#pragma once + +namespace openroller::psp { + +constexpr int kScreenWidth = 480; +constexpr int kScreenHeight = 272; +constexpr int kLogicalWidth = 720; +constexpr int kLogicalHeight = 1280; +constexpr float kLogicalScale = 3.0f / 8.0f; +constexpr int kScaledWidth = 270; +constexpr int kBorder = (kScreenHeight - kScaledWidth) / 2; + +enum class TateSide { + Clockwise, + CounterClockwise, +}; + +struct Point { + float x; + float y; +}; + +inline Point toScreen(float logicalX, float logicalY, TateSide side) { + if (side == TateSide::Clockwise) { + return { + static_cast(kScreenWidth) - logicalY * kLogicalScale, + static_cast(kBorder) + logicalX * kLogicalScale, + }; + } + return { + logicalY * kLogicalScale, + static_cast(kBorder + kScaledWidth) - logicalX * kLogicalScale, + }; +} + +inline Point screenDirectionToLogical(float screenX, float screenY, TateSide side) { + if (side == TateSide::Clockwise) { + return {screenY, -screenX}; + } + return {-screenY, screenX}; +} + +} // namespace openroller::psp diff --git a/psp/src/AudioPlayer.cpp b/psp/src/AudioPlayer.cpp new file mode 100644 index 0000000..fba05dd --- /dev/null +++ b/psp/src/AudioPlayer.cpp @@ -0,0 +1,418 @@ +#include "AudioPlayer.hpp" + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace openroller::psp { +namespace { + +struct Mp3Decoder { + int file = -1; + int handle = -1; + alignas(64) unsigned char streamBuffer[16 * 1024]; + alignas(64) unsigned char pcmBuffer[16 * (1152 / 2)]; +}; + +struct AudioState { + struct Effect { + short* samples = nullptr; + std::uint32_t valueCount = 0; + volatile std::uint32_t voices[2]{}; + volatile std::uint32_t nextVoice = 0; + }; + + Mp3Decoder bgm; + Effect effects[3]; + int channel = -1; + int thread = -1; + int sampleRate = 0; + int channels = 0; + int lastDecodedBytes = 0; + volatile bool running = false; + volatile bool paused = false; + volatile bool finished = false; + volatile int requestedSeekMs = -1; + volatile std::uint32_t playedSamples = 0; + int consecutiveOutputFailures = 0; + bool resourceInitialized = false; + bool avcodecLoaded = false; + bool mp3ModuleLoaded = false; + alignas(64) std::int32_t mixAccumulator[1152 * 2]; + // sceAudioSRCOutputBlocking waits until a buffer can be queued; it does + // not make the submitted memory immediately reusable. Keep the buffer + // currently consumed by the audio hardware separate from the one being + // prepared by the decoder/mixer. + alignas(64) short mixBuffers[2][1152 * 2]; + std::uint32_t mixBufferIndex = 0; +}; + +AudioState gAudio; + +short softLimit(std::int32_t sample) { + // Preserve the quiet 75% of the range, then progressively compress peaks. + // The old hard clamp produced flat-topped waves whenever BGM, SHOT and a + // hit sound crossed 0 dBFS, which is heard as continuous crackle. + const bool negative = sample < 0; + std::int32_t magnitude = negative ? -sample : sample; + if (magnitude > 24576) { + if (magnitude <= 32768) { + magnitude = 24576 + ((magnitude - 24576) >> 1); + } else if (magnitude <= 65528) { + magnitude = 28672 + ((magnitude - 32768) >> 3); + } else { + magnitude = 32767; + } + } + const std::int32_t limited = negative ? -magnitude : magnitude; + return static_cast(std::clamp( + limited, static_cast(-32768), + static_cast(32767))); +} + +bool loadEffect(const char* path, AudioState::Effect* effect) { + if (!path || !*path || !effect) return false; + const int file = sceIoOpen(path, PSP_O_RDONLY, 0777); + if (file < 0) return false; + const int length = sceIoLseek32(file, 0, PSP_SEEK_END); + if (length <= 0 || length > 1024 * 1024 || (length & 3) != 0 || + sceIoLseek32(file, 0, PSP_SEEK_SET) < 0) { + sceIoClose(file); + return false; + } + short* samples = static_cast(std::malloc(static_cast(length))); + if (!samples || sceIoRead(file, samples, length) != length) { + std::free(samples); + sceIoClose(file); + return false; + } + sceIoClose(file); + effect->samples = samples; + effect->valueCount = static_cast(length / 2); + effect->voices[0] = effect->valueCount; + effect->voices[1] = effect->valueCount; + return true; +} + +void closeEffects() { + for (AudioState::Effect& effect : gAudio.effects) { + std::free(effect.samples); + effect = {}; + } +} + +void closeDecoder(Mp3Decoder* decoder) { + if (decoder->handle >= 0) { + sceMp3ReleaseMp3Handle(decoder->handle); + decoder->handle = -1; + } + if (decoder->file >= 0) { + sceIoClose(decoder->file); + decoder->file = -1; + } +} + +bool fillDecoder(Mp3Decoder* decoder) { + SceUChar8* destination = nullptr; + SceInt32 writable = 0; + SceInt32 sourcePosition = 0; + if (!decoder || decoder->handle < 0 || + sceMp3GetInfoToAddStreamData( + decoder->handle, &destination, &writable, &sourcePosition) < 0) { + return false; + } + if (sceIoLseek32(decoder->file, sourcePosition, PSP_SEEK_SET) < 0) return false; + const int read = sceIoRead(decoder->file, destination, writable); + if (read <= 0) return false; + return sceMp3NotifyAddStreamData(decoder->handle, read) >= 0; +} + +bool openDecoder(const char* path, Mp3Decoder* decoder) { + if (!path || !*path || !decoder) return false; + decoder->file = sceIoOpen(path, PSP_O_RDONLY, 0777); + if (decoder->file < 0) return false; + SceMp3InitArg arguments{}; + arguments.mp3StreamStart = 0; + arguments.mp3StreamEnd = sceIoLseek32(decoder->file, 0, PSP_SEEK_END); + arguments.mp3Buf = decoder->streamBuffer; + arguments.mp3BufSize = sizeof(decoder->streamBuffer); + arguments.pcmBuf = decoder->pcmBuffer; + arguments.pcmBufSize = sizeof(decoder->pcmBuffer); + decoder->handle = sceMp3ReserveMp3Handle(&arguments); + if (decoder->handle < 0 || !fillDecoder(decoder) || + sceMp3Init(decoder->handle) < 0 || + // The firmware decoder retains an implicit looping mode on some PSP + // revisions. Zero means no repeats (play the stream exactly once). + // Without making this explicit, sceMp3Decode jumps back to frame zero + // instead of returning EOF, so the gameplay loop never sees + // audioPlayerFinished(). + sceMp3SetLoopNum(decoder->handle, 0) < 0) { + closeDecoder(decoder); + return false; + } + return true; +} + +void resetDecoder(Mp3Decoder* decoder, std::uint32_t frame) { + if (!decoder || decoder->handle < 0) return; + if (frame == 0) sceMp3ResetPlayPosition(decoder->handle); + else sceMp3ResetPlayPositionByFrame(decoder->handle, frame); +} + +int decode(Mp3Decoder* decoder, short** samples) { + if (!decoder || decoder->handle < 0 || !samples) return 0; + if (sceMp3CheckStreamDataNeeded(decoder->handle) > 0) fillDecoder(decoder); + int decoded = sceMp3Decode(decoder->handle, samples); + if (decoded <= 0 && + sceMp3CheckStreamDataNeeded(decoder->handle) > 0 && + fillDecoder(decoder)) { + decoded = sceMp3Decode(decoder->handle, samples); + } + return decoded; +} + +void resetPlayback(std::uint32_t timeMs) { + const std::uint64_t targetSamples = + static_cast(timeMs) * + static_cast(gAudio.sampleRate) / 1000u; + const std::uint32_t frame = static_cast(targetSamples / 1152u); + resetDecoder(&gAudio.bgm, frame); + gAudio.playedSamples = frame * 1152u; + gAudio.finished = false; + gAudio.consecutiveOutputFailures = 0; +} + +int outputSamples(const short* samples, int decodedBytes) { + if (decodedBytes <= 0 || !samples) return -1; + if (gAudio.channel < 0 || decodedBytes != gAudio.lastDecodedBytes) { + if (gAudio.channel >= 0) sceAudioSRCChRelease(); + const int sampleCount = decodedBytes / (2 * gAudio.channels); + gAudio.channel = sceAudioSRCChReserve( + sampleCount, gAudio.sampleRate, gAudio.channels); + gAudio.lastDecodedBytes = decodedBytes; + } + if (gAudio.channel < 0) return gAudio.channel; + return sceAudioSRCOutputBlocking( + PSP_AUDIO_VOLUME_MAX, const_cast(samples)); +} + +int audioThread(SceSize, void*) { + while (gAudio.running) { + const int requestedSeek = gAudio.requestedSeekMs; + if (requestedSeek >= 0) { + resetPlayback(static_cast(requestedSeek)); + gAudio.requestedSeekMs = -1; + } + if (gAudio.paused) { + sceKernelDelayThread(5000); + continue; + } + + short* bgmSamples = nullptr; + int bgmBytes = decode(&gAudio.bgm, &bgmSamples); + if (bgmBytes <= 0) { + // The compressed stream is authoritative. Stage DAT duration + // describes authored gameplay data and may end before or after + // the actual mix; do not synthesize silence to match it. + gAudio.finished = true; + sceKernelDelayThread(5000); + continue; + } + + const int valueCount = bgmBytes / 2; + short* const mixBuffer = gAudio.mixBuffers[gAudio.mixBufferIndex]; + for (int i = 0; i < valueCount; ++i) { + gAudio.mixAccumulator[i] = static_cast(bgmSamples[i]); + } + for (AudioState::Effect& effect : gAudio.effects) { + if (!effect.samples) continue; + for (volatile std::uint32_t& voice : effect.voices) { + std::uint32_t position = voice; + if (position >= effect.valueCount) continue; + const int available = std::min( + valueCount, + static_cast(effect.valueCount - position)); + for (int i = 0; i < available; ++i) { + gAudio.mixAccumulator[i] += + static_cast(effect.samples[position + i]); + } + position += static_cast(available); + voice = position; + } + } + for (int i = 0; i < valueCount; ++i) { + mixBuffer[i] = softLimit(gAudio.mixAccumulator[i]); + } + const int outputResult = outputSamples(mixBuffer, bgmBytes); + if (outputResult >= 0) { + // Some firmwares return the queued sample count and some audio + // implementations return zero on success. The submitted frame + // size is authoritative in either case. + const int sampleCount = bgmBytes / (2 * gAudio.channels); + gAudio.playedSamples += static_cast(sampleCount); + gAudio.mixBufferIndex ^= 1u; + gAudio.consecutiveOutputFailures = 0; + } else { + if (++gAudio.consecutiveOutputFailures >= 8) { + // Do not leave the application trapped forever in a chart if + // the firmware loses its SRC channel. + gAudio.finished = true; + gAudio.paused = true; + } + sceKernelDelayThread(5000); + } + } + return 0; +} + +void cleanupAudio() { + if (gAudio.channel >= 0) { + sceAudioSRCChRelease(); + gAudio.channel = -1; + } + closeDecoder(&gAudio.bgm); + closeEffects(); + if (gAudio.resourceInitialized) { + sceMp3TermResource(); + gAudio.resourceInitialized = false; + } + if (gAudio.mp3ModuleLoaded) { + sceUtilityUnloadModule(PSP_MODULE_AV_MP3); + gAudio.mp3ModuleLoaded = false; + } + if (gAudio.avcodecLoaded) { + sceUtilityUnloadModule(PSP_MODULE_AV_AVCODEC); + gAudio.avcodecLoaded = false; + } +} + +} // namespace + +bool startAudioPlayer( + const char* bgmPath, + const char* shotPath, + const char* effectDirectory) { + stopAudioPlayer(); + // PSP-1000 cannot reliably decode and mix two independent MP3 streams + // while the 3D stage is running. Release assets are pre-mixed, so SHOT is + // intentionally ignored here; hit/ad-lib PCM effects remain interactive. + (void)shotPath; + if (!bgmPath || !*bgmPath) return false; + if (sceUtilityLoadModule(PSP_MODULE_AV_AVCODEC) < 0) return false; + gAudio.avcodecLoaded = true; + if (sceUtilityLoadModule(PSP_MODULE_AV_MP3) < 0) { + cleanupAudio(); + return false; + } + gAudio.mp3ModuleLoaded = true; + if (sceMp3InitResource() < 0) { + cleanupAudio(); + return false; + } + gAudio.resourceInitialized = true; + if (!openDecoder(bgmPath, &gAudio.bgm)) { + cleanupAudio(); + return false; + } + if (effectDirectory && *effectDirectory) { + static constexpr const char* names[3] = { + "adlib.pcm", "tap1.pcm", "tap2.pcm", + }; + for (int i = 0; i < 3; ++i) { + char path[384]{}; + std::snprintf(path, sizeof(path), "%s/%s", effectDirectory, names[i]); + loadEffect(path, &gAudio.effects[i]); + } + } + + gAudio.sampleRate = sceMp3GetSamplingRate(gAudio.bgm.handle); + gAudio.channels = sceMp3GetMp3ChannelNum(gAudio.bgm.handle); + if (gAudio.sampleRate <= 0 || + (gAudio.channels != 1 && gAudio.channels != 2)) { + cleanupAudio(); + return false; + } + gAudio.playedSamples = 0; + gAudio.mixBufferIndex = 0; + gAudio.consecutiveOutputFailures = 0; + gAudio.requestedSeekMs = -1; + gAudio.paused = false; + gAudio.finished = false; + gAudio.running = true; + gAudio.thread = sceKernelCreateThread( + "OpenRoller audio", audioThread, 0x12, 0x5000, + PSP_THREAD_ATTR_USER, nullptr); + if (gAudio.thread < 0 || + sceKernelStartThread(gAudio.thread, 0, nullptr) < 0) { + gAudio.running = false; + cleanupAudio(); + return false; + } + return true; +} + +void stopAudioPlayer() { + if (gAudio.running) { + gAudio.running = false; + if (gAudio.thread >= 0) sceKernelWaitThreadEnd(gAudio.thread, nullptr); + } + if (gAudio.thread >= 0) { + sceKernelDeleteThread(gAudio.thread); + gAudio.thread = -1; + } + cleanupAudio(); + gAudio.sampleRate = 0; + gAudio.channels = 0; + gAudio.lastDecodedBytes = 0; + gAudio.playedSamples = 0; + gAudio.mixBufferIndex = 0; + gAudio.consecutiveOutputFailures = 0; + gAudio.requestedSeekMs = -1; + gAudio.paused = false; + gAudio.finished = false; +} + +bool audioPlayerRunning() { + return gAudio.running && gAudio.sampleRate > 0; +} + +bool audioPlayerFinished() { + return gAudio.finished; +} + +std::uint32_t audioPlayerTimeMs() { + if (gAudio.sampleRate <= 0) return 0; + return static_cast( + static_cast(gAudio.playedSamples) * 1000u / + static_cast(gAudio.sampleRate)); +} + +void setAudioPlayerPaused(bool paused) { + gAudio.paused = paused; +} + +void seekAudioPlayer(std::uint32_t timeMs) { + if (gAudio.running) gAudio.requestedSeekMs = static_cast(timeMs); +} + +void setAudioPlayerShotMuted(bool muted) { + (void)muted; +} + +void playAudioPlayerEffect(AudioEffect effect) { + const unsigned index = static_cast(effect); + if (index >= 3 || !gAudio.effects[index].samples) return; + AudioState::Effect& slot = gAudio.effects[index]; + const std::uint32_t voice = slot.nextVoice++ & 1u; + slot.voices[voice] = 0; +} + +} // namespace openroller::psp diff --git a/psp/src/Gameplay.cpp b/psp/src/Gameplay.cpp new file mode 100644 index 0000000..de846e3 --- /dev/null +++ b/psp/src/Gameplay.cpp @@ -0,0 +1,344 @@ +#include "Gameplay.hpp" + +#include +#include +#include + +namespace openroller::psp { +namespace { + +bool tapTarget(std::uint8_t type) { + return type == 1 || type == 2; +} + +bool dualTapTarget(std::uint8_t type) { + return type == 9; +} + +bool holdTarget(std::uint8_t type) { + return type == 3 || type == 15; +} + +bool rhythmLongTarget(std::uint8_t type) { + return type == 4 || type == 5; +} + +bool supportedTarget(std::uint8_t type) { + return tapTarget(type) || dualTapTarget(type) || + holdTarget(type) || rhythmLongTarget(type); +} + +Judgment tapJudgment( + const PackageNote& note, + float clockMs, + float greatMinimumMs) { + const float error = std::fabs(clockMs - static_cast(note.timeMs)); + const float outer = std::max( + 0.0f, + clockMs > static_cast(note.timeMs) + ? note.lateTimingMs + : note.earlyTimingMs); + float great = outer * 0.25f; + float cool = outer * 0.50f; + if (great < greatMinimumMs) { + great = greatMinimumMs; + cool = std::min(outer, great + (outer - great) / 3.0f); + } + if (error < great) return Judgment::Great; + if (error < cool) return Judgment::Cool; + if (error < outer) return Judgment::Good; + return Judgment::Miss; +} + +Judgment longJudgment( + const PackageNote& note, + float pressTimeMs, + float releaseTimeMs) { + const float durationMs = std::max( + 1.0f, + note.endTimeMs - static_cast(note.timeMs)); + const float heldStartMs = std::max(static_cast(note.timeMs), pressTimeMs); + const float heldEndMs = std::min(note.endTimeMs, releaseTimeMs); + float heldMs = std::max(0.0f, heldEndMs - heldStartMs); + const float finalTwentyPercentMs = durationMs * 0.20f; + if (finalTwentyPercentMs < 66.666664f) { + heldMs += 66.666664f - finalTwentyPercentMs; + } + const float heldPercent = heldMs * 100.0f / durationMs; + if (heldPercent > 80.0f) return Judgment::Great; + if (heldPercent > 60.0f) return Judgment::Cool; + if (heldPercent > 40.0f) return Judgment::Good; + return Judgment::Miss; +} + +void resetRuntime(GameplayState::NoteRuntime* note) { + *note = {}; + note->judgment = static_cast(Judgment::Pending); + note->inputStartTimeMs = -1.0f; + note->lastInputTimeMs = -1.0f; +} + +void advancePending(GameplayState* gameplay) { + while (gameplay->nextPending < gameplay->noteCount && + gameplay->notes[gameplay->nextPending].judgment != + static_cast(Judgment::Pending)) { + ++gameplay->nextPending; + } +} + +void recordJudgment( + GameplayState* gameplay, + std::uint32_t noteIndex, + Judgment judgment, + float clockMs) { + GameplayState::NoteRuntime& runtime = gameplay->notes[noteIndex]; + runtime.judgment = static_cast(judgment); + runtime.holding = 0; + runtime.inputMask = 0; + gameplay->lastJudgment = judgment; + gameplay->lastJudgmentClockMs = clockMs; + gameplay->lastJudgedNote = static_cast(noteIndex); + gameplay->shotMuted = judgment == Judgment::Miss; + if (judgment == Judgment::Great) { + ++gameplay->greatCount; + ++gameplay->combo; + } else if (judgment == Judgment::Cool) { + ++gameplay->coolCount; + ++gameplay->combo; + } else if (judgment == Judgment::Good) { + ++gameplay->goodCount; + ++gameplay->combo; + } else { + ++gameplay->missCount; + gameplay->combo = 0; + } + gameplay->maximumCombo = std::max(gameplay->maximumCombo, gameplay->combo); + if (noteIndex == gameplay->nextPending) advancePending(gameplay); +} + +} // namespace + +bool initializeGameplay(const StageView& stage, GameplayState* gameplay) { + if (!gameplay || !stage.header || stage.header->notes.count == 0) return false; + destroyGameplay(gameplay); + gameplay->notes = static_cast( + std::malloc( + static_cast(stage.header->notes.count) * + sizeof(GameplayState::NoteRuntime))); + if (!gameplay->notes) return false; + gameplay->noteCount = stage.header->notes.count; + for (std::uint32_t i = 0; i < gameplay->noteCount; ++i) { + resetRuntime(&gameplay->notes[i]); + } + return true; +} + +void destroyGameplay(GameplayState* gameplay) { + if (!gameplay) return; + std::free(gameplay->notes); + *gameplay = {}; +} + +void seekGameplay(const StageView& stage, GameplayState* gameplay, float clockMs) { + if (!gameplay || !gameplay->notes || !stage.header) return; + gameplay->nextPending = 0; + gameplay->combo = 0; + gameplay->maximumCombo = 0; + gameplay->greatCount = 0; + gameplay->coolCount = 0; + gameplay->goodCount = 0; + gameplay->missCount = 0; + gameplay->lastJudgment = Judgment::Pending; + gameplay->lastJudgmentClockMs = -10000.0f; + gameplay->lastJudgedNote = -1; + gameplay->previousClockMs = clockMs; + gameplay->shotMuted = false; + for (std::uint32_t i = 0; i < gameplay->noteCount; ++i) { + resetRuntime(&gameplay->notes[i]); + const PackageNote& note = stage.notes[i]; + if (supportedTarget(note.effectiveType) && + static_cast(note.timeMs) + note.lateTimingMs < clockMs) { + gameplay->notes[i].judgment = static_cast(Judgment::Miss); + } + } + advancePending(gameplay); +} + +void updateGameplay(const StageView& stage, GameplayState* gameplay, float clockMs) { + if (!gameplay || !gameplay->notes || !stage.header) return; + if (clockMs + 1000.0f < gameplay->previousClockMs) { + seekGameplay(stage, gameplay, clockMs); + } + gameplay->previousClockMs = clockMs; + + for (std::uint32_t i = 0; i < gameplay->noteCount; ++i) { + const PackageNote& note = stage.notes[i]; + GameplayState::NoteRuntime& runtime = gameplay->notes[i]; + if (!supportedTarget(note.effectiveType) || + runtime.judgment != static_cast(Judgment::Pending)) { + continue; + } + if (!runtime.holding && !runtime.shotMuteApplied && + clockMs >= static_cast(note.timeMs) + note.muteTimingMs) { + runtime.shotMuteApplied = 1; + gameplay->shotMuted = true; + } + if (rhythmLongTarget(note.effectiveType) && runtime.holding) { + const float enableMs = note.effectiveType == 4 + ? stage.header->scratchEnableTimeMs + : stage.header->beatEnableTimeMs; + const bool bodyEnded = clockMs >= note.endTimeMs; + const bool inputExpired = clockMs - runtime.lastInputTimeMs > enableMs; + if (bodyEnded || inputExpired) { + float coveredEndMs = runtime.lastInputTimeMs; + if (bodyEnded && + note.endTimeMs - runtime.lastInputTimeMs < 1000.0f / 60.0f) { + coveredEndMs = note.endTimeMs; + } + recordJudgment( + gameplay, + i, + longJudgment(note, runtime.inputStartTimeMs, coveredEndMs), + clockMs); + } + continue; + } + if (holdTarget(note.effectiveType) && runtime.holding && + clockMs >= note.endTimeMs) { + recordJudgment( + gameplay, + i, + longJudgment(note, runtime.inputStartTimeMs, note.endTimeMs), + clockMs); + continue; + } + if (!runtime.holding && + clockMs > static_cast(note.timeMs) + note.lateTimingMs) { + recordJudgment(gameplay, i, Judgment::Miss, clockMs); + } + } +} + +Judgment pressGameplay( + const StageView& stage, + GameplayState* gameplay, + float clockMs, + std::uint32_t inputBit) { + if (!gameplay || !gameplay->notes || !stage.header || inputBit == 0) { + return Judgment::Pending; + } + updateGameplay(stage, gameplay, clockMs); + + for (std::uint32_t i = 0; i < gameplay->noteCount; ++i) { + const PackageNote& note = stage.notes[i]; + GameplayState::NoteRuntime& runtime = gameplay->notes[i]; + if (runtime.judgment != static_cast(Judgment::Pending) || + !runtime.holding || !rhythmLongTarget(note.effectiveType)) { + continue; + } + if (note.effectiveType == 5 || inputBit != runtime.lastInputBit) { + runtime.lastInputTimeMs = clockMs; + runtime.lastInputBit = inputBit; + } + return Judgment::Pending; + } + + for (std::uint32_t i = 0; i < gameplay->noteCount; ++i) { + const PackageNote& note = stage.notes[i]; + GameplayState::NoteRuntime& runtime = gameplay->notes[i]; + if (runtime.judgment != static_cast(Judgment::Pending) || + runtime.inputMask == 0 || (runtime.inputMask & inputBit) != 0 || + !(dualTapTarget(note.effectiveType) || + (note.effectiveType == 15 && !runtime.holding))) { + continue; + } + const float error = clockMs - static_cast(note.timeMs); + if (error < -note.earlyTimingMs || error > note.lateTimingMs) continue; + runtime.inputMask |= inputBit; + if (dualTapTarget(note.effectiveType)) { + const Judgment result = tapJudgment( + note, runtime.inputStartTimeMs, + stage.header->greatMinimumTimeMs); + recordJudgment(gameplay, i, result, clockMs); + return result; + } + runtime.holding = 1; + runtime.inputStartTimeMs = clockMs; + gameplay->shotMuted = false; + return Judgment::Pending; + } + + std::uint32_t candidate = gameplay->noteCount; + float candidateError = 0.0f; + for (std::uint32_t i = 0; i < gameplay->noteCount; ++i) { + const PackageNote& note = stage.notes[i]; + const GameplayState::NoteRuntime& runtime = gameplay->notes[i]; + if (runtime.judgment != static_cast(Judgment::Pending) || + runtime.inputMask != 0 || !supportedTarget(note.effectiveType)) { + continue; + } + const float error = clockMs - static_cast(note.timeMs); + if (error < -note.earlyTimingMs || error > note.lateTimingMs) continue; + if (candidate == gameplay->noteCount || std::fabs(error) < candidateError) { + candidate = i; + candidateError = std::fabs(error); + } + } + if (candidate == gameplay->noteCount) return Judgment::Pending; + + const PackageNote& note = stage.notes[candidate]; + GameplayState::NoteRuntime& runtime = gameplay->notes[candidate]; + if (tapTarget(note.effectiveType)) { + const Judgment result = tapJudgment( + note, clockMs, stage.header->greatMinimumTimeMs); + recordJudgment(gameplay, candidate, result, clockMs); + return result; + } + runtime.inputMask = inputBit; + runtime.inputStartTimeMs = clockMs; + runtime.lastInputTimeMs = clockMs; + runtime.lastInputBit = inputBit; + runtime.holding = + note.effectiveType == 3 || rhythmLongTarget(note.effectiveType); + if (runtime.holding) gameplay->shotMuted = false; + return Judgment::Pending; +} + +Judgment releaseGameplay( + const StageView& stage, + GameplayState* gameplay, + float clockMs, + std::uint32_t inputBit) { + if (!gameplay || !gameplay->notes || !stage.header || inputBit == 0) { + return Judgment::Pending; + } + for (std::uint32_t i = 0; i < gameplay->noteCount; ++i) { + const PackageNote& note = stage.notes[i]; + GameplayState::NoteRuntime& runtime = gameplay->notes[i]; + if (runtime.judgment != static_cast(Judgment::Pending) || + !holdTarget(note.effectiveType) || (runtime.inputMask & inputBit) == 0) { + continue; + } + if (runtime.holding) { + const Judgment result = + longJudgment(note, runtime.inputStartTimeMs, clockMs); + recordJudgment(gameplay, i, result, clockMs); + return result; + } + runtime.inputMask &= ~inputBit; + if (runtime.inputMask == 0) runtime.inputStartTimeMs = -1.0f; + return Judgment::Pending; + } + return Judgment::Pending; +} + +Judgment tapGameplay(const StageView& stage, GameplayState* gameplay, float clockMs) { + return pressGameplay(stage, gameplay, clockMs, 1u); +} + +Judgment noteJudgment(const GameplayState& gameplay, std::uint32_t noteIndex) { + if (!gameplay.notes || noteIndex >= gameplay.noteCount) return Judgment::Pending; + return static_cast(gameplay.notes[noteIndex].judgment); +} + +} // namespace openroller::psp diff --git a/psp/src/SongMenu.cpp b/psp/src/SongMenu.cpp new file mode 100644 index 0000000..1fa9f66 --- /dev/null +++ b/psp/src/SongMenu.cpp @@ -0,0 +1,201 @@ +#include "SongMenu.hpp" + +#include + +#include +#include +#include + +namespace openroller::psp { +namespace { + +void setError(char* output, std::size_t capacity, const char* message) { + if (output && capacity > 0) std::snprintf(output, capacity, "%s", message); +} + +int firstDifficulty(const SongCatalogRecord& song, int preferred) { + if (preferred >= 0 && preferred < 4 && (song.availableMask & (1u << preferred)) != 0) { + return preferred; + } + for (int distance = 1; distance < 4; ++distance) { + const int lower = preferred - distance; + const int upper = preferred + distance; + if (lower >= 0 && (song.availableMask & (1u << lower)) != 0) return lower; + if (upper < 4 && (song.availableMask & (1u << upper)) != 0) return upper; + } + for (int difficulty = 0; difficulty < 4; ++difficulty) { + if ((song.availableMask & (1u << difficulty)) != 0) return difficulty; + } + return 0; +} + +bool loadJacket(SongMenu* menu) { + std::free(menu->jacketStorage); + menu->jacketStorage = nullptr; + menu->jacketPixels = nullptr; + menu->jacketSize = 0; + const SongCatalogRecord* song = selectedSong(*menu); + if (!song) return false; + char path[384]{}; + std::snprintf(path, sizeof(path), "%s/songs/%s/jacket.orpj", menu->rootPath, song->key); + std::FILE* file = std::fopen(path, "rb"); + if (!file) return false; + std::fseek(file, 0, SEEK_END); + const long length = std::ftell(file); + std::fseek(file, 0, SEEK_SET); + if (length < static_cast(sizeof(JacketHeader)) || length > 512 * 512 * 2 + 64) { + std::fclose(file); + return false; + } + void* storage = std::malloc(static_cast(length)); + if (!storage || std::fread(storage, 1, static_cast(length), file) != + static_cast(length)) { + std::fclose(file); + std::free(storage); + return false; + } + std::fclose(file); + const auto* header = static_cast(storage); + const std::size_t expected = static_cast(header->width) * header->height * 2; + if (std::memcmp(header->magic, kJacketMagic, sizeof(header->magic)) != 0 || + header->version != kJacketVersion || header->pixelFormat != 0 || + header->width == 0 || header->height == 0 || header->dataSize != expected || + sizeof(JacketHeader) + expected != static_cast(length)) { + std::free(storage); + return false; + } + menu->jacketStorage = storage; + menu->jacketSize = static_cast(length); + menu->jacketPixels = reinterpret_cast( + static_cast(storage) + sizeof(JacketHeader)); + menu->jacketWidth = header->width; + menu->jacketHeight = header->height; + sceKernelDcacheWritebackRange(storage, static_cast(length)); + return true; +} + +} // namespace + +bool loadSongMenu(const char* catalogPath, SongMenu* menu, char* error, std::size_t errorCapacity) { + if (!catalogPath || !menu) return false; + unloadSongMenu(menu); + std::FILE* file = std::fopen(catalogPath, "rb"); + if (!file) { + setError(error, errorCapacity, "catalog.orpc not found"); + return false; + } + std::fseek(file, 0, SEEK_END); + const long length = std::ftell(file); + std::fseek(file, 0, SEEK_SET); + if (length < static_cast(sizeof(SongCatalogHeader)) || length > 64 * 1024) { + std::fclose(file); + setError(error, errorCapacity, "invalid catalog size"); + return false; + } + void* storage = std::malloc(static_cast(length)); + if (!storage || std::fread(storage, 1, static_cast(length), file) != + static_cast(length)) { + std::fclose(file); + std::free(storage); + setError(error, errorCapacity, "could not read catalog"); + return false; + } + std::fclose(file); + const auto* header = static_cast(storage); + const bool valid = std::memcmp(header->magic, kSongCatalogMagic, sizeof(header->magic)) == 0 && + header->version == kSongCatalogVersion && header->headerSize == sizeof(SongCatalogHeader) && + header->recordSize == sizeof(SongCatalogRecord) && header->songCount > 0 && + header->songCount <= kMaximumCatalogSongs && header->fileSize == static_cast(length) && + sizeof(SongCatalogHeader) + header->songCount * sizeof(SongCatalogRecord) == + static_cast(length); + if (!valid) { + std::free(storage); + setError(error, errorCapacity, "invalid catalog header"); + return false; + } + menu->catalogStorage = storage; + menu->catalogSize = static_cast(length); + menu->header = header; + menu->songs = reinterpret_cast( + static_cast(storage) + sizeof(SongCatalogHeader)); + std::snprintf(menu->rootPath, sizeof(menu->rootPath), "%s", catalogPath); + char* slash = std::strrchr(menu->rootPath, '/'); + if (slash) *slash = '\0'; + else std::snprintf(menu->rootPath, sizeof(menu->rootPath), "."); + menu->difficulty = firstDifficulty(menu->songs[0], 2); + loadJacket(menu); + setError(error, errorCapacity, "ok"); + return true; +} + +void unloadSongMenu(SongMenu* menu) { + if (!menu) return; + std::free(menu->jacketStorage); + std::free(menu->catalogStorage); + *menu = {}; +} + +const SongCatalogRecord* selectedSong(const SongMenu& menu) { + if (!menu.header || !menu.songs || menu.selection < 0 || + static_cast(menu.selection) >= menu.header->songCount) return nullptr; + return &menu.songs[menu.selection]; +} + +bool moveSongSelection(SongMenu* menu, int delta) { + if (!menu || !menu->header || menu->header->songCount == 0) return false; + const int count = static_cast(menu->header->songCount); + menu->selection = (menu->selection + delta) % count; + if (menu->selection < 0) menu->selection += count; + menu->difficulty = firstDifficulty(menu->songs[menu->selection], menu->difficulty); + loadJacket(menu); + return true; +} + +bool moveDifficultySelection(SongMenu* menu, int delta) { + const SongCatalogRecord* song = menu ? selectedSong(*menu) : nullptr; + if (!song) return false; + for (int step = 1; step <= 4; ++step) { + int candidate = (menu->difficulty + delta * step) % 4; + if (candidate < 0) candidate += 4; + if ((song->availableMask & (1u << candidate)) != 0) { + menu->difficulty = candidate; + return true; + } + } + return false; +} + +bool selectedSongPath(const SongMenu& menu, char* output, std::size_t capacity) { + static constexpr const char* names[4] = {"easy", "normal", "hard", "extra"}; + const SongCatalogRecord* song = selectedSong(menu); + if (!song || !output || capacity == 0 || menu.difficulty < 0 || menu.difficulty >= 4) return false; + const int written = std::snprintf( + output, capacity, "%s/songs/%s/%s.orps", menu.rootPath, song->key, names[menu.difficulty]); + return written > 0 && static_cast(written) < capacity; +} + +bool selectedAudioPath(const SongMenu& menu, char* output, std::size_t capacity) { + static constexpr const char* names[4] = {"easy", "normal", "hard", "extra"}; + const SongCatalogRecord* song = selectedSong(menu); + if (!song || !output || capacity == 0 || menu.difficulty < 0 || menu.difficulty >= 4) { + return false; + } + const int written = std::snprintf( + output, capacity, "%s/songs/%s/%s_bgm.mp3", + menu.rootPath, song->key, names[menu.difficulty]); + return written > 0 && static_cast(written) < capacity; +} + +bool selectedShotAudioPath(const SongMenu& menu, char* output, std::size_t capacity) { + static constexpr const char* names[4] = {"easy", "normal", "hard", "extra"}; + const SongCatalogRecord* song = selectedSong(menu); + if (!song || !output || capacity == 0 || menu.difficulty < 0 || menu.difficulty >= 4) { + return false; + } + const int written = std::snprintf( + output, capacity, "%s/songs/%s/%s_shot.mp3", + menu.rootPath, song->key, names[menu.difficulty]); + return written > 0 && static_cast(written) < capacity; +} + +} // namespace openroller::psp diff --git a/psp/src/StageRuntime.cpp b/psp/src/StageRuntime.cpp new file mode 100644 index 0000000..7e848fe --- /dev/null +++ b/psp/src/StageRuntime.cpp @@ -0,0 +1,444 @@ +#include "StageRuntime.hpp" + +#include +#include +#include +#include +#include +#include + +namespace openroller::psp { +namespace { + +constexpr std::size_t kMaximumStageBytes = 4u * 1024u * 1024u; +constexpr float kPi = 3.14159265358979323846f; + +void setError(char* output, std::size_t capacity, const char* message) { + if (!output || capacity == 0) return; + std::snprintf(output, capacity, "%s", message); +} + +template +bool sectionValid(const StagePackageHeader& header, PackageSection section) { + if ((section.offset & 15u) != 0 || section.offset < header.headerSize) return false; + if (section.count > std::numeric_limits::max() / sizeof(T)) return false; + const std::uint32_t bytes = section.count * static_cast(sizeof(T)); + return section.offset <= header.fileSize && bytes <= header.fileSize - section.offset; +} + +template +const T* sectionPointer(const void* storage, PackageSection section) { + const auto* bytes = static_cast(storage); + return reinterpret_cast(bytes + section.offset); +} + +bool rangeValid(PackageRange range, std::uint32_t count) { + return range.first <= count && range.count <= count - range.first; +} + +Vec3 add(Vec3 a, Vec3 b) { return {a.x + b.x, a.y + b.y, a.z + b.z}; } +Vec3 subtract(Vec3 a, Vec3 b) { return {a.x - b.x, a.y - b.y, a.z - b.z}; } +Vec3 multiply(Vec3 a, float value) { return {a.x * value, a.y * value, a.z * value}; } +float dot(Vec3 a, Vec3 b) { return a.x * b.x + a.y * b.y + a.z * b.z; } +Vec3 cross(Vec3 a, Vec3 b) { + return { + a.y * b.z - a.z * b.y, + a.z * b.x - a.x * b.z, + a.x * b.y - a.y * b.x, + }; +} +float length(Vec3 value) { return std::sqrt(dot(value, value)); } +Vec3 normalize(Vec3 value, Vec3 fallback = {0.0f, 0.0f, 0.0f}) { + const float magnitude = length(value); + return magnitude > 1.0e-6f ? multiply(value, 1.0f / magnitude) : fallback; +} +Vec3 mix(Vec3 a, Vec3 b, float u) { return add(a, multiply(subtract(b, a), u)); } +float mix(float a, float b, float u) { return a + (b - a) * u; } + +Vec3 fromArray(const float value[3]) { return {value[0], value[1], value[2]}; } + +std::uint32_t lowerTrackIndex(const StageView& stage, float timeMs) { + const std::uint32_t count = stage.header->track.count; + std::uint32_t first = 0; + std::uint32_t last = count; + while (first < last) { + const std::uint32_t middle = first + (last - first) / 2; + if (static_cast(stage.track[middle].timeMs) <= timeMs) first = middle + 1; + else last = middle; + } + return first == 0 ? 0 : first - 1; +} + +std::uint32_t lowerCameraIndex(const StageView& stage, float timeMs) { + const std::uint32_t count = stage.header->cameras.count; + std::uint32_t first = 0; + std::uint32_t last = count; + while (first < last) { + const std::uint32_t middle = first + (last - first) / 2; + if (static_cast(stage.cameras[middle].timeMs) <= timeMs) first = middle + 1; + else last = middle; + } + return first == 0 ? 0 : first - 1; +} + +Vec3 cameraOrbit(const PackageCameraPoint& camera) { + const float a = (-camera.rotationA[1] * kPi / 180.0f) * 0.5f; + const float b = (camera.rotationA[0] * kPi / 180.0f) * 0.5f; + const float c = 0.0f; + const float ca = std::cos(a), cb = std::cos(b), cc = std::cos(c); + const float sa = std::sin(a), sb = std::sin(b), sc = std::sin(c); + const float qw = sc * sa * sb + cc * ca * cb; + const float qx = sc * ca * sb + cc * sa * cb; + const float qy = cc * ca * sb - sc * sa * cb; + const float qz = cc * sa * sb - sc * ca * cb; + return multiply({ + 2.0f * (qz * qx + qw * qy), + 2.0f * (qy * qz - qw * qx), + 1.0f - 2.0f * (qx * qx + qy * qy), + }, camera.distance); +} + +Vec3 rotateAround(Vec3 value, Vec3 axis, float degrees) { + const float radians = degrees * kPi / 180.0f; + const float cosine = std::cos(radians); + const float sine = std::sin(radians); + return add( + add(multiply(value, cosine), multiply(cross(axis, value), sine)), + multiply(axis, dot(axis, value) * (1.0f - cosine))); +} + +void adjustCameraUp(CameraState* camera, float rollDegrees) { + const Vec3 view = normalize(subtract(camera->target, camera->eye)); + if (dot(view, view) < 1.0e-10f) { + camera->up = {0.0f, 1.0f, 0.0f}; + return; + } + Vec3 reference{0.0f, 1.0f, 0.0f}; + Vec3 projected = subtract(reference, multiply(view, dot(reference, view))); + if (dot(projected, projected) < 1.0e-8f) { + reference = {0.0f, 0.0f, 1.0f}; + projected = subtract(reference, multiply(view, dot(reference, view))); + } + camera->up = normalize(projected, {0.0f, 1.0f, 0.0f}); + if (rollDegrees != 0.0f) camera->up = rotateAround(camera->up, view, rollDegrees); +} + +PackageCameraPoint mixCamera(const PackageCameraPoint& a, const PackageCameraPoint& b, float u) { + PackageCameraPoint output = a; + output.distance = mix(a.distance, b.distance, u); + for (int i = 0; i < 2; ++i) output.rotationA[i] = mix(a.rotationA[i], b.rotationA[i], u); + for (int i = 0; i < 3; ++i) { + output.originOffset[i] = mix(a.originOffset[i], b.originOffset[i], u); + output.fieldFar[i] = mix(a.fieldFar[i], b.fieldFar[i], u); + output.fieldNear[i] = mix(a.fieldNear[i], b.fieldNear[i], u); + } + output.rotationB = mix(a.rotationB, b.rotationB, u); + return output; +} + +CameraState evaluateCameraInternal( + const StageView& stage, + float timeMs, + bool interpolate, + int depth) { + CameraState state{}; + if (stage.header->cameras.count == 0 || depth > 8) { + state.target = trackPositionAt(stage, timeMs); + state.eye = add(state.target, {0.0f, 0.0f, 10.0f}); + state.projectionBlend = 1.0f; + return state; + } + + const std::uint32_t index = lowerCameraIndex(stage, timeMs); + const PackageCameraPoint* key = &stage.cameras[index]; + PackageCameraPoint mixed{}; + const bool between = interpolate && key->fMode != 0 && + timeMs > static_cast(key->timeMs) && index + 1 < stage.header->cameras.count && + timeMs < static_cast(stage.cameras[index + 1].timeMs); + if (between) { + const PackageCameraPoint& following = stage.cameras[index + 1]; + const float span = static_cast(following.timeMs - key->timeMs); + const float u = span > 0.0f ? (timeMs - static_cast(key->timeMs)) / span : 0.0f; + if (key->fMode == 1) { + CameraState from = evaluateCameraInternal(stage, static_cast(key->timeMs), true, depth + 1); + CameraState to = evaluateCameraInternal(stage, static_cast(following.timeMs), true, depth + 1); + adjustCameraUp(&from, 0.0f); + adjustCameraUp(&to, 0.0f); + state.eye = mix(from.eye, to.eye, u); + state.target = mix(from.target, to.target, u); + state.up = normalize(mix(from.up, to.up, u), {0.0f, 1.0f, 0.0f}); + state.projectionBlend = mix(from.projectionBlend, to.projectionBlend, u); + const float roll = mix(key->rotationB, following.rotationB, u); + const Vec3 axis = normalize(subtract(state.target, state.eye)); + if (roll != 0.0f && dot(axis, axis) > 0.0f) state.up = rotateAround(state.up, axis, roll); + return state; + } + if (key->fMode == 2) { + mixed = mixCamera(*key, following, u); + key = &mixed; + } + } + + const Vec3 orbit = cameraOrbit(*key); + const Vec3 currentTrack = trackPositionAt(stage, timeMs); + const Vec3 origin = fromArray(key->originOffset); + switch (key->aMode) { + case 0: + state.target = add(currentTrack, origin); + state.eye = add(state.target, orbit); + break; + case 1: + state.target = add(trackPositionAt(stage, static_cast(key->timeMs)), origin); + state.eye = add(state.target, orbit); + break; + case 2: + if (index > 1) { + return evaluateCameraInternal( + stage, static_cast(stage.cameras[index].timeMs) - 1.0f, false, depth + 1); + } + state.target = add(currentTrack, origin); + state.eye = add(state.target, orbit); + break; + case 3: + state.target = add(currentTrack, origin); + state.eye = add(add(trackPositionAt(stage, static_cast(key->timeMs)), origin), orbit); + break; + case 4: + state.target = fromArray(key->fieldFar); + state.eye = fromArray(key->fieldNear); + break; + case 5: + state.target = add(currentTrack, origin); + state.eye = fromArray(key->fieldNear); + break; + case 6: + state.target = fromArray(key->fieldFar); + state.eye = add(currentTrack, orbit); + break; + default: + state.target = add(currentTrack, origin); + state.eye = add(state.target, orbit); + break; + } + adjustCameraUp(&state, key->rotationB); + state.projectionBlend = key->projectionType ? 1.0f : 0.0f; + return state; +} + +std::uint8_t colorChannel(std::uint32_t color, int shift) { + return static_cast((color >> shift) & 0xffu); +} + +std::uint32_t mixColor(std::uint32_t a, std::uint32_t b, float u) { + std::uint32_t output = 0; + for (int shift = 0; shift <= 24; shift += 8) { + const float value = mix( + static_cast(colorChannel(a, shift)), + static_cast(colorChannel(b, shift)), u); + output |= static_cast(std::clamp(value, 0.0f, 255.0f) + 0.5f) << shift; + } + return output; +} + +} // namespace + +bool loadStagePackage(const char* path, StageView* stage, char* error, std::size_t errorCapacity) { + if (!path || !stage) { + setError(error, errorCapacity, "invalid stage loader arguments"); + return false; + } + unloadStagePackage(stage); + + std::FILE* file = std::fopen(path, "rb"); + if (!file) { + setError(error, errorCapacity, "stage.orps not found"); + return false; + } + if (std::fseek(file, 0, SEEK_END) != 0) { + std::fclose(file); + setError(error, errorCapacity, "could not seek stage.orps"); + return false; + } + const long length = std::ftell(file); + if (length < static_cast(sizeof(StagePackageHeader)) || + length > static_cast(kMaximumStageBytes) || + std::fseek(file, 0, SEEK_SET) != 0) { + std::fclose(file); + setError(error, errorCapacity, "stage.orps has an invalid size"); + return false; + } + + void* storage = std::malloc(static_cast(length)); + if (!storage) { + std::fclose(file); + setError(error, errorCapacity, "not enough memory for stage.orps"); + return false; + } + const std::size_t read = std::fread(storage, 1, static_cast(length), file); + std::fclose(file); + if (read != static_cast(length)) { + std::free(storage); + setError(error, errorCapacity, "could not read complete stage.orps"); + return false; + } + + const auto* header = static_cast(storage); + const bool headerValid = + std::memcmp(header->magic, kStagePackageMagic, sizeof(header->magic)) == 0 && + header->version == kStagePackageVersion && + header->headerSize == sizeof(StagePackageHeader) && + header->fileSize == static_cast(length); + const bool sectionsValid = headerValid && + sectionValid(*header, header->track) && + sectionValid(*header, header->notes) && + sectionValid(*header, header->cameras) && + sectionValid(*header, header->drawDistances) && + sectionValid(*header, header->backgroundColors) && + sectionValid(*header, header->backgroundModels) && + sectionValid(*header, header->backgroundVertices) && + sectionValid(*header, header->backgroundObjects) && + sectionValid(*header, header->visibilityKeys) && + sectionValid(*header, header->transformKeys) && + sectionValid(*header, header->objectColorKeys) && + sectionValid(*header, header->particles) && + sectionValid(*header, header->visualizer) && + sectionValid(*header, header->bpmChanges); + bool contentsValid = sectionsValid; + if (contentsValid) { + const auto* models = sectionPointer(storage, header->backgroundModels); + for (std::uint32_t i = 0; i < header->backgroundModels.count; ++i) { + contentsValid = contentsValid && + rangeValid(models[i].triangles, header->backgroundVertices.count) && + rangeValid(models[i].solidLines, header->backgroundVertices.count) && + rangeValid(models[i].wireframeLines, header->backgroundVertices.count); + } + const auto* objects = sectionPointer(storage, header->backgroundObjects); + for (std::uint32_t i = 0; i < header->backgroundObjects.count; ++i) { + const bool parentValid = objects[i].parentIndex < 0 || + static_cast(objects[i].parentIndex) < header->backgroundObjects.count; + contentsValid = contentsValid && + (objects[i].model == std::numeric_limits::max() || + objects[i].model < header->backgroundModels.count) && parentValid && + rangeValid(objects[i].visibility, header->visibilityKeys.count) && + rangeValid(objects[i].movement, header->transformKeys.count) && + rangeValid(objects[i].scaling, header->transformKeys.count) && + rangeValid(objects[i].rotations, header->transformKeys.count) && + rangeValid(objects[i].colorChanges, header->objectColorKeys.count); + } + } + if (!contentsValid || header->track.count < 2 || + header->backgroundObjects.count > kMaximumPackageBackgroundObjects) { + std::free(storage); + setError(error, errorCapacity, "stage.orps header or sections are invalid"); + return false; + } + + stage->storage = storage; + stage->storageSize = static_cast(length); + stage->header = header; + stage->track = sectionPointer(storage, header->track); + stage->notes = sectionPointer(storage, header->notes); + stage->cameras = sectionPointer(storage, header->cameras); + stage->drawDistances = sectionPointer(storage, header->drawDistances); + stage->backgroundColors = sectionPointer(storage, header->backgroundColors); + stage->backgroundModels = sectionPointer(storage, header->backgroundModels); + stage->backgroundVertices = sectionPointer(storage, header->backgroundVertices); + stage->backgroundObjects = sectionPointer(storage, header->backgroundObjects); + stage->visibilityKeys = sectionPointer(storage, header->visibilityKeys); + stage->transformKeys = sectionPointer(storage, header->transformKeys); + stage->objectColorKeys = sectionPointer(storage, header->objectColorKeys); + stage->particles = sectionPointer(storage, header->particles); + stage->visualizer = sectionPointer(storage, header->visualizer); + stage->bpmChanges = sectionPointer(storage, header->bpmChanges); + setError(error, errorCapacity, "ok"); + return true; +} + +void unloadStagePackage(StageView* stage) { + if (!stage) return; + std::free(stage->storage); + *stage = {}; +} + +Vec3 trackPositionAt(const StageView& stage, float timeMs) { + if (!stage.header || stage.header->track.count == 0) return {}; + if (timeMs <= static_cast(stage.track[0].timeMs)) { + return {stage.track[0].x, stage.track[0].y, stage.track[0].z}; + } + const std::uint32_t last = stage.header->track.count - 1; + if (timeMs >= static_cast(stage.track[last].timeMs)) { + return {stage.track[last].x, stage.track[last].y, stage.track[last].z}; + } + const std::uint32_t index = lowerTrackIndex(stage, timeMs); + const PackageTrackPoint& a = stage.track[index]; + const PackageTrackPoint& b = stage.track[index + 1]; + const float span = static_cast(b.timeMs - a.timeMs); + const float u = span > 0.0f ? (timeMs - static_cast(a.timeMs)) / span : 0.0f; + return mix({a.x, a.y, a.z}, {b.x, b.y, b.z}, u); +} + +Vec3 trackTangentAt(const StageView& stage, float timeMs) { + const float first = static_cast(stage.track[0].timeMs); + const float last = static_cast(stage.track[stage.header->track.count - 1].timeMs); + const Vec3 before = trackPositionAt(stage, std::max(first, timeMs - 4.0f)); + const Vec3 after = trackPositionAt(stage, std::min(last, timeMs + 4.0f)); + return normalize(subtract(after, before), {0.0f, 0.0f, 1.0f}); +} + +CameraState evaluateCamera(const StageView& stage, float timeMs) { + return evaluateCameraInternal(stage, timeMs, true, 0); +} + +BackgroundColors evaluateBackground(const StageView& stage, float timeMs) { + if (!stage.header || stage.header->backgroundColors.count == 0) return {}; + const std::uint32_t count = stage.header->backgroundColors.count; + std::uint32_t index = 0; + while (index + 1 < count && static_cast(stage.backgroundColors[index + 1].timeMs) <= timeMs) { + ++index; + } + const PackageBackgroundColorPoint& a = stage.backgroundColors[index]; + if (index + 1 >= count) { + return {a.topRightRgba, a.topLeftRgba, a.bottomRightRgba, a.bottomLeftRgba}; + } + const PackageBackgroundColorPoint& b = stage.backgroundColors[index + 1]; + const float span = static_cast(b.timeMs - a.timeMs); + const float u = span > 0.0f + ? std::clamp((timeMs - static_cast(a.timeMs)) / span, 0.0f, 1.0f) + : 0.0f; + return { + mixColor(a.topRightRgba, b.topRightRgba, u), + mixColor(a.topLeftRgba, b.topLeftRgba, u), + mixColor(a.bottomRightRgba, b.bottomRightRgba, u), + mixColor(a.bottomLeftRgba, b.bottomLeftRgba, u), + }; +} + +float evaluateDrawAhead(const StageView& stage, float timeMs) { + if (!stage.header || stage.header->drawDistances.count == 0) { + return stage.header ? stage.header->forwardDrawDistance : 7.0f; + } + const std::uint32_t count = stage.header->drawDistances.count; + std::uint32_t index = 0; + while (index + 1 < count && static_cast(stage.drawDistances[index + 1].timeMs) <= timeMs) ++index; + const PackageDrawDistancePoint& a = stage.drawDistances[index]; + if (index + 1 >= count) return std::max(0.0f, a.distance); + const PackageDrawDistancePoint& b = stage.drawDistances[index + 1]; + const float span = static_cast(b.timeMs - a.timeMs); + const float u = span > 0.0f + ? std::clamp((timeMs - static_cast(a.timeMs)) / span, 0.0f, 1.0f) + : 0.0f; + return std::max(0.0f, mix(a.distance, b.distance, u)); +} + +float evaluateBeatDurationMs(const StageView& stage, float timeMs) { + if (!stage.header || stage.header->bpmChanges.count == 0) return 500.0f; + std::uint32_t active = 0; + for (std::uint32_t i = 1; i < stage.header->bpmChanges.count; ++i) { + if (static_cast(stage.bpmChanges[i].timeMs) > timeMs) break; + active = i; + } + const std::uint32_t bpm = stage.bpmChanges[active].bpm; + return bpm > 0 ? 60000.0f / static_cast(bpm) : 500.0f; +} + +} // namespace openroller::psp diff --git a/psp/src/main.cpp b/psp/src/main.cpp new file mode 100644 index 0000000..ed74875 --- /dev/null +++ b/psp/src/main.cpp @@ -0,0 +1,2014 @@ +#include "AudioPlayer.hpp" +#include "Gameplay.hpp" +#include "SongMenu.hpp" +#include "StageRuntime.hpp" +#include "TateTransform.hpp" + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +PSP_MODULE_INFO("OpenRoller PSP", 0, 0, 1); +PSP_MAIN_THREAD_ATTR(THREAD_ATTR_USER); +PSP_HEAP_SIZE_KB(-1024); + +#ifndef OPENROLLER_BUILD_TIMESTAMP +#define OPENROLLER_BUILD_TIMESTAMP "unknown" +#endif + +namespace { + +using openroller::psp::BackgroundColors; +using openroller::psp::CameraState; +using openroller::psp::GameplayState; +using openroller::psp::Judgment; +using openroller::psp::Point; +using openroller::psp::StageView; +using openroller::psp::SongMenu; +using openroller::psp::TateSide; +using openroller::psp::Vec3; + +constexpr int kBufferWidth = 512; +constexpr int kDisplayListWords = 65536; +constexpr int kMaximumRailSegments = 1024; +constexpr int kMaximumNoteLineVertices = 8192; +constexpr int kMaximumNoteHeadVertices = 16384; +constexpr int kMaximumParticleVertices = 2048; +constexpr int kMaximumBackgroundObjects = + static_cast(openroller::psp::kMaximumPackageBackgroundObjects); +constexpr float kPi = 3.14159265358979323846f; +constexpr float kFovDegrees = 75.0f; +constexpr float kPortraitAspect = 720.0f / 1280.0f; + +alignas(16) unsigned int gDisplayList[kDisplayListWords]; +volatile bool gRunning = true; + +struct Vertex { + std::uint32_t color; + short x; + short y; + short z; +}; + +constexpr int kMaximumMarqueeVertices = 9600; +alignas(64) Vertex gMarqueeVertices[kMaximumMarqueeVertices]; + +// Keep bitmap-font geometry out of the GU display list. menuText used to +// allocate four vertices and submit a draw call for every lit pixel. Long +// song names could therefore fill the 256 KiB list while merely scrolling the +// menu and hard-lock the GE on real hardware (PPSSPP is much more forgiving). +// Every call now appends triangles to a frame-lifetime arena and submits the +// whole string at once. The arena is reset only after the previous frame has +// completed, so all pointers remain valid until sceGuSync(). +constexpr int kMaximumTextVertices = 98304; +alignas(64) Vertex gTextVertices[kMaximumTextVertices]; +int gTextVertexCount = 0; + +struct WorldVertex { + std::uint32_t color; + float x; + float y; + float z; +}; + +// Gameplay geometry must not live inside the 256 KiB GU display list. The +// previous fixed reservations totalled about 261 KiB before any draw commands +// or pause UI were submitted, so complex charts inevitably corrupted the list +// on real hardware even though PPSSPP often tolerated it. +alignas(64) WorldVertex gParticleVertices[kMaximumParticleVertices]; +alignas(64) WorldVertex gRailVertices[kMaximumRailSegments * 6]; +alignas(64) WorldVertex gNoteVertices[kMaximumNoteLineVertices]; +alignas(64) WorldVertex gNoteHeadFillVertices[kMaximumNoteHeadVertices]; +alignas(64) WorldVertex gNoteHeadLineVertices[kMaximumNoteHeadVertices]; +alignas(64) WorldVertex gNoteApproachVertices[kMaximumNoteHeadVertices]; + +struct TextureVertex { + short u; + short v; + std::uint32_t color; + short x; + short y; + short z; +}; + +struct Matrix4 { + float value[16]; +}; + +struct Color4 { + float r; + float g; + float b; + float a; +}; + +struct BackgroundDraw { + Matrix4 transform; + Color4 color; + float cameraDistance2; + std::uint32_t objectIndex; + bool visible; +}; + +BackgroundDraw gBackgroundDraws[kMaximumBackgroundObjects]; +std::uint16_t gTranslucentBackground[kMaximumBackgroundObjects]; + +std::uint32_t rgba(std::uint8_t r, std::uint8_t g, std::uint8_t b, std::uint8_t a = 255) { + return static_cast(r) | + (static_cast(g) << 8) | + (static_cast(b) << 16) | + (static_cast(a) << 24); +} + +short pixel(float value) { + value = std::clamp(value, -4096.0f, 4096.0f); + return static_cast(std::lround(value)); +} + +Vertex vertex(Point point, std::uint32_t color) { + return {color, pixel(point.x), pixel(point.y), 0}; +} + +WorldVertex worldVertex(Vec3 point, std::uint32_t color) { + return {color, point.x, point.y, point.z}; +} + +Vec3 add(Vec3 a, Vec3 b) { return {a.x + b.x, a.y + b.y, a.z + b.z}; } +Vec3 subtract(Vec3 a, Vec3 b) { return {a.x - b.x, a.y - b.y, a.z - b.z}; } +Vec3 multiply(Vec3 value, float scalar) { return {value.x * scalar, value.y * scalar, value.z * scalar}; } +float dot(Vec3 a, Vec3 b) { return a.x * b.x + a.y * b.y + a.z * b.z; } +Vec3 cross(Vec3 a, Vec3 b) { + return { + a.y * b.z - a.z * b.y, + a.z * b.x - a.x * b.z, + a.x * b.y - a.y * b.x, + }; +} +float length(Vec3 value) { return std::sqrt(dot(value, value)); } +Vec3 normalize(Vec3 value, Vec3 fallback) { + const float magnitude = length(value); + return magnitude > 1.0e-6f ? multiply(value, 1.0f / magnitude) : fallback; +} + +void line(Point a, Point b, std::uint32_t color) { + auto* vertices = static_cast(sceGuGetMemory(2 * sizeof(Vertex))); + vertices[0] = vertex(a, color); + vertices[1] = vertex(b, color); + sceGuDrawArray( + GU_LINES, + GU_COLOR_8888 | GU_VERTEX_16BIT | GU_TRANSFORM_2D, + 2, + nullptr, + vertices); +} + +void logicalLine(float ax, float ay, float bx, float by, std::uint32_t color, TateSide side) { + line( + openroller::psp::toScreen(ax, ay, side), + openroller::psp::toScreen(bx, by, side), + color); +} + +void logicalRect(float left, float top, float right, float bottom, std::uint32_t color, TateSide side) { + logicalLine(left, top, right, top, color, side); + logicalLine(right, top, right, bottom, color, side); + logicalLine(right, bottom, left, bottom, color, side); + logicalLine(left, bottom, left, top, color, side); +} + +void logicalFillRect(float left, float top, float right, float bottom, + std::uint32_t color, TateSide side) { + auto* vertices = static_cast(sceGuGetMemory(4 * sizeof(Vertex))); + vertices[0] = vertex(openroller::psp::toScreen(left, top, side), color); + vertices[1] = vertex(openroller::psp::toScreen(right, top, side), color); + vertices[2] = vertex(openroller::psp::toScreen(left, bottom, side), color); + vertices[3] = vertex(openroller::psp::toScreen(right, bottom, side), color); + sceGuDrawArray( + GU_TRIANGLE_STRIP, + GU_COLOR_8888 | GU_VERTEX_16BIT | GU_TRANSFORM_2D, + 4, + nullptr, + vertices); +} + +std::array menuGlyph(char c) { + switch (c) { + case 'A': return {14,17,17,31,17,17,17}; case 'B': return {30,17,17,30,17,17,30}; + case 'C': return {14,17,16,16,16,17,14}; case 'D': return {30,17,17,17,17,17,30}; + case 'E': return {31,16,16,30,16,16,31}; case 'F': return {31,16,16,30,16,16,16}; + case 'G': return {14,17,16,23,17,17,15}; case 'H': return {17,17,17,31,17,17,17}; + case 'I': return {31,4,4,4,4,4,31}; case 'J': return {7,2,2,2,18,18,12}; + case 'K': return {17,18,20,24,20,18,17}; case 'L': return {16,16,16,16,16,16,31}; + case 'M': return {17,27,21,21,17,17,17}; case 'N': return {17,25,21,19,17,17,17}; + case 'O': return {14,17,17,17,17,17,14}; case 'P': return {30,17,17,30,16,16,16}; + case 'Q': return {14,17,17,17,21,18,13}; case 'R': return {30,17,17,30,20,18,17}; + case 'S': return {15,16,16,14,1,1,30}; case 'T': return {31,4,4,4,4,4,4}; + case 'U': return {17,17,17,17,17,17,14}; case 'V': return {17,17,17,17,17,10,4}; + case 'W': return {17,17,17,21,21,21,10}; case 'X': return {17,17,10,4,10,17,17}; + case 'Y': return {17,17,10,4,4,4,4}; case 'Z': return {31,1,2,4,8,16,31}; + case '0': return {14,17,19,21,25,17,14}; case '1': return {4,12,4,4,4,4,14}; + case '2': return {14,17,1,2,4,8,31}; case '3': return {30,1,1,14,1,1,30}; + case '4': return {2,6,10,18,31,2,2}; case '5': return {31,16,16,30,1,1,30}; + case '6': return {14,16,16,30,17,17,14}; case '7': return {31,1,2,4,8,8,8}; + case '8': return {14,17,17,14,17,17,14}; case '9': return {14,17,17,15,1,1,14}; + case '-': return {0,0,0,31,0,0,0}; case ':': return {0,4,4,0,4,4,0}; + case '/': return {1,2,2,4,8,8,16}; case '.': return {0,0,0,0,0,12,12}; + case '!': return {4,4,4,4,4,0,4}; case '+': return {0,4,4,31,4,4,0}; + case '(': return {2,4,8,8,8,4,2}; case ')': return {8,4,2,2,2,4,8}; + case '@': return {14,17,23,21,23,16,14}; case '|': return {4,4,4,4,4,4,4}; + case '&': return {12,18,20,8,21,18,13}; + case 'a': return {0,0,14,1,15,17,15}; case 'b': return {16,16,30,17,17,17,30}; + case 'c': return {0,0,14,16,16,17,14}; case 'd': return {1,1,15,17,17,17,15}; + case 'e': return {0,0,14,17,31,16,14}; case 'g': return {0,0,15,17,15,1,14}; + case 'h': return {16,16,30,17,17,17,17}; case 'i': return {4,0,12,4,4,4,14}; + case 'k': return {16,16,18,20,24,20,18}; case 'l': return {12,4,4,4,4,4,14}; + case 'm': return {0,0,26,21,21,21,21}; case 'n': return {0,0,30,17,17,17,17}; + case 'o': return {0,0,14,17,17,17,14}; case 'p': return {0,0,30,17,30,16,16}; + case 'r': return {0,0,22,25,16,16,16}; case 's': return {0,0,15,16,14,1,30}; + case 't': return {8,8,30,8,8,9,6}; case 'u': return {0,0,17,17,17,19,13}; + case 'v': return {0,0,17,17,17,10,4}; case 'y': return {0,0,17,17,15,1,14}; + default: return {0,0,0,0,0,0,0}; + } +} + +void menuText(float x, float y, float scale, const char* text, + std::uint32_t color, TateSide side, float rightLimit = 710.0f, + bool preserveCase = false) { + if (!text) return; + const int firstVertex = gTextVertexCount; + bool arenaFull = false; + for (const unsigned char* cursor = reinterpret_cast(text); *cursor; ++cursor) { + if (x + scale * 5.0f > rightLimit) break; + char c = static_cast(*cursor); + if (!preserveCase && c >= 'a' && c <= 'z') { + c = static_cast(c - 'a' + 'A'); + } + const auto rows = menuGlyph(c); + for (int row = 0; row < 7; ++row) { + for (int column = 0; column < 5; ++column) { + if ((rows[row] & (1u << (4 - column))) == 0) continue; + if (gTextVertexCount + 6 > kMaximumTextVertices) { + arenaFull = true; + break; + } + const float left = x + column * scale; + const float top = y + row * scale; + const Point tl = openroller::psp::toScreen(left, top, side); + const Point tr = openroller::psp::toScreen(left + scale, top, side); + const Point bl = openroller::psp::toScreen(left, top + scale, side); + const Point br = openroller::psp::toScreen(left + scale, top + scale, side); + gTextVertices[gTextVertexCount++] = vertex(tl, color); + gTextVertices[gTextVertexCount++] = vertex(tr, color); + gTextVertices[gTextVertexCount++] = vertex(bl, color); + gTextVertices[gTextVertexCount++] = vertex(bl, color); + gTextVertices[gTextVertexCount++] = vertex(tr, color); + gTextVertices[gTextVertexCount++] = vertex(br, color); + } + if (arenaFull) break; + } + if (arenaFull) break; + x += scale * 6.0f; + } + const int vertexCount = gTextVertexCount - firstVertex; + if (vertexCount > 0) { + Vertex* const vertices = &gTextVertices[firstVertex]; + sceKernelDcacheWritebackRange( + vertices, static_cast(vertexCount) * sizeof(Vertex)); + sceGuDrawArray( + GU_TRIANGLES, + GU_COLOR_8888 | GU_VERTEX_16BIT | GU_TRANSFORM_2D, + vertexCount, + nullptr, + vertices); + } +} + +void drawBuildMarquee(float timeMs, TateSide side) { + static constexpr const char text[] = + "OpenRoller PSP v0.1.5-publicTest tsuki@kagebaito-" + OPENROLLER_BUILD_TIMESTAMP + " | PLEASE DO NOT SHARE BUILD OUTSIDE KAGEBAITO DISCORD | " + "VIDEOS & SCREENSHOTS ARE ALLOWED |"; + constexpr float scale = 2.0f; + constexpr float gap = 96.0f; + const float width = static_cast(sizeof(text) - 1) * scale * 6.0f; + const float period = width + gap; + const float offset = std::fmod(std::max(0.0f, timeMs) * 0.045f, period); + const float x = 720.0f - offset; + logicalFillRect(0.0f, 1088.0f, 720.0f, 1120.0f, rgba(12, 8, 42, 238), side); + int vertexCount = 0; + const auto appendText = [&](float startX) { + float glyphX = startX; + for (const unsigned char* cursor = + reinterpret_cast(text); + *cursor && glyphX < 720.0f; + ++cursor, glyphX += scale * 6.0f) { + if (glyphX + scale * 5.0f < 0.0f) continue; + const auto rows = menuGlyph(static_cast(*cursor)); + for (int row = 0; row < 7; ++row) { + for (int column = 0; column < 5; ++column) { + if ((rows[row] & (1u << (4 - column))) == 0 || + vertexCount + 6 > kMaximumMarqueeVertices) { + continue; + } + const float left = glyphX + column * scale; + const float top = 1096.0f + row * scale; + const Point tl = openroller::psp::toScreen(left, top, side); + const Point tr = openroller::psp::toScreen(left + scale, top, side); + const Point bl = openroller::psp::toScreen(left, top + scale, side); + const Point br = openroller::psp::toScreen( + left + scale, top + scale, side); + const std::uint32_t color = rgba(255, 238, 82); + gMarqueeVertices[vertexCount++] = vertex(tl, color); + gMarqueeVertices[vertexCount++] = vertex(tr, color); + gMarqueeVertices[vertexCount++] = vertex(bl, color); + gMarqueeVertices[vertexCount++] = vertex(bl, color); + gMarqueeVertices[vertexCount++] = vertex(tr, color); + gMarqueeVertices[vertexCount++] = vertex(br, color); + } + } + } + }; + appendText(x); + appendText(x + period); + if (vertexCount > 0) { + sceKernelDcacheWritebackRange( + gMarqueeVertices, + static_cast(vertexCount) * sizeof(Vertex)); + sceGuDrawArray( + GU_TRIANGLES, + GU_COLOR_8888 | GU_VERTEX_16BIT | GU_TRANSFORM_2D, + vertexCount, + nullptr, + gMarqueeVertices); + } +} + +TextureVertex textureVertex(Point point, short u, short v) { + return {u, v, 0xffffffffu, pixel(point.x), pixel(point.y), 0}; +} + +void drawMenuJacket(const SongMenu& menu, TateSide side) { + if (!menu.jacketPixels || menu.jacketWidth == 0 || menu.jacketHeight == 0) return; + constexpr float left = 42.0f; + constexpr float top = 544.0f; + constexpr float right = 250.0f; + constexpr float bottom = 752.0f; + auto* vertices = static_cast(sceGuGetMemory(4 * sizeof(TextureVertex))); + vertices[0] = textureVertex(openroller::psp::toScreen(left, top, side), 0, 0); + vertices[1] = textureVertex(openroller::psp::toScreen(right, top, side), menu.jacketWidth, 0); + vertices[2] = textureVertex(openroller::psp::toScreen(left, bottom, side), 0, menu.jacketHeight); + vertices[3] = textureVertex( + openroller::psp::toScreen(right, bottom, side), menu.jacketWidth, menu.jacketHeight); + sceGuEnable(GU_TEXTURE_2D); + sceGuTexMode(GU_PSM_4444, 0, 0, GU_FALSE); + sceGuTexImage(0, menu.jacketWidth, menu.jacketHeight, menu.jacketWidth, menu.jacketPixels); + sceGuTexFunc(GU_TFX_MODULATE, GU_TCC_RGBA); + sceGuTexFilter(GU_LINEAR, GU_LINEAR); + sceGuTexWrap(GU_CLAMP, GU_CLAMP); + sceGuDrawArray( + GU_TRIANGLE_STRIP, + GU_TEXTURE_16BIT | GU_COLOR_8888 | GU_VERTEX_16BIT | GU_TRANSFORM_2D, + 4, + nullptr, + vertices); + sceGuDisable(GU_TEXTURE_2D); +} + +void logicalCircle(float cx, float cy, float radius, int segments, std::uint32_t color, TateSide side) { + float previousX = cx + radius; + float previousY = cy; + for (int i = 1; i <= segments; ++i) { + const float angle = (2.0f * kPi * static_cast(i)) / static_cast(segments); + const float x = cx + std::cos(angle) * radius; + const float y = cy + std::sin(angle) * radius; + logicalLine(previousX, previousY, x, y, color, side); + previousX = x; + previousY = y; + } +} + +void drawBackground(const BackgroundColors& colors, TateSide side) { + auto* vertices = static_cast(sceGuGetMemory(4 * sizeof(Vertex))); + vertices[0] = vertex(openroller::psp::toScreen(0.0f, 0.0f, side), colors.topLeft | 0xff000000u); + vertices[1] = vertex(openroller::psp::toScreen(720.0f, 0.0f, side), colors.topRight | 0xff000000u); + vertices[2] = vertex(openroller::psp::toScreen(0.0f, 1280.0f, side), colors.bottomLeft | 0xff000000u); + vertices[3] = vertex(openroller::psp::toScreen(720.0f, 1280.0f, side), colors.bottomRight | 0xff000000u); + sceGuDrawArray( + GU_TRIANGLE_STRIP, + GU_COLOR_8888 | GU_VERTEX_16BIT | GU_TRANSFORM_2D, + 4, + nullptr, + vertices); +} + +std::uint32_t withScaledAlpha(std::uint32_t color, float alpha) { + const float source = static_cast((color >> 24) & 0xffu); + const std::uint32_t result = static_cast( + std::clamp(source * alpha, 0.0f, 255.0f) + 0.5f); + return (color & 0x00ffffffu) | (result << 24); +} + +void drawVisualizer(const StageView& stage, float timeMs, TateSide side) { + if (stage.header->visualizer.count == 0) return; + std::uint32_t active = 0; + for (std::uint32_t i = 1; i < stage.header->visualizer.count; ++i) { + if (static_cast(stage.visualizer[i].timeMs) > timeMs) break; + active = i; + } + const auto& key = stage.visualizer[active]; + if (key.type == 0 || ((key.rgba >> 24) & 0xffu) == 0) return; + const float phase = timeMs * 0.006f; + const std::uint32_t color = withScaledAlpha(key.rgba, 0.55f); + switch (key.type) { + case 1: + for (int ring = 0; ring < 7; ++ring) { + const float pulse = std::fmod(timeMs * 0.12f + ring * 46.0f, 320.0f); + logicalCircle(360.0f, 640.0f, 45.0f + pulse, 36, color, side); + } + break; + case 2: + for (int bar = 0; bar < 18; ++bar) { + const float y = 150.0f + bar * 56.0f; + const float width = 30.0f + (std::sin(phase + bar * 0.73f) + 1.0f) * 75.0f; + logicalLine(0.0f, y, width, y, color, side); + logicalLine(720.0f - width, y, 720.0f, y, color, side); + } + break; + case 3: + for (int ray = 0; ray < 24; ++ray) { + const float angle = ray * (2.0f * kPi / 24.0f) + phase * 0.08f; + const float inner = 100.0f; + const float outer = 250.0f + std::sin(phase + ray) * 65.0f; + logicalLine(360.0f + std::cos(angle) * inner, 640.0f + std::sin(angle) * inner, + 360.0f + std::cos(angle) * outer, 640.0f + std::sin(angle) * outer, + color, side); + } + break; + case 4: + for (int row = 0; row < 9; ++row) { + const float y = std::fmod(timeMs * 0.18f + row * 170.0f, 1450.0f) - 85.0f; + logicalLine(0.0f, y, 720.0f, y, color, side); + } + break; + case 5: + for (int ring = 0; ring < 5; ++ring) { + const float radius = 95.0f + ring * 62.0f + std::sin(phase + ring) * 18.0f; + logicalCircle(360.0f, 640.0f, radius, 48, color, side); + } + break; + default: + for (int x = 0; x <= 720; x += 90) logicalLine(x, 0, x, 1280, color, side); + for (int y = 0; y <= 1280; y += 100) logicalLine(0, y, 720, y, color, side); + break; + } +} + +void setupStageMatrices(const CameraState& camera, TateSide side) { + constexpr float nearPlane = 1.0f; + constexpr float farPlane = 1000.0f; + constexpr float physicalAspect = 480.0f / 270.0f; + const float portraitTangent = std::tan(kFovDegrees * kPi / 360.0f); + const float physicalFov = 2.0f * std::atan(portraitTangent * kPortraitAspect) * 180.0f / kPi; + const float focusDistance = std::max(1.0f, length(subtract(camera.target, camera.eye))); + const float physicalHalfWidth = focusDistance * portraitTangent; + const float physicalHalfHeight = physicalHalfWidth * kPortraitAspect; + + ScePspFMatrix4 perspective{}; + ScePspFMatrix4 orthographic{}; + ScePspFMatrix4 projection{}; + gumLoadIdentity(&perspective); + gumLoadIdentity(&orthographic); + gumPerspective(&perspective, physicalFov, physicalAspect, nearPlane, farPlane); + gumOrtho( + &orthographic, + -physicalHalfWidth, + physicalHalfWidth, + -physicalHalfHeight, + physicalHalfHeight, + nearPlane, + farPlane); + float blend = std::clamp(camera.projectionBlend, 0.0f, 1.0f); + blend = blend * blend * blend; + const auto* perspectiveValues = reinterpret_cast(&perspective); + const auto* orthographicValues = reinterpret_cast(&orthographic); + auto* projectionValues = reinterpret_cast(&projection); + for (int i = 0; i < 16; ++i) { + projectionValues[i] = orthographicValues[i] + + (perspectiveValues[i] - orthographicValues[i]) * blend; + } + + const Vec3 forward = normalize(subtract(camera.target, camera.eye), {0.0f, 0.0f, -1.0f}); + const Vec3 portraitRight = normalize(cross(forward, camera.up), {1.0f, 0.0f, 0.0f}); + const Vec3 tateUp = side == TateSide::Clockwise + ? multiply(portraitRight, -1.0f) + : portraitRight; + ScePspFVector3 eye{camera.eye.x, camera.eye.y, camera.eye.z}; + ScePspFVector3 target{camera.target.x, camera.target.y, camera.target.z}; + ScePspFVector3 up{tateUp.x, tateUp.y, tateUp.z}; + + sceGumMatrixMode(GU_PROJECTION); + sceGumLoadMatrix(&projection); + sceGumMatrixMode(GU_VIEW); + sceGumLoadIdentity(); + sceGumLookAt(&eye, &target, &up); + sceGumMatrixMode(GU_MODEL); + sceGumLoadIdentity(); +} + +Matrix4 identityMatrix() { + Matrix4 output{}; + output.value[0] = 1.0f; + output.value[5] = 1.0f; + output.value[10] = 1.0f; + output.value[15] = 1.0f; + return output; +} + +Matrix4 multiplyMatrix(const Matrix4& a, const Matrix4& b) { + Matrix4 output{}; + for (int column = 0; column < 4; ++column) { + for (int row = 0; row < 4; ++row) { + float value = 0.0f; + for (int inner = 0; inner < 4; ++inner) { + value += a.value[inner * 4 + row] * b.value[column * 4 + inner]; + } + output.value[column * 4 + row] = value; + } + } + return output; +} + +Matrix4 translationMatrix(Vec3 position) { + Matrix4 output = identityMatrix(); + output.value[12] = position.x; + output.value[13] = position.y; + output.value[14] = position.z; + return output; +} + +Matrix4 scaleMatrix(Vec3 scale) { + Matrix4 output{}; + output.value[0] = scale.x; + output.value[5] = scale.y; + output.value[10] = scale.z; + output.value[15] = 1.0f; + return output; +} + +Matrix4 rotationMatrix(Vec3 degrees) { + // FUN_005e0330 passes the stage XYZ angles to its quaternion builder as + // (-Y, X, Z). This is the same WXYZ quaternion/matrix convention used by + // the recovered desktop renderer. + const float a = -degrees.y * kPi / 360.0f; + const float b = degrees.x * kPi / 360.0f; + const float c = degrees.z * kPi / 360.0f; + const float ca = std::cos(a), sa = std::sin(a); + const float cb = std::cos(b), sb = std::sin(b); + const float cc = std::cos(c), sc = std::sin(c); + const float w = sc * sa * sb + cc * ca * cb; + const float x = sc * ca * sb + cc * sa * cb; + const float y = cc * ca * sb - sc * sa * cb; + const float z = sc * ca * cb - cc * sa * sb; + Matrix4 output = identityMatrix(); + output.value[0] = 1.0f - 2.0f * (y * y + z * z); + output.value[1] = 2.0f * (x * y + w * z); + output.value[2] = 2.0f * (x * z - w * y); + output.value[4] = 2.0f * (x * y - w * z); + output.value[5] = 1.0f - 2.0f * (x * x + z * z); + output.value[6] = 2.0f * (y * z + w * x); + output.value[8] = 2.0f * (x * z + w * y); + output.value[9] = 2.0f * (y * z - w * x); + output.value[10] = 1.0f - 2.0f * (x * x + y * y); + return output; +} + +struct ObjectKeySample { + int index; + float blendToNext; + float sampledTimeMs; +}; + +template +ObjectKeySample sampleObjectKeys( + const Key* keys, + openroller::psp::PackageRange range, + float timeMs) { + if (!keys || range.count == 0) return {-1, 0.0f, timeMs}; + const auto activeAt = [&](float sampleTime) { + int active = -1; + for (std::uint32_t i = 0; i < range.count; ++i) { + if (static_cast(keys[range.first + i].timeMs) > sampleTime) break; + active = static_cast(i); + } + return active; + }; + + float sampleTime = timeMs; + int active = activeAt(sampleTime); + if (active < 0) return {-1, 0.0f, sampleTime}; + const auto flags = [&](int index) { return keys[range.first + index].flags; }; + if (active > 0 && + (flags(active) & openroller::psp::kObjectKeyLoop) != 0 && + (flags(active - 1) & openroller::psp::kObjectKeyLoop) != 0) { + int first = active - 1; + while (first > 0 && (flags(first - 1) & openroller::psp::kObjectKeyLoop) != 0) --first; + int last = active; + while (last + 1 < static_cast(range.count) && + (flags(last + 1) & openroller::psp::kObjectKeyLoop) != 0) ++last; + const float begin = static_cast(keys[range.first + first].timeMs); + const float duration = static_cast(keys[range.first + last].timeMs) - begin; + if (duration > 0.0f && sampleTime >= begin) { + sampleTime = begin + std::fmod(sampleTime - begin, duration); + active = activeAt(sampleTime); + } + } + + float blend = 0.0f; + if (active >= 0 && active + 1 < static_cast(range.count) && + (flags(active) & openroller::psp::kObjectKeyInterpolate) != 0) { + const float begin = static_cast(keys[range.first + active].timeMs); + const float end = static_cast(keys[range.first + active + 1].timeMs); + if (end > begin) blend = std::clamp((sampleTime - begin) / (end - begin), 0.0f, 1.0f); + } + return {active, blend, sampleTime}; +} + +Vec3 sampleTransform( + const StageView& stage, + openroller::psp::PackageRange range, + float timeMs, + bool* present) { + const ObjectKeySample sample = sampleObjectKeys(stage.transformKeys, range, timeMs); + if (sample.index < 0) { + if (present) *present = false; + return {}; + } + if (present) *present = true; + const auto& key = stage.transformKeys[range.first + sample.index]; + Vec3 value{key.value[0], key.value[1], key.value[2]}; + if (sample.blendToNext > 0.0f && sample.index + 1 < static_cast(range.count)) { + const auto& next = stage.transformKeys[range.first + sample.index + 1]; + value = add(value, multiply( + subtract({next.value[0], next.value[1], next.value[2]}, value), + sample.blendToNext)); + } + return value; +} + +Matrix4 objectTransform( + const StageView& stage, + const openroller::psp::PackageBackgroundObject& object, + float timeMs) { + Matrix4 model = translationMatrix({object.position[0], object.position[1], object.position[2]}); + bool present = false; + Vec3 value = sampleTransform(stage, object.movement, timeMs, &present); + if (present) model = multiplyMatrix(model, translationMatrix(value)); + const Vec3 baseRotation{object.rotation[0], object.rotation[1], object.rotation[2]}; + if (baseRotation.x != 0.0f || baseRotation.y != 0.0f || baseRotation.z != 0.0f) { + model = multiplyMatrix(model, rotationMatrix(baseRotation)); + } + value = sampleTransform(stage, object.rotations, timeMs, &present); + if (present) model = multiplyMatrix(model, rotationMatrix(value)); + const Vec3 baseScale{object.scale[0], object.scale[1], object.scale[2]}; + if (baseScale.x != 0.0f || baseScale.y != 0.0f || baseScale.z != 0.0f) { + model = multiplyMatrix(model, scaleMatrix(baseScale)); + } + value = sampleTransform(stage, object.scaling, timeMs, &present); + if (present) model = multiplyMatrix(model, scaleMatrix(value)); + return model; +} + +Color4 unpackColor(std::uint32_t color) { + return { + static_cast(color & 0xffu) / 255.0f, + static_cast((color >> 8) & 0xffu) / 255.0f, + static_cast((color >> 16) & 0xffu) / 255.0f, + static_cast((color >> 24) & 0xffu) / 255.0f, + }; +} + +Color4 objectColor( + const StageView& stage, + const openroller::psp::PackageBackgroundObject& object, + float timeMs) { + Color4 color{object.color[0], object.color[1], object.color[2], object.color[3]}; + const ObjectKeySample sample = sampleObjectKeys( + stage.objectColorKeys, object.colorChanges, timeMs); + if (sample.index < 0) return color; + color = unpackColor(stage.objectColorKeys[object.colorChanges.first + sample.index].rgba); + if (sample.blendToNext > 0.0f && sample.index + 1 < static_cast(object.colorChanges.count)) { + const Color4 next = unpackColor( + stage.objectColorKeys[object.colorChanges.first + sample.index + 1].rgba); + color.r += (next.r - color.r) * sample.blendToNext; + color.g += (next.g - color.g) * sample.blendToNext; + color.b += (next.b - color.b) * sample.blendToNext; + color.a += (next.a - color.a) * sample.blendToNext; + } + return color; +} + +float objectVisibility( + const StageView& stage, + const openroller::psp::PackageBackgroundObject& object, + float timeMs) { + constexpr float fadeMs = 250.0f; + if (object.visibility.count == 0) return 1.0f; + const ObjectKeySample sample = sampleObjectKeys(stage.visibilityKeys, object.visibility, timeMs); + if (sample.index < 0) return 1.0f; + const auto& key = stage.visibilityKeys[object.visibility.first + sample.index]; + bool visible = key.visible != 0; + float alpha = visible ? 1.0f : 0.0f; + if (sample.index + 1 < static_cast(object.visibility.count)) { + const auto& next = stage.visibilityKeys[object.visibility.first + sample.index + 1]; + const float untilNext = static_cast(next.timeMs) - sample.sampledTimeMs; + if ((next.visible != 0) != visible && + (next.flags & openroller::psp::kObjectKeyInterpolate) != 0 && + untilNext >= 0.0f && untilNext < fadeMs) { + const float remaining = std::clamp(untilNext / fadeMs, 0.0f, 1.0f); + alpha = next.visible != 0 ? 1.0f - remaining : remaining; + } + } + return alpha; +} + +std::uint32_t packColor(Color4 color) { + const auto channel = [](float value) { + return static_cast(std::clamp(value, 0.0f, 1.0f) * 255.0f + 0.5f); + }; + return channel(color.r) | (channel(color.g) << 8) | + (channel(color.b) << 16) | (channel(color.a) << 24); +} + +void drawBackgroundObject(const StageView& stage, const BackgroundDraw& draw) { + const auto& object = stage.backgroundObjects[draw.objectIndex]; + if (object.model >= stage.header->backgroundModels.count) return; + const auto& model = stage.backgroundModels[object.model]; + sceGumMatrixMode(GU_MODEL); + sceGumLoadMatrix(reinterpret_cast(&draw.transform)); + sceGuColor(packColor(draw.color)); + const auto drawRange = [&](int primitive, openroller::psp::PackageRange range) { + if (range.count == 0) return; + sceGumDrawArray( + primitive, + GU_VERTEX_32BITF | GU_TRANSFORM_3D, + static_cast(range.count), + nullptr, + stage.backgroundVertices + range.first); + }; + if ((object.flags & openroller::psp::kBackgroundObjectWireframe) != 0) { + drawRange(GU_LINES, model.wireframeLines); + } else { + drawRange(GU_TRIANGLES, model.triangles); + drawRange(GU_LINES, model.solidLines); + } +} + +void drawStageObjects(const StageView& stage, const CameraState& camera, float timeMs) { + const std::uint32_t count = std::min( + stage.header->backgroundObjects.count, kMaximumBackgroundObjects); + int translucentCount = 0; + for (std::uint32_t i = 0; i < count; ++i) { + const auto& object = stage.backgroundObjects[i]; + BackgroundDraw& draw = gBackgroundDraws[i]; + draw.objectIndex = i; + draw.visible = object.model < stage.header->backgroundModels.count; + if (!draw.visible) continue; + draw.transform = objectTransform(stage, object, timeMs); + draw.color = objectColor(stage, object, timeMs); + if (object.parentIndex >= 0 && + static_cast(object.parentIndex) < count && + object.parentIndex != static_cast(i)) { + const auto& parent = stage.backgroundObjects[object.parentIndex]; + draw.transform = multiplyMatrix(objectTransform(stage, parent, timeMs), draw.transform); + const Color4 parentColor = objectColor(stage, parent, timeMs); + draw.color.r *= parentColor.r; + draw.color.g *= parentColor.g; + draw.color.b *= parentColor.b; + draw.color.a *= parentColor.a; + } + draw.color.a *= objectVisibility(stage, object, timeMs); + draw.visible = draw.color.a > 0.0f; + const Vec3 delta{ + draw.transform.value[12] - camera.eye.x, + draw.transform.value[13] - camera.eye.y, + draw.transform.value[14] - camera.eye.z, + }; + draw.cameraDistance2 = dot(delta, delta); + if (draw.visible && draw.color.a >= 0.999f) drawBackgroundObject(stage, draw); + else if (draw.visible && translucentCount < kMaximumBackgroundObjects) { + int insert = translucentCount; + while (insert > 0 && + gBackgroundDraws[gTranslucentBackground[insert - 1]].cameraDistance2 < + draw.cameraDistance2) { + gTranslucentBackground[insert] = gTranslucentBackground[insert - 1]; + --insert; + } + gTranslucentBackground[insert] = static_cast(i); + ++translucentCount; + } + } + sceGuDepthMask(GU_TRUE); + for (int i = 0; i < translucentCount; ++i) { + drawBackgroundObject(stage, gBackgroundDraws[gTranslucentBackground[i]]); + } + sceGuDepthMask(GU_FALSE); + sceGumMatrixMode(GU_MODEL); + sceGumLoadIdentity(); + sceGuColor(0xffffffffu); +} + +std::uint32_t particleHash(std::uint32_t value) { + value ^= value >> 16; + value *= 0x7feb352du; + value ^= value >> 15; + value *= 0x846ca68bu; + return value ^ (value >> 16); +} + +float particleRandom(std::uint32_t seed) { + return static_cast(particleHash(seed) & 0xffffu) / 32767.5f - 1.0f; +} + +void drawParticles(const StageView& stage, const CameraState& camera, float timeMs) { + if (stage.header->particles.count == 0) return; + std::uint32_t active = 0; + for (std::uint32_t i = 1; i < stage.header->particles.count; ++i) { + if (static_cast(stage.particles[i].timeMs) > timeMs) break; + active = i; + } + const auto& key = stage.particles[active]; + if (key.enabled == 0 || key.repeatMeasure <= 0.0f || key.lifespanMeasure <= 0.0f) return; + const float beatMs = openroller::psp::evaluateBeatDurationMs(stage, timeMs); + const float periodMs = std::max(16.0f, key.repeatMeasure * beatMs); + const float lifeMs = std::max(periodMs, key.lifespanMeasure * beatMs); + const float sinceKey = timeMs - static_cast(key.timeMs); + if (sinceKey < 0.0f) return; + const int latest = static_cast(std::floor(sinceKey / periodMs)); + const int earliest = std::max(0, static_cast(std::ceil((sinceKey - lifeMs) / periodMs))); + const Vec3 forward = normalize(subtract(camera.target, camera.eye), {0.0f, 0.0f, -1.0f}); + const Vec3 right = normalize(cross(forward, camera.up), {1.0f, 0.0f, 0.0f}); + const Vec3 up = normalize(camera.up, {0.0f, 1.0f, 0.0f}); + WorldVertex* vertices = gParticleVertices; + int vertexCount = 0; + for (int event = earliest; event <= latest && vertexCount + 4 <= kMaximumParticleVertices; ++event) { + const float spawnMs = static_cast(key.timeMs) + event * periodMs; + const float ageMs = timeMs - spawnMs; + const float ageSeconds = ageMs / 1000.0f; + const int copies = key.shape == 3 ? 6 : 1; + for (int copy = 0; copy < copies && vertexCount + 4 <= kMaximumParticleVertices; ++copy) { + const std::uint32_t seed = active * 0x9e3779b9u + event * 31u + copy * 131u; + Vec3 center = openroller::psp::trackPositionAt(stage, spawnMs); + float offsetX = particleRandom(seed + 1u) * 4.0f; + float offsetY = particleRandom(seed + 2u) * 7.0f; + if (key.shape == 2 && key.groupShapeSize > 0) { + const float grid = std::max(0.25f, static_cast(key.groupShapeSize) / 40.0f); + offsetX = std::round(offsetX / grid) * grid; + offsetY = std::round(offsetY / grid) * grid; + } else if (key.shape == 3) { + const float angle = copy * (2.0f * kPi / 6.0f); + const float radius = std::max(1.0f, static_cast(key.groupShapeSize) / 30.0f); + offsetX = std::cos(angle) * radius; + offsetY = std::sin(angle) * radius; + } + center = add(center, add(multiply(right, offsetX), multiply(up, offsetY))); + center = add(center, multiply({key.velocity[0], key.velocity[1], key.velocity[2]}, ageSeconds)); + const float radius = 0.12f + static_cast(key.texture & 3u) * 0.035f; + const std::uint32_t color = withScaledAlpha(key.rgba, 1.0f - ageMs / lifeMs); + vertices[vertexCount++] = worldVertex(subtract(center, multiply(right, radius)), color); + vertices[vertexCount++] = worldVertex(add(center, multiply(right, radius)), color); + vertices[vertexCount++] = worldVertex(subtract(center, multiply(up, radius)), color); + vertices[vertexCount++] = worldVertex(add(center, multiply(up, radius)), color); + } + } + if (vertexCount > 0) { + sceKernelDcacheWritebackRange( + vertices, static_cast(vertexCount) * sizeof(WorldVertex)); + sceGuDepthMask(GU_TRUE); + sceGumDrawArray( + GU_LINES, + GU_COLOR_8888 | GU_VERTEX_32BITF | GU_TRANSFORM_3D, + vertexCount, + nullptr, + vertices); + sceGuDepthMask(GU_FALSE); + } +} + +Vec3 railRight(const StageView& stage, float timeMs) { + const Vec3 tangent = openroller::psp::trackTangentAt(stage, timeMs); + return normalize(cross(tangent, {0.0f, 1.0f, 0.0f}), {1.0f, 0.0f, 0.0f}); +} + +void appendRailSegment( + WorldVertex* output, + int* vertexCount, + const StageView& stage, + float fromTime, + float toTime, + float currentTime) { + if (*vertexCount >= kMaximumRailSegments * 6) return; + constexpr float halfWidth = 0.18f; + const Vec3 from = openroller::psp::trackPositionAt(stage, fromTime); + const Vec3 to = openroller::psp::trackPositionAt(stage, toTime); + const Vec3 fromRight = multiply(railRight(stage, fromTime), halfWidth); + const Vec3 toRight = multiply(railRight(stage, toTime), halfWidth); + const Vec3 fromLeft = subtract(from, fromRight); + const Vec3 fromRightPoint = add(from, fromRight); + const Vec3 toLeft = subtract(to, toRight); + const Vec3 toRightPoint = add(to, toRight); + + const std::uint32_t fromColor = fromTime < currentTime + ? stage.header->trackBehindRgba + : stage.header->trackAheadRgba; + const std::uint32_t toColor = toTime <= currentTime + ? stage.header->trackBehindRgba + : stage.header->trackAheadRgba; + output[(*vertexCount)++] = worldVertex(fromLeft, fromColor); + output[(*vertexCount)++] = worldVertex(fromRightPoint, fromColor); + output[(*vertexCount)++] = worldVertex(toLeft, toColor); + output[(*vertexCount)++] = worldVertex(fromRightPoint, fromColor); + output[(*vertexCount)++] = worldVertex(toRightPoint, toColor); + output[(*vertexCount)++] = worldVertex(toLeft, toColor); +} + +void drawRail(const StageView& stage, float currentTime) { + const float firstTime = std::max(0.0f, currentTime - stage.header->backwardsDrawDistance * 1000.0f); + const float lastTime = std::min( + static_cast(stage.header->durationMs), + currentTime + openroller::psp::evaluateDrawAhead(stage, currentTime) * 1000.0f); + if (lastTime <= firstTime) return; + + WorldVertex* vertices = gRailVertices; + int vertexCount = 0; + float previousTime = firstTime; + bool insertedCurrent = currentTime <= firstTime; + for (std::uint32_t i = 0; i < stage.header->track.count && vertexCount < kMaximumRailSegments * 6; ++i) { + const float keyTime = static_cast(stage.track[i].timeMs); + if (keyTime <= firstTime || keyTime >= lastTime) continue; + if (!insertedCurrent && currentTime > previousTime && currentTime < keyTime) { + appendRailSegment(vertices, &vertexCount, stage, previousTime, currentTime, currentTime); + previousTime = currentTime; + insertedCurrent = true; + } + appendRailSegment(vertices, &vertexCount, stage, previousTime, keyTime, currentTime); + previousTime = keyTime; + } + if (!insertedCurrent && currentTime > previousTime && currentTime < lastTime) { + appendRailSegment(vertices, &vertexCount, stage, previousTime, currentTime, currentTime); + previousTime = currentTime; + } + appendRailSegment(vertices, &vertexCount, stage, previousTime, lastTime, currentTime); + + if (vertexCount > 0) { + sceKernelDcacheWritebackRange( + vertices, static_cast(vertexCount) * sizeof(WorldVertex)); + sceGumDrawArray( + GU_TRIANGLES, + GU_COLOR_8888 | GU_VERTEX_32BITF | GU_TRANSFORM_3D, + vertexCount, + nullptr, + vertices); + } +} + +std::uint32_t noteColor(std::uint8_t type) { + static constexpr std::uint32_t palette[16] = { + 0xffd8d8d8, 0xff8033ff, 0xffffd933, 0xff26bfff, + 0xff40ff8c, 0xffff59bf, 0xff2673ff, 0xffbfff4c, + 0xffff8c4c, 0xffd94cff, 0xff33f2f2, 0xff8cf28c, + 0xff8c8cf2, 0xfff2f28c, 0xfff28c8c, 0xffffffff, + }; + return palette[type & 0x0f]; +} + +float noteAlpha(const openroller::psp::PackageNote& note, float currentTime) { + const float fadeMs = std::max(1.0f, note.beatDurationMs * 0.5f); + if (currentTime < note.appearTimeMs || currentTime > note.markerFadeEndTimeMs) return 0.0f; + if (currentTime < note.appearTimeMs + fadeMs) { + return std::clamp((currentTime - note.appearTimeMs) / fadeMs, 0.0f, 1.0f); + } + if (currentTime > note.markerFadeEndTimeMs - fadeMs) { + return std::clamp( + (note.markerFadeEndTimeMs - currentTime) / fadeMs, + 0.0f, + 1.0f); + } + return 1.0f; +} + +struct BillboardBasis { + Vec3 right; + Vec3 up; +}; + +BillboardBasis markerBillboardBasis(const CameraState& camera) { + const Vec3 forward = + normalize(subtract(camera.target, camera.eye), {0.0f, 0.0f, 1.0f}); + const Vec3 cameraUp = normalize(camera.up, {0.0f, 1.0f, 0.0f}); + const Vec3 right = normalize(cross(forward, cameraUp), {1.0f, 0.0f, 0.0f}); + return {right, normalize(cross(right, forward), cameraUp)}; +} + +std::uint32_t authoredNoteColor(const openroller::psp::PackageNote& note) { + // Stage notes store their colour as authored RRGGBBAA; GU_COLOR_8888 uses + // the byte order produced by rgba(). + const std::uint32_t packed = note.packedColor; + return rgba( + static_cast(packed >> 24), + static_cast(packed >> 16), + static_cast(packed >> 8), + static_cast(packed)); +} + +void appendBillboardRing( + WorldVertex* vertices, + int* vertexCount, + int capacity, + Vec3 center, + const BillboardBasis& billboard, + float radius, + std::uint32_t color, + int segments = 16) { + if (*vertexCount + segments * 2 > capacity) return; + for (int segment = 0; segment < segments; ++segment) { + const float a = 2.0f * kPi * static_cast(segment) / + static_cast(segments); + const float b = 2.0f * kPi * static_cast(segment + 1) / + static_cast(segments); + vertices[(*vertexCount)++] = worldVertex( + add(center, add( + multiply(billboard.right, std::cos(a) * radius), + multiply(billboard.up, std::sin(a) * radius))), + color); + vertices[(*vertexCount)++] = worldVertex( + add(center, add( + multiply(billboard.right, std::cos(b) * radius), + multiply(billboard.up, std::sin(b) * radius))), + color); + } +} + +void appendBillboardDisc( + WorldVertex* vertices, + int* vertexCount, + int capacity, + Vec3 center, + const BillboardBasis& billboard, + float radius, + std::uint32_t color, + int segments = 16) { + if (*vertexCount + segments * 3 > capacity) return; + for (int segment = 0; segment < segments; ++segment) { + const float a = 2.0f * kPi * static_cast(segment) / + static_cast(segments); + const float b = 2.0f * kPi * static_cast(segment + 1) / + static_cast(segments); + vertices[(*vertexCount)++] = worldVertex(center, color); + vertices[(*vertexCount)++] = worldVertex( + add(center, add( + multiply(billboard.right, std::cos(a) * radius), + multiply(billboard.up, std::sin(a) * radius))), + color); + vertices[(*vertexCount)++] = worldVertex( + add(center, add( + multiply(billboard.right, std::cos(b) * radius), + multiply(billboard.up, std::sin(b) * radius))), + color); + } +} + +template +void forEachPathMarkerTime( + const StageView& stage, + float startTime, + float endTime, + float spacing, + Callback callback) { + if (endTime < startTime || spacing <= 0.0f) return; + callback(startTime); + Vec3 segmentStart = openroller::psp::trackPositionAt(stage, startTime); + float segmentStartTime = startTime; + float distanceToMarker = spacing; + int emitted = 1; + + for (std::uint32_t key = 0; + key <= stage.header->track.count && segmentStartTime < endTime && emitted < 256; + ++key) { + float segmentEndTime = endTime; + if (key < stage.header->track.count) { + const float keyTime = static_cast(stage.track[key].timeMs); + if (keyTime <= segmentStartTime) continue; + segmentEndTime = std::min(keyTime, endTime); + } + Vec3 segmentEnd = openroller::psp::trackPositionAt(stage, segmentEndTime); + float segmentLength = length(subtract(segmentEnd, segmentStart)); + while (segmentLength + 1.0e-5f >= distanceToMarker && emitted < 256) { + const float amount = segmentLength > 1.0e-6f + ? distanceToMarker / segmentLength + : 1.0f; + const float markerTime = segmentStartTime + + (segmentEndTime - segmentStartTime) * amount; + segmentStart = add( + segmentStart, + multiply(subtract(segmentEnd, segmentStart), amount)); + segmentStartTime = markerTime; + segmentLength = length(subtract(segmentEnd, segmentStart)); + callback(markerTime); + ++emitted; + distanceToMarker = spacing; + } + distanceToMarker -= segmentLength; + segmentStart = segmentEnd; + segmentStartTime = segmentEndTime; + } +} + +void drawNotes( + const StageView& stage, + const GameplayState* gameplay, + const CameraState& camera, + float currentTime) { + const float firstTime = currentTime - stage.header->backwardsDrawDistance * 1000.0f; + const float lastTime = currentTime + openroller::psp::evaluateDrawAhead(stage, currentTime) * 1000.0f; + WorldVertex* vertices = gNoteVertices; + int vertexCount = 0; + int fillVertexCount = 0; + int headLineVertexCount = 0; + int approachVertexCount = 0; + const BillboardBasis billboard = markerBillboardBasis(camera); + + for (std::uint32_t i = 0; i < stage.header->notes.count; ++i) { + if (gameplay && openroller::psp::noteJudgment(*gameplay, i) != Judgment::Pending) continue; + const auto& note = stage.notes[i]; + const float noteTime = static_cast(note.timeMs); + if (note.endTimeMs < firstTime || noteTime > lastTime) continue; + const float alpha = noteAlpha(note, currentTime); + if (alpha <= 0.0f) continue; + const std::uint32_t color = withScaledAlpha(authoredNoteColor(note), alpha); + const bool duration = + note.effectiveType == 3 || note.effectiveType == 4 || + note.effectiveType == 10 || note.effectiveType == 15; + if (duration && note.endTimeMs > noteTime) { + const float stepMs = note.effectiveType == 4 + ? std::max(8.0f, note.beatDurationMs * 0.025f) + : std::max(12.0f, note.beatDurationMs * 0.04f); + float previous = noteTime; + for (float time = noteTime + stepMs; + time <= note.endTimeMs && vertexCount + 4 <= kMaximumNoteLineVertices; + time += stepMs) { + const float next = std::min(time, note.endTimeMs); + const float halfWidth = note.effectiveType == 15 ? 0.38f : 0.22f; + const Vec3 previousRight = multiply(railRight(stage, previous), halfWidth); + const Vec3 nextRight = multiply(railRight(stage, next), halfWidth); + const Vec3 previousCenter = openroller::psp::trackPositionAt(stage, previous); + const Vec3 nextCenter = openroller::psp::trackPositionAt(stage, next); + vertices[vertexCount++] = + worldVertex(subtract(previousCenter, previousRight), color); + vertices[vertexCount++] = + worldVertex(subtract(nextCenter, nextRight), color); + vertices[vertexCount++] = + worldVertex(add(previousCenter, previousRight), color); + vertices[vertexCount++] = + worldVertex(add(nextCenter, nextRight), color); + previous = next; + } + } + + const auto appendHead = [&](float markerTime, float radius) { + if (markerTime < firstTime || markerTime > lastTime) return; + const Vec3 center = openroller::psp::trackPositionAt(stage, markerTime); + appendBillboardDisc( + gNoteHeadFillVertices, &fillVertexCount, kMaximumNoteHeadVertices, + center, billboard, radius * 0.92f, + withScaledAlpha(rgba(2, 3, 8), alpha * 0.96f), 20); + appendBillboardRing( + gNoteHeadLineVertices, &headLineVertexCount, kMaximumNoteHeadVertices, + center, billboard, radius, color, 20); + appendBillboardRing( + gNoteHeadLineVertices, &headLineVertexCount, kMaximumNoteHeadVertices, + center, billboard, radius * 0.70f, + withScaledAlpha(rgba(255, 255, 255), alpha * 0.88f), 16); + }; + + const float headRadius = + (note.effectiveType == 9 || note.effectiveType == 15) ? 0.80f : 0.40f; + + if (note.effectiveType == 6 && note.merryCount > 0 && + note.endTimeMs > noteTime) { + for (std::uint32_t marker = 0; marker < note.merryCount; ++marker) { + const float amount = static_cast(marker) / + static_cast(note.merryCount); + appendHead( + noteTime + (note.endTimeMs - noteTime) * amount, + 0.40f); + } + continue; + } + + if (note.effectiveType == 5 && note.endTimeMs > noteTime) { + forEachPathMarkerTime( + stage, noteTime, note.endTimeMs, 0.55f, + [&](float markerTime) { + if (markerTime >= currentTime && markerTime <= lastTime) { + appendHead(markerTime, 0.32f); + } + }); + continue; + } + + appendHead(noteTime, headRadius); + if (duration && note.endTimeMs > noteTime) { + appendHead(note.endTimeMs, headRadius); + } + + if (currentTime < noteTime) { + const float earlyBoundary = noteTime - note.earlyTimingMs; + const float lateBoundary = noteTime + note.lateTimingMs; + const float timingSpan = + 2.0f * std::max(1.0f, lateBoundary - earlyBoundary); + const float progress = std::min( + 1.0f, (lateBoundary - currentTime) / timingSpan); + if (progress > 0.0f) { + appendBillboardRing( + gNoteApproachVertices, &approachVertexCount, + kMaximumNoteHeadVertices, + openroller::psp::trackPositionAt(stage, noteTime), + billboard, 0.20f + progress * 0.50f, + withScaledAlpha(rgba(255, 255, 255), alpha * 0.5f), 16); + } + } + } + if (vertexCount > 0) { + sceKernelDcacheWritebackRange( + vertices, static_cast(vertexCount) * sizeof(WorldVertex)); + sceGumDrawArray( + GU_LINES, + GU_COLOR_8888 | GU_VERTEX_32BITF | GU_TRANSFORM_3D, + vertexCount, + nullptr, + vertices); + } + if (approachVertexCount > 0) { + sceKernelDcacheWritebackRange( + gNoteApproachVertices, + static_cast(approachVertexCount) * sizeof(WorldVertex)); + sceGuBlendFunc(GU_ADD, GU_SRC_ALPHA, GU_FIX, 0, 0xffffffffu); + sceGumDrawArray( + GU_LINES, + GU_COLOR_8888 | GU_VERTEX_32BITF | GU_TRANSFORM_3D, + approachVertexCount, + nullptr, + gNoteApproachVertices); + sceGuBlendFunc(GU_ADD, GU_SRC_ALPHA, GU_ONE_MINUS_SRC_ALPHA, 0, 0); + } + if (fillVertexCount > 0) { + sceKernelDcacheWritebackRange( + gNoteHeadFillVertices, + static_cast(fillVertexCount) * sizeof(WorldVertex)); + sceGumDrawArray( + GU_TRIANGLES, + GU_COLOR_8888 | GU_VERTEX_32BITF | GU_TRANSFORM_3D, + fillVertexCount, + nullptr, + gNoteHeadFillVertices); + } + if (headLineVertexCount > 0) { + sceKernelDcacheWritebackRange( + gNoteHeadLineVertices, + static_cast(headLineVertexCount) * sizeof(WorldVertex)); + sceGumDrawArray( + GU_LINES, + GU_COLOR_8888 | GU_VERTEX_32BITF | GU_TRANSFORM_3D, + headLineVertexCount, + nullptr, + gNoteHeadLineVertices); + } +} + +Point projectToLogical(const CameraState& camera, Vec3 point) { + const Vec3 forward = + normalize(subtract(camera.target, camera.eye), {0.0f, 0.0f, 1.0f}); + Vec3 up = normalize(camera.up, {0.0f, 1.0f, 0.0f}); + const Vec3 right = normalize(cross(forward, up), {1.0f, 0.0f, 0.0f}); + up = normalize(cross(right, forward), up); + const Vec3 relative = subtract(point, camera.eye); + const float viewX = dot(relative, right); + const float viewY = dot(relative, up); + const float viewZ = dot(relative, forward); + const float tangent = std::tan(kFovDegrees * kPi / 360.0f); + const float focus = std::max(1.0f, length(subtract(camera.target, camera.eye))); + const float halfHeight = focus * tangent; + const float halfWidth = halfHeight * kPortraitAspect; + float blend = std::clamp(camera.projectionBlend, 0.0f, 1.0f); + blend = blend * blend * blend; + const float clipX = viewX * ( + (1.0f - blend) / halfWidth + + blend / (tangent * kPortraitAspect)); + const float clipY = viewY * ( + (1.0f - blend) / halfHeight + + blend / tangent); + const float divisor = + std::max(1.0e-5f, std::fabs((1.0f - blend) + blend * viewZ)); + return { + (clipX / divisor + 1.0f) * 360.0f, + (1.0f - clipY / divisor) * 640.0f, + }; +} + +float noteDirectionDegrees(const StageView& stage, const openroller::psp::PackageNote& note) { + const Vec3 direction{ + note.directionVector[0], + note.directionVector[1], + note.directionVector[2], + }; + if (dot(direction, direction) < 1.0e-10f) return 0.0f; + const CameraState camera = + openroller::psp::evaluateCamera(stage, static_cast(note.timeMs)); + const Vec3 origin = + openroller::psp::trackPositionAt(stage, static_cast(note.timeMs)); + const Point from = projectToLogical(camera, origin); + const Point to = projectToLogical(camera, add(origin, direction)); + return std::atan2(to.x - from.x, to.y - from.y) * 180.0f / kPi; +} + +void drawDirectionalNotes( + const StageView& stage, + const GameplayState* gameplay, + const CameraState& camera, + TateSide side, + float currentTime) { + const float lastTime = + currentTime + openroller::psp::evaluateDrawAhead(stage, currentTime) * 1000.0f; + for (std::uint32_t i = 0; i < stage.header->notes.count; ++i) { + const auto& note = stage.notes[i]; + if (note.effectiveType != 2 && note.effectiveType != 10) continue; + if (gameplay && + openroller::psp::noteJudgment(*gameplay, i) != Judgment::Pending) continue; + if (currentTime < note.appearTimeMs || + static_cast(note.timeMs) > lastTime) continue; + const float alpha = noteAlpha(note, currentTime); + if (alpha <= 0.0f) continue; + const Point center = projectToLogical( + camera, + openroller::psp::trackPositionAt( + stage, static_cast(note.timeMs))); + const float angle = noteDirectionDegrees(stage, note) * kPi / 180.0f; + const float dx = std::sin(angle); + const float dy = std::cos(angle); + const float px = -dy; + const float py = dx; + const std::uint32_t color = + withScaledAlpha(rgba(255, 255, 255), alpha); + logicalLine( + center.x - dx * 9.0f, center.y - dy * 9.0f, + center.x + dx * 25.0f, center.y + dy * 25.0f, + color, side); + logicalLine( + center.x + dx * 25.0f, center.y + dy * 25.0f, + center.x + dx * 10.0f + px * 10.0f, + center.y + dy * 10.0f + py * 10.0f, + color, side); + logicalLine( + center.x + dx * 25.0f, center.y + dy * 25.0f, + center.x + dx * 10.0f - px * 10.0f, + center.y + dy * 10.0f - py * 10.0f, + color, side); + } +} + +const char* helperName(std::uint8_t type) { + static constexpr const char* names[16] = { + "", "HIT", "FLICK", "HOLD", "SCRATCH", "BEAT", "HIT", "", + "", "CRITICAL", "SLIDE", "", "", "", "", "DUAL", + }; + return type < 16 ? names[type] : ""; +} + +void drawNoteHelper( + const StageView& stage, + const GameplayState* gameplay, + TateSide side, + float currentTime) { + const openroller::psp::PackageNote* helper = nullptr; + for (std::uint32_t i = 0; i < stage.header->notes.count; ++i) { + const auto& note = stage.notes[i]; + if (note.timeMs + note.lateTimingMs < currentTime || + currentTime < note.appearTimeMs || + helperName(note.effectiveType)[0] == '\0') continue; + if (gameplay && + openroller::psp::noteJudgment(*gameplay, i) != Judgment::Pending) continue; + helper = ¬e; + break; + } + if (!helper) return; + const std::uint32_t color = noteColor(helper->effectiveType); + logicalCircle(580.0f, 788.0f, 30.0f, 20, color, side); + logicalCircle(580.0f, 788.0f, 20.0f, 16, rgba(255, 255, 255), side); + menuText(520.0f, 834.0f, 3.0f, helperName(helper->effectiveType), + rgba(255, 255, 255), side); + if (helper->effectiveType == 2 || helper->effectiveType == 10) { + const float angle = noteDirectionDegrees(stage, *helper) * kPi / 180.0f; + const float dx = std::sin(angle); + const float dy = std::cos(angle); + logicalLine( + 580.0f - dx * 12.0f, 788.0f - dy * 12.0f, + 580.0f + dx * 24.0f, 788.0f + dy * 24.0f, + rgba(255, 255, 255), side); + } +} + +void drawJudgmentBurst( + const StageView& stage, + const GameplayState* gameplay, + float currentTime) { + if (!gameplay || gameplay->lastJudgedNote < 0 || + static_cast(gameplay->lastJudgedNote) >= stage.header->notes.count) return; + const float age = currentTime - gameplay->lastJudgmentClockMs; + if (age < 0.0f || age > 260.0f) return; + + const auto& note = stage.notes[static_cast(gameplay->lastJudgedNote)]; + const Vec3 center = openroller::psp::trackPositionAt(stage, static_cast(note.timeMs)); + const Vec3 tangent = openroller::psp::trackTangentAt(stage, static_cast(note.timeMs)); + const Vec3 right = railRight(stage, static_cast(note.timeMs)); + const Vec3 up = normalize(cross(right, tangent), {0.0f, 1.0f, 0.0f}); + const float radius = 0.45f + age * (1.1f / 260.0f); + const std::uint32_t color = gameplay->lastJudgment == Judgment::Great + ? rgba(255, 232, 48) + : gameplay->lastJudgment == Judgment::Cool + ? rgba(55, 235, 255) + : gameplay->lastJudgment == Judgment::Good + ? rgba(255, 105, 215) + : rgba(255, 45, 75); + constexpr int segments = 16; + auto* vertices = static_cast(sceGuGetMemory(segments * 2 * sizeof(WorldVertex))); + for (int segment = 0; segment < segments; ++segment) { + const float a = 2.0f * kPi * static_cast(segment) / static_cast(segments); + const float b = 2.0f * kPi * static_cast(segment + 1) / static_cast(segments); + const Vec3 pointA = add(center, add(multiply(right, std::cos(a) * radius), multiply(up, std::sin(a) * radius))); + const Vec3 pointB = add(center, add(multiply(right, std::cos(b) * radius), multiply(up, std::sin(b) * radius))); + vertices[segment * 2] = worldVertex(pointA, color); + vertices[segment * 2 + 1] = worldVertex(pointB, color); + } + sceGumDrawArray( + GU_LINES, + GU_COLOR_8888 | GU_VERTEX_32BITF | GU_TRANSFORM_3D, + segments * 2, + nullptr, + vertices); +} + +void drawAvatar(const StageView& stage, float currentTime) { + const Vec3 center = openroller::psp::trackPositionAt(stage, currentTime); + const Vec3 right = multiply(railRight(stage, currentTime), 0.3f); + const Vec3 tangent = openroller::psp::trackTangentAt(stage, currentTime); + const Vec3 up = multiply(normalize(cross(right, tangent), {0.0f, 1.0f, 0.0f}), 0.3f); + auto* vertices = static_cast(sceGuGetMemory(4 * sizeof(WorldVertex))); + vertices[0] = worldVertex(subtract(center, right), rgba(255, 255, 255)); + vertices[1] = worldVertex(add(center, right), rgba(255, 255, 255)); + vertices[2] = worldVertex(subtract(center, up), rgba(255, 255, 255)); + vertices[3] = worldVertex(add(center, up), rgba(255, 255, 255)); + sceGumDrawArray( + GU_LINES, + GU_COLOR_8888 | GU_VERTEX_32BITF | GU_TRANSFORM_3D, + 4, + nullptr, + vertices); +} + +void drawStage( + const StageView& stage, + const GameplayState* gameplay, + TateSide side, + float currentTime, + bool paused) { + sceGuDisable(GU_DEPTH_TEST); + drawBackground(openroller::psp::evaluateBackground(stage, currentTime), side); + drawVisualizer(stage, currentTime, side); + const CameraState camera = openroller::psp::evaluateCamera(stage, currentTime); + setupStageMatrices(camera, side); + sceGuEnable(GU_DEPTH_TEST); + drawStageObjects(stage, camera, currentTime); + drawParticles(stage, camera, currentTime); + drawRail(stage, currentTime); + // Arcade submits mark/effect layers without rail depth writes: notes and + // their arrows remain readable even when the authored route crosses them. + sceGuDisable(GU_DEPTH_TEST); + drawNotes(stage, gameplay, camera, currentTime); + drawAvatar(stage, currentTime); + drawJudgmentBurst(stage, gameplay, currentTime); + drawDirectionalNotes(stage, gameplay, camera, side, currentTime); + drawNoteHelper(stage, gameplay, side, currentTime); + + const float duration = std::max(1.0f, static_cast(stage.header->durationMs)); + const float progress = std::clamp(currentTime / duration, 0.0f, 1.0f); + logicalLine(0.0f, 1268.0f, 720.0f, 1268.0f, rgba(75, 75, 83), side); + logicalLine(0.0f, 1268.0f, 720.0f * progress, 1268.0f, rgba(255, 35, 116), side); + if (gameplay) { + const float feedbackAge = currentTime - gameplay->lastJudgmentClockMs; + if (feedbackAge >= 0.0f && feedbackAge < 300.0f) { + const std::uint32_t feedbackColor = gameplay->lastJudgment == Judgment::Great + ? rgba(255, 232, 48) + : gameplay->lastJudgment == Judgment::Cool + ? rgba(55, 235, 255) + : gameplay->lastJudgment == Judgment::Good + ? rgba(255, 105, 215) + : rgba(255, 45, 75); + logicalLine(285.0f, 42.0f, 435.0f, 42.0f, feedbackColor, side); + logicalLine(310.0f, 54.0f, 410.0f, 54.0f, feedbackColor, side); + } + } + if (paused) { + logicalLine(335.0f, 28.0f, 335.0f, 68.0f, rgba(255, 255, 255), side); + logicalLine(385.0f, 28.0f, 385.0f, 68.0f, rgba(255, 255, 255), side); + } +} + +void drawFallback(TateSide side, float phase) { + const std::uint32_t purple = rgba(126, 38, 242); + const std::uint32_t cyan = rgba(53, 245, 255); + const std::uint32_t white = rgba(235, 242, 255); + const std::uint32_t pink = rgba(255, 47, 158); + logicalRect(0.0f, 0.0f, 720.0f, 1280.0f, rgba(75, 84, 106), side); + logicalRect(0.0f, 0.0f, 720.0f, 92.0f, purple, side); + logicalLine(0.0f, 96.0f, 720.0f, 96.0f, cyan, side); + float previousX = 360.0f; + float previousY = 170.0f; + for (int i = 1; i <= 96; ++i) { + const float t = static_cast(i) / 96.0f; + const float angle = t * 5.5f * kPi + phase; + const float radius = 35.0f + t * 260.0f; + const float x = 360.0f + std::cos(angle) * radius; + const float y = 170.0f + t * 900.0f + std::sin(angle) * radius * 0.25f; + logicalLine(previousX, previousY, x, y, white, side); + previousX = x; + previousY = y; + } + logicalCircle(360.0f, 640.0f, 24.0f, 16, pink, side); +} + +std::uint32_t menuGenreColor(std::uint8_t genre) { + switch (genre) { + case 1: return rgba(242, 107, 128); + case 2: return rgba(38, 178, 219); + case 3: return rgba(240, 184, 20); + case 4: return rgba(110, 184, 13); + case 5: return rgba(33, 125, 184); + case 6: return rgba(163, 82, 158); + case 7: return rgba(64, 148, 92); + default: return rgba(245, 87, 41); + } +} + +void drawSongMenu(const SongMenu& menu, TateSide side, float timeMs) { + const auto* song = openroller::psp::selectedSong(menu); + if (!song || !menu.header) return; + BackgroundColors gradient{}; + gradient.topLeft = gradient.topRight = rgba(48, 48, 155); + gradient.bottomLeft = gradient.bottomRight = rgba(229, 115, 134); + drawBackground(gradient, side); + + const std::uint32_t white = rgba(255, 255, 255); + const std::uint32_t purple = rgba(67, 32, 143, 235); + const std::uint32_t pink = rgba(255, 72, 178); + const std::uint32_t yellow = rgba(255, 241, 61); + const std::uint32_t dark = rgba(34, 24, 74); + logicalFillRect(0, 0, 720, 95, rgba(28, 13, 91, 240), side); + logicalFillRect(0, 98, 720, 150, purple, side); + menuText(18, 24, 5, "TUNE 1/3", white, side); + menuText(392, 24, 5, menu.difficultyMode ? "SELECT MODE" : "SELECT MUSIC", white, side); + menuText(18, 112, 3, "NEW", yellow, side); + menuText(105, 112, 3, "GENRE", white, side); + menuText(245, 112, 3, "DIFFICULTY", white, side); + menuText(485, 112, 3, "TITLE", white, side); + + const int rowOffsets[10] = {-5, -4, -3, -2, -1, 1, 2, 3, 4, 5}; + const float rowY[10] = {180, 236, 292, 348, 404, 820, 876, 932, 988, 1044}; + const int count = static_cast(menu.header->songCount); + for (int slot = 0; slot < 10; ++slot) { + int index = (menu.selection + rowOffsets[slot]) % count; + if (index < 0) index += count; + const auto& row = menu.songs[index]; + const float inset = static_cast(std::abs(rowOffsets[slot]) - 1) * 11.0f; + const float alpha = 1.0f - static_cast(std::abs(rowOffsets[slot]) - 1) * 0.12f; + const std::uint32_t rowColor = withScaledAlpha(menuGenreColor(row.genre), alpha * 0.78f); + logicalFillRect(18 + inset, rowY[slot], 535 - inset, rowY[slot] + 42, rowColor, side); + logicalRect(18 + inset, rowY[slot], 535 - inset, rowY[slot] + 42, + withScaledAlpha(white, alpha), side); + menuText(30 + inset, rowY[slot] + 10, 3, row.title, white, side, 520 - inset); + } + + const float pulse = 0.72f + 0.28f * (std::sin(timeMs * 0.006f) * 0.5f + 0.5f); + logicalFillRect(8, 466, 712, 800, rgba(249, 240, 246, 238), side); + logicalRect(8, 466, 712, 800, withScaledAlpha(yellow, pulse), side); + drawMenuJacket(menu, side); + logicalRect(34, 536, 258, 760, menuGenreColor(song->genre), side); + menuText(280, 490, 4, song->title, pink, side); + menuText(280, 528, 2, song->artist, dark, side); + + static constexpr const char* difficultyNames[4] = {"SIMPLE", "NORMAL", "HARD", "EXTRA"}; + static constexpr std::uint32_t difficultyColors[4] = { + 0xffd6f1dd, 0xff9ff5f5, 0xffb8b8f7, 0xffdadada, + }; + for (int difficulty = 0; difficulty < 4; ++difficulty) { + const float y = 572.0f + difficulty * 50.0f; + const bool available = (song->availableMask & (1u << difficulty)) != 0; + logicalFillRect(280, y, 680, y + 40, + available ? difficultyColors[difficulty] : rgba(120, 120, 128, 100), side); + if (menu.difficultyMode && difficulty == menu.difficulty) { + logicalRect(276, y - 4, 684, y + 44, yellow, side); + logicalRect(278, y - 2, 682, y + 42, pink, side); + } + menuText(292, y + 9, 3, difficultyNames[difficulty], available ? dark : rgba(90, 90, 90), side); + char rating[8]{}; + if (available) std::snprintf(rating, sizeof(rating), "%u", song->ratings[difficulty]); + else std::snprintf(rating, sizeof(rating), "-"); + menuText(618, y + 9, 3, rating, available ? dark : rgba(90, 90, 90), side); + } + char metadata[64]{}; + std::snprintf(metadata, sizeof(metadata), "BPM %s TIME %s", song->bpm, song->duration); + menuText(280, 776, 2, metadata, dark, side); + + drawBuildMarquee(timeMs, side); + logicalFillRect(0, 1120, 720, 1280, rgba(30, 18, 83, 225), side); + if (menu.difficultyMode) { + menuText(35, 1160, 3, "D-PAD CHANGE MODE", white, side); + menuText(35, 1205, 3, "O PLAY X BACK", yellow, side); + } else { + menuText(35, 1160, 3, "UP/DOWN SELECT MUSIC", white, side); + menuText(35, 1205, 3, "O SELECT MODE", yellow, side); + } +} + +void siblingPath(int argc, char** argv, const char* name, char* output, std::size_t capacity) { + if (argc <= 0 || !argv || !argv[0]) { + std::snprintf(output, capacity, "%s", name); + return; + } + std::snprintf(output, capacity, "%s", argv[0]); + char* slash = std::strrchr(output, '/'); + if (!slash) { + std::snprintf(output, capacity, "%s", name); + return; + } + slash[1] = '\0'; + const std::size_t used = std::strlen(output); + std::snprintf(output + used, capacity - used, "%s", name); +} + +std::uint32_t logicalDpad(std::uint32_t physical, TateSide side) { + std::uint32_t logical = 0; + if (side == TateSide::Clockwise) { + if ((physical & PSP_CTRL_LEFT) != 0) logical |= PSP_CTRL_DOWN; + if ((physical & PSP_CTRL_UP) != 0) logical |= PSP_CTRL_LEFT; + if ((physical & PSP_CTRL_RIGHT) != 0) logical |= PSP_CTRL_UP; + if ((physical & PSP_CTRL_DOWN) != 0) logical |= PSP_CTRL_RIGHT; + } else { + if ((physical & PSP_CTRL_LEFT) != 0) logical |= PSP_CTRL_UP; + if ((physical & PSP_CTRL_UP) != 0) logical |= PSP_CTRL_RIGHT; + if ((physical & PSP_CTRL_RIGHT) != 0) logical |= PSP_CTRL_DOWN; + if ((physical & PSP_CTRL_DOWN) != 0) logical |= PSP_CTRL_LEFT; + } + return logical; +} + +void drawPauseMenu(TateSide side, int selection) { + const std::uint32_t white = rgba(255, 255, 255); + const std::uint32_t pink = rgba(255, 47, 158); + const std::uint32_t yellow = rgba(255, 231, 38); + logicalFillRect(72.0f, 360.0f, 648.0f, 920.0f, rgba(8, 5, 25, 232), side); + logicalRect(72.0f, 360.0f, 648.0f, 920.0f, pink, side); + menuText(250.0f, 405.0f, 6.0f, "PAUSE", white, side); + static constexpr const char* labels[3] = { + "CONTINUE", "RESTART", "BACK TO MENU", + }; + for (int i = 0; i < 3; ++i) { + const float top = 535.0f + static_cast(i) * 105.0f; + if (i == selection) { + logicalFillRect(130.0f, top - 18.0f, 590.0f, top + 54.0f, + rgba(91, 39, 160, 220), side); + logicalRect(130.0f, top - 18.0f, 590.0f, top + 54.0f, + yellow, side); + } + menuText(175.0f, top, 4.0f, labels[i], + i == selection ? yellow : white, side); + } + menuText(170.0f, 855.0f, 3.0f, "O CONFIRM X CANCEL", + white, side); +} + +int exitCallback(int, int, void*) { + gRunning = false; + return 0; +} + +int callbackThread(SceSize, void*) { + const int callback = sceKernelCreateCallback("OpenRoller exit", exitCallback, nullptr); + sceKernelRegisterExitCallback(callback); + sceKernelSleepThreadCB(); + return 0; +} + +void setupCallbacks() { + const int thread = sceKernelCreateThread( + "OpenRoller callbacks", callbackThread, 0x11, 0xFA0, PSP_THREAD_ATTR_USER, nullptr); + if (thread >= 0) sceKernelStartThread(thread, 0, nullptr); +} + +void setupGraphics() { + sceGuInit(); + sceGuStart(GU_DIRECT, gDisplayList); + sceGuDrawBuffer(GU_PSM_5650, nullptr, kBufferWidth); + sceGuDispBuffer(openroller::psp::kScreenWidth, openroller::psp::kScreenHeight, + reinterpret_cast(0x44000), kBufferWidth); + sceGuDepthBuffer(reinterpret_cast(0x88000), kBufferWidth); + sceGuOffset(2048 - openroller::psp::kScreenWidth / 2, + 2048 - openroller::psp::kScaledWidth / 2 + openroller::psp::kBorder); + sceGuViewport(2048, 2048, openroller::psp::kScreenWidth, openroller::psp::kScaledWidth); + sceGuDepthRange(0xc350, 0x2710); + sceGuDepthFunc(GU_GEQUAL); + sceGuScissor(0, 0, openroller::psp::kScreenWidth, openroller::psp::kScreenHeight); + sceGuEnable(GU_SCISSOR_TEST); + sceGuEnable(GU_CLIP_PLANES); + sceGuDisable(GU_DEPTH_TEST); + sceGuDisable(GU_TEXTURE_2D); + sceGuDisable(GU_CULL_FACE); + sceGuEnable(GU_BLEND); + sceGuBlendFunc(GU_ADD, GU_SRC_ALPHA, GU_ONE_MINUS_SRC_ALPHA, 0, 0); + sceGuShadeModel(GU_SMOOTH); + sceGuFinish(); + sceGuSync(GU_SYNC_FINISH, GU_SYNC_WHAT_DONE); + sceDisplayWaitVblankStart(); + sceGuDisplay(GU_TRUE); +} + +} // namespace + +int main(int argc, char** argv) { + setupCallbacks(); + setupGraphics(); + sceCtrlSetSamplingCycle(0); + sceCtrlSetSamplingMode(PSP_CTRL_MODE_ANALOG); + + enum class AppMode { Menu, Gameplay, Fallback }; + + char catalogPath[256]{}; + char stagePath[384]{}; + char audioPath[384]{}; + char shotAudioPath[384]{}; + char effectDirectory[384]{}; + char stageError[96]{}; + char menuError[96]{}; + siblingPath(argc, argv, "catalog.orpc", catalogPath, sizeof(catalogPath)); + SongMenu menu{}; + bool menuLoaded = openroller::psp::loadSongMenu( + catalogPath, &menu, menuError, sizeof(menuError)); + if (!menuLoaded && std::strcmp(catalogPath, "catalog.orpc") != 0) { + menuLoaded = openroller::psp::loadSongMenu( + "catalog.orpc", &menu, menuError, sizeof(menuError)); + } + + StageView stage{}; + GameplayState gameplay{}; + bool stageLoaded = false; + bool gameplayLoaded = false; + bool audioLoaded = false; + AppMode mode = menuLoaded ? AppMode::Menu : AppMode::Fallback; + + if (!menuLoaded) { + siblingPath(argc, argv, "stage.orps", stagePath, sizeof(stagePath)); + siblingPath(argc, argv, "audio.mp3", audioPath, sizeof(audioPath)); + siblingPath(argc, argv, "shot.mp3", shotAudioPath, sizeof(shotAudioPath)); + siblingPath(argc, argv, "sounds", effectDirectory, sizeof(effectDirectory)); + stageLoaded = openroller::psp::loadStagePackage(stagePath, &stage, stageError, sizeof(stageError)); + if (!stageLoaded && std::strcmp(stagePath, "stage.orps") != 0) { + stageLoaded = openroller::psp::loadStagePackage("stage.orps", &stage, stageError, sizeof(stageError)); + } + if (stageLoaded) { + sceKernelDcacheWritebackRange(stage.storage, stage.storageSize); + gameplayLoaded = openroller::psp::initializeGameplay(stage, &gameplay); + audioLoaded = openroller::psp::startAudioPlayer( + audioPath, shotAudioPath, effectDirectory); + mode = AppMode::Gameplay; + } + } + + TateSide side = TateSide::Clockwise; + std::uint32_t previousButtons = 0; + std::uint64_t previousTick = sceKernelGetSystemTimeWide(); + float playheadMs = 0.0f; + float fallbackPhase = 0.0f; + bool paused = false; + int pauseSelection = 0; + std::int32_t lastAudioJudgedNote = -1; + + const auto unloadCurrentSong = [&] { + openroller::psp::stopAudioPlayer(); + audioLoaded = false; + if (gameplayLoaded) openroller::psp::destroyGameplay(&gameplay); + gameplay = {}; + gameplayLoaded = false; + openroller::psp::unloadStagePackage(&stage); + stageLoaded = false; + playheadMs = 0.0f; + paused = false; + pauseSelection = 0; + lastAudioJudgedNote = -1; + }; + const auto loadMenuSong = [&] { + unloadCurrentSong(); + if (!openroller::psp::selectedSongPath(menu, stagePath, sizeof(stagePath)) || + !openroller::psp::selectedAudioPath(menu, audioPath, sizeof(audioPath)) || + !openroller::psp::selectedShotAudioPath( + menu, shotAudioPath, sizeof(shotAudioPath)) || + !openroller::psp::loadStagePackage(stagePath, &stage, stageError, sizeof(stageError))) { + mode = AppMode::Menu; + return false; + } + stageLoaded = true; + sceKernelDcacheWritebackRange(stage.storage, stage.storageSize); + gameplayLoaded = openroller::psp::initializeGameplay(stage, &gameplay); + if (!gameplayLoaded) { + unloadCurrentSong(); + mode = AppMode::Menu; + return false; + } + std::snprintf( + effectDirectory, sizeof(effectDirectory), "%s/sounds", menu.rootPath); + audioLoaded = openroller::psp::startAudioPlayer( + audioPath, shotAudioPath, effectDirectory); + mode = AppMode::Gameplay; + return true; + }; + + while (gRunning) { + const std::uint64_t tick = sceKernelGetSystemTimeWide(); + const float elapsedMs = static_cast(tick - previousTick) / 1000.0f; + previousTick = tick; + + fallbackPhase += elapsedMs; + if (mode == AppMode::Gameplay && stageLoaded) { + const float duration = std::max(1.0f, static_cast(stage.header->durationMs)); + if (audioLoaded) playheadMs = static_cast(openroller::psp::audioPlayerTimeMs()); + else if (!paused) playheadMs += std::min(elapsedMs, 100.0f); + if ((audioLoaded && openroller::psp::audioPlayerFinished()) || + (!audioLoaded && playheadMs >= duration)) { + unloadCurrentSong(); + menu.difficultyMode = false; + mode = menuLoaded ? AppMode::Menu : AppMode::Fallback; + } + } + + SceCtrlData pad{}; + sceCtrlPeekBufferPositive(&pad, 1); + const std::uint32_t pressed = pad.Buttons & ~previousButtons; + const std::uint32_t released = previousButtons & ~pad.Buttons; + previousButtons = pad.Buttons; + constexpr std::uint32_t dpadMask = + PSP_CTRL_UP | PSP_CTRL_DOWN | PSP_CTRL_LEFT | PSP_CTRL_RIGHT; + const std::uint32_t logicalPressed = + (pressed & ~dpadMask) | logicalDpad(pressed, side); + const std::uint32_t logicalReleased = + (released & ~dpadMask) | logicalDpad(released, side); + const bool exitChord = + (pad.Buttons & (PSP_CTRL_START | PSP_CTRL_SELECT)) == + (PSP_CTRL_START | PSP_CTRL_SELECT) && + (pressed & (PSP_CTRL_START | PSP_CTRL_SELECT)) != 0; + if (exitChord) { + gRunning = false; + } else if (mode == AppMode::Menu) { + if ((pressed & PSP_CTRL_SELECT) != 0) { + side = side == TateSide::Clockwise ? TateSide::CounterClockwise : TateSide::Clockwise; + } else if (!menu.difficultyMode) { + if ((logicalPressed & PSP_CTRL_UP) != 0) openroller::psp::moveSongSelection(&menu, -1); + if ((logicalPressed & PSP_CTRL_DOWN) != 0) openroller::psp::moveSongSelection(&menu, 1); + if ((pressed & PSP_CTRL_CIRCLE) != 0) menu.difficultyMode = true; + if ((pressed & PSP_CTRL_CROSS) != 0) gRunning = false; + } else { + if ((logicalPressed & (PSP_CTRL_UP | PSP_CTRL_LEFT)) != 0) { + openroller::psp::moveDifficultySelection(&menu, -1); + } + if ((logicalPressed & (PSP_CTRL_DOWN | PSP_CTRL_RIGHT)) != 0) { + openroller::psp::moveDifficultySelection(&menu, 1); + } + if ((pressed & PSP_CTRL_CROSS) != 0) menu.difficultyMode = false; + if ((pressed & PSP_CTRL_CIRCLE) != 0) loadMenuSong(); + } + } else if (mode == AppMode::Gameplay) { + if (!paused && (pressed & PSP_CTRL_START) != 0) { + paused = true; + pauseSelection = 0; + if (audioLoaded) openroller::psp::setAudioPlayerPaused(true); + } else if (paused) { + if ((pressed & (PSP_CTRL_START | PSP_CTRL_CROSS)) != 0) { + paused = false; + if (audioLoaded) openroller::psp::setAudioPlayerPaused(false); + } else { + if ((logicalPressed & PSP_CTRL_UP) != 0) { + pauseSelection = (pauseSelection + 2) % 3; + } + if ((logicalPressed & PSP_CTRL_DOWN) != 0) { + pauseSelection = (pauseSelection + 1) % 3; + } + if ((pressed & PSP_CTRL_CIRCLE) != 0) { + if (pauseSelection == 0) { + paused = false; + if (audioLoaded) openroller::psp::setAudioPlayerPaused(false); + } else if (pauseSelection == 1) { + playheadMs = 0.0f; + openroller::psp::seekGameplay(stage, &gameplay, 0.0f); + lastAudioJudgedNote = -1; + if (audioLoaded) { + openroller::psp::seekAudioPlayer(0); + openroller::psp::setAudioPlayerShotMuted(false); + openroller::psp::setAudioPlayerPaused(false); + } + paused = false; + } else { + unloadCurrentSong(); + menu.difficultyMode = false; + mode = menuLoaded ? AppMode::Menu : AppMode::Fallback; + } + } + } + } else { + if ((pressed & PSP_CTRL_SELECT) != 0) { + side = side == TateSide::Clockwise ? TateSide::CounterClockwise : TateSide::Clockwise; + } + if (gameplayLoaded && !paused) { + constexpr std::uint32_t tapButtons = + PSP_CTRL_UP | PSP_CTRL_DOWN | PSP_CTRL_LEFT | PSP_CTRL_RIGHT | + PSP_CTRL_CROSS | PSP_CTRL_CIRCLE | PSP_CTRL_TRIANGLE | PSP_CTRL_SQUARE; + std::uint32_t taps = logicalPressed & tapButtons; + while (taps != 0) { + const std::uint32_t input = taps & (~taps + 1u); + openroller::psp::playAudioPlayerEffect( + (input & (PSP_CTRL_UP | PSP_CTRL_DOWN | + PSP_CTRL_LEFT | PSP_CTRL_RIGHT)) != 0 + ? openroller::psp::AudioEffect::Tap1 + : openroller::psp::AudioEffect::Tap2); + openroller::psp::pressGameplay( + stage, &gameplay, playheadMs, input); + taps &= taps - 1; + } + std::uint32_t releases = logicalReleased & tapButtons; + while (releases != 0) { + const std::uint32_t input = releases & (~releases + 1u); + openroller::psp::releaseGameplay( + stage, &gameplay, playheadMs, input); + releases &= releases - 1; + } + openroller::psp::updateGameplay(stage, &gameplay, playheadMs); + if (audioLoaded) { + openroller::psp::setAudioPlayerShotMuted(gameplay.shotMuted); + if (gameplay.lastJudgedNote >= 0 && + gameplay.lastJudgedNote != lastAudioJudgedNote) { + const std::uint32_t index = + static_cast(gameplay.lastJudgedNote); + if (gameplay.lastJudgment != Judgment::Miss && + (stage.notes[index].flags & + openroller::psp::kNoteTypeOverride) != 0) { + openroller::psp::playAudioPlayerEffect( + openroller::psp::AudioEffect::Adlib); + } + lastAudioJudgedNote = gameplay.lastJudgedNote; + } + } + } + } + } + + sceGuStart(GU_DIRECT, gDisplayList); + gTextVertexCount = 0; + sceGuClearColor(rgba(3, 5, 14)); + sceGuClearDepth(0); + sceGuClear(GU_COLOR_BUFFER_BIT | GU_DEPTH_BUFFER_BIT); + if (mode == AppMode::Menu) drawSongMenu(menu, side, fallbackPhase); + else if (mode == AppMode::Gameplay && stageLoaded) { + drawStage( + stage, gameplayLoaded ? &gameplay : nullptr, + side, playheadMs, paused); + if (paused) drawPauseMenu(side, pauseSelection); + } + else drawFallback(side, fallbackPhase * 0.0025f); + sceGuFinish(); + sceGuSync(GU_SYNC_FINISH, GU_SYNC_WHAT_DONE); + sceDisplayWaitVblankStart(); + sceGuSwapBuffers(); + } + + unloadCurrentSong(); + openroller::psp::unloadSongMenu(&menu); + sceGuTerm(); + sceKernelExitGame(); + return 0; +} diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..6ef237b --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,2997 @@ +#include "gc/EventStream.hpp" +#include "gc/NoteTypes.hpp" +#include "gc/StageCatalog.hpp" +#include "gc/StageDat.hpp" +#include "gc/StagePattern.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +enum class RenderTimeMode { + Auto, + Time, + Fit, + Index, +}; + +enum class ExportAtMode { + Auto, // pick bars if time looks meaningful, else index + Beats, // raw timestamp as beats (string) + Bars, // bar:fracOfBar (string) + Seconds, // not implemented yet (needs BPM map); falls back to auto + Index, // uniform by index +}; + +static bool parseExportAtMode(const std::string& s, ExportAtMode* out) { + if (!out) return false; + if (s == "auto") { *out = ExportAtMode::Auto; return true; } + if (s == "beats") { *out = ExportAtMode::Beats; return true; } + if (s == "bars") { *out = ExportAtMode::Bars; return true; } + if (s == "seconds") { *out = ExportAtMode::Seconds; return true; } + if (s == "index") { *out = ExportAtMode::Index; return true; } + return false; +} + +static bool parseRenderTimeMode(const std::string& s, RenderTimeMode* out) { + if (!out) return false; + if (s == "auto") { *out = RenderTimeMode::Auto; return true; } + if (s == "time") { *out = RenderTimeMode::Time; return true; } + if (s == "fit") { *out = RenderTimeMode::Fit; return true; } + if (s == "index") { *out = RenderTimeMode::Index; return true; } + return false; +} + +static void printUsage(const char* argv0) { + std::cerr + << "Usage:\n" + << " " << argv0 << " [--dump N] [--section IDX] [--align N] [--svg OUT.svg] [--svg-y value|type]\n" + << " [--type-max N] [--no-filter] [--stats] [--stats-top N] [--stats-all] [--stats-keep-zero] [--find-align]\n" + << " [--survey] [--strings] [--strings-section IDX] [--strings-min N] [--strings-max N] [--strings-all]\n" + << " [--meta] [--meta-section IDX] (scan u16-length ASCII tokens; useful for backgrounds/ids)\n" + << " [--stats-section IDX] [--stats-rs auto|12|16]\n" + << " [--raw] (treat input as a single section, for files like *_ext.dat)\n" + << " [--rs auto|12|16] (force event record size for --dump/--svg section picking)\n" + << " [--find-align-section IDX] [--find-align-rs 12|16]\n" + << " [--render OUT.svg] [--track-section IDX] [--note-section IDX] [--note-align N]\n" + << " [--render-time auto|time|fit|index] [--render-keep-type0] [--render-notes-only] [--render-max-notes N]\n" + << " " << argv0 << " --track-info \n" + << " " << argv0 << " --play [--play-what bgm|shot] [--play-tool auto|ffplay|aplay|paplay|mpv]\n" + << " " << argv0 << " --viz [--viz-what bgm|shot] [--viz-notes-only]\n" + << " " << argv0 << " --export-json \n" + << " [--export-at auto|bars|beats|index|seconds] [--export-relative] [--export-notes-only]\n" + << " " << argv0 << " --export-gcsim \n" + << " [--gcsim-what bgm|shot] [--gcsim-bpm N] [--gcsim-title TITLE]\n" + << " " << argv0 << " --export-gcsim-project \n" + << " [--stage-param PATH] [--ac-id ID] [--music WAV] [--gcsim-what bgm|shot] [--gcsim-bpm N] [--gcsim-title TITLE]\n" + << " " << argv0 << " --export-vectomapper \n" + << " [--note-section IDX] [--note-align N] (track/camera from docs/stage.pat; notes still heuristic)\n" + << "\n" + << "Example:\n" + << " " << argv0 << " GC/data/stage/ac_10pt8tion_easy.dat --dump 20\n" + << " " << argv0 << " GC/data/stage/ac_10pt8tion_easy.dat --dump 20 --section 4\n" + << " " << argv0 << " GC/data/stage/ac_10pt8tion_easy.dat --svg out.svg\n" + << " " << argv0 << " GC/data/stage/ac_10pt8tion_hard.dat --stats\n" + << " " << argv0 << " GC/data/stage/ac_10pt8tion_hard.dat --find-align\n" + << " " << argv0 << " GC/data/stage/ac_10pt8tion_hard.dat --render out.svg\n" + << " " << argv0 << " GC/data/boot/stage_param.dat --track-info ac_10pt8tion_hard\n" + << " " << argv0 << " GC/data/boot/stage_param.dat --play ac_10pt8tion_hard\n" + << " " << argv0 << " GC/data/boot/stage_param.dat --viz ac_10pt8tion_hard /tmp/viz.html\n" + << " " << argv0 << " GC/data/boot/stage_param.dat --export-json ac_10pt8tion_hard /tmp/track.json\n"; +} + +static bool isFiniteF(float f) { + return std::isfinite(static_cast(f)); +} + +static std::string rgbHexForType(uint32_t type) { + if (type == 0 || type == 0xFFFFFFFFu) return "#999999"; + // Deterministic hash -> RGB, trying to avoid too-dark colors. + const uint32_t x = static_cast(type) * 0x9e37u + 0x7f4a7c15u; + const uint8_t r = static_cast(64 + ((x >> 0) & 0x7F)); + const uint8_t g = static_cast(64 + ((x >> 8) & 0x7F)); + const uint8_t b = static_cast(64 + ((x >> 16) & 0x7F)); + std::ostringstream oss; + oss << "#" + << std::hex << std::setw(2) << std::setfill('0') << static_cast(r) + << std::hex << std::setw(2) << std::setfill('0') << static_cast(g) + << std::hex << std::setw(2) << std::setfill('0') << static_cast(b); + return oss.str(); +} + +static bool isGarbage12(const gc::GameEvent& e) { + if (!isFiniteF(e.timestamp)) return true; + if (e.timestamp < -1.0f || e.timestamp > 1.0e6f) return true; + if (e.type == 0xFFFFFFFFu) return true; + // value may legitimately be NaN for some opcodes, but as a default filter it helps a lot. + if (!isFiniteF(e.value)) return true; + if (std::fabs(static_cast(e.value)) > 1.0e7) return true; + return false; +} + +static bool parseU32(const char* s, uint32_t* out) { + if (!s || !out) return false; + errno = 0; + char* end = nullptr; + unsigned long v = std::strtoul(s, &end, 0); // accepts 123 or 0x7b + if (errno != 0 || end == s || *end != '\0') return false; + if (v > 0xFFFFFFFFul) return false; + *out = static_cast(v); + return true; +} + +static uint32_t u32be_bytes(const std::vector& b, size_t off) { + return (static_cast(b[off + 0]) << 24) | + (static_cast(b[off + 1]) << 16) | + (static_cast(b[off + 2]) << 8) | + (static_cast(b[off + 3]) << 0); +} + +static float f32be_bytes(const std::vector& b, size_t off) { + const uint32_t u = u32be_bytes(b, off); + float f = 0.0f; + static_assert(sizeof(float) == sizeof(uint32_t), "float must be 32-bit"); + std::memcpy(&f, &u, sizeof(float)); + return f; +} + +static bool readWholeFile(const std::string& path, std::vector* out, std::string* err) { + if (!out) return false; + out->clear(); + std::ifstream in(path, std::ios::binary); + if (!in.is_open()) { + if (err) *err = "could not open file"; + return false; + } + in.seekg(0, std::ios::end); + std::streamoff sz = in.tellg(); + in.seekg(0, std::ios::beg); + if (sz < 0) { + if (err) *err = "could not stat file size"; + return false; + } + out->resize(static_cast(sz)); + if (!out->empty()) in.read(reinterpret_cast(out->data()), static_cast(out->size())); + if (!in.good() && !in.eof()) { + if (err) *err = "read failed"; + return false; + } + return true; +} + +static bool isAsciiPrintable(uint8_t c) { + return (c >= 0x20 && c <= 0x7E); +} + +struct AsciiStringHit { + size_t off = 0; // absolute offset in file + std::string s; +}; + +static bool isLikelyInterestingToken(const std::string& s) { + if (s.size() < 4) return false; + if (s.rfind("ac_", 0) == 0) return true; + if (s.rfind("bgm_", 0) == 0) return true; + if (s.find('/') != std::string::npos) return true; + if (s.find('\\') != std::string::npos) return true; + if (s.find(".wav") != std::string::npos) return true; + if (s.find(".dat") != std::string::npos) return true; + if (s.find(".png") != std::string::npos) return true; + if (s.find(".dds") != std::string::npos) return true; + if (s.find(".tga") != std::string::npos) return true; + if (s.find("shader") != std::string::npos) return true; + if (s.find("tex") != std::string::npos) return true; + if (s.find("bg") != std::string::npos && s.size() <= 32) return true; + return false; +} + +static bool isSaneAsciiToken(const std::string& s) { + if (s.size() < 4) return false; + size_t good = 0; + size_t alnum = 0; + for (unsigned char c : s) { + if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')) { + good++; + alnum++; + continue; + } + // Common separators in file ids / paths / keys. + if (c == '_' || c == '-' || c == '.' || c == '/' || c == '\\' || c == ':' || c == ' ') { + good++; + continue; + } + } + const double ratio = static_cast(good) / static_cast(s.size()); + // Avoid random punctuation soup: require mostly "path-like" chars and at least some alnum. + return ratio >= 0.85 && alnum >= 3; +} + +static std::vector scanAsciiStrings( + const std::vector& bytes, + size_t start, + size_t end, + size_t minLen, + size_t maxHits, + bool keepAll) { + std::vector out; + if (start >= end || start >= bytes.size()) return out; + end = std::min(end, bytes.size()); + if (minLen < 2) minLen = 2; + if (maxHits == 0) maxHits = 1; + + std::unordered_set seen; + + size_t i = start; + while (i < end) { + while (i < end && !isAsciiPrintable(bytes[i])) i++; + size_t j = i; + while (j < end && isAsciiPrintable(bytes[j])) j++; + const size_t len = (j > i) ? (j - i) : 0; + if (len >= minLen) { + std::string s(reinterpret_cast(&bytes[i]), len); + if (s.size() > 512) s.resize(512); + if (!keepAll && !isSaneAsciiToken(s)) { + i = (j > i) ? j : (i + 1); + continue; + } + if (keepAll || isLikelyInterestingToken(s)) { + if (seen.insert(s).second) { + out.push_back(AsciiStringHit{i, std::move(s)}); + if (out.size() >= maxHits) break; + } + } + } + i = (j > i) ? j : (i + 1); + } + return out; +} + +static double shannonEntropyBytes(const std::vector& bytes, size_t start, size_t end) { + if (start >= end || start >= bytes.size()) return 0.0; + end = std::min(end, bytes.size()); + const size_t n = end - start; + if (n == 0) return 0.0; + uint32_t hist[256]; + std::memset(hist, 0, sizeof(hist)); + for (size_t i = start; i < end; i++) hist[bytes[i]]++; + double ent = 0.0; + for (size_t c = 0; c < 256; c++) { + if (!hist[c]) continue; + const double p = static_cast(hist[c]) / static_cast(n); + ent -= p * (std::log(p) / std::log(2.0)); + } + return ent; // bits per byte (0..8) +} + +static uint64_t countVec3TriplesF32BE(const std::vector& bytes, size_t start, size_t end) { + if (start >= end || start >= bytes.size()) return 0; + end = std::min(end, bytes.size()); + if (end - start < 12) return 0; + uint64_t hits = 0; + // Scan in 4-byte steps; count a hit when 3 consecutive floats are finite and not absurd. + for (size_t off = start; off + 12 <= end; off += 4) { + const float a = f32be_bytes(bytes, off + 0); + const float b = f32be_bytes(bytes, off + 4); + const float c = f32be_bytes(bytes, off + 8); + if (!isFiniteF(a) || !isFiniteF(b) || !isFiniteF(c)) continue; + if (std::fabs(static_cast(a)) > 1.0e5) continue; + if (std::fabs(static_cast(b)) > 1.0e5) continue; + if (std::fabs(static_cast(c)) > 1.0e5) continue; + hits++; + } + return hits; +} + +static std::filesystem::path guessGcRootFromStageParamPath(const std::filesystem::path& stageParamPath) { + // stageParam.dat usually lives at: /data/boot/stage_param.dat + std::filesystem::path p = stageParamPath; + if (p.has_filename()) p = p.parent_path(); + for (int i = 0; i < 8; i++) { + if (p.filename() == "data") return p.parent_path(); + if (!p.has_parent_path()) break; + p = p.parent_path(); + } + return std::filesystem::current_path(); +} + +static std::string shEscapeSingleQuotes(const std::string& s) { + // POSIX-ish /bin/sh escaping using single quotes. + // abc'd -> 'abc'"'"'d' + std::string out; + out.reserve(s.size() + 8); + out.push_back('\''); + for (char c : s) { + if (c == '\'') out += "'\"'\"'"; + else out.push_back(c); + } + out.push_back('\''); + return out; +} + +static bool tryReadWavDurationSec(const std::filesystem::path& wavPath, double* outSec, std::string* err) { + if (outSec) *outSec = 0.0; + std::ifstream in(wavPath, std::ios::binary); + if (!in.is_open()) { + if (err) *err = "could not open wav"; + return false; + } + auto readU32le = [&](uint32_t* v) -> bool { + uint8_t b[4]; + if (!in.read(reinterpret_cast(b), 4)) return false; + *v = (static_cast(b[0]) << 0) | + (static_cast(b[1]) << 8) | + (static_cast(b[2]) << 16) | + (static_cast(b[3]) << 24); + return true; + }; + auto readU16le = [&](uint16_t* v) -> bool { + uint8_t b[2]; + if (!in.read(reinterpret_cast(b), 2)) return false; + *v = static_cast((static_cast(b[0]) << 0) | (static_cast(b[1]) << 8)); + return true; + }; + auto readFourCC = [&](char out[5]) -> bool { + char c[4]; + if (!in.read(c, 4)) return false; + out[0] = c[0]; out[1] = c[1]; out[2] = c[2]; out[3] = c[3]; out[4] = '\0'; + return true; + }; + + char riff[5] = {0}, wave[5] = {0}; + uint32_t riffSize = 0; + if (!readFourCC(riff) || std::string(riff) != "RIFF") { if (err) *err = "not RIFF"; return false; } + if (!readU32le(&riffSize)) { if (err) *err = "short file"; return false; } + if (!readFourCC(wave) || std::string(wave) != "WAVE") { if (err) *err = "not WAVE"; return false; } + + uint16_t fmtAudioFormat = 0; + uint16_t fmtNumChannels = 0; + uint32_t fmtSampleRate = 0; + uint32_t fmtByteRate = 0; + uint16_t fmtBlockAlign = 0; + uint16_t fmtBitsPerSample = 0; + uint32_t dataSize = 0; + bool haveFmt = false; + bool haveData = false; + + // Walk chunks. + while (in.good() && (!haveFmt || !haveData)) { + char id[5] = {0}; + uint32_t sz = 0; + if (!readFourCC(id)) break; + if (!readU32le(&sz)) break; + const std::string sid(id); + std::streamoff payloadStart = in.tellg(); + if (sid == "fmt ") { + if (sz < 16) { if (err) *err = "fmt chunk too small"; return false; } + if (!readU16le(&fmtAudioFormat)) return false; + if (!readU16le(&fmtNumChannels)) return false; + if (!readU32le(&fmtSampleRate)) return false; + if (!readU32le(&fmtByteRate)) return false; + if (!readU16le(&fmtBlockAlign)) return false; + if (!readU16le(&fmtBitsPerSample)) return false; + haveFmt = true; + } else if (sid == "data") { + dataSize = sz; + haveData = true; + } + // Seek to end of chunk (account for what we already read in fmt) + in.seekg(payloadStart + static_cast(sz), std::ios::beg); + if (sz & 1) in.seekg(1, std::ios::cur); // pad byte + } + + if (!haveFmt || !haveData) { + if (err) *err = "missing fmt/data chunk"; + return false; + } + if (fmtSampleRate == 0 || fmtNumChannels == 0 || fmtBitsPerSample == 0) { + if (err) *err = "invalid fmt"; + return false; + } + const double bytesPerSec = static_cast(fmtSampleRate) * + static_cast(fmtNumChannels) * + static_cast(fmtBitsPerSample / 8.0); + if (bytesPerSec <= 0.0) { + if (err) *err = "invalid bytes/sec"; + return false; + } + if (outSec) *outSec = static_cast(dataSize) / bytesPerSec; + return true; +} + +static uint32_t gcdU32(uint32_t a, uint32_t b) { + while (b) { + uint32_t t = a % b; + a = b; + b = t; + } + return a; +} + +static bool approxToRational(double x, const std::vector& dens, uint32_t* outNum, uint32_t* outDen, double tol = 1.0e-4) { + if (!outNum || !outDen) return false; + if (!std::isfinite(x)) return false; + if (x < 0.0) x = -x; + uint32_t bestN = 0, bestD = 1; + double bestErr = 1e100; + for (uint32_t d : dens) { + if (d == 0) continue; + const double n = std::round(x * static_cast(d)); + const double v = n / static_cast(d); + const double e = std::fabs(v - x); + if (e < bestErr) { + bestErr = e; + bestN = static_cast(std::max(0.0, n)); + bestD = d; + } + } + if (bestErr > tol) return false; + const uint32_t g = gcdU32(bestN, bestD); + *outNum = bestN / (g ? g : 1); + *outDen = bestD / (g ? g : 1); + return true; +} + +static std::string fmtFixed(double x, int digits) { + std::ostringstream oss; + oss << std::fixed << std::setprecision(digits) << x; + return oss.str(); +} + +static std::string jsonEscape(const std::string& s) { + std::ostringstream oss; + for (unsigned char c : s) { + switch (c) { + case '\\': oss << "\\\\"; break; + case '"': oss << "\\\""; break; + case '\n': oss << "\\n"; break; + case '\r': oss << "\\r"; break; + case '\t': oss << "\\t"; break; + default: + if (c < 0x20) { + oss << "\\u" << std::hex << std::setw(4) << std::setfill('0') << static_cast(c) << std::dec << std::setfill(' '); + } else { + oss << static_cast(c); + } + } + } + return oss.str(); +} + +static std::string typeToExportString(uint32_t type) { + switch (static_cast(type)) { + case gc::NoteType::Tap: return "hit"; + case gc::NoteType::Critical: return "critical"; + case gc::NoteType::HoldStart: return "hold"; + case gc::NoteType::HoldEnd: return "hold"; + case gc::NoteType::DualHold: return "dualhold"; + case gc::NoteType::Slide: return "slide"; + case gc::NoteType::DualSlide: return "dualslide"; + case gc::NoteType::SlideHold: return "slidehold"; + default: break; + } + // Legacy / unknown in our hypothesis + if (type == 0x100u) return "hit2"; + if (type == 0xA00u) return "slide"; + return "event"; +} + +static std::string directionFromAngleRad(float a) { + if (!std::isfinite(static_cast(a))) return {}; + // Normalize to [-pi, pi) + const double pi = 3.14159265358979323846; + double x = static_cast(a); + while (x >= pi) x -= 2.0 * pi; + while (x < -pi) x += 2.0 * pi; + // 8-way + const double deg = x * 180.0 / pi; + auto sector = [&](double d) -> int { + // center sectors at multiples of 45 degrees + int s = static_cast(std::floor((d + 22.5) / 45.0)); + s %= 8; + if (s < 0) s += 8; + return s; + }; + switch (sector(deg)) { + case 0: return "right"; + case 1: return "up_right"; + case 2: return "up"; + case 3: return "up_left"; + case 4: return "left"; + case 5: return "down_left"; + case 6: return "down"; + case 7: return "down_right"; + default: return {}; + } +} + +struct TrackAssets { + std::filesystem::path gcRoot; + std::string trackId; // ac_... + std::string title; + std::string imageKey; + std::string artist; + std::string duration; + std::string bpm; + std::array difficultyRatings{}; + std::array chartIds{}; + std::string bgmBase; // bgm_... + std::filesystem::path stageDat; + std::filesystem::path stageExt; + std::filesystem::path stageClip; + std::filesystem::path menuDds; + std::filesystem::path menuDdsEnglish; + std::filesystem::path startDds; + std::filesystem::path startDdsEnglish; + std::filesystem::path wavBgm; + std::filesystem::path wavShot; + std::filesystem::path vibCsv; +}; + +static bool resolveTrackAssetsFromStageParam( + const std::string& stageParamPath, + const std::string& trackId, + TrackAssets* out, + std::string* err) { + if (!out) return false; + *out = TrackAssets{}; + + std::vector bytes; + std::string ferr; + if (!readWholeFile(stageParamPath, &bytes, &ferr)) { + if (err) *err = "failed to read stage_param: " + ferr; + return false; + } + + std::vector entries; + if (!gc::ParseStageCatalog(bytes, &entries, &ferr)) { + if (err) *err = "failed to parse stage_param: " + ferr; + return false; + } + const gc::StageCatalogEntry* entry = gc::FindStageCatalogEntryByChart(entries, trackId); + if (!entry) { + if (err) *err = "chart id not found in stage_param: " + trackId; + return false; + } + + const std::filesystem::path root = guessGcRootFromStageParamPath(std::filesystem::path(stageParamPath)); + const std::filesystem::path stageSoundDir = root / "data" / "stage" / "sound"; + const std::filesystem::path stageDir = root / "data" / "stage"; + const std::filesystem::path stage2dDir = stageDir / "2d"; + + out->gcRoot = root; + out->trackId = trackId; + out->title = entry->title; + out->imageKey = entry->imageKey; + out->artist = entry->artist; + out->duration = entry->duration; + out->bpm = entry->bpm; + out->difficultyRatings = entry->difficultyRatings; + out->chartIds = entry->chartIds; + out->bgmBase = entry->bgmBase; + out->stageDat = stageDir / (trackId + ".dat"); + out->stageExt = stageDir / (trackId + "_ext.dat"); + out->stageClip = stageDir / (trackId + "_clip.dat"); + out->menuDds = stage2dDir / (entry->imageKey + "_menu.dds"); + out->menuDdsEnglish = stage2dDir / "eng" / (entry->imageKey + "_menu.dds"); + out->startDds = stage2dDir / (entry->imageKey + "_start.dds"); + out->startDdsEnglish = stage2dDir / "eng" / (entry->imageKey + "_start.dds"); + size_t difficultyIndex = 0; + for (size_t i = 0; i < entry->chartIds.size(); ++i) { + if (entry->chartIds[i] == trackId) { + difficultyIndex = i; + break; + } + } + out->wavBgm = stageSoundDir / + (entry->bgmBase + entry->chartGroup0[difficultyIndex] + "_BGM.wav"); + out->wavShot = stageSoundDir / + (entry->bgmBase + entry->chartSuffixes[difficultyIndex] + "_SHOT.wav"); + out->vibCsv = stageSoundDir / (entry->bgmBase + "_vib.csv"); + return true; +} + +struct TrackPt { + float t = 0.0f; + float v = 0.0f; +}; + +struct NoteEv { + float t = 0.0f; + uint32_t type = 0; + float value = 0.0f; +}; + +static float lerp(float a, float b, float t) { return a + (b - a) * t; } + +static float clamp01(float t) { + if (t < 0.0f) return 0.0f; + if (t > 1.0f) return 1.0f; + return t; +} + +static float trackValueAt(const std::vector& pts, float t) { + if (pts.empty()) return 0.0f; + if (t <= pts.front().t) return pts.front().v; + if (t >= pts.back().t) return pts.back().v; + + // upper_bound by time + size_t lo = 0, hi = pts.size(); + while (lo + 1 < hi) { + const size_t mid = lo + (hi - lo) / 2; + if (pts[mid].t <= t) lo = mid; + else hi = mid; + } + const TrackPt& a = pts[lo]; + const TrackPt& b = pts[std::min(lo + 1, pts.size() - 1)]; + const float dt = (b.t - a.t); + const float u = (dt > 1.0e-6f) ? clamp01((t - a.t) / dt) : 0.0f; + return lerp(a.v, b.v, u); +} + +static std::string colorForNote(uint32_t type) { + switch (static_cast(type)) { + case gc::NoteType::Tap: return "#1f77b4"; + case gc::NoteType::Critical: return "#ffbf00"; + case gc::NoteType::HoldStart: return "#ff7f0e"; + case gc::NoteType::HoldEnd: return "#ff7f0e"; + case gc::NoteType::DualHold: return "#d62728"; + case gc::NoteType::Slide: return "#2ca02c"; + case gc::NoteType::DualSlide: return "#2ca02c"; + case gc::NoteType::SlideHold: return "#9467bd"; + default: break; + } + // fallback + return "#444444"; +} + +static bool buildTrackFromSection16( + const gc::StageDat& dat, + size_t sectionIndex, + std::vector* outPts, + std::string* err) { + if (!outPts) return false; + outPts->clear(); + if (sectionIndex >= dat.sections.size()) { + if (err) *err = "track section index out of range"; + return false; + } + const auto& sec = dat.sections[sectionIndex]; + + // Force 16-byte decoding and alignment search. + const auto res = gc::TryDecodeEventStreamFixed(dat.bytes, sec.start, sec.end, 16); + const size_t start = sec.start + static_cast(res.alignment); + + std::vector events; + std::string tmpErr; + if (!gc::DecodeEventStream(dat.bytes, start, sec.end, 16, &events, &tmpErr)) { + if (err) *err = "DecodeEventStream(track) failed: " + tmpErr; + return false; + } + + // Interpret (timestamp,value) as a 2D curve for now. + outPts->reserve(events.size()); + for (const auto& e : events) { + if (!isFiniteF(e.timestamp) || !isFiniteF(e.value)) continue; + if (e.timestamp < -1.0f || e.timestamp > 1.0e6f) continue; + if (std::fabs(static_cast(e.value)) > 1.0e7) continue; + outPts->push_back(TrackPt{e.timestamp, e.value}); + } + std::sort(outPts->begin(), outPts->end(), [](const TrackPt& a, const TrackPt& b) { return a.t < b.t; }); + // drop duplicates with same t to stabilize interpolation + outPts->erase(std::unique(outPts->begin(), outPts->end(), [](const TrackPt& a, const TrackPt& b) { + return std::fabs(static_cast(a.t - b.t)) < 1.0e-6; + }), outPts->end()); + + if (outPts->size() < 2) { + if (err) *err = "not enough track points decoded"; + return false; + } + return true; +} + +static int pickBestNoteAlign12(const gc::StageDat& dat, const gc::Section& sec) { + // Score shifts by a mixture of markers and note-like opcodes. + const size_t rs = 12; + int bestShift = 0; + uint64_t bestScore = 0; + for (int shift = 0; shift < 12; shift++) { + const size_t start = sec.start + static_cast(shift); + const size_t end = sec.end; + const size_t span = (end > start) ? (end - start) - ((end - start) % rs) : 0; + uint64_t noteLike = 0; + uint64_t bpmMarkers = 0; + uint64_t plausible = 0; + for (size_t rel = 0; rel + rs <= span; rel += rs) { + const size_t off = start + rel; + if (off + rs > dat.bytes.size()) break; + const float ts = f32be_bytes(dat.bytes, off + 0); + const uint32_t type = u32be_bytes(dat.bytes, off + 4); + const float val = f32be_bytes(dat.bytes, off + 8); + if (!isFiniteF(ts) || ts < -1.0f || ts > 1.0e6f) continue; + if (type == 0xFFFFFFFFu) continue; + if (!isFiniteF(val) || std::fabs(static_cast(val)) > 1.0e7) continue; + if (type <= 0xFFFFu && gc::IsNote(type)) noteLike++; + if (type == 0 && std::fabs(static_cast(val - 1.0f)) < 1.0e-6) bpmMarkers++; + plausible++; + } + const uint64_t score = noteLike * 100000 + bpmMarkers * 1000 + plausible; + if (score > bestScore) { + bestScore = score; + bestShift = shift; + } + } + return bestShift; +} + +static bool buildNotesFromSection12( + const gc::StageDat& dat, + size_t sectionIndex, + int forcedAlign, + bool keepType0, + bool notesOnly, + std::vector* outNotes, + std::string* err) { + if (!outNotes) return false; + outNotes->clear(); + if (sectionIndex >= dat.sections.size()) { + if (err) *err = "note section index out of range"; + return false; + } + const auto& sec = dat.sections[sectionIndex]; + const int align = (forcedAlign >= 0) ? forcedAlign : pickBestNoteAlign12(dat, sec); + + const size_t rs = 12; + const size_t start = sec.start + static_cast(align); + const size_t end = sec.end; + const size_t span = (end > start) ? (end - start) - ((end - start) % rs) : 0; + + outNotes->reserve(span / rs); + for (size_t rel = 0; rel + rs <= span; rel += rs) { + const size_t off = start + rel; + if (off + rs > dat.bytes.size()) break; + const float ts = f32be_bytes(dat.bytes, off + 0); + const uint32_t type = u32be_bytes(dat.bytes, off + 4); + const float val = f32be_bytes(dat.bytes, off + 8); + if (!isFiniteF(ts) || ts < -1.0f || ts > 1.0e6f) continue; + if (type == 0xFFFFFFFFu) continue; + if (!isFiniteF(val) || std::fabs(static_cast(val)) > 1.0e7) continue; + if (type > 0xFFFFu) continue; + if (!keepType0 && type == 0) continue; + if (notesOnly && !gc::IsNote(type)) continue; + outNotes->push_back(NoteEv{ts, type, val}); + } + + std::sort(outNotes->begin(), outNotes->end(), [](const NoteEv& a, const NoteEv& b) { return a.t < b.t; }); + if (outNotes->empty()) { + if (err) *err = "no events decoded from note section (try --note-align 0..11, or --render-keep-type0)"; + return false; + } + return true; +} + +static std::string mapperNoteType(uint32_t type) { + switch (static_cast(type)) { + case gc::NoteType::Tap: + case gc::NoteType::Legacy_Tap: + return "tap"; + case gc::NoteType::Critical: + return "critical"; + case gc::NoteType::HoldStart: + case gc::NoteType::HoldEnd: + return "hold"; + case gc::NoteType::DualHold: + return "dualhold"; + case gc::NoteType::Slide: + case gc::NoteType::Legacy_Slide: + return "slide"; + case gc::NoteType::DualSlide: + return "dualslide"; + case gc::NoteType::SlideHold: + return "slidehold"; + default: + break; + } + return "event"; +} + +static double dist2d(const gc::TrackPiece& a, const gc::TrackPiece& b) { + const double dx = static_cast(b.x) - static_cast(a.x); + const double dz = static_cast(b.z) - static_cast(a.z); + return std::sqrt(dx * dx + dz * dz); +} + +static bool exportVectoMapperProject( + const std::filesystem::path& stageDatPath, + const gc::ParsedStagePattern& stage, + const std::vector& notes, + const std::filesystem::path& outDir, + std::string* err) { + if (stage.track.size() < 2) { + if (err) *err = "stage pattern track has fewer than two points"; + return false; + } + + std::error_code ec; + std::filesystem::create_directories(outDir / "track", ec); + if (ec) { + if (err) *err = "failed to create track directory: " + ec.message(); + return false; + } + std::filesystem::create_directories(outDir / "charts", ec); + if (ec) { + if (err) *err = "failed to create charts directory: " + ec.message(); + return false; + } + std::filesystem::create_directories(outDir / "bg", ec); + if (ec) { + if (err) *err = "failed to create bg directory: " + ec.message(); + return false; + } + + const std::string title = !stage.config.chartName.empty() ? stage.config.chartName : stageDatPath.stem().string(); + + std::vector cumulative; + cumulative.reserve(stage.track.size()); + cumulative.push_back(0.0); + for (size_t i = 1; i < stage.track.size(); i++) { + cumulative.push_back(cumulative.back() + dist2d(stage.track[i - 1], stage.track[i])); + } + + { + std::ofstream out(outDir / "track" / "track_graph.json", std::ios::binary); + if (!out.is_open()) { + if (err) *err = "failed to write track_graph.json"; + return false; + } + out << "{\n"; + out << " \"schema\": 1,\n"; + out << " \"source\": {\"format\": \"gc_stage_dat\", \"file\": \"" << jsonEscape(stageDatPath.filename().string()) << "\"},\n"; + out << " \"nodes\": [\n"; + for (size_t i = 0; i < stage.track.size(); i++) { + const auto& p = stage.track[i]; + out << " {\"id\": \"N" << i << "\", \"x\": " << fmtFixed(p.x, 6) + << ", \"z\": " << fmtFixed(p.z, 6) + << ", \"gc_time_ms\": " << p.timeMs + << ", \"gc_y\": " << fmtFixed(p.y, 6) << "}"; + out << (i + 1 < stage.track.size() ? "," : "") << "\n"; + } + out << " ],\n"; + out << " \"segments\": [\n"; + for (size_t i = 0; i + 1 < stage.track.size(); i++) { + const auto& a = stage.track[i]; + const auto& b = stage.track[i + 1]; + const double c1x = static_cast(a.x) + (static_cast(b.x) - static_cast(a.x)) / 3.0; + const double c1z = static_cast(a.z) + (static_cast(b.z) - static_cast(a.z)) / 3.0; + const double c2x = static_cast(a.x) + 2.0 * (static_cast(b.x) - static_cast(a.x)) / 3.0; + const double c2z = static_cast(a.z) + 2.0 * (static_cast(b.z) - static_cast(a.z)) / 3.0; + out << " {\"id\": \"S" << i << "\", \"type\": \"bezier\", \"mode\": \"smooth\", " + << "\"a\": \"N" << i << "\", \"b\": \"N" << (i + 1) << "\", " + << "\"c1\": [" << fmtFixed(c1x, 6) << ", " << fmtFixed(c1z, 6) << "], " + << "\"c2\": [" << fmtFixed(c2x, 6) << ", " << fmtFixed(c2z, 6) << "], " + << "\"visible\": 1}"; + out << (i + 2 < stage.track.size() ? "," : "") << "\n"; + } + out << " ],\n"; + out << " \"pieces\": [],\n"; + out << " \"paths\": {\"main\": ["; + for (size_t i = 0; i + 1 < stage.track.size(); i++) { + out << (i ? ", " : "") << "\"S" << i << "\""; + } + out << "]},\n"; + out << " \"active_path\": \"main\"\n"; + out << "}\n"; + } + + { + std::ofstream out(outDir / "track" / "camera_timeline.txt", std::ios::binary); + if (!out.is_open()) { + if (err) *err = "failed to write camera_timeline.txt"; + return false; + } + const double lastMs = std::max(1.0, static_cast(stage.track.back().timeMs)); + const double maxParam = static_cast(stage.track.size() - 1); + for (const auto& c : stage.cameras) { + const double t = std::max(0.0, std::min(maxParam, (static_cast(c.timeMs) / lastMs) * maxParam)); + const double fov = (std::isfinite(c.fieldNear[0]) && c.fieldNear[0] > 1.0f && c.fieldNear[0] < 179.0f) + ? static_cast(c.fieldNear[0]) + : 90.0; + const double camY = (std::isfinite(c.originOff[2]) && std::fabs(static_cast(c.originOff[2])) > 1.0e-6) + ? static_cast(c.originOff[2]) + : 3.5; + const double camZ = (std::isfinite(c.dist) && std::fabs(static_cast(c.dist)) > 1.0e-6) + ? static_cast(c.dist) + : 3.5; + out << fmtFixed(t, 6) << " fov " << fmtFixed(fov, 6) << "\n"; + out << fmtFixed(t, 6) << " cam_y " << fmtFixed(camY, 6) << "\n"; + out << fmtFixed(t, 6) << " cam_z " << fmtFixed(camZ, 6) << "\n"; + } + } + + { + std::ofstream out(outDir / "charts" / "normal.json", std::ios::binary); + if (!out.is_open()) { + if (err) *err = "failed to write charts/normal.json"; + return false; + } + out << "{\n"; + out << " \"schema\": 1,\n"; + out << " \"name\": \"normal\",\n"; + out << " \"source\": {\"format\": \"gc_stage_dat\", \"note_decode\": \"heuristic_section12\"},\n"; + out << " \"avatar_move\": [\n"; + for (size_t i = 0; i < stage.track.size(); i++) { + const double t = stage.track[i].timeMs ? (static_cast(stage.track[i].timeMs) / 1000.0) : (cumulative[i] / 30.0); + out << " {\"t\": " << fmtFixed(t, 6) << ", \"d\": " << fmtFixed(cumulative[i], 6) << "}"; + out << (i + 1 < stage.track.size() ? "," : "") << "\n"; + } + out << " ],\n"; + out << " \"notes\": [\n"; + for (size_t i = 0; i < notes.size(); i++) { + const auto& n = notes[i]; + out << " {\"t\": " << fmtFixed(n.t, 6) + << ", \"type\": \"" << jsonEscape(mapperNoteType(n.type)) << "\"" + << ", \"raw_type\": \"0x" << std::hex << std::setw(8) << std::setfill('0') << n.type + << std::dec << std::setfill(' ') << "\"" + << ", \"value\": " << fmtFixed(n.value, 6) << "}"; + out << (i + 1 < notes.size() ? "," : "") << "\n"; + } + out << " ]\n"; + out << "}\n"; + } + + { + std::ofstream out(outDir / "bg" / "bg.json", std::ios::binary); + if (!out.is_open()) { + if (err) *err = "failed to write bg/bg.json"; + return false; + } + out << "{\n"; + out << " \"schema\": 1,\n"; + out << " \"type\": \"color\",\n"; + out << " \"params\": {\"color\": \"#0f1116\"}\n"; + out << "}\n"; + } + + { + std::ofstream out(outDir / "level.json", std::ios::binary); + if (!out.is_open()) { + if (err) *err = "failed to write level.json"; + return false; + } + out << "{\n"; + out << " \"schema\": 1,\n"; + out << " \"title\": \"" << jsonEscape(title) << "\",\n"; + out << " \"audio\": \"\",\n"; + out << " \"track\": \"track/track_graph.json\",\n"; + out << " \"camera\": \"track/camera_timeline.txt\",\n"; + out << " \"background\": \"bg/bg.json\",\n"; + out << " \"charts\": {\"normal\": \"charts/normal.json\"},\n"; + out << " \"timing\": {\"base_dps\": 30.0, \"playback_rate\": 1.0},\n"; + out << " \"gc\": {\n"; + out << " \"stage_file\": \"" << jsonEscape(stageDatPath.string()) << "\",\n"; + out << " \"chart_name\": \"" << jsonEscape(stage.config.chartName) << "\",\n"; + out << " \"bgm_name\": \"" << jsonEscape(stage.config.bgmName) << "\",\n"; + out << " \"shot_name\": \"" << jsonEscape(stage.config.shotName) << "\",\n"; + out << " \"track_points\": " << stage.track.size() << ",\n"; + out << " \"camera_points\": " << stage.cameras.size() << ",\n"; + out << " \"draw_distance_points\": " << stage.drawDistances.size() << "\n"; + out << " }\n"; + out << "}\n"; + } + + return true; +} + +int main(int argc, char** argv) { + if (argc < 2) { + printUsage(argv[0]); + return 2; + } + + std::string path = argv[1]; + bool rawMode = false; + int dumpN = 0; + int dumpSection = -1; + int forcedAlign = -1; + std::string dumpRs = "auto"; // auto|12|16 (affects --dump/--svg candidate selection) + std::string svgOut; + std::string svgY = "value"; + int svgMaxPoints = 6000; + bool svgKeepAll = false; + bool filterGarbage = true; + uint32_t typeMax = 0xFFFFFFFFu; // for rs=12, defaulted later to 0xFFFF when filtering + bool statsMode = false; + int statsTop = 50; + bool statsAll = false; + bool statsSkipZero = true; + int statsSection = -1; + std::string statsRs = "12"; // auto|12|16 + bool findAlignMode = false; + int findAlignSection = -1; + std::string findAlignRs = "12"; // 12|16 + bool surveyMode = false; + bool stringsMode = false; + int stringsSection = -1; + int stringsMinLen = 4; + int stringsMax = 250; + bool stringsAll = false; + bool metaMode = false; + int metaSection = 0; + std::string renderOut; + int trackSection = 2; + int noteSection = 4; + int noteAlign = -1; + RenderTimeMode renderTimeMode = RenderTimeMode::Auto; + bool renderKeepType0 = false; + bool renderNotesOnly = false; + int renderMaxNotes = 8000; + bool trackInfoMode = false; + std::string trackInfoId; + bool playMode = false; + std::string playId; + std::string playWhat = "bgm"; // bgm|shot + std::string playTool = "auto"; // auto|ffplay|aplay|paplay|mpv + bool vizMode = false; + std::string vizId; + std::string vizOutHtml; + std::string vizWhat = "bgm"; + bool vizNotesOnly = false; + bool exportJsonMode = false; + std::string exportId; + std::string exportOutJson; + ExportAtMode exportAtMode = ExportAtMode::Auto; + bool exportRelative = false; + bool exportNotesOnly = false; + bool exportGcsimMode = false; + std::string gcsimId; + std::string gcsimOutDir; + std::string gcsimWhat = "bgm"; + int gcsimBpm = 120; + std::string gcsimTitle; + bool exportGcsimProjectMode = false; + std::string gcsimProjectOutDir; + std::string gcsimProjectStageParam; + std::string gcsimProjectAcId; + std::string gcsimProjectMusic; + bool exportVectoMapperMode = false; + std::string exportVectoMapperOutDir; + for (int i = 2; i < argc; i++) { + std::string a = argv[i]; + if (a == "--dump") { + if (i + 1 >= argc) { + std::cerr << "--dump requires an integer\n"; + return 2; + } + dumpN = std::atoi(argv[i + 1]); + i++; + } else if (a == "--section") { + if (i + 1 >= argc) { + std::cerr << "--section requires an integer\n"; + return 2; + } + dumpSection = std::atoi(argv[i + 1]); + i++; + } else if (a == "--align") { + if (i + 1 >= argc) { + std::cerr << "--align requires an integer\n"; + return 2; + } + forcedAlign = std::atoi(argv[i + 1]); + if (forcedAlign < 0) forcedAlign = 0; + i++; + } else if (a == "--rs") { + if (i + 1 >= argc) { + std::cerr << "--rs requires one of: auto, 12, 16\n"; + return 2; + } + dumpRs = argv[i + 1]; + if (dumpRs != "auto" && dumpRs != "12" && dumpRs != "16") { + std::cerr << "--rs requires one of: auto, 12, 16\n"; + return 2; + } + i++; + } else if (a == "--svg") { + if (i + 1 >= argc) { + std::cerr << "--svg requires a path\n"; + return 2; + } + svgOut = argv[i + 1]; + i++; + } else if (a == "--svg-y") { + if (i + 1 >= argc) { + std::cerr << "--svg-y requires one of: value, type\n"; + return 2; + } + svgY = argv[i + 1]; + if (svgY != "value" && svgY != "type") { + std::cerr << "--svg-y requires one of: value, type\n"; + return 2; + } + i++; + } else if (a == "--type-max") { + if (i + 1 >= argc) { + std::cerr << "--type-max requires an integer (e.g. 65535 or 0xffff)\n"; + return 2; + } + uint32_t v = 0; + if (!parseU32(argv[i + 1], &v)) { + std::cerr << "--type-max parse failed: " << argv[i + 1] << "\n"; + return 2; + } + typeMax = v; + i++; + } else if (a == "--svg-max") { + if (i + 1 >= argc) { + std::cerr << "--svg-max requires an integer\n"; + return 2; + } + svgMaxPoints = std::atoi(argv[i + 1]); + if (svgMaxPoints < 10) svgMaxPoints = 10; + i++; + } else if (a == "--svg-all") { + svgKeepAll = true; + } else if (a == "--no-filter") { + filterGarbage = false; + } else if (a == "--stats") { + statsMode = true; + } else if (a == "--stats-section") { + if (i + 1 >= argc) { + std::cerr << "--stats-section requires an integer\n"; + return 2; + } + statsSection = std::atoi(argv[i + 1]); + i++; + } else if (a == "--stats-rs") { + if (i + 1 >= argc) { + std::cerr << "--stats-rs requires one of: auto, 12, 16\n"; + return 2; + } + statsRs = argv[i + 1]; + if (statsRs != "auto" && statsRs != "12" && statsRs != "16") { + std::cerr << "--stats-rs requires one of: auto, 12, 16\n"; + return 2; + } + i++; + } else if (a == "--stats-top") { + if (i + 1 >= argc) { + std::cerr << "--stats-top requires an integer\n"; + return 2; + } + statsTop = std::atoi(argv[i + 1]); + if (statsTop < 1) statsTop = 1; + i++; + } else if (a == "--stats-all") { + statsAll = true; + } else if (a == "--stats-keep-zero") { + statsSkipZero = false; + } else if (a == "--find-align") { + findAlignMode = true; + } else if (a == "--find-align-section") { + if (i + 1 >= argc) { + std::cerr << "--find-align-section requires an integer\n"; + return 2; + } + findAlignSection = std::atoi(argv[i + 1]); + i++; + } else if (a == "--find-align-rs") { + if (i + 1 >= argc) { + std::cerr << "--find-align-rs requires one of: 12, 16\n"; + return 2; + } + findAlignRs = argv[i + 1]; + if (findAlignRs != "12" && findAlignRs != "16") { + std::cerr << "--find-align-rs requires one of: 12, 16\n"; + return 2; + } + i++; + } else if (a == "--survey") { + surveyMode = true; + } else if (a == "--strings") { + stringsMode = true; + } else if (a == "--strings-section") { + if (i + 1 >= argc) { + std::cerr << "--strings-section requires an integer\n"; + return 2; + } + stringsSection = std::atoi(argv[i + 1]); + i++; + } else if (a == "--strings-min") { + if (i + 1 >= argc) { + std::cerr << "--strings-min requires an integer\n"; + return 2; + } + stringsMinLen = std::atoi(argv[i + 1]); + if (stringsMinLen < 2) stringsMinLen = 2; + i++; + } else if (a == "--strings-max") { + if (i + 1 >= argc) { + std::cerr << "--strings-max requires an integer\n"; + return 2; + } + stringsMax = std::atoi(argv[i + 1]); + if (stringsMax < 1) stringsMax = 1; + i++; + } else if (a == "--strings-all") { + stringsAll = true; + } else if (a == "--meta") { + metaMode = true; + } else if (a == "--meta-section") { + if (i + 1 >= argc) { + std::cerr << "--meta-section requires an integer\n"; + return 2; + } + metaSection = std::atoi(argv[i + 1]); + i++; + } else if (a == "--raw") { + rawMode = true; + } else if (a == "--render") { + if (i + 1 >= argc) { + std::cerr << "--render requires a path\n"; + return 2; + } + renderOut = argv[i + 1]; + i++; + } else if (a == "--render-time") { + if (i + 1 >= argc) { + std::cerr << "--render-time requires one of: auto, time, fit, index\n"; + return 2; + } + if (!parseRenderTimeMode(argv[i + 1], &renderTimeMode)) { + std::cerr << "--render-time requires one of: auto, time, fit, index\n"; + return 2; + } + i++; + } else if (a == "--render-keep-type0") { + renderKeepType0 = true; + } else if (a == "--render-notes-only") { + renderNotesOnly = true; + } else if (a == "--render-max-notes") { + if (i + 1 >= argc) { + std::cerr << "--render-max-notes requires an integer\n"; + return 2; + } + renderMaxNotes = std::atoi(argv[i + 1]); + if (renderMaxNotes < 100) renderMaxNotes = 100; + i++; + } else if (a == "--track-info") { + if (i + 1 >= argc) { + std::cerr << "--track-info requires an id like: ac_10pt8tion_hard\n"; + return 2; + } + trackInfoMode = true; + trackInfoId = argv[i + 1]; + i++; + } else if (a == "--play") { + if (i + 1 >= argc) { + std::cerr << "--play requires an id like: ac_10pt8tion_hard\n"; + return 2; + } + playMode = true; + playId = argv[i + 1]; + i++; + } else if (a == "--play-what") { + if (i + 1 >= argc) { + std::cerr << "--play-what requires one of: bgm, shot\n"; + return 2; + } + playWhat = argv[i + 1]; + if (playWhat != "bgm" && playWhat != "shot") { + std::cerr << "--play-what requires one of: bgm, shot\n"; + return 2; + } + i++; + } else if (a == "--play-tool") { + if (i + 1 >= argc) { + std::cerr << "--play-tool requires one of: auto, ffplay, aplay, paplay, mpv\n"; + return 2; + } + playTool = argv[i + 1]; + if (playTool != "auto" && playTool != "ffplay" && playTool != "aplay" && playTool != "paplay" && playTool != "mpv") { + std::cerr << "--play-tool requires one of: auto, ffplay, aplay, paplay, mpv\n"; + return 2; + } + i++; + } else if (a == "--viz") { + if (i + 2 >= argc) { + std::cerr << "--viz requires: \n"; + return 2; + } + vizMode = true; + vizId = argv[i + 1]; + vizOutHtml = argv[i + 2]; + i += 2; + } else if (a == "--viz-what") { + if (i + 1 >= argc) { + std::cerr << "--viz-what requires one of: bgm, shot\n"; + return 2; + } + vizWhat = argv[i + 1]; + if (vizWhat != "bgm" && vizWhat != "shot") { + std::cerr << "--viz-what requires one of: bgm, shot\n"; + return 2; + } + i++; + } else if (a == "--viz-notes-only") { + vizNotesOnly = true; + } else if (a == "--export-json") { + if (i + 2 >= argc) { + std::cerr << "--export-json requires: \n"; + return 2; + } + exportJsonMode = true; + exportId = argv[i + 1]; + exportOutJson = argv[i + 2]; + i += 2; + } else if (a == "--export-at") { + if (i + 1 >= argc) { + std::cerr << "--export-at requires one of: auto, bars, beats, index, seconds\n"; + return 2; + } + if (!parseExportAtMode(argv[i + 1], &exportAtMode)) { + std::cerr << "--export-at requires one of: auto, bars, beats, index, seconds\n"; + return 2; + } + i++; + } else if (a == "--export-relative") { + exportRelative = true; + } else if (a == "--export-notes-only") { + exportNotesOnly = true; + } else if (a == "--export-gcsim") { + if (i + 2 >= argc) { + std::cerr << "--export-gcsim requires: \n"; + return 2; + } + exportGcsimMode = true; + gcsimId = argv[i + 1]; + gcsimOutDir = argv[i + 2]; + i += 2; + } else if (a == "--gcsim-what") { + if (i + 1 >= argc) { + std::cerr << "--gcsim-what requires one of: bgm, shot\n"; + return 2; + } + gcsimWhat = argv[i + 1]; + if (gcsimWhat != "bgm" && gcsimWhat != "shot") { + std::cerr << "--gcsim-what requires one of: bgm, shot\n"; + return 2; + } + i++; + } else if (a == "--gcsim-bpm") { + if (i + 1 >= argc) { + std::cerr << "--gcsim-bpm requires an integer\n"; + return 2; + } + gcsimBpm = std::atoi(argv[i + 1]); + if (gcsimBpm < 1) gcsimBpm = 1; + i++; + } else if (a == "--gcsim-title") { + if (i + 1 >= argc) { + std::cerr << "--gcsim-title requires a string\n"; + return 2; + } + gcsimTitle = argv[i + 1]; + i++; + } else if (a == "--export-gcsim-project") { + if (i + 1 >= argc) { + std::cerr << "--export-gcsim-project requires: \n"; + return 2; + } + exportGcsimProjectMode = true; + gcsimProjectOutDir = argv[i + 1]; + i++; + } else if (a == "--export-vectomapper") { + if (i + 1 >= argc) { + std::cerr << "--export-vectomapper requires: \n"; + return 2; + } + exportVectoMapperMode = true; + exportVectoMapperOutDir = argv[i + 1]; + i++; + } else if (a == "--stage-param") { + if (i + 1 >= argc) { + std::cerr << "--stage-param requires a path to stage_param.dat\n"; + return 2; + } + gcsimProjectStageParam = argv[i + 1]; + i++; + } else if (a == "--ac-id") { + if (i + 1 >= argc) { + std::cerr << "--ac-id requires an id like: ac_10pt8tion_hard\n"; + return 2; + } + gcsimProjectAcId = argv[i + 1]; + i++; + } else if (a == "--music") { + if (i + 1 >= argc) { + std::cerr << "--music requires a wav path\n"; + return 2; + } + gcsimProjectMusic = argv[i + 1]; + i++; + } else if (a == "--track-section") { + if (i + 1 >= argc) { + std::cerr << "--track-section requires an integer\n"; + return 2; + } + trackSection = std::atoi(argv[i + 1]); + i++; + } else if (a == "--note-section") { + if (i + 1 >= argc) { + std::cerr << "--note-section requires an integer\n"; + return 2; + } + noteSection = std::atoi(argv[i + 1]); + i++; + } else if (a == "--note-align") { + if (i + 1 >= argc) { + std::cerr << "--note-align requires an integer (0..11)\n"; + return 2; + } + noteAlign = std::atoi(argv[i + 1]); + if (noteAlign < 0) noteAlign = 0; + if (noteAlign > 11) noteAlign = 11; + i++; + } else if (a == "--help" || a == "-h") { + printUsage(argv[0]); + return 0; + } else { + std::cerr << "Unknown arg: " << a << "\n"; + return 2; + } + } + + if (exportVectoMapperMode) { + const std::filesystem::path stageDatPath(path); + gc::StageDat dat; + std::string eerr; + if (!gc::StageDat::LoadFromFile(stageDatPath.string(), dat, &eerr)) { + std::cerr << "VectoMapper export: failed to load stage dat: " << stageDatPath.string() << "\n"; + if (!eerr.empty()) std::cerr << eerr << "\n"; + return 1; + } + + gc::ParsedStagePattern stage; + if (!gc::ParseStagePattern(dat, &stage, &eerr)) { + std::cerr << "VectoMapper export: stage.pat parse failed: " << eerr << "\n"; + return 1; + } + + std::vector notes; + if (!buildNotesFromSection12(dat, static_cast(noteSection), noteAlign, /*keepType0*/false, /*notesOnly*/true, ¬es, &eerr)) { + std::cerr << "VectoMapper export: note decode warning: " << eerr << "\n"; + notes.clear(); + } + + if (!exportVectoMapperProject(stageDatPath, stage, notes, std::filesystem::path(exportVectoMapperOutDir), &eerr)) { + std::cerr << "VectoMapper export: write failed: " << eerr << "\n"; + return 1; + } + + std::cout << "Wrote VectoMapper project: " << exportVectoMapperOutDir << "\n"; + std::cout << " track points: " << stage.track.size() << "\n"; + std::cout << " camera points: " << stage.cameras.size() << "\n"; + std::cout << " notes: " << notes.size() << " (heuristic)\n"; + return 0; + } + + if (playMode) { + TrackAssets ta; + std::string perr; + if (!resolveTrackAssetsFromStageParam(path, playId, &ta, &perr)) { + std::cerr << "Play: resolve failed: " << perr << "\n"; + return 1; + } + std::filesystem::path wav; + if (playWhat == "bgm") wav = ta.wavBgm; + else wav = ta.wavShot; + + if (!std::filesystem::exists(wav)) { + std::cerr << "Play: missing wav: " << wav.string() << "\n"; + return 1; + } + + // Choose a tool. + auto existsInPath = [&](const std::string& exe) -> bool { + const char* p = std::getenv("PATH"); + if (!p) return false; + std::string cmd = "command -v " + exe + " >/dev/null 2>&1"; + return std::system(cmd.c_str()) == 0; + }; + + std::string tool = playTool; + if (tool == "auto") { + if (existsInPath("ffplay")) tool = "ffplay"; + else if (existsInPath("aplay")) tool = "aplay"; + else if (existsInPath("paplay")) tool = "paplay"; + else if (existsInPath("mpv")) tool = "mpv"; + else tool.clear(); + } + if (tool.empty()) { + std::cerr << "Play: no audio tool found. Install one of: ffplay, aplay(alsa-utils), paplay(pulseaudio), mpv\n"; + return 1; + } + + const std::string wavEsc = shEscapeSingleQuotes(wav.string()); + std::string cmd; + if (tool == "ffplay") { + cmd = "ffplay -nodisp -autoexit -hide_banner -loglevel warning " + wavEsc; + } else if (tool == "aplay") { + cmd = "aplay " + wavEsc; + } else if (tool == "paplay") { + cmd = "paplay " + wavEsc; + } else if (tool == "mpv") { + cmd = "mpv --no-video --really-quiet " + wavEsc; + } else { + std::cerr << "Play: unsupported tool: " << tool << "\n"; + return 1; + } + + std::cout << "Track: " << ta.trackId << "\n"; + std::cout << "BGM base: " << ta.bgmBase << "\n"; + std::cout << "WAV (" << playWhat << "): " << wav.string() << "\n"; + if (std::filesystem::exists(ta.vibCsv)) std::cout << "VIB: " << ta.vibCsv.string() << "\n"; + std::cout << "Player: " << tool << "\n"; + std::cout << "Running: " << cmd << "\n"; + return std::system(cmd.c_str()); + } + + if (vizMode) { + TrackAssets ta; + std::string verr; + if (!resolveTrackAssetsFromStageParam(path, vizId, &ta, &verr)) { + std::cerr << "Viz: resolve failed: " << verr << "\n"; + return 1; + } + std::filesystem::path wav; + if (vizWhat == "bgm") wav = ta.wavBgm; + else wav = ta.wavShot; + if (!std::filesystem::exists(wav)) { + std::cerr << "Viz: missing wav: " << wav.string() << "\n"; + return 1; + } + + double durSec = 0.0; + std::string werr; + if (!tryReadWavDurationSec(wav, &durSec, &werr)) { + std::cerr << "Viz: could not read wav duration: " << werr << "\n"; + return 1; + } + + // Load stage dat and decode. + gc::StageDat dat; + if (!gc::StageDat::LoadFromFile(ta.stageDat.string(), dat, &verr)) { + std::cerr << "Viz: failed to load stage dat: " << ta.stageDat.string() << "\n"; + if (!verr.empty()) std::cerr << verr << "\n"; + return 1; + } + + std::vector track; + std::vector notes; + if (!buildTrackFromSection16(dat, static_cast(trackSection), &track, &verr)) { + std::cerr << "Viz: track decode failed: " << verr << "\n"; + return 1; + } + if (!buildNotesFromSection12(dat, static_cast(noteSection), noteAlign, /*keepType0*/false, /*notesOnly*/vizNotesOnly, ¬es, &verr)) { + std::cerr << "Viz: note decode failed: " << verr << "\n"; + return 1; + } + + // Downsample for DOM size. + const int maxDomNotes = 6000; + if (static_cast(notes.size()) > maxDomNotes) { + const size_t stride = std::max(1, notes.size() / static_cast(maxDomNotes)); + std::vector ds; + ds.reserve(static_cast(maxDomNotes)); + for (size_t i = 0; i < notes.size(); i += stride) ds.push_back(notes[i]); + notes.swap(ds); + } + + // Map note times to audio seconds: if note time range looks meaningful, fit it; else spread by index. + float nMinT = notes.front().t; + float nMaxT = notes.back().t; + const float nRange = (nMaxT > nMinT) ? (nMaxT - nMinT) : 0.0f; + uint64_t zeroTs = 0; + for (const auto& n : notes) if (std::fabs(static_cast(n.t)) < 1.0e-9) zeroTs++; + const double pZero = notes.empty() ? 0.0 : (100.0 * static_cast(zeroTs) / static_cast(notes.size())); + const bool useIndex = (nRange <= 2.0f) || (pZero >= 80.0); + + auto noteAudioSec = [&](const NoteEv& n, size_t idx, size_t total) -> double { + if (durSec <= 0.0) return 0.0; + if (!useIndex && nRange > 1.0e-6f) { + const double u = clamp01((n.t - nMinT) / nRange); + return u * durSec; + } + if (total <= 1) return 0.0; + const double u = static_cast(idx) / static_cast(total - 1); + return u * durSec; + }; + + // Prepare SVG coordinate mapping using track time. + float tMin = track.front().t; + float tMax = track.back().t; + float vMin = std::numeric_limits::infinity(); + float vMax = -std::numeric_limits::infinity(); + for (const auto& p : track) { vMin = std::min(vMin, p.v); vMax = std::max(vMax, p.v); } + const float tRange = (tMax > tMin) ? (tMax - tMin) : 1.0f; + const float vRange = (vMax > vMin) ? (vMax - vMin) : 1.0f; + + const int W = 1600; + const int H = 900; + const int M = 70; + auto mapX = [&](float t) -> float { + const float u = (t - tMin) / tRange; + return static_cast(M) + clamp01(u) * static_cast(W - 2 * M); + }; + auto mapY = [&](float v) -> float { + const float u = (v - vMin) / vRange; + return static_cast(H - M) - clamp01(u) * static_cast(H - 2 * M); + }; + auto mapAudioToTrackT = [&](double sec) -> float { + const double u = (durSec > 1.0e-6) ? std::max(0.0, std::min(1.0, sec / durSec)) : 0.0; + return tMin + static_cast(u) * tRange; + }; + + std::ofstream out(vizOutHtml, std::ios::binary); + if (!out.is_open()) { + std::cerr << "Viz: failed to open for write: " << vizOutHtml << "\n"; + return 1; + } + + // Use absolute file path for browser