Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
313370ed7f |
@@ -1,5 +1,7 @@
|
||||
# OpenRoller
|
||||
|
||||
meow
|
||||
|
||||
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.
|
||||
@@ -37,9 +39,7 @@ workspace/
|
||||
└── openroller/
|
||||
```
|
||||
|
||||
Install CMake, a C++ compiler, and OpenGL development files, then build. SDL3,
|
||||
GLM, and the PNG decoder are fetched automatically when no installed package
|
||||
is available:
|
||||
Install SDL3, OpenGL, libpng and GLM, then build:
|
||||
|
||||
```sh
|
||||
cmake -S openroller -B openroller/build -DCMAKE_BUILD_TYPE=Release
|
||||
@@ -53,33 +53,6 @@ with `VECTORAIL_CORE_SOURCE_DIR` and `VECTORAIL_GC_SOURCE_DIR`.
|
||||
|
||||
The raw stage inspection utility is built as `openroller-stage-probe`.
|
||||
|
||||
### Windows
|
||||
|
||||
To cross-build a self-contained x86-64 package from Linux with MinGW-w64:
|
||||
|
||||
```sh
|
||||
cmake -S openroller -B openroller/build-windows \
|
||||
-DCMAKE_TOOLCHAIN_FILE=openroller/cmake/toolchains/mingw-x86_64.cmake \
|
||||
-DCMAKE_BUILD_TYPE=Release
|
||||
cmake --build openroller/build-windows --parallel
|
||||
cmake --install openroller/build-windows \
|
||||
--prefix openroller/build-windows/package
|
||||
```
|
||||
|
||||
For a native MSYS2 CLANG64 build, install CMake, Ninja, Clang, SDL3, GLM, and
|
||||
OpenGL packages in the CLANG64 shell, then run:
|
||||
|
||||
```sh
|
||||
cmake -S openroller -B openroller/build-windows -G Ninja \
|
||||
-DCMAKE_BUILD_TYPE=Release
|
||||
cmake --build openroller/build-windows --parallel
|
||||
cmake --install openroller/build-windows \
|
||||
--prefix openroller/build-windows/package
|
||||
```
|
||||
|
||||
The package directory contains `OpenRoller.exe`, `SDL3.dll`,
|
||||
`openroller.cfg`, and the shaders needed at runtime.
|
||||
|
||||
## PSP build
|
||||
|
||||
The PSP port is built with the `pspdev/pspdev` container:
|
||||
|
||||
@@ -17,11 +17,6 @@ target_link_libraries(openroller-desktop
|
||||
Vectorail::GCEffects
|
||||
)
|
||||
|
||||
if(MINGW AND CMAKE_CXX_COMPILER_ID STREQUAL "GNU")
|
||||
target_link_options(openroller-desktop PRIVATE
|
||||
-static -static-libgcc -static-libstdc++)
|
||||
endif()
|
||||
|
||||
if(MSVC)
|
||||
target_compile_options(openroller-desktop PRIVATE /W4)
|
||||
else()
|
||||
@@ -30,27 +25,3 @@ endif()
|
||||
|
||||
configure_file(openroller.cfg openroller.cfg COPYONLY)
|
||||
file(COPY shaders DESTINATION "${CMAKE_CURRENT_BINARY_DIR}")
|
||||
|
||||
add_custom_command(TARGET openroller-desktop POST_BUILD
|
||||
COMMAND ${CMAKE_COMMAND} -E copy_directory
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/shaders"
|
||||
"$<TARGET_FILE_DIR:openroller-desktop>/shaders"
|
||||
COMMAND ${CMAKE_COMMAND} -E copy_if_different
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/openroller.cfg"
|
||||
"$<TARGET_FILE_DIR:openroller-desktop>/openroller.cfg"
|
||||
VERBATIM
|
||||
)
|
||||
|
||||
if(WIN32 AND TARGET SDL3::SDL3-shared)
|
||||
add_custom_command(TARGET openroller-desktop POST_BUILD
|
||||
COMMAND ${CMAKE_COMMAND} -E copy_if_different
|
||||
"$<TARGET_FILE:SDL3::SDL3-shared>"
|
||||
"$<TARGET_FILE_DIR:openroller-desktop>"
|
||||
VERBATIM
|
||||
)
|
||||
install(FILES "$<TARGET_FILE:SDL3::SDL3-shared>" DESTINATION .)
|
||||
endif()
|
||||
|
||||
install(TARGETS openroller-desktop RUNTIME DESTINATION .)
|
||||
install(DIRECTORY shaders DESTINATION .)
|
||||
install(FILES openroller.cfg DESTINATION .)
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include "vectorail/core/gl_loader.hpp"
|
||||
|
||||
inline void ActivateTexture(GLenum texture) {
|
||||
using ActiveTextureProc = void(APIENTRY *)(GLenum);
|
||||
static const auto activeTexture = reinterpret_cast<ActiveTextureProc>(
|
||||
SDL_GL_GetProcAddress("glActiveTexture"));
|
||||
if (activeTexture) activeTexture(texture);
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
#include "openroller/desktop/ServiceMenu.hpp"
|
||||
|
||||
#include "openroller/desktop/CabinetBackend.hpp"
|
||||
#include "openroller/desktop/GlCompat.hpp"
|
||||
#include "vectorail/core/Shader.hpp"
|
||||
#include "vectorail/core/gl_loader.hpp"
|
||||
|
||||
@@ -110,7 +109,7 @@ public:
|
||||
shader_.setBool("uUseTexture", true);
|
||||
shader_.setBool("uUseGradient", false);
|
||||
shader_.setVec4("uColor", color);
|
||||
ActivateTexture(GL_TEXTURE0);
|
||||
glActiveTexture(GL_TEXTURE0);
|
||||
glBindTexture(GL_TEXTURE_2D, activeTexture);
|
||||
glBindVertexArray(vao_);
|
||||
glBindBuffer(GL_ARRAY_BUFFER, vbo_);
|
||||
|
||||
+105
-540
@@ -17,9 +17,7 @@
|
||||
#include <cstdint>
|
||||
#include <cstdlib>
|
||||
#include <fstream>
|
||||
#include <initializer_list>
|
||||
#include <iostream>
|
||||
#include <random>
|
||||
#include <unordered_map>
|
||||
#include <unordered_set>
|
||||
#include <vector>
|
||||
@@ -46,67 +44,6 @@ enum class SelectTask {
|
||||
Difficulty,
|
||||
};
|
||||
|
||||
enum class MenuMotion {
|
||||
MusicEnter,
|
||||
Idle,
|
||||
SongScroll,
|
||||
SortChange,
|
||||
MusicDecision,
|
||||
MusicExit,
|
||||
ModeEnter,
|
||||
DifficultyChange,
|
||||
ModeExitBack,
|
||||
ModeDecision,
|
||||
ModeExitConfirm,
|
||||
};
|
||||
|
||||
constexpr float kMenuEnterSeconds = 0.375f;
|
||||
constexpr float kMenuExitSeconds = 0.375f;
|
||||
constexpr float kSongScrollSeconds = 0.125f;
|
||||
constexpr float kMenuChangeSeconds = 0.15f;
|
||||
constexpr float kDecisionSeconds = 0.25f;
|
||||
|
||||
constexpr std::array<int, 12> kCarouselOffsets{
|
||||
-5, -4, -3, -2, -1, 0, 0, 1, 2, 3, 4, 5
|
||||
};
|
||||
constexpr std::array<float, 12> kCarouselRowY{
|
||||
175, 228, 281, 334, 387, 440, 701, 754, 807, 860, 913, 966
|
||||
};
|
||||
constexpr std::array<float, 12> kCarouselIndexY{
|
||||
189, 242, 295, 348, 401, 454, 715, 768, 821, 874, 927, 980
|
||||
};
|
||||
constexpr std::array<float, 12> kCarouselCentersY{
|
||||
209, 262, 315, 368, 421, 474, 735, 788, 841, 894, 947, 1000
|
||||
};
|
||||
constexpr std::array<float, 12> kCarouselAlpha{
|
||||
0.0f, 0.7f, 0.8f, 0.9f, 1.0f, 0.0f,
|
||||
0.0f, 1.0f, 0.9f, 0.8f, 0.7f, 0.0f
|
||||
};
|
||||
|
||||
float saturate(float value) {
|
||||
return std::clamp(value, 0.0f, 1.0f);
|
||||
}
|
||||
|
||||
float lerp(float from, float to, float amount) {
|
||||
return from + (to - from) * amount;
|
||||
}
|
||||
|
||||
int carouselSlotForOffset(int offset, bool lowerZero = false) {
|
||||
if (offset < -5 || offset > 5) return -1;
|
||||
if (offset < 0) return offset + 5;
|
||||
if (offset > 0) return offset + 6;
|
||||
return lowerZero ? 6 : 5;
|
||||
}
|
||||
|
||||
int wrappedCarouselDistance(size_t origin, size_t target, size_t count) {
|
||||
if (count == 0) return 0;
|
||||
int distance = static_cast<int>(target) - static_cast<int>(origin);
|
||||
const int wrappedCount = static_cast<int>(count);
|
||||
if (distance > wrappedCount / 2) distance -= wrappedCount;
|
||||
if (distance < -wrappedCount / 2) distance += wrappedCount;
|
||||
return distance;
|
||||
}
|
||||
|
||||
struct Song {
|
||||
gc::StageCatalogEntry catalog;
|
||||
fs::path menuTexture;
|
||||
@@ -423,18 +360,6 @@ bool readFile(const fs::path& path, std::vector<uint8_t>* bytes) {
|
||||
return file.good() || file.eof();
|
||||
}
|
||||
|
||||
bool loadSongMenuTexture(const std::string& path, DdsTexture& texture,
|
||||
std::string* error = nullptr) {
|
||||
if (!loadDdsTexture(path, texture, error)) return false;
|
||||
// The title and artist glyphs are already antialiased in the atlas.
|
||||
// Nearest magnification keeps their transparent outline texels intact at
|
||||
// the arcade's authored 1:1 size; minified carousel copies remain linear.
|
||||
glBindTexture(GL_TEXTURE_2D, texture.id);
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
|
||||
glBindTexture(GL_TEXTURE_2D, 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
size_t imageTextureIndex(const std::string& symbol) {
|
||||
if (symbol.rfind("Image", 0) != 0 || symbol.size() <= 5) return SIZE_MAX;
|
||||
size_t number = 0;
|
||||
@@ -495,16 +420,9 @@ public:
|
||||
const auto found = textures_.find(draw.imageSymbol);
|
||||
if (found == textures_.end()) continue;
|
||||
auto corners = draw.corners;
|
||||
float symbolDx = 0.0f;
|
||||
float symbolDy = 0.0f;
|
||||
const auto translated = symbolTranslations_.find(draw.imageSymbol);
|
||||
if (translated != symbolTranslations_.end()) {
|
||||
symbolDx = translated->second[0];
|
||||
symbolDy = translated->second[1];
|
||||
}
|
||||
for (auto& corner : corners) {
|
||||
corner[0] += dx + symbolDx;
|
||||
corner[1] += dy + symbolDy;
|
||||
corner[0] += dx;
|
||||
corner[1] += dy;
|
||||
}
|
||||
ui.textureQuad(found->second, corners,
|
||||
glm::vec4(draw.color[0], draw.color[1], draw.color[2],
|
||||
@@ -515,7 +433,13 @@ public:
|
||||
bool ready() const { return !textures_.empty(); }
|
||||
|
||||
void translateSymbol(const std::string& symbol, float dx, float dy) {
|
||||
symbolTranslations_[symbol] = {dx, dy};
|
||||
for (gc::RvbImageDraw& draw : draws_) {
|
||||
if (draw.imageSymbol != symbol) continue;
|
||||
for (auto& corner : draw.corners) {
|
||||
corner[0] += dx;
|
||||
corner[1] += dy;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void clear() {
|
||||
@@ -523,7 +447,6 @@ public:
|
||||
if (texture.id != 0) glDeleteTextures(1, &texture.id);
|
||||
}
|
||||
textures_.clear();
|
||||
symbolTranslations_.clear();
|
||||
draws_.clear();
|
||||
rvbBytes_.clear();
|
||||
mtxBytes_.clear();
|
||||
@@ -552,7 +475,6 @@ private:
|
||||
}
|
||||
std::vector<gc::RvbImageDraw> draws_;
|
||||
std::unordered_map<std::string, DdsTexture> textures_;
|
||||
std::unordered_map<std::string, std::array<float, 2>> symbolTranslations_;
|
||||
std::vector<uint8_t> rvbBytes_;
|
||||
std::vector<uint8_t> mtxBytes_;
|
||||
gc::RvbScene scene_;
|
||||
@@ -574,8 +496,7 @@ gc::RvbSnapshotState selectMusicRowState() {
|
||||
return state;
|
||||
}
|
||||
|
||||
gc::RvbSnapshotState selectMusicSceneState(const Song* song = nullptr,
|
||||
int activeSort = 3) {
|
||||
gc::RvbSnapshotState selectMusicSceneState(const Song* song = nullptr) {
|
||||
gc::RvbSnapshotState state;
|
||||
auto& frame = state.frameByPath;
|
||||
frame["/"] = "jf_slmusic_start";
|
||||
@@ -610,10 +531,10 @@ gc::RvbSnapshotState selectMusicSceneState(const Song* song = nullptr,
|
||||
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 and maps them
|
||||
// through "34621857". Callers pass the resulting visual tab (1..8).
|
||||
activeSort = std::clamp(activeSort, 1, 8);
|
||||
frame["/imc_sort"] = "jf_sort" + std::to_string(activeSort) + "_ini";
|
||||
// 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.
|
||||
@@ -628,22 +549,6 @@ gc::RvbSnapshotState selectMusicSceneState(const Song* song = nullptr,
|
||||
return state;
|
||||
}
|
||||
|
||||
gc::RvbSnapshotState selectMusicExitSceneState(const Song* song, int activeSort) {
|
||||
gc::RvbSnapshotState state = selectMusicSceneState(song, activeSort);
|
||||
auto& frame = state.frameByPath;
|
||||
frame["/"] = "jf_slmusic_end";
|
||||
frame["/imc_navi"] = "tg_navi_end";
|
||||
frame["/imc_title"] = "lf_title_selectmusic_fo";
|
||||
frame["/imc_focus"] = "jf_focus_fd_out_m";
|
||||
return state;
|
||||
}
|
||||
|
||||
gc::RvbSnapshotState selectMusicDecisionSceneState(const Song* song, int activeSort) {
|
||||
gc::RvbSnapshotState state = selectMusicSceneState(song, activeSort);
|
||||
state.frameByPath["/imc_focus"] = "jf_decision";
|
||||
return state;
|
||||
}
|
||||
|
||||
gc::RvbSnapshotState selectModeSceneState(const Song* song = nullptr, int difficulty = 0) {
|
||||
gc::RvbSnapshotState state;
|
||||
auto& frame = state.frameByPath;
|
||||
@@ -659,13 +564,13 @@ gc::RvbSnapshotState selectModeSceneState(const Song* song = nullptr, int diffic
|
||||
"/imc_slmode/imc_mode/imc_m_hard", "/imc_slmode/imc_mode/imc_m_extra"
|
||||
};
|
||||
static constexpr std::array<const char*, 4> selected{
|
||||
"jf_m_simple_on", "jf_m_normal_on", "jf_m_hard_on", "jf_m_extra_on"
|
||||
"jf_m_simple_ini", "jf_m_normal_ini", "jf_m_hard_ini", "jf_m_extra_ini"
|
||||
};
|
||||
static constexpr std::array<const char*, 4> visible{
|
||||
"jf_m_simple_off", "jf_m_normal_off", "jf_m_hard_off", "jf_m_extra_off"
|
||||
"jf_m_simple_on", "jf_m_normal_on", "jf_m_hard_on", "jf_m_extra_on"
|
||||
};
|
||||
static constexpr std::array<const char*, 4> unavailable{
|
||||
"jf_m_simple_xxx", "jf_m_normal_xxx", "jf_m_hard_xxx", "jf_m_extra_xxx"
|
||||
"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();
|
||||
@@ -685,16 +590,6 @@ gc::RvbSnapshotState selectModeSceneState(const Song* song = nullptr, int diffic
|
||||
return state;
|
||||
}
|
||||
|
||||
gc::RvbSnapshotState selectModeExitSceneState(const Song* song, int difficulty) {
|
||||
gc::RvbSnapshotState state = selectModeSceneState(song, difficulty);
|
||||
auto& frame = state.frameByPath;
|
||||
frame["/"] = "jf_slmode_end";
|
||||
frame["/imc_navi"] = "tg_navi_end";
|
||||
frame["/imc_title"] = "jf_title_mode_fo";
|
||||
frame["/imc_slmode"] = "jf_mode_fo";
|
||||
return state;
|
||||
}
|
||||
|
||||
gc::RvbSnapshotState commonSelectSceneState() {
|
||||
gc::RvbSnapshotState state;
|
||||
auto& frame = state.frameByPath;
|
||||
@@ -714,15 +609,6 @@ gc::RvbSnapshotState commonSelectSceneState() {
|
||||
return state;
|
||||
}
|
||||
|
||||
gc::RvbSnapshotState commonSelectExitSceneState() {
|
||||
gc::RvbSnapshotState state = commonSelectSceneState();
|
||||
auto& frame = state.frameByPath;
|
||||
frame["/imc_ctrl_anim"] = "jf_ctrl_end";
|
||||
frame["/imc_head"] = "jf_head_fo";
|
||||
frame["/imc_foot"] = "jf_foot_fo";
|
||||
return state;
|
||||
}
|
||||
|
||||
gc::RvbSnapshotState navigatorSelectSceneState() {
|
||||
gc::RvbSnapshotState state;
|
||||
auto& frame = state.frameByPath;
|
||||
@@ -733,19 +619,17 @@ gc::RvbSnapshotState navigatorSelectSceneState() {
|
||||
return state;
|
||||
}
|
||||
|
||||
gc::RvbSnapshotState navigatorSelectExitSceneState() {
|
||||
gc::RvbSnapshotState state = navigatorSelectSceneState();
|
||||
state.frameByPath["/"] = "jf_ope_end";
|
||||
return state;
|
||||
}
|
||||
|
||||
gc::RvbSnapshotState animateRvbState(
|
||||
gc::RvbSnapshotState state, uint32_t animationFrame,
|
||||
std::initializer_list<const char*> paths) {
|
||||
for (const char* path : paths) {
|
||||
state.animationFrameByPath[path] = animationFrame;
|
||||
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";
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
int genreLabelRow(int genre) {
|
||||
@@ -916,94 +800,35 @@ bool runSongSelect(SDL_Window* window, const fs::path& gcRoot, std::string* sele
|
||||
}
|
||||
std::unordered_map<size_t, DdsTexture> textures;
|
||||
|
||||
// Visual order in selectmusic2: New, Monthly, Genre, Difficulty,
|
||||
// Score Average, Title, Favorite, At random.
|
||||
constexpr size_t kGenreSort = 2;
|
||||
size_t sortSlot = kGenreSort;
|
||||
const std::array<int, 8> genres{-1, 1, 2, 7, 3, 4, 5, 6};
|
||||
size_t genreSlot = 0;
|
||||
std::vector<size_t> visible;
|
||||
std::vector<CarouselEntry> carousel;
|
||||
std::mt19937 random(std::random_device{}());
|
||||
auto rebuildVisible = [&] {
|
||||
visible.clear();
|
||||
carousel.clear();
|
||||
visible.reserve(songs.size());
|
||||
for (size_t i = 0; i < songs.size(); ++i) visible.push_back(i);
|
||||
|
||||
const auto titleLess = [&](size_t lhs, size_t rhs) {
|
||||
const auto& a = songs[lhs].catalog;
|
||||
const auto& b = songs[rhs].catalog;
|
||||
const std::string& aKey = a.sortKey.empty() ? a.title : a.sortKey;
|
||||
const std::string& bKey = b.sortKey.empty() ? b.title : b.sortKey;
|
||||
return aKey == bKey ? a.id < b.id : aKey < bKey;
|
||||
};
|
||||
switch (sortSlot) {
|
||||
case 0: // Newest catalog entries first.
|
||||
std::stable_sort(visible.begin(), visible.end(), [&](size_t lhs, size_t rhs) {
|
||||
return songs[lhs].catalog.id > songs[rhs].catalog.id;
|
||||
});
|
||||
break;
|
||||
case kGenreSort:
|
||||
std::stable_sort(visible.begin(), visible.end(), [&](size_t lhs, size_t rhs) {
|
||||
const int a = genreLabelRow(songs[lhs].catalog.genre);
|
||||
const int b = genreLabelRow(songs[rhs].catalog.genre);
|
||||
return a == b ? titleLess(lhs, rhs) : a < b;
|
||||
});
|
||||
break;
|
||||
case 3: // Highest chart rating first.
|
||||
std::stable_sort(visible.begin(), visible.end(), [&](size_t lhs, size_t rhs) {
|
||||
const auto& a = songs[lhs].catalog.difficultyRatings;
|
||||
const auto& b = songs[rhs].catalog.difficultyRatings;
|
||||
const uint8_t aMax = *std::max_element(a.begin(), a.end());
|
||||
const uint8_t bMax = *std::max_element(b.begin(), b.end());
|
||||
return aMax == bMax ? titleLess(lhs, rhs) : aMax > bMax;
|
||||
});
|
||||
break;
|
||||
case 5:
|
||||
std::stable_sort(visible.begin(), visible.end(), titleLess);
|
||||
break;
|
||||
case 7:
|
||||
std::shuffle(visible.begin(), visible.end(), random);
|
||||
break;
|
||||
default:
|
||||
// Theme, score and favorite data live in the arcade profile
|
||||
// services. Preserve catalog order when no profile is loaded.
|
||||
break;
|
||||
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);
|
||||
}
|
||||
|
||||
if (sortSlot == kGenreSort) {
|
||||
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});
|
||||
}
|
||||
} else {
|
||||
for (const size_t songIndex : visible) {
|
||||
carousel.push_back({false, songIndex, songs[songIndex].catalog.genre});
|
||||
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;
|
||||
size_t previousSelection = selection;
|
||||
int scrollDelta = 0;
|
||||
MenuMotion motion = MenuMotion::MusicEnter;
|
||||
Uint64 motionStartTicks = SDL_GetTicks();
|
||||
const auto startMotion = [&](MenuMotion next) {
|
||||
motion = next;
|
||||
motionStartTicks = SDL_GetTicks();
|
||||
};
|
||||
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, static_cast<int>(sortSlot + 1)));
|
||||
}
|
||||
if (originalMusic.ready()) originalMusic.setState(selectMusicSceneState(&song));
|
||||
if (originalDifficulty.ready()) {
|
||||
originalDifficulty.setState(selectModeSceneState(&song, difficulty));
|
||||
}
|
||||
@@ -1011,30 +836,21 @@ bool runSongSelect(SDL_Window* window, const fs::path& gcRoot, std::string* sele
|
||||
refreshOriginalScenes();
|
||||
auto changeSong = [&](int delta) {
|
||||
if (visible.empty()) return;
|
||||
previousSelection = selection;
|
||||
scrollDelta = delta;
|
||||
const int count = static_cast<int>(visible.size());
|
||||
int next = (static_cast<int>(selection) + delta) % count;
|
||||
if (next < 0) next += count;
|
||||
selection = static_cast<size_t>(next);
|
||||
difficulty = firstPlayableDifficulty(songs[visible[selection]], difficulty);
|
||||
refreshOriginalScenes();
|
||||
startMotion(MenuMotion::SongScroll);
|
||||
};
|
||||
auto changeSort = [&](int delta) {
|
||||
const size_t selectedSong = visible.empty() ? 0 : visible[selection];
|
||||
int next = (static_cast<int>(sortSlot) + delta) % 8;
|
||||
if (next < 0) next += 8;
|
||||
sortSlot = static_cast<size_t>(next);
|
||||
auto changeGenre = [&](int delta) {
|
||||
int next = (static_cast<int>(genreSlot) + delta) % static_cast<int>(genres.size());
|
||||
if (next < 0) next += static_cast<int>(genres.size());
|
||||
genreSlot = static_cast<size_t>(next);
|
||||
selection = 0;
|
||||
rebuildVisible();
|
||||
const auto selected = std::find(visible.begin(), visible.end(), selectedSong);
|
||||
selection = selected == visible.end()
|
||||
? 0 : static_cast<size_t>(std::distance(visible.begin(), selected));
|
||||
if (!visible.empty()) {
|
||||
difficulty = firstPlayableDifficulty(songs[visible[selection]], difficulty);
|
||||
}
|
||||
if (!visible.empty()) difficulty = firstPlayableDifficulty(songs[visible[selection]]);
|
||||
refreshOriginalScenes();
|
||||
startMotion(MenuMotion::SortChange);
|
||||
};
|
||||
auto changeDifficulty = [&](int delta) {
|
||||
const Song& song = songs[visible[selection]];
|
||||
@@ -1044,7 +860,6 @@ bool runSongSelect(SDL_Window* window, const fs::path& gcRoot, std::string* sele
|
||||
if (!song.stages[next].empty()) {
|
||||
difficulty = next;
|
||||
refreshOriginalScenes();
|
||||
startMotion(MenuMotion::DifficultyChange);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -1055,78 +870,10 @@ bool runSongSelect(SDL_Window* window, const fs::path& gcRoot, std::string* sele
|
||||
SelectTask task = SelectTask::Music;
|
||||
bool capturedMusic = false;
|
||||
bool capturedDifficulty = false;
|
||||
bool capturedMusicDecision = false;
|
||||
bool capturedMusicExit = false;
|
||||
bool capturedModeEnter = false;
|
||||
const char* capturePrefix = std::getenv("OPENROLLER_CAPTURE_PREFIX");
|
||||
const bool captureBoth = std::getenv("OPENROLLER_CAPTURE_BOTH") != nullptr;
|
||||
const bool captureTransitions =
|
||||
std::getenv("OPENROLLER_CAPTURE_TRANSITIONS") != nullptr;
|
||||
const Uint64 menuStartTicks = SDL_GetTicks();
|
||||
while (running) {
|
||||
const Uint64 nowTicks = SDL_GetTicks();
|
||||
const float motionSeconds = static_cast<float>(nowTicks - motionStartTicks) / 1000.0f;
|
||||
switch (motion) {
|
||||
case MenuMotion::MusicEnter:
|
||||
if (motionSeconds >= kMenuEnterSeconds) {
|
||||
motion = MenuMotion::Idle;
|
||||
refreshOriginalScenes();
|
||||
originalCommon.setState(commonSelectSceneState());
|
||||
originalNavigator.setState(navigatorSelectSceneState());
|
||||
}
|
||||
break;
|
||||
case MenuMotion::SongScroll:
|
||||
if (motionSeconds >= kSongScrollSeconds) {
|
||||
motion = MenuMotion::Idle;
|
||||
refreshOriginalScenes();
|
||||
}
|
||||
break;
|
||||
case MenuMotion::SortChange:
|
||||
case MenuMotion::DifficultyChange:
|
||||
if (motionSeconds >= kMenuChangeSeconds) {
|
||||
motion = MenuMotion::Idle;
|
||||
refreshOriginalScenes();
|
||||
}
|
||||
break;
|
||||
case MenuMotion::MusicDecision:
|
||||
if (motionSeconds >= kDecisionSeconds) {
|
||||
startMotion(MenuMotion::MusicExit);
|
||||
}
|
||||
break;
|
||||
case MenuMotion::MusicExit:
|
||||
if (motionSeconds >= kMenuExitSeconds) {
|
||||
task = SelectTask::Difficulty;
|
||||
startMotion(MenuMotion::ModeEnter);
|
||||
}
|
||||
break;
|
||||
case MenuMotion::ModeEnter:
|
||||
if (motionSeconds >= kMenuEnterSeconds) {
|
||||
motion = MenuMotion::Idle;
|
||||
refreshOriginalScenes();
|
||||
originalCommon.setState(commonSelectSceneState());
|
||||
originalNavigator.setState(navigatorSelectSceneState());
|
||||
}
|
||||
break;
|
||||
case MenuMotion::ModeExitBack:
|
||||
if (motionSeconds >= kMenuExitSeconds) {
|
||||
task = SelectTask::Music;
|
||||
startMotion(MenuMotion::MusicEnter);
|
||||
}
|
||||
break;
|
||||
case MenuMotion::ModeDecision:
|
||||
if (motionSeconds >= kDecisionSeconds) {
|
||||
startMotion(MenuMotion::ModeExitConfirm);
|
||||
}
|
||||
break;
|
||||
case MenuMotion::ModeExitConfirm:
|
||||
if (motionSeconds >= kMenuExitSeconds) {
|
||||
confirmed = true;
|
||||
running = false;
|
||||
}
|
||||
break;
|
||||
case MenuMotion::Idle:
|
||||
break;
|
||||
}
|
||||
SDL_Event event;
|
||||
while (SDL_PollEvent(&event)) {
|
||||
if (event.type == SDL_EVENT_QUIT) running = false;
|
||||
@@ -1136,7 +883,6 @@ bool runSongSelect(SDL_Window* window, const fs::path& gcRoot, std::string* sele
|
||||
SDL_SetWindowTitle(window, "OpenRoller");
|
||||
continue;
|
||||
}
|
||||
if (motion != MenuMotion::Idle) continue;
|
||||
if (task == SelectTask::Music) {
|
||||
switch (event.key.key) {
|
||||
case SDLK_ESCAPE: running = false; break;
|
||||
@@ -1144,17 +890,17 @@ bool runSongSelect(SDL_Window* window, const fs::path& gcRoot, std::string* sele
|
||||
case SDLK_DOWN: case SDLK_S: changeSong(1); break;
|
||||
case SDLK_PAGEUP: changeSong(-8); break;
|
||||
case SDLK_PAGEDOWN: changeSong(8); break;
|
||||
case SDLK_Q: changeSort(-1); break;
|
||||
case SDLK_E: case SDLK_TAB: changeSort(1); break;
|
||||
case SDLK_Q: changeGenre(-1); break;
|
||||
case SDLK_E: case SDLK_TAB: changeGenre(1); break;
|
||||
case SDLK_RETURN: case SDLK_SPACE:
|
||||
startMotion(MenuMotion::MusicDecision);
|
||||
task = SelectTask::Difficulty;
|
||||
break;
|
||||
default: break;
|
||||
}
|
||||
} else {
|
||||
switch (event.key.key) {
|
||||
case SDLK_ESCAPE:
|
||||
startMotion(MenuMotion::ModeExitBack);
|
||||
task = SelectTask::Music;
|
||||
break;
|
||||
case SDLK_UP: case SDLK_W: case SDLK_LEFT: case SDLK_A:
|
||||
changeDifficulty(-1);
|
||||
@@ -1163,7 +909,8 @@ bool runSongSelect(SDL_Window* window, const fs::path& gcRoot, std::string* sele
|
||||
changeDifficulty(1);
|
||||
break;
|
||||
case SDLK_RETURN: case SDLK_SPACE:
|
||||
startMotion(MenuMotion::ModeDecision);
|
||||
confirmed = true;
|
||||
running = false;
|
||||
break;
|
||||
default: break;
|
||||
}
|
||||
@@ -1172,86 +919,6 @@ bool runSongSelect(SDL_Window* window, const fs::path& gcRoot, std::string* sele
|
||||
if (visible.empty()) continue;
|
||||
|
||||
const Song& selected = songs[visible[selection]];
|
||||
const float activeMotionSeconds =
|
||||
static_cast<float>(SDL_GetTicks() - motionStartTicks) / 1000.0f;
|
||||
const uint32_t rvbFrame = static_cast<uint32_t>(activeMotionSeconds * 60.0f);
|
||||
if (motion == MenuMotion::MusicEnter) {
|
||||
originalMusic.setState(animateRvbState(
|
||||
selectMusicSceneState(&selected, static_cast<int>(sortSlot + 1)), rvbFrame,
|
||||
{"/", "/imc_navi", "/imc_title", "/imc_focus",
|
||||
"/imc_focus/imc_fd_jacket_anim", "/imc_sort"}));
|
||||
originalCommon.setState(animateRvbState(
|
||||
commonSelectSceneState(), rvbFrame,
|
||||
{"/imc_ctrl_anim", "/imc_head", "/imc_foot"}));
|
||||
originalNavigator.setState(animateRvbState(
|
||||
navigatorSelectSceneState(), rvbFrame, {"/", "/imc_ope"}));
|
||||
} else if (motion == MenuMotion::SongScroll) {
|
||||
gc::RvbSnapshotState state =
|
||||
selectMusicSceneState(&selected, static_cast<int>(sortSlot + 1));
|
||||
state.frameByPath["/imc_focus"] = "jf_focus_fd_move";
|
||||
originalMusic.setState(animateRvbState(
|
||||
std::move(state), rvbFrame, {"/imc_focus"}));
|
||||
} else if (motion == MenuMotion::SortChange) {
|
||||
originalMusic.setState(animateRvbState(
|
||||
selectMusicSceneState(&selected, static_cast<int>(sortSlot + 1)), rvbFrame,
|
||||
{"/imc_sort"}));
|
||||
} else if (motion == MenuMotion::MusicDecision) {
|
||||
originalMusic.setState(animateRvbState(
|
||||
selectMusicDecisionSceneState(&selected, static_cast<int>(sortSlot + 1)),
|
||||
rvbFrame, {"/imc_focus"}));
|
||||
} else if (motion == MenuMotion::MusicExit) {
|
||||
originalMusic.setState(animateRvbState(
|
||||
selectMusicExitSceneState(&selected, static_cast<int>(sortSlot + 1)), rvbFrame,
|
||||
{"/", "/imc_navi", "/imc_title", "/imc_focus"}));
|
||||
originalCommon.setState(animateRvbState(
|
||||
commonSelectExitSceneState(), rvbFrame,
|
||||
{"/imc_ctrl_anim", "/imc_head", "/imc_foot"}));
|
||||
originalNavigator.setState(animateRvbState(
|
||||
navigatorSelectExitSceneState(), rvbFrame, {"/"}));
|
||||
} else if (motion == MenuMotion::ModeEnter) {
|
||||
originalDifficulty.setState(animateRvbState(
|
||||
selectModeSceneState(&selected, difficulty), rvbFrame,
|
||||
{"/", "/imc_navi", "/imc_title", "/imc_slmode", "/imc_tab"}));
|
||||
originalCommon.setState(animateRvbState(
|
||||
commonSelectSceneState(), rvbFrame,
|
||||
{"/imc_ctrl_anim", "/imc_head", "/imc_foot"}));
|
||||
originalNavigator.setState(animateRvbState(
|
||||
navigatorSelectSceneState(), rvbFrame, {"/", "/imc_ope"}));
|
||||
} else if (motion == MenuMotion::DifficultyChange) {
|
||||
originalDifficulty.setState(animateRvbState(
|
||||
selectModeSceneState(&selected, difficulty), rvbFrame,
|
||||
{"/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",
|
||||
"/imc_slmode/imc_mode/imc_tri_set_exoff",
|
||||
"/imc_slmode/imc_mode/imc_tri_set_exon",
|
||||
"/imc_tab/imc_info_m_tab"}));
|
||||
} else if (motion == MenuMotion::ModeDecision) {
|
||||
gc::RvbSnapshotState state = selectModeSceneState(&selected, difficulty);
|
||||
static constexpr std::array<const char*, 4> focusPaths{
|
||||
"/imc_slmode/imc_mode/imc_m_smpl/imc_m_focus",
|
||||
"/imc_slmode/imc_mode/imc_m_nrml/imc_m_focus",
|
||||
"/imc_slmode/imc_mode/imc_m_hard/imc_m_focus",
|
||||
"/imc_slmode/imc_mode/imc_m_extra/imc_m_focus"
|
||||
};
|
||||
const char* focusPath = focusPaths[static_cast<size_t>(difficulty)];
|
||||
state.frameByPath[focusPath] = "jf_mode_decision";
|
||||
originalDifficulty.setState(animateRvbState(
|
||||
std::move(state), rvbFrame, {focusPath}));
|
||||
} else if (motion == MenuMotion::ModeExitBack ||
|
||||
motion == MenuMotion::ModeExitConfirm) {
|
||||
gc::RvbSnapshotState state =
|
||||
selectModeExitSceneState(&selected, difficulty);
|
||||
originalDifficulty.setState(animateRvbState(
|
||||
std::move(state), rvbFrame,
|
||||
{"/", "/imc_navi", "/imc_title", "/imc_slmode"}));
|
||||
originalCommon.setState(animateRvbState(
|
||||
commonSelectExitSceneState(), rvbFrame,
|
||||
{"/imc_ctrl_anim", "/imc_head", "/imc_foot"}));
|
||||
originalNavigator.setState(animateRvbState(
|
||||
navigatorSelectExitSceneState(), rvbFrame, {"/"}));
|
||||
}
|
||||
SDL_SetWindowTitle(window, ("OpenRoller - " + selected.catalog.imageKey).c_str());
|
||||
int pixelWidth = 0, pixelHeight = 0;
|
||||
SDL_GetWindowSizeInPixels(window, &pixelWidth, &pixelHeight);
|
||||
@@ -1296,90 +963,39 @@ bool runSongSelect(SDL_Window* window, const fs::path& gcRoot, std::string* sele
|
||||
if (logical < 0) logical += count;
|
||||
return carousel[static_cast<size_t>(logical)];
|
||||
};
|
||||
size_t previousCarouselEntry = selectedCarouselEntry;
|
||||
if (motion == MenuMotion::SongScroll && previousSelection < visible.size()) {
|
||||
const size_t previousSongIndex = visible[previousSelection];
|
||||
const auto previousEntry = std::find_if(
|
||||
carousel.begin(), carousel.end(), [previousSongIndex](const CarouselEntry& entry) {
|
||||
return !entry.category && entry.songIndex == previousSongIndex;
|
||||
});
|
||||
if (previousEntry != carousel.end()) {
|
||||
previousCarouselEntry = static_cast<size_t>(
|
||||
std::distance(carousel.begin(), previousEntry));
|
||||
}
|
||||
}
|
||||
const float scrollProgress = motion == MenuMotion::SongScroll
|
||||
? saturate(activeMotionSeconds / kSongScrollSeconds) : 1.0f;
|
||||
float musicLayerDx = 0.0f;
|
||||
float musicLayerAlpha = 1.0f;
|
||||
if (motion == MenuMotion::MusicEnter) {
|
||||
const float progress = saturate(activeMotionSeconds / kMenuEnterSeconds);
|
||||
const float remaining = 1.0f - progress;
|
||||
musicLayerDx = -static_cast<float>(kUiWidth) * remaining * remaining * remaining * remaining;
|
||||
musicLayerAlpha = progress;
|
||||
} else if (motion == MenuMotion::MusicExit) {
|
||||
const float progress = saturate(activeMotionSeconds / kMenuExitSeconds);
|
||||
const float eased = progress * progress * progress * progress;
|
||||
musicLayerDx = static_cast<float>(kUiWidth) * eased;
|
||||
musicLayerAlpha = 1.0f - progress;
|
||||
}
|
||||
const auto animatedSlot = [&](size_t targetSlot) {
|
||||
std::array<float, 4> geometry{
|
||||
kCarouselRowY[targetSlot], kCarouselIndexY[targetSlot],
|
||||
kCarouselCentersY[targetSlot], kCarouselAlpha[targetSlot]
|
||||
};
|
||||
if (motion != MenuMotion::SongScroll || carousel.empty()) return geometry;
|
||||
|
||||
int targetLogical = static_cast<int>(selectedCarouselEntry) +
|
||||
kCarouselOffsets[targetSlot];
|
||||
const int count = static_cast<int>(carousel.size());
|
||||
targetLogical %= count;
|
||||
if (targetLogical < 0) targetLogical += count;
|
||||
const int sourceOffset = wrappedCarouselDistance(
|
||||
previousCarouselEntry, static_cast<size_t>(targetLogical), carousel.size());
|
||||
const int sourceSlot = carouselSlotForOffset(
|
||||
sourceOffset, sourceOffset == 0 && scrollDelta < 0);
|
||||
if (sourceSlot >= 0) {
|
||||
geometry[0] = lerp(kCarouselRowY[static_cast<size_t>(sourceSlot)],
|
||||
geometry[0], scrollProgress);
|
||||
geometry[1] = lerp(kCarouselIndexY[static_cast<size_t>(sourceSlot)],
|
||||
geometry[1], scrollProgress);
|
||||
geometry[2] = lerp(kCarouselCentersY[static_cast<size_t>(sourceSlot)],
|
||||
geometry[2], scrollProgress);
|
||||
geometry[3] = lerp(kCarouselAlpha[static_cast<size_t>(sourceSlot)],
|
||||
geometry[3], scrollProgress);
|
||||
} else {
|
||||
geometry[3] *= scrollProgress;
|
||||
}
|
||||
return geometry;
|
||||
};
|
||||
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.
|
||||
for (size_t slot = 0; slot < kCarouselRowY.size(); ++slot) {
|
||||
if (carousel.empty()) continue;
|
||||
if (kCarouselOffsets[slot] == 0 &&
|
||||
((scrollDelta >= 0 && slot == 5) || (scrollDelta < 0 && slot == 6))) {
|
||||
continue;
|
||||
}
|
||||
const auto geometry = animatedSlot(slot);
|
||||
const float alpha = geometry[3] * musicLayerAlpha;
|
||||
if (alpha <= 0.0f) continue;
|
||||
const CarouselEntry& entry = carouselAtOffset(kCarouselOffsets[slot]);
|
||||
static constexpr std::array<int, 12> offsets{
|
||||
-5, -4, -3, -2, -1, 0, 0, 1, 2, 3, 4, 5
|
||||
};
|
||||
static constexpr std::array<float, 12> rowY{
|
||||
175, 228, 281, 334, 387, 440, 701, 754, 807, 860, 913, 966
|
||||
};
|
||||
static constexpr std::array<float, 12> indexY{
|
||||
189, 242, 295, 348, 401, 454, 715, 768, 821, 874, 927, 980
|
||||
};
|
||||
static constexpr std::array<float, 12> 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 + musicLayerDx, geometry[0], alpha);
|
||||
originalMusicRow.render(ui, 8.0f, rowY[slot], rowAlpha[slot]);
|
||||
} else if (entry.category && originalMusicIndex.ready()) {
|
||||
originalMusicIndex.render(ui, 88.0f + musicLayerDx, geometry[1], alpha);
|
||||
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 + musicLayerDx, geometry[1] + 2.0f,
|
||||
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, alpha));
|
||||
glm::vec4(1.0f, 1.0f, 1.0f, rowAlpha[slot]));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1391,22 +1007,10 @@ bool runSongSelect(SDL_Window* window, const fs::path& gcRoot, std::string* sele
|
||||
auto selectedTexture = textures.find(selectedSongIndex);
|
||||
if (selectedTexture == textures.end()) {
|
||||
DdsTexture loaded;
|
||||
if (loadSongMenuTexture(selected.menuTexture.string(), loaded, nullptr)) {
|
||||
if (loadDdsTexture(selected.menuTexture.string(), loaded, nullptr)) {
|
||||
selectedTexture = textures.emplace(selectedSongIndex, loaded).first;
|
||||
}
|
||||
}
|
||||
auto previousTexture = textures.end();
|
||||
if (motion == MenuMotion::SongScroll && previousSelection < visible.size()) {
|
||||
const size_t previousSongIndex = visible[previousSelection];
|
||||
previousTexture = textures.find(previousSongIndex);
|
||||
if (previousTexture == textures.end()) {
|
||||
DdsTexture loaded;
|
||||
if (loadSongMenuTexture(songs[previousSongIndex].menuTexture.string(), loaded,
|
||||
nullptr)) {
|
||||
previousTexture = textures.emplace(previousSongIndex, loaded).first;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!usingOriginal) {
|
||||
const glm::vec4 orange{1.0f, 0.31f, 0.08f, 1.0f};
|
||||
@@ -1420,14 +1024,10 @@ bool runSongSelect(SDL_Window* window, const fs::path& gcRoot, std::string* sele
|
||||
ui.text(550, 32, 3, "LOCAL", dark);
|
||||
ui.text(550, 62, 5, std::to_string(selection + 1) + "/" + std::to_string(visible.size()), dark);
|
||||
|
||||
static constexpr std::array<const char*, 8> sortNames{
|
||||
"NEW", "MONTHLY THEME", "GENRE", "DIFFICULTY",
|
||||
"SCORE AVERAGE", "TITLE", "FAVORITE", "AT RANDOM"
|
||||
};
|
||||
const glm::vec4 activeColor = genreColor(static_cast<int>(sortSlot));
|
||||
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(sortNames[sortSlot]) + " > E", glm::vec4(1.0f));
|
||||
ui.text(38, 149, 3, "Q < " + std::string(genreName(activeGenre)) + " > E", glm::vec4(1.0f));
|
||||
|
||||
constexpr int rows = 8;
|
||||
constexpr float rowY = 200.0f;
|
||||
@@ -1447,7 +1047,7 @@ bool runSongSelect(SDL_Window* window, const fs::path& gcRoot, std::string* sele
|
||||
auto found = textures.find(songIndex);
|
||||
if (found == textures.end()) {
|
||||
DdsTexture loaded;
|
||||
if (loadSongMenuTexture(songs[songIndex].menuTexture.string(), loaded, nullptr)) {
|
||||
if (loadDdsTexture(songs[songIndex].menuTexture.string(), loaded, nullptr)) {
|
||||
found = textures.emplace(songIndex, loaded).first;
|
||||
}
|
||||
}
|
||||
@@ -1504,69 +1104,51 @@ bool runSongSelect(SDL_Window* window, const fs::path& gcRoot, std::string* sele
|
||||
if (task == SelectTask::Music) {
|
||||
// FUN_005aca40 + FUN_005b34f0: fixed focus fragments from the
|
||||
// selected song's original 512x256 menu atlas.
|
||||
const auto renderFocus = [&](const DdsTexture& focusAtlas, float alpha) {
|
||||
const glm::vec4 tint{1.0f, 1.0f, 1.0f, alpha * musicLayerAlpha};
|
||||
ui.texture(focusAtlas, 39 + musicLayerDx, 497, 196, 196,
|
||||
1, 1, 196, 196, tint);
|
||||
ui.texture(focusAtlas, 251 + musicLayerDx, 467, 374, 34,
|
||||
0, 197, 374, 34, tint);
|
||||
ui.texture(focusAtlas, 262 + musicLayerDx, 500, 374, 24,
|
||||
0, 232, 374, 24, tint);
|
||||
ui.texture(focusAtlas, 262 + musicLayerDx, 522, 314, 16,
|
||||
198, 180, 314, 16, tint);
|
||||
};
|
||||
if (motion == MenuMotion::SongScroll && previousTexture != textures.end()) {
|
||||
renderFocus(previousTexture->second, 1.0f - scrollProgress);
|
||||
renderFocus(atlas, scrollProgress);
|
||||
} else {
|
||||
renderFocus(atlas, 1.0f);
|
||||
}
|
||||
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.
|
||||
for (size_t slot = 0; slot < kCarouselOffsets.size(); ++slot) {
|
||||
if (kCarouselOffsets[slot] == 0 &&
|
||||
((scrollDelta >= 0 && slot == 5) || (scrollDelta < 0 && slot == 6))) {
|
||||
continue;
|
||||
}
|
||||
const auto geometry = animatedSlot(slot);
|
||||
const float scale = geometry[3];
|
||||
const float alpha = scale * musicLayerAlpha;
|
||||
static constexpr std::array<int, 12> offsets{
|
||||
-5, -4, -3, -2, -1, 0, 0, 1, 2, 3, 4, 5
|
||||
};
|
||||
static constexpr std::array<float, 12> centersY{
|
||||
209, 262, 315, 368, 421, 474, 735, 788, 841, 894, 947, 1000
|
||||
};
|
||||
static constexpr std::array<float, 12> 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(kCarouselOffsets[slot]);
|
||||
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 (loadSongMenuTexture(songs[songIndex].menuTexture.string(), loaded, nullptr)) {
|
||||
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 + musicLayerDx - width * 0.5f,
|
||||
geometry[2] - height * 0.5f, width, height,
|
||||
0, 197, 374, 34,
|
||||
glm::vec4(1.0f, 1.0f, 1.0f, alpha));
|
||||
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.
|
||||
float modeAlpha = 1.0f;
|
||||
if (motion == MenuMotion::ModeEnter) {
|
||||
modeAlpha = saturate(activeMotionSeconds / kMenuEnterSeconds);
|
||||
} else if (motion == MenuMotion::ModeExitBack ||
|
||||
motion == MenuMotion::ModeExitConfirm) {
|
||||
modeAlpha = 1.0f - saturate(activeMotionSeconds / kMenuExitSeconds);
|
||||
}
|
||||
const glm::vec4 tint{1.0f, 1.0f, 1.0f, modeAlpha};
|
||||
ui.texture(atlas, 105, 167, 98, 98, 1, 1, 196, 196, tint);
|
||||
ui.texture(atlas, 209, 178, 374, 34, 0, 197, 374, 34, tint);
|
||||
ui.texture(atlas, 220, 214, 374, 24, 0, 232, 374, 24, tint);
|
||||
ui.texture(atlas, 220, 239, 314, 16, 198, 180, 314, 16, tint);
|
||||
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
|
||||
@@ -1575,28 +1157,11 @@ bool runSongSelect(SDL_Window* window, const fs::path& gcRoot, std::string* sele
|
||||
if (originalCommon.ready()) originalCommon.render(ui);
|
||||
ui.flush();
|
||||
|
||||
if (capturePrefix && captureTransitions && !capturedMusicDecision &&
|
||||
motion == MenuMotion::MusicDecision &&
|
||||
activeMotionSeconds >= kDecisionSeconds * 0.5f) {
|
||||
captureFrameForReverse(capturePrefix, "music-decision", pixelWidth, pixelHeight);
|
||||
capturedMusicDecision = true;
|
||||
} else if (capturePrefix && captureTransitions && !capturedMusicExit &&
|
||||
motion == MenuMotion::MusicExit &&
|
||||
activeMotionSeconds >= kMenuExitSeconds * 0.5f) {
|
||||
captureFrameForReverse(capturePrefix, "music-exit", pixelWidth, pixelHeight);
|
||||
capturedMusicExit = true;
|
||||
} else if (capturePrefix && captureTransitions && !capturedModeEnter &&
|
||||
motion == MenuMotion::ModeEnter &&
|
||||
activeMotionSeconds >= kMenuEnterSeconds * 0.5f) {
|
||||
captureFrameForReverse(capturePrefix, "mode-enter", pixelWidth, pixelHeight);
|
||||
capturedModeEnter = true;
|
||||
} else if (capturePrefix && motion == MenuMotion::Idle &&
|
||||
task == SelectTask::Music && !capturedMusic) {
|
||||
if (capturePrefix && task == SelectTask::Music && !capturedMusic) {
|
||||
captureFrameForReverse(capturePrefix, "music", pixelWidth, pixelHeight);
|
||||
capturedMusic = true;
|
||||
if (captureBoth) startMotion(MenuMotion::MusicDecision);
|
||||
} else if (capturePrefix && motion == MenuMotion::Idle &&
|
||||
task == SelectTask::Difficulty && !capturedDifficulty) {
|
||||
if (captureBoth) task = SelectTask::Difficulty;
|
||||
} else if (capturePrefix && task == SelectTask::Difficulty && !capturedDifficulty) {
|
||||
captureFrameForReverse(capturePrefix, "difficulty", pixelWidth, pixelHeight);
|
||||
capturedDifficulty = true;
|
||||
}
|
||||
|
||||
+10
-36
@@ -14,13 +14,8 @@
|
||||
#include <filesystem>
|
||||
#include <limits>
|
||||
#include <string_view>
|
||||
#ifdef _WIN32
|
||||
#include <windows.h>
|
||||
#include <process.h>
|
||||
#else
|
||||
#include <limits.h>
|
||||
#include <unistd.h>
|
||||
#endif
|
||||
#include <glm/glm.hpp>
|
||||
#include <glm/gtc/matrix_transform.hpp>
|
||||
#include <glm/gtc/quaternion.hpp>
|
||||
@@ -28,7 +23,6 @@
|
||||
#include "vectorail/core/Shader.hpp"
|
||||
#include "openroller/desktop/LevelLoader.hpp"
|
||||
#include "openroller/desktop/AudioManager.hpp"
|
||||
#include "openroller/desktop/GlCompat.hpp"
|
||||
#include "vectorail/core/DdsTexture.hpp"
|
||||
#include "gc/GcTargetEffect.hpp"
|
||||
#include "vectorail/core/PngTexture.hpp"
|
||||
@@ -686,12 +680,12 @@ struct GcKeySample {
|
||||
float sampledTimeMs = 0.0f;
|
||||
};
|
||||
|
||||
bool gcLoopFlag(const gc::TransformPoint& key) { return key.repeat; }
|
||||
bool gcLoopFlag(const gc::ObjectColorPoint& key) { return key.repeat; }
|
||||
bool gcLoopFlag(const gc::VisibilityPoint& key) { return key.repeat; }
|
||||
bool gcInterpolateFlag(const gc::TransformPoint& key) { return key.interpolate; }
|
||||
bool gcInterpolateFlag(const gc::ObjectColorPoint& key) { return key.interpolate; }
|
||||
bool gcInterpolateFlag(const gc::VisibilityPoint& key) { return key.interpolate; }
|
||||
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.
|
||||
@@ -807,7 +801,7 @@ GcVisibilityState gcObjectVisibility(const gc::StageObject& object, float timeMs
|
||||
if (active + 1 < static_cast<int>(keys.size())) {
|
||||
const auto& next = keys[active + 1];
|
||||
const float untilNext = static_cast<float>(next.timeMs) - sample.sampledTimeMs;
|
||||
if (next.visible != visible && next.interpolate &&
|
||||
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;
|
||||
@@ -2083,7 +2077,7 @@ int main(int argc, char* argv[]) {
|
||||
noteShader.use();
|
||||
noteShader.setMat4("uProjection", proj);
|
||||
noteShader.setInt("uTexture", 0);
|
||||
ActivateTexture(GL_TEXTURE0);
|
||||
glActiveTexture(GL_TEXTURE0);
|
||||
glBindVertexArray(noteSpriteVAO);
|
||||
for (const MarkerDraw& marker : markerDraws) {
|
||||
if (marker.effectId <= 0 || marker.uvRecordBase < 0) continue;
|
||||
@@ -2175,7 +2169,7 @@ int main(int argc, char* argv[]) {
|
||||
noteShader.use();
|
||||
noteShader.setMat4("uProjection", proj);
|
||||
noteShader.setInt("uTexture", 0);
|
||||
ActivateTexture(GL_TEXTURE0);
|
||||
glActiveTexture(GL_TEXTURE0);
|
||||
glBindVertexArray(noteSpriteVAO);
|
||||
glDepthMask(GL_FALSE);
|
||||
glBlendFunc(GL_SRC_ALPHA, GL_ONE);
|
||||
@@ -2256,7 +2250,7 @@ int main(int argc, char* argv[]) {
|
||||
glm::ortho(0.0f, static_cast<float>(kGameWidth),
|
||||
static_cast<float>(kGameHeight), 0.0f, -1.0f, 1.0f));
|
||||
noteShader.setInt("uTexture", 0);
|
||||
ActivateTexture(GL_TEXTURE0);
|
||||
glActiveTexture(GL_TEXTURE0);
|
||||
glBindTexture(GL_TEXTURE_2D, helperAtlas.id);
|
||||
glBindVertexArray(noteSpriteVAO);
|
||||
const glm::mat4 helperRoot = glm::scale(
|
||||
@@ -2334,25 +2328,6 @@ int main(int argc, char* argv[]) {
|
||||
}
|
||||
for (SDL_Gamepad* gamepad : gamepads) SDL_CloseGamepad(gamepad);
|
||||
if (chartCompleted && !gcRoot.empty()) {
|
||||
#ifdef _WIN32
|
||||
char executable[MAX_PATH]{};
|
||||
const DWORD length = GetModuleFileNameA(nullptr, executable, sizeof(executable));
|
||||
if (length > 0 && length < sizeof(executable)) {
|
||||
const std::string root = gcRoot.string();
|
||||
if (gameplayItem == GcGameplayItem::Mirror) {
|
||||
_execl(executable, executable, "--item", "mirror",
|
||||
"--menu", root.c_str(), static_cast<char*>(nullptr));
|
||||
} else if (gameplayItem == GcGameplayItem::Reverse) {
|
||||
_execl(executable, executable, "--item", "reverse",
|
||||
"--menu", root.c_str(), static_cast<char*>(nullptr));
|
||||
} else {
|
||||
_execl(executable, executable, "--menu", root.c_str(),
|
||||
static_cast<char*>(nullptr));
|
||||
}
|
||||
std::cerr << "Could not return to song menu: "
|
||||
<< std::strerror(errno) << std::endl;
|
||||
}
|
||||
#else
|
||||
char executable[PATH_MAX]{};
|
||||
const ssize_t length = readlink(
|
||||
"/proc/self/exe", executable, sizeof(executable) - 1);
|
||||
@@ -2372,7 +2347,6 @@ int main(int argc, char* argv[]) {
|
||||
std::cerr << "Could not return to song menu: "
|
||||
<< std::strerror(errno) << std::endl;
|
||||
}
|
||||
#endif
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
set(CMAKE_SYSTEM_NAME Windows)
|
||||
set(CMAKE_SYSTEM_PROCESSOR x86_64)
|
||||
|
||||
set(CMAKE_C_COMPILER x86_64-w64-mingw32-gcc)
|
||||
set(CMAKE_CXX_COMPILER x86_64-w64-mingw32-g++)
|
||||
set(CMAKE_RC_COMPILER x86_64-w64-mingw32-windres)
|
||||
|
||||
set(CMAKE_FIND_ROOT_PATH /usr/x86_64-w64-mingw32)
|
||||
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
|
||||
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
|
||||
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
|
||||
set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY)
|
||||
+10
-49
@@ -70,21 +70,20 @@ The file is big-endian. The initial words are not all section offsets. Confirmed
|
||||
| 2 | track points |
|
||||
| 3 | notes |
|
||||
| 4 | camera |
|
||||
| 5 | `TuneBGEffectData` / FlowItem keys |
|
||||
| 5 | particles |
|
||||
| 6 | visualizer |
|
||||
| 7 | background texture names and image keys |
|
||||
| 7 | unknown section |
|
||||
| 8 | first color table |
|
||||
| 9 | objects |
|
||||
| 10 | scalar/unknown; often `0x30`, not an offset |
|
||||
| 11 | points into the post-object extension offset table |
|
||||
| 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.
|
||||
|
||||
The post-object extension base is the reader position after the variable
|
||||
object stream. Relative offset slot 1 extends background colors and slot 2
|
||||
stores object parents. Deriving that base from consumed bytes, as Android
|
||||
`LoadBGData` does, also handles the small mobile chart revision correctly.
|
||||
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
|
||||
|
||||
@@ -200,15 +199,8 @@ BPM active at the note timestamp. With `beat_ms = 60000 / bpm`:
|
||||
| ---: | --- |
|
||||
| `+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)` |
|
||||
| `+37` | enable positional fly-in |
|
||||
| `+38` | draw the additive fly-in trail |
|
||||
| `+43` | fly-in interpolation start, in beats after appearance |
|
||||
| `+47` | fly-in interpolation end, in beats before the hit time |
|
||||
| `+51` | duration in beats for types 3/4/5/10/15; runtime `+0x58` and `+0xac = time + value * beat_ms` |
|
||||
| `+55` | primary packed authored `RRGGBBAA` target colour; also used by duration geometry |
|
||||
| `+59` | secondary packed `RRGGBBAA` colour |
|
||||
| `+63` | integer fly-in oscillation count |
|
||||
| `+67` | fly-in oscillation endpoint/phase scalar |
|
||||
| `+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 |
|
||||
|
||||
@@ -341,7 +333,7 @@ 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
|
||||
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.
|
||||
|
||||
@@ -374,13 +366,8 @@ synthetic pulsing rings are no longer used for decoded GC stages.
|
||||
|
||||
Duration bodies are also type-specific in the executable:
|
||||
|
||||
- Android HOLD does not submit the prebuilt ribbon array. Its normal gameplay
|
||||
branch calls `DrawWay(max(current_ms, appear_ms), end_ms, color, color)` with
|
||||
additive blending and a beat-reactive width from 3 to 5 pixels. This exact
|
||||
moving timestamp boundary is why dropping whole prebuilt samples produces a
|
||||
visibly stepped disappearance. Arcade still constructs HOLD helper geometry,
|
||||
but it is not evidence for the mobile draw path.
|
||||
- SLIDE HOLD calls its prebuilt triangle-ribbon path.
|
||||
- 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.
|
||||
@@ -404,32 +391,6 @@ Duration bodies are also type-specific in the executable:
|
||||
screen direction. Both endpoint layers inherit the long note's colour, fade alpha,
|
||||
billboard transform, beat-synchronised animation and `0.025` sprite scale.
|
||||
|
||||
### Positional fly-in
|
||||
|
||||
`GameScene::DrawMark` converts wire `+25/+29/+33` into direction vector `D` and
|
||||
starts from the authored route position `P`. With:
|
||||
|
||||
```text
|
||||
start = max(0, appear_ms + field_43 * beat_ms)
|
||||
end = max(0, note_ms - field_47 * beat_ms)
|
||||
u = 0 before start, (now-start)/(end-start) in between, 1 at/after end
|
||||
```
|
||||
|
||||
`0x005ebaa0` first stores runtime `+0xc0 = ROUND(appear_ms / frame_ms)`.
|
||||
`0x0064ab80` then computes the start from that stored integer, but computes the
|
||||
end from the still-fractional `note_ms / frame_ms`; each final boundary passes
|
||||
through another nearest-integer conversion at `0x0050a4a0`. The live clock is
|
||||
quantized the same way.
|
||||
Flag `+37` selects positional interpolation. With no oscillations the displayed
|
||||
position is `P + D*(1-u)`. When integer `cycles = +63` is positive, let
|
||||
`c = +67` for values up to 1 and `c = 2-(+67)` otherwise; then position is
|
||||
`P + D*(c - abs(sin(u*(asin(c) + cycles*2*pi))))`. Integer cycles guarantee
|
||||
that the marker lands back on `P` at `u=1`. The approach circle follows the
|
||||
same moved position. Flag `+38` draws an additive line from the initial external
|
||||
anchor to the moving marker before the hit time. The player preserves that
|
||||
nearest-frame 60 Hz quantization rather than smoothing the movement on the
|
||||
audio clock.
|
||||
|
||||
## Tap judgment timing
|
||||
|
||||
The system-config loader at `0x00635c90` lays out the timing overrides in four
|
||||
|
||||
@@ -1,200 +0,0 @@
|
||||
# Groove Coaster Android Runtime Reverse
|
||||
|
||||
This is a clean-room interoperability record for the offline Android package
|
||||
`gc2offlinev4.xapk`. It documents facts used to validate the GC stage loader
|
||||
and Vectorail runtime. Addresses below are ELF virtual addresses in
|
||||
`lib/arm64-v8a/libtune.so`; the Ghidra project uses an additional `0x100000`
|
||||
image-base offset.
|
||||
|
||||
## Provenance
|
||||
|
||||
```text
|
||||
XAPK c6c394f7a1cc65331edc98aac94014668f9d5277ce17262fecf9aeeb1a2a3003
|
||||
APK 62c9e739dc9b8c1bcbcb4b5234d78f154b20d1a930329b9c21a3c9236b92a0dc
|
||||
libtune 689b2e4c0bc4479a3f309944b5070796878c66c0dfdbd283c11ec0bbeeea9efa
|
||||
```
|
||||
|
||||
Stage and audio ZIPs use the password returned by
|
||||
`mtxc::ObbFile::getZipPassword`: `eiprblFFv69R83J5`.
|
||||
|
||||
The Android `ac_10pt8tion_{easy,normal,hard}{,_ext}.dat` payloads are binary
|
||||
identical to the corresponding files in `GC/data/stage`. For example both
|
||||
copies of `ac_10pt8tion_easy.dat` have SHA-256
|
||||
`86a75c86b91bbbe25cae78bcb51d4f7842b3fc4946e4b091f2ec5ba084497765`.
|
||||
The Android executable is therefore a valid independent specification for the
|
||||
arcade files consumed by Vectorail.
|
||||
|
||||
## Stage Selection And Containers
|
||||
|
||||
`TuneAppMain::LoadStageData` (`0x9da24`) loads six chart ids per song. The
|
||||
first three are serialized mobile ids. The other three are synthesized by
|
||||
prefixing those ids with `ac_`. `GameScene::makeFilenameStageDat` (`0xc4bac`)
|
||||
then appends `.dat` or `_ext.dat`.
|
||||
|
||||
The sampled offline package contains one-note `placeholder_bgm` mobile charts,
|
||||
while its `ac_` charts contain the complete arcade route, camera, notes and
|
||||
background scene. `GameScene::LoadStageData` (`0xc607c`) performs this order:
|
||||
|
||||
1. Load the selected main DAT from `<stage-pack>.zip`.
|
||||
2. Call `TuneGameData::LoadGameData` (`0x933a0`).
|
||||
3. Call `TuneGameData::LoadBGData` (`0x93aa4`) on the same bytes.
|
||||
4. Load `_ext.dat` only for an extra/arcade difficulty when arrange mode is off.
|
||||
5. Build runtime data and load stage resources.
|
||||
|
||||
For every selected `ac_` chart, `LoadExtData` starts at byte 6 of the matching
|
||||
`_ext.dat`, replaces the four timing lists, reads another array of 99-byte
|
||||
notes, and links each ext note to a same-time main note. If both effective
|
||||
types are FLICK, the main runtime type becomes `0x10` (dual flick).
|
||||
|
||||
The corpus contains 2927 valid sidecars with 18965 ext notes. Every sidecar
|
||||
parses with the layout described by `docs/stage_ext.pat`, and all ext note
|
||||
records are FLICK entries. One unusual main chart is itself named
|
||||
`SW_marianne_hard_ext.dat`, so its sidecar is
|
||||
`SW_marianne_hard_ext_ext.dat`. File discovery must check whether removing the
|
||||
suffix names an existing main chart instead of excluding every `_ext.dat`.
|
||||
|
||||
## Parsed Stage Sections
|
||||
|
||||
The parser now follows every section consumed by Android `LoadGameData` and
|
||||
`LoadBGData`:
|
||||
|
||||
| Header | Runtime data |
|
||||
| ---: | --- |
|
||||
| 0 | stage config, BPM and four timing tables |
|
||||
| 1 | route draw-distance keys |
|
||||
| 2 | route points |
|
||||
| 3 | 99-byte note records |
|
||||
| 4 | 59-byte camera records |
|
||||
| 5 | 44-byte `TuneBGEffectData` / FlowItem keys |
|
||||
| 6 | 12-byte visualizer keys |
|
||||
| 7 | background texture names and 20-byte image keys |
|
||||
| 8 | background color keys |
|
||||
| 9 | model/shader names and animated stage objects |
|
||||
|
||||
After the variable-length object stream, Android records the current read
|
||||
position and treats it as the base of four relative extension offsets. The
|
||||
third extension contains signed object parent indices. Header word 11 points
|
||||
inside this table on current charts; it is not the object-stream end. The
|
||||
parser now derives the base from the consumed object stream exactly as Android
|
||||
does, which also fixes the small mobile DAT revision.
|
||||
|
||||
## Timing, Route And Long Elements
|
||||
|
||||
`TuneTimingData::GetTime` selects the latest timing key at or before the note.
|
||||
Mode 1 is absolute milliseconds, mode 3 is next-note spacing, and other modes
|
||||
multiply the authored value by `60000 / BPM`.
|
||||
|
||||
`TuneGameData::GetWayPosition` (`0x953bc`) linearly interpolates route points
|
||||
by timestamp and clamps before/after the route. `WaySplitCheck` (`0x95c4c`)
|
||||
clips the polyline to the requested time range, emits samples at a fixed world
|
||||
distance, carries the unused distance across authored segments, and stores the
|
||||
true interpolated timestamp for each sample.
|
||||
|
||||
Android spacing is `0.20` for HOLD/SLIDE/DUAL, `0.15` for SCRATCH and `0.40`
|
||||
for BEAT. Arcade `game471.exe` uses `0.55` for BEAT; Vectorail intentionally
|
||||
keeps the arcade value when playing arcade DATs.
|
||||
|
||||
`LoadTuneMarkDataOne` (`0x967d8`) confirms the wire-field translation and
|
||||
compatibility remaps. Runtime types 7/8 become 1, 11 becomes 10, 12/14 become
|
||||
9, and 13 becomes 4. Marker effects are forced to 35 for type 10, 32 for type
|
||||
9, 37 for type 15 and 11 for raw type 13. Duration types are 3, 4, 5, 10 and
|
||||
15. MERRY type 6 expands to `count` targets separated by the authored beat
|
||||
spacing.
|
||||
|
||||
## Camera
|
||||
|
||||
`TuneGameData::GetCameraData` (`0x95588`) independently confirms the seven
|
||||
anchor modes, the two interpolation paths and the orthographic/perspective
|
||||
blend documented in `re_gc_camera.md`. Android uses right-handed OpenGL
|
||||
look-at/projection matrices. The arcade executable uses the corresponding D3D
|
||||
left-handed path; Vectorail converts the evaluated eye/target/up state to its
|
||||
OpenGL renderer and uses the arcade fixed 75 degree FOV.
|
||||
|
||||
No camera wire fields are discarded, including non-finite values in the three
|
||||
`ac_comet_*` intros.
|
||||
|
||||
## Background Runtime
|
||||
|
||||
`DrawBGColor` (`0xb3fc8`) selects the active color key, optionally cross-fades
|
||||
to the next key, and applies BPM-derived HSV brightness modulation when its
|
||||
rhythm-reactive flag is set. `DrawBGImage` (`0xb49b0`) supports tiled atlas
|
||||
images, a built-in centered image and external texture entries.
|
||||
`DrawBGVisualizer` (`0xb6118`) selects timed visualizer keys and calls the
|
||||
8572-byte procedural `DrawVisualizer` path. The conventional FFT routine at
|
||||
`0x104ec0` is not used to drive this stage visualizer.
|
||||
|
||||
The 44-byte table previously called particles is `TuneBGEffectData`.
|
||||
`GameScene::ExecFlowItem` (`0xc0610`) uses it to spawn and update authored
|
||||
flowing background elements. Shape 1 spawns a random screen point, shape 2 a
|
||||
screen-space grid and shape 3 an eight-point ring. Repeat and lifetime are
|
||||
scaled by the active beat before points are unprojected to route depth. It is
|
||||
separate from hit-effect particles.
|
||||
|
||||
## Audio
|
||||
|
||||
Stage audio is stored as encrypted `*_bgm.ogg.zip` and `*_shot.ogg.zip` files.
|
||||
`MtxSoundBuffer::LoadData` (`0x10a614`) rewrites `.m4a` resource names to
|
||||
`.ogg`. The sampled Android 10pt8tion pair is Vorbis, 44.1 kHz stereo, and both
|
||||
streams are 129.621859 seconds. The corresponding arcade BGM WAV is PCM s16le,
|
||||
44.1 kHz stereo, with the same duration.
|
||||
|
||||
`GameScene::ExecGameStage` (`0xaf8ac`) maintains a 60 Hz logical count, reads
|
||||
`MtxSoundSource::GetPlaySecTime` (`0x10af3c`) from BGM, and replaces the logical
|
||||
count when they differ by more than two frames. BGM and SHOT are prepared and
|
||||
started together. If either source is no longer playing, both are stopped,
|
||||
repositioned and restarted; SHOT is also periodically aligned to the BGM time.
|
||||
|
||||
Vectorail now derives gameplay time from consumed BGM source bytes instead of
|
||||
wall time. Its two SDL streams start together, retain their source PCM and are
|
||||
cleared/requeued at the BGM position when their source clocks differ by more
|
||||
than two frames. Corpus verification found 114 of 1674 unique arcade pairs
|
||||
with different PCM frame counts; exhausted SHOT tails remain silent when the
|
||||
BGM position is already beyond the shorter stream.
|
||||
|
||||
## Verification Status
|
||||
|
||||
`opencoaster-stage-verify` currently validates all 2970 main DATs recursively,
|
||||
including the nested `stage/sound/ac_dontfight_ex.dat` and the chart whose
|
||||
actual name ends in `_ext`. It
|
||||
compares the retained wire model to the runtime route, all camera fields, note
|
||||
type/effect/color/timing/distance translation and clip dimensions. It also
|
||||
validates resolved BGM/SHOT containers and PCM formats.
|
||||
|
||||
The final aggregate result is `2970/2970` main charts, 2927 loaded sidecars,
|
||||
18965 ext notes and 18948 linked dual-flicks. Section coverage is 16869 FlowItem
|
||||
keys, 54152 visualizer keys, 79 background image keys, 417451 background color
|
||||
keys and 1171999 stage objects. Audio resolution succeeds for 2918 charts;
|
||||
2768 have paired BGM/SHOT and 150 intentionally resolve only BGM. Gameplay
|
||||
coverage includes 193 MERRY records expanded to 676 timed targets and 6364
|
||||
SLIDE HOLD records.
|
||||
|
||||
Implemented in the player:
|
||||
|
||||
- route timing and draw windows;
|
||||
- camera modes, interpolation and projection;
|
||||
- note heads, directional overlays, long paths, BEAT samples and MERRY layout;
|
||||
- ext timing tables and dual-flick pairing, including both direction vectors;
|
||||
- stage model transforms, visibility, colors and one-level parents;
|
||||
- exact background color cross-fade and BPM brightness;
|
||||
- timed background image selection and external DDS loading;
|
||||
- timed visualizer execution for types 1 through 7;
|
||||
- FlowItem spawning, screen layouts, route-depth unprojection and motion;
|
||||
- MERRY target timing and desktop SLIDE HOLD duration judging;
|
||||
- BGM/SHOT playback, hit SE, source-position game clock and pair resync.
|
||||
|
||||
Remaining asset/input fidelity gaps:
|
||||
|
||||
- the seven visualizer types execute as GLSL procedural equivalents; the
|
||||
original Android vertex generators have not been copied constant-for-constant;
|
||||
- FlowItem timing/layout/motion executes, but original texture selectors
|
||||
34/35 are represented by colored billboards;
|
||||
- mode-3 background loading is implemented, but `stage_back10` is referenced
|
||||
by the corpus and absent from the supplied game dump; built-in modes 1/2
|
||||
still need their original atlas resources;
|
||||
- dual flick and directional SLIDE HOLD use desktop multi-input/hold semantics
|
||||
rather than Android touch lines;
|
||||
- Android touch-line gesture semantics (the desktop player maps controls to
|
||||
keyboard/gamepad inputs).
|
||||
|
||||
This distinction is intentional: a green parser/runtime translation result
|
||||
does not claim pixel-identical procedural meshes or unavailable textures.
|
||||
+8
-69
@@ -288,11 +288,9 @@ 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`. The Android `camera3D::BuildUpVector` uses `(0,0,1)`
|
||||
when the normalized view direction's L1 distance from either Y pole is below
|
||||
`0.001`; otherwise it uses `(0,1,0)`. `rotationB` then rotates that up vector
|
||||
around the normalized view axis. This is not equivalent to a generic
|
||||
dot-product parallelism threshold on near-vertical authored cameras.
|
||||
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
|
||||
|
||||
@@ -316,13 +314,9 @@ 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.
|
||||
|
||||
The matrix convention is backend-specific. Arcade emits D3D matrices, while
|
||||
Android's `matrix44::LookAt` uses `eye-target` and its perspective matrix has
|
||||
`-1` at `m[2][3]`, the OpenGL right-handed/no-depth-remap convention. Because
|
||||
Vectorail also renders through OpenGL, its GC path follows the Android RH/NO
|
||||
builders rather than feeding D3D handedness directly to GLM.
|
||||
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
|
||||
@@ -355,66 +349,11 @@ 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. The runtime uses Android's `a + (b-a)*u` float operation order and
|
||||
builds the GC view matrix without an extra host-side up-vector fallback.
|
||||
|
||||
An independent verifier reimplements the Android evaluator from decoded wire
|
||||
records instead of calling the player's camera helpers. Across the current
|
||||
2970-chart corpus it checked 171333 keys and 9113837 sampled times, including
|
||||
every track vertex and the interior of every camera-key interval. All seven
|
||||
`aMode` branches and `fMode` 0/1/2 were present:
|
||||
|
||||
```text
|
||||
eye/target max error: 0
|
||||
up max error: 0
|
||||
view max error: 0
|
||||
projection max error: 4.76837e-7
|
||||
```
|
||||
|
||||
This pass found and corrected a sign error in the expanded quaternion's `qz`
|
||||
term and replaced the previous approximate vertical-up threshold with the
|
||||
exact Android `0.001` L1 test.
|
||||
smoothing. Remaining camera work is validation against captured original
|
||||
frames.
|
||||
|
||||
## Switch and Android cross-version validation
|
||||
|
||||
The Android reference used here comes from
|
||||
`/home/au/Downloads/gc2offlinev4.xapk`. The outer package is an offline
|
||||
installer; the original game is its nested `assets/groovecoaster.apk`:
|
||||
|
||||
```text
|
||||
package: jp.co.taito.groovecoasterzero
|
||||
version: 1.0.18 (versionCode 76)
|
||||
native ABI: arm64-v8a
|
||||
native code: lib/arm64-v8a/libtune.so
|
||||
|
||||
XAPK SHA-256: c6c394f7a1cc65331edc98aac94014668f9d5277ce17262fecf9aeeb1a2a3003
|
||||
nested APK SHA-256: 62c9e739dc9b8c1bcbcb4b5234d78f154b20d1a930329b9c21a3c9236b92a0dc
|
||||
libtune.so SHA-256: 689b2e4c0bc4479a3f309944b5070796878c66c0dfdbd283c11ec0bbeeea9efa
|
||||
```
|
||||
|
||||
Unlike the arcade executable, this `libtune.so` retains C++ symbols. Relevant
|
||||
ELF virtual addresses (before Ghidra's `+0x100000` image base) are:
|
||||
|
||||
```text
|
||||
0x0933a0 TuneGameData::LoadGameData(bytearray*, bool)
|
||||
0x0953bc TuneGameData::GetWayPosition(int)
|
||||
0x095588 TuneGameData::GetCameraData(int, bool)
|
||||
0x0ad1a4 GameScene::SetCommonParam()
|
||||
0x0ad524 GameScene::CalcGameProjectionMatrix(camera3D&)
|
||||
0x0b8410 GameScene::CalcGamePerspectiveMatrix(camera3D&)
|
||||
0x0b847c GameScene::CalcGameOrthoMatrix(camera3D&)
|
||||
0x102c78 matrix44::Perspective(float, float, float, float)
|
||||
0x102d34 matrix44::LookAt(vector3 const&, vector3 const&, vector3 const&)
|
||||
0x1031f0 camera3D::BuildUpVector(float)
|
||||
0x103e48 RotateHPB::ToVector_Deg(float)
|
||||
```
|
||||
|
||||
`TuneGameData::LoadGameData` independently confirms every wire read and the
|
||||
59-byte camera record order. It expands each record to an aligned 0x48-byte
|
||||
runtime entry. `GameScene::SetCommonParam` then calls `GetCameraData` for the
|
||||
current chart time, builds the projection, and passes `eye`, `target`, and
|
||||
`up` to `matrix44::LookAt`.
|
||||
|
||||
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
|
||||
|
||||
@@ -12,12 +12,6 @@ record count, allocates `count * 0xb4` bytes, and deserializes every variable-si
|
||||
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 loader stops after the declared record count; it does not require EOF.
|
||||
GC 4.75 catalogs can append a version-specific metadata block after the song
|
||||
array (one observed 917-record catalog has 4116 trailing bytes). The clean-room
|
||||
reader therefore validates every declared record but intentionally leaves such
|
||||
post-array data uninterpreted.
|
||||
|
||||
The game's primitives used by this loader are:
|
||||
|
||||
- `FUN_005d8bc0`: big-endian `u32`;
|
||||
|
||||
+6
-14
@@ -90,11 +90,9 @@ list deltas rather than complete scenes:
|
||||
- `COLT` carries RGBA multiplication, including authored visibility fades;
|
||||
- `ASRC` contains the exported `play();`, `stop();`, and target actions.
|
||||
|
||||
`gc::BuildRvbSnapshot` accumulates those deltas through a selected label. Its
|
||||
snapshot state can either advance a `play()` entry to the following `stop()`
|
||||
or request a relative animation frame globally or per MovieClip path. Per-path
|
||||
evaluation is required here because the selected transition must advance while
|
||||
unrelated child loops remain at their authored steady frames.
|
||||
`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
|
||||
@@ -139,15 +137,9 @@ 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`.
|
||||
|
||||
`FUN_00447170` also supplies the transition timing used by the executable. Song
|
||||
slot changes interpolate linearly over `0.125` seconds. The list enters/exits
|
||||
over `0.375` seconds and shifts by one viewport width using exponent `4` easing.
|
||||
The row plates retain their full dimensions; their opacity and the title scale
|
||||
use the slot values `0.7`, `0.8`, `0.9`, and `1.0`. The desktop selector now
|
||||
combines these executable-owned values with frame-based RVB entry, exit, sort,
|
||||
focus, difficulty-change, and decision states. Confirmation is a distinct
|
||||
`jf_decision`/`jf_mode_decision` phase before the task exit timeline; collapsing
|
||||
both phases is why an immediate screen switch does not resemble the cabinet.
|
||||
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
|
||||
|
||||
|
||||
@@ -26,8 +26,7 @@ What is already wired:
|
||||
- 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;
|
||||
- the eight original sort tabs, with genre headings and catalog-backed title,
|
||||
difficulty, newest and random ordering;
|
||||
- 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;
|
||||
@@ -49,7 +48,7 @@ Controls:
|
||||
```text
|
||||
Up / Down, W / S previous / next song
|
||||
PageUp / PageDown jump by eight songs
|
||||
Q / E or Tab song order
|
||||
Q / E or Tab genre
|
||||
Enter or Space enter difficulty screen
|
||||
Escape close selector
|
||||
|
||||
|
||||
+26
-40
@@ -47,22 +47,10 @@ 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 past/current and
|
||||
current/future line strips with endpoint color gradients. Android
|
||||
`DrawGameStageCharacter` proves that the active `forwardDrawDist` value is a
|
||||
count of beats, not seconds. If `beat_ms = 60000 / bpm`, it computes:
|
||||
|
||||
```text
|
||||
beat_start = floor(current_ms / beat_ms) * beat_ms
|
||||
future_ms = forwardDrawDist * beat_ms
|
||||
first_ms = max(0, beat_start - max(beat_ms, future_ms))
|
||||
last_ms = min(song_end, beat_start + future_ms)
|
||||
```
|
||||
|
||||
The same active value drives both sides, but only the past side has a minimum
|
||||
one-beat span. Thus a dynamic value of zero removes the future route while
|
||||
retaining one past beat. The first `backwardsDrawDist` config float is retained
|
||||
while parsing but is not consumed by this normal Android rendering path.
|
||||
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
|
||||
@@ -76,22 +64,18 @@ For `ac_10pt8tion_hard.dat`:
|
||||
```text
|
||||
first track key: 0 ms, (0, 0, 0)
|
||||
second track key: 6486 ms, (0, 0, 199.985)
|
||||
first range field: 10
|
||||
active range: 7 beats
|
||||
draw behind: 10 s
|
||||
draw ahead: 7 s
|
||||
```
|
||||
|
||||
The rail brightness is also chart-clock driven. Over a two-beat triangle wave,
|
||||
`pulse = triangle * 0.6 + 0.4`; the past segment grades from `0.7*pulse` to
|
||||
`pulse` alpha and the future segment from `pulse` to `0.5*pulse`. Both are
|
||||
submitted with additive blending.
|
||||
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.
|
||||
Android uses the first for the past/current call and the second for the
|
||||
current/future call. For `10pt8tion_hard` they are `(255,0,128)` and
|
||||
`(255,255,255)` respectively; the older ahead/behind field names were inferred
|
||||
backwards.
|
||||
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
|
||||
|
||||
@@ -104,14 +88,15 @@ rgba top_left
|
||||
rgba bottom_right
|
||||
rgba bottom_left
|
||||
u8 interpolate_to_next
|
||||
u8 rhythm_reactive_color
|
||||
u8 audio_reactive_color
|
||||
```
|
||||
|
||||
`FUN_00642390` holds the active colors unless `interpolate_to_next` is set.
|
||||
The transition uses `CalcCrossFadeColor`'s 25% overlap rather than a plain
|
||||
linear mix. `rhythm_reactive_color` multiplies HSV brightness by a 0.5..0.75
|
||||
triangle wave derived from chart time and the active BPM in `SetCommonParam`;
|
||||
it is not sourced from an audio analyser. The Linux player implements both.
|
||||
`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/<song>_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
|
||||
@@ -120,7 +105,7 @@ 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 FlowItem,
|
||||
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,
|
||||
@@ -130,13 +115,14 @@ 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.
|
||||
|
||||
Android `GameScene::ExecFlowItem` scales both `repeatMeasure` and
|
||||
`lifespanMeasure` by the active beat duration. Recovered spawn layouts are:
|
||||
type 1, a random screen point; type 2, a screen/grid group using
|
||||
`groupShapeSize`; type 3, eight points at 45-degree intervals around a circle.
|
||||
The player follows those timing/layout rules, unprojects the points to route
|
||||
depth and applies the authored velocity, but substitutes colored billboards
|
||||
for original texture selectors 34/35.
|
||||
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,
|
||||
|
||||
+1
-18
@@ -186,21 +186,6 @@ struct VisualizerArray {
|
||||
Visualizer entries[sz];
|
||||
};
|
||||
|
||||
// Background Images
|
||||
struct BackgroundImage {
|
||||
u32 timeMs;
|
||||
u32 mode;
|
||||
u32 atlasIndex;
|
||||
s32 textureIndex;
|
||||
color color;
|
||||
} [[same_color]];
|
||||
struct BackgroundImageArray {
|
||||
u32 textureCount;
|
||||
string8 textureNames[textureCount];
|
||||
u32 sz;
|
||||
BackgroundImage entries[sz];
|
||||
};
|
||||
|
||||
// Color Table 1
|
||||
struct ColorTable {
|
||||
u32 timeMs;
|
||||
@@ -342,8 +327,6 @@ NoteArray notes @ hdr.notes;
|
||||
CameraArray camera @ hdr.camera;
|
||||
ParticleArray particles @ hdr.particles;
|
||||
VisualizerArray visualizer @ hdr.visualizer;
|
||||
BackgroundImageArray backgroundImages @ hdr.unk1;
|
||||
ColorTableArray colorTable @ hdr.colors;
|
||||
ObjectArray objects @ hdr.objects;
|
||||
// Header word 11 points to offset slot 2, one word into this table.
|
||||
ColorTable2Array colorTable2 @ hdr.colors2 - 4;
|
||||
ColorTable2Array colorTable2 @ hdr.colors2;
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
// Groove Coaster Android/arcade *_ext.dat
|
||||
// LoadExtData starts reading at byte 6. Bytes after notes are not consumed by
|
||||
// the recovered Android routine and are intentionally left untyped.
|
||||
|
||||
struct NoteTimingEntry {
|
||||
u32 timeMs;
|
||||
u32 mode;
|
||||
float value;
|
||||
} [[single_color]];
|
||||
struct NoteTimingList {
|
||||
u16 size;
|
||||
NoteTimingEntry entries[size];
|
||||
} [[single_color]];
|
||||
|
||||
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 StageExtension {
|
||||
NoteTimingList noteTimings[4];
|
||||
u32 unknown;
|
||||
u32 noteCount;
|
||||
Note notes[noteCount];
|
||||
};
|
||||
|
||||
u8 prefix[6] @ 0x00;
|
||||
StageExtension extension @ 0x06;
|
||||
+7
-7
@@ -347,7 +347,7 @@ PackageBackgroundColorPoint packBackgroundColor(const gc::BackgroundColorPoint&
|
||||
output.bottomRightRgba = rgba(source.bottomRight);
|
||||
output.bottomLeftRgba = rgba(source.bottomLeft);
|
||||
output.flags = (source.interpolateToNext ? 1u : 0u) |
|
||||
(source.rhythmReactive ? 2u : 0u);
|
||||
(source.audioReactive ? 2u : 0u);
|
||||
return output;
|
||||
}
|
||||
|
||||
@@ -373,8 +373,8 @@ PackageVisibilityKey packVisibility(const gc::VisibilityPoint& source) {
|
||||
PackageVisibilityKey output{};
|
||||
output.timeMs = source.timeMs;
|
||||
output.flags = static_cast<std::uint8_t>(
|
||||
(source.repeat ? static_cast<std::uint8_t>(kObjectKeyLoop) : 0u) |
|
||||
(source.interpolate ? static_cast<std::uint8_t>(kObjectKeyInterpolate) : 0u));
|
||||
(source.fadeOut ? static_cast<std::uint8_t>(kObjectKeyLoop) : 0u) |
|
||||
(source.fadeIn ? static_cast<std::uint8_t>(kObjectKeyInterpolate) : 0u));
|
||||
output.visible = source.visible ? 1u : 0u;
|
||||
return output;
|
||||
}
|
||||
@@ -383,8 +383,8 @@ PackageTransformKey packTransform(const gc::TransformPoint& source) {
|
||||
PackageTransformKey output{};
|
||||
output.timeMs = source.timeMs;
|
||||
output.flags = static_cast<std::uint8_t>(
|
||||
(source.repeat ? static_cast<std::uint8_t>(kObjectKeyLoop) : 0u) |
|
||||
(source.interpolate ? static_cast<std::uint8_t>(kObjectKeyInterpolate) : 0u));
|
||||
(source.tweenTowards ? static_cast<std::uint8_t>(kObjectKeyLoop) : 0u) |
|
||||
(source.tweenAway ? static_cast<std::uint8_t>(kObjectKeyInterpolate) : 0u));
|
||||
std::copy(std::begin(source.value), std::end(source.value), output.value);
|
||||
return output;
|
||||
}
|
||||
@@ -393,8 +393,8 @@ PackageObjectColorKey packObjectColor(const gc::ObjectColorPoint& source) {
|
||||
PackageObjectColorKey output{};
|
||||
output.timeMs = source.timeMs;
|
||||
output.flags = static_cast<std::uint8_t>(
|
||||
(source.repeat ? static_cast<std::uint8_t>(kObjectKeyLoop) : 0u) |
|
||||
(source.interpolate ? static_cast<std::uint8_t>(kObjectKeyInterpolate) : 0u));
|
||||
(source.tweenTowards ? static_cast<std::uint8_t>(kObjectKeyLoop) : 0u) |
|
||||
(source.tweenAway ? static_cast<std::uint8_t>(kObjectKeyInterpolate) : 0u));
|
||||
output.rgba = rgba(source.color);
|
||||
return output;
|
||||
}
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
// GhidraScript: DecompileBySymbol.java
|
||||
// Usage (headless):
|
||||
// analyzeHeadless <projDir> <projName> -process <program> -noanalysis -readOnly \
|
||||
// -scriptPath <path> -postScript DecompileBySymbol.java <name-fragment>...
|
||||
|
||||
import ghidra.app.decompiler.DecompInterface;
|
||||
import ghidra.app.decompiler.DecompileResults;
|
||||
import ghidra.app.script.GhidraScript;
|
||||
import ghidra.program.model.listing.Function;
|
||||
import ghidra.program.model.listing.FunctionIterator;
|
||||
|
||||
public class DecompileBySymbol extends GhidraScript {
|
||||
@Override
|
||||
public void run() throws Exception {
|
||||
String[] args = getScriptArgs();
|
||||
if (args == null || args.length == 0) {
|
||||
printerr("DecompileBySymbol: needs one or more symbol-name fragments");
|
||||
return;
|
||||
}
|
||||
|
||||
DecompInterface decomp = new DecompInterface();
|
||||
decomp.openProgram(currentProgram);
|
||||
|
||||
for (String query : args) {
|
||||
boolean found = false;
|
||||
FunctionIterator functions = currentProgram.getFunctionManager().getFunctions(true);
|
||||
while (functions.hasNext()) {
|
||||
Function function = functions.next();
|
||||
String name = function.getName(true);
|
||||
if (!name.contains(query)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
found = true;
|
||||
println("================================================================================");
|
||||
println("query: " + query);
|
||||
println("function: " + name + " @ " + function.getEntryPoint());
|
||||
|
||||
DecompileResults result = decomp.decompileFunction(function, 60, monitor);
|
||||
if (!result.decompileCompleted()) {
|
||||
println("(decompile failed)");
|
||||
continue;
|
||||
}
|
||||
println(result.getDecompiledFunction().getC());
|
||||
}
|
||||
if (!found) {
|
||||
println("No function matched: " + query);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -72,9 +72,6 @@ def main() -> int:
|
||||
override_count = 0
|
||||
overrides = collections.Counter()
|
||||
note_count = 0
|
||||
fly_in_count = 0
|
||||
fly_trail_count = 0
|
||||
fly_bounce_count = 0
|
||||
samples = {}
|
||||
failures = []
|
||||
for path in paths:
|
||||
@@ -92,12 +89,6 @@ def main() -> int:
|
||||
if note[2] != 0:
|
||||
overrides[(raw_type, note[2])] += 1
|
||||
samples.setdefault(raw_type, (path, note))
|
||||
flag37 = note[16]
|
||||
flag38 = note[17]
|
||||
cycles = note[24]
|
||||
fly_in_count += flag37 != 0
|
||||
fly_trail_count += flag38 != 0
|
||||
fly_bounce_count += flag37 != 0 and cycles != 0
|
||||
|
||||
print(f"record_size={NOTE.size}")
|
||||
print(f"files={len(paths)} valid={len(paths) - len(failures)} invalid={len(failures)} notes={note_count}")
|
||||
@@ -107,7 +98,6 @@ def main() -> int:
|
||||
for key, value in sorted(types.items())
|
||||
))
|
||||
print(f"type_override_nonzero={override_count}")
|
||||
print(f"fly_in={fly_in_count} fly_trail={fly_trail_count} fly_bounce={fly_bounce_count}")
|
||||
print("overrides=" + " ".join(
|
||||
f"0x{raw:02x}/0x{override:02x}:{count}"
|
||||
for (raw, override), count in sorted(overrides.items())
|
||||
|
||||
Reference in New Issue
Block a user