5 Commits
17 changed files with 1144 additions and 174 deletions
+30 -1
View File
@@ -37,7 +37,9 @@ workspace/
└── openroller/
```
Install SDL3, OpenGL, libpng and GLM, then build:
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:
```sh
cmake -S openroller -B openroller/build -DCMAKE_BUILD_TYPE=Release
@@ -51,6 +53,33 @@ 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:
+29
View File
@@ -17,6 +17,11 @@ 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()
@@ -25,3 +30,27 @@ 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 .)
+541 -106
View File
@@ -17,7 +17,9 @@
#include <cstdint>
#include <cstdlib>
#include <fstream>
#include <initializer_list>
#include <iostream>
#include <random>
#include <unordered_map>
#include <unordered_set>
#include <vector>
@@ -44,6 +46,67 @@ 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;
@@ -360,6 +423,18 @@ 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;
@@ -420,9 +495,16 @@ 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;
corner[1] += dy;
corner[0] += dx + symbolDx;
corner[1] += dy + symbolDy;
}
ui.textureQuad(found->second, corners,
glm::vec4(draw.color[0], draw.color[1], draw.color[2],
@@ -433,13 +515,7 @@ public:
bool ready() const { return !textures_.empty(); }
void translateSymbol(const std::string& symbol, float dx, float dy) {
for (gc::RvbImageDraw& draw : draws_) {
if (draw.imageSymbol != symbol) continue;
for (auto& corner : draw.corners) {
corner[0] += dx;
corner[1] += dy;
}
}
symbolTranslations_[symbol] = {dx, dy};
}
void clear() {
@@ -447,6 +523,7 @@ public:
if (texture.id != 0) glDeleteTextures(1, &texture.id);
}
textures_.clear();
symbolTranslations_.clear();
draws_.clear();
rvbBytes_.clear();
mtxBytes_.clear();
@@ -475,6 +552,7 @@ 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_;
@@ -496,7 +574,8 @@ gc::RvbSnapshotState selectMusicRowState() {
return state;
}
gc::RvbSnapshotState selectMusicSceneState(const Song* song = nullptr) {
gc::RvbSnapshotState selectMusicSceneState(const Song* song = nullptr,
int activeSort = 3) {
gc::RvbSnapshotState state;
auto& frame = state.frameByPath;
frame["/"] = "jf_slmusic_start";
@@ -531,10 +610,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, 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";
// 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";
// 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.
@@ -549,6 +628,22 @@ 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;
@@ -564,14 +659,14 @@ 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_ini", "jf_m_normal_ini", "jf_m_hard_ini", "jf_m_extra_ini"
};
static constexpr std::array<const char*, 4> visible{
"jf_m_simple_on", "jf_m_normal_on", "jf_m_hard_on", "jf_m_extra_on"
};
static constexpr std::array<const char*, 4> unavailable{
static constexpr std::array<const char*, 4> visible{
"jf_m_simple_off", "jf_m_normal_off", "jf_m_hard_off", "jf_m_extra_off"
};
static constexpr std::array<const char*, 4> unavailable{
"jf_m_simple_xxx", "jf_m_normal_xxx", "jf_m_hard_xxx", "jf_m_extra_xxx"
};
for (size_t i = 0; i < paths.size(); ++i) {
const bool exists = !song || !song->stages[i].empty();
frame[paths[i]] = static_cast<int>(i) == difficulty && exists
@@ -590,6 +685,16 @@ 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;
@@ -609,6 +714,15 @@ 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;
@@ -619,17 +733,19 @@ gc::RvbSnapshotState navigatorSelectSceneState() {
return state;
}
const char* genreName(int genre) {
switch (genre) {
case 1: return "ANIME AND POPS";
case 2: return "VOCALOID";
case 3: return "RHYTHM GAME";
case 4: return "GAME";
case 5: return "VARIETY";
case 6: return "ORIGINAL";
case 7: return "TOUHOU";
default: return "ALL SONGS";
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;
}
return state;
}
int genreLabelRow(int genre) {
@@ -800,35 +916,94 @@ bool runSongSelect(SDL_Window* window, const fs::path& gcRoot, std::string* sele
}
std::unordered_map<size_t, DdsTexture> textures;
const std::array<int, 8> genres{-1, 1, 2, 7, 3, 4, 5, 6};
size_t genreSlot = 0;
// Visual order in selectmusic2: New, Monthly, Genre, Difficulty,
// Score Average, Title, Favorite, At random.
constexpr size_t kGenreSort = 2;
size_t sortSlot = kGenreSort;
std::vector<size_t> visible;
std::vector<CarouselEntry> carousel;
std::mt19937 random(std::random_device{}());
auto rebuildVisible = [&] {
visible.clear();
carousel.clear();
const int genre = genres[genreSlot];
for (size_t i = 0; i < songs.size(); ++i) {
if (genre < 0 || songs[i].catalog.genre == genre) visible.push_back(i);
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;
}
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;
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});
}
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));
if (originalMusic.ready()) {
originalMusic.setState(selectMusicSceneState(&song, static_cast<int>(sortSlot + 1)));
}
if (originalDifficulty.ready()) {
originalDifficulty.setState(selectModeSceneState(&song, difficulty));
}
@@ -836,21 +1011,30 @@ 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 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;
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);
rebuildVisible();
if (!visible.empty()) difficulty = firstPlayableDifficulty(songs[visible[selection]]);
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);
}
refreshOriginalScenes();
startMotion(MenuMotion::SortChange);
};
auto changeDifficulty = [&](int delta) {
const Song& song = songs[visible[selection]];
@@ -860,6 +1044,7 @@ bool runSongSelect(SDL_Window* window, const fs::path& gcRoot, std::string* sele
if (!song.stages[next].empty()) {
difficulty = next;
refreshOriginalScenes();
startMotion(MenuMotion::DifficultyChange);
return;
}
}
@@ -870,10 +1055,78 @@ 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;
@@ -883,6 +1136,7 @@ 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;
@@ -890,17 +1144,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: changeGenre(-1); break;
case SDLK_E: case SDLK_TAB: changeGenre(1); break;
case SDLK_Q: changeSort(-1); break;
case SDLK_E: case SDLK_TAB: changeSort(1); break;
case SDLK_RETURN: case SDLK_SPACE:
task = SelectTask::Difficulty;
startMotion(MenuMotion::MusicDecision);
break;
default: break;
}
} else {
switch (event.key.key) {
case SDLK_ESCAPE:
task = SelectTask::Music;
startMotion(MenuMotion::ModeExitBack);
break;
case SDLK_UP: case SDLK_W: case SDLK_LEFT: case SDLK_A:
changeDifficulty(-1);
@@ -909,8 +1163,7 @@ bool runSongSelect(SDL_Window* window, const fs::path& gcRoot, std::string* sele
changeDifficulty(1);
break;
case SDLK_RETURN: case SDLK_SPACE:
confirmed = true;
running = false;
startMotion(MenuMotion::ModeDecision);
break;
default: break;
}
@@ -919,6 +1172,86 @@ 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);
@@ -963,39 +1296,90 @@ 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.
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]);
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]);
if (!entry.category && originalMusicRow.ready()) {
originalMusicRow.render(ui, 8.0f, rowY[slot], rowAlpha[slot]);
originalMusicRow.render(ui, 8.0f + musicLayerDx, geometry[0], alpha);
} else if (entry.category && originalMusicIndex.ready()) {
originalMusicIndex.render(ui, 88.0f, indexY[slot], rowAlpha[slot]);
originalMusicIndex.render(ui, 88.0f + musicLayerDx, geometry[1], alpha);
if (genreLabels.id != 0) {
const float sourceY = 32.0f * genreLabelRow(entry.genre);
// FUN_005aca40: index X/Y plus 53 and 2; FUN_005b3fc0
// copies a 256x32 cell from s_j_eng.dds.
ui.texture(genreLabels, 141.0f, indexY[slot] + 2.0f,
ui.texture(genreLabels, 141.0f + musicLayerDx, geometry[1] + 2.0f,
256.0f, 32.0f, 0.0f, sourceY, 256.0f, 32.0f,
glm::vec4(1.0f, 1.0f, 1.0f, rowAlpha[slot]));
glm::vec4(1.0f, 1.0f, 1.0f, alpha));
}
}
}
@@ -1007,10 +1391,22 @@ bool runSongSelect(SDL_Window* window, const fs::path& gcRoot, std::string* sele
auto selectedTexture = textures.find(selectedSongIndex);
if (selectedTexture == textures.end()) {
DdsTexture loaded;
if (loadDdsTexture(selected.menuTexture.string(), loaded, nullptr)) {
if (loadSongMenuTexture(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};
@@ -1024,10 +1420,14 @@ 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);
const int activeGenre = genres[genreSlot];
const glm::vec4 activeColor = genreColor(activeGenre);
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));
ui.rect(18, 136, 684, 48, activeColor);
ui.text(38, 149, 3, "Q < " + std::string(genreName(activeGenre)) + " > E", glm::vec4(1.0f));
ui.text(38, 149, 3,
"Q < " + std::string(sortNames[sortSlot]) + " > E", glm::vec4(1.0f));
constexpr int rows = 8;
constexpr float rowY = 200.0f;
@@ -1047,7 +1447,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 (loadDdsTexture(songs[songIndex].menuTexture.string(), loaded, nullptr)) {
if (loadSongMenuTexture(songs[songIndex].menuTexture.string(), loaded, nullptr)) {
found = textures.emplace(songIndex, loaded).first;
}
}
@@ -1104,51 +1504,69 @@ 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.
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);
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);
}
// FUN_00447170/FUN_00447620: the twelve real carousel slots.
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];
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;
if (scale <= 0.0f || carousel.empty()) continue;
const CarouselEntry& entry = carouselAtOffset(offsets[slot]);
const CarouselEntry& entry = carouselAtOffset(kCarouselOffsets[slot]);
if (entry.category) continue;
const size_t songIndex = entry.songIndex;
auto texture = textures.find(songIndex);
if (texture == textures.end()) {
DdsTexture loaded;
if (loadDdsTexture(songs[songIndex].menuTexture.string(), loaded, nullptr)) {
if (loadSongMenuTexture(songs[songIndex].menuTexture.string(), loaded, nullptr)) {
texture = textures.emplace(songIndex, loaded).first;
}
}
if (texture == textures.end()) continue;
const float width = 374.0f * scale;
const float height = 34.0f * scale;
ui.texture(texture->second, 201.0f - width * 0.5f,
centersY[slot] - height * 0.5f, width, height,
0, 197, 374, 34);
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));
}
} else {
// Fixed selected-song fragments in CDifficultyTask's renderer
// (FUN_005be2d0), using its executable constants.
// FUN_005b33a0 treats the executable constants as sprite
// centres and subtracts half of the scaled source size.
ui.texture(atlas, 105, 167, 98, 98, 1, 1, 196, 196);
ui.texture(atlas, 209, 178, 374, 34, 0, 197, 374, 34);
ui.texture(atlas, 220, 214, 374, 24, 0, 232, 374, 24);
ui.texture(atlas, 220, 239, 314, 16, 198, 180, 314, 16);
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);
}
}
// The navigator and common HUD are later compositing passes in
@@ -1157,11 +1575,28 @@ bool runSongSelect(SDL_Window* window, const fs::path& gcRoot, std::string* sele
if (originalCommon.ready()) originalCommon.render(ui);
ui.flush();
if (capturePrefix && task == SelectTask::Music && !capturedMusic) {
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) {
captureFrameForReverse(capturePrefix, "music", pixelWidth, pixelHeight);
capturedMusic = true;
if (captureBoth) task = SelectTask::Difficulty;
} else if (capturePrefix && task == SelectTask::Difficulty && !capturedDifficulty) {
if (captureBoth) startMotion(MenuMotion::MusicDecision);
} else if (capturePrefix && motion == MenuMotion::Idle &&
task == SelectTask::Difficulty && !capturedDifficulty) {
captureFrameForReverse(capturePrefix, "difficulty", pixelWidth, pixelHeight);
capturedDifficulty = true;
}
+7 -7
View File
@@ -686,12 +686,12 @@ struct GcKeySample {
float sampledTimeMs = 0.0f;
};
bool gcLoopFlag(const gc::TransformPoint& key) { return key.tweenTowards; }
bool gcLoopFlag(const gc::ObjectColorPoint& key) { return key.tweenTowards; }
bool gcLoopFlag(const gc::VisibilityPoint& key) { return key.fadeOut; }
bool gcInterpolateFlag(const gc::TransformPoint& key) { return key.tweenAway; }
bool gcInterpolateFlag(const gc::ObjectColorPoint& key) { return key.tweenAway; }
bool gcInterpolateFlag(const gc::VisibilityPoint& key) { return key.fadeIn; }
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; }
// 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 +807,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.fadeIn &&
if (next.visible != visible && next.interpolate &&
untilNext >= 0.0f && untilNext < kGcVisibilityFadeMs) {
const float remaining = std::clamp(untilNext / kGcVisibilityFadeMs, 0.0f, 1.0f);
alpha = next.visible ? 1.0f - remaining : remaining;
+12
View File
@@ -0,0 +1,12 @@
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)
+49 -10
View File
@@ -70,20 +70,21 @@ The file is big-endian. The initial words are not all section offsets. Confirmed
| 2 | track points |
| 3 | notes |
| 4 | camera |
| 5 | particles |
| 5 | `TuneBGEffectData` / FlowItem keys |
| 6 | visualizer |
| 7 | unknown section |
| 7 | background texture names and image keys |
| 8 | first color table |
| 9 | objects |
| 10 | scalar/unknown; often `0x30`, not an offset |
| 11 | second color table |
| 11 | points into the post-object extension offset table |
| 12 | scalar/unknown; can accidentally look like an in-file offset |
This matters because treating every plausible header word as an offset can split the notes or camera section at a false boundary.
Older 11-word stage headers instead store the second color-table offset in
slot 10. The scalar/offset/scalar arrangement in slots 10..12 belongs to the
newer 13-word format used by the 4.71-era charts.
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.
## Note array: confirmed wire layout
@@ -199,8 +200,15 @@ 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` | packed authored `RRGGBBAA` colour used by duration-target geometry |
| `+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 |
| `+71` | number of generated MERRY GO ROUND targets |
| `+75` | MERRY GO ROUND spacing in beats |
@@ -333,7 +341,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.
@@ -366,8 +374,13 @@ synthetic pulsing rings are no longer used for decoded GC stages.
Duration bodies are also type-specific in the executable:
- HOLD calls `0x00647cf0` and SLIDE HOLD calls `0x00641fd0`; both emit ribbon
triangles rather than an OpenGL-style line.
- 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.
- 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.
@@ -391,6 +404,32 @@ 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
+200
View File
@@ -0,0 +1,200 @@
# 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.
+69 -8
View File
@@ -288,9 +288,11 @@ mixed as ordinary scalars.
### Up vector and roll
`FUN_005e0ad0` rebuilds `up` by projecting world-up `(0,1,0)` onto the plane
normal to `target-eye`. If the two directions are collinear it falls back to
`(0,0,1)`. `rotationB` then rotates that up vector around the normalized view
axis.
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.
### Gameplay projection
@@ -314,9 +316,13 @@ orthographic sections.
The distance is not clamped to the near plane. A zero-length camera therefore
also produces zero orthographic extents in the original. Likewise, its vector
normalizer returns `(0,0,0)` for a zero-length input instead of propagating a
NaN as `glm::normalize` does. The Linux port mirrors both edge cases and uses
explicit left-handed view/projection builders; the `NO` depth variant is the
OpenGL backend adaptation of the original D3D left-handed matrices.
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.
The evaluator sets `projBlend` to `0` for `projType=0` and `1` for
`projType=1`. An `fMode=1` transition linearly interpolates the endpoint
@@ -349,11 +355,66 @@ The player now imports the raw camera keys (including non-finite sentinel
values) and ports the confirmed `aMode`,
`fMode`, orbit, up/roll, projection type/blend, FOV and clipping-plane
behavior. GC cameras are evaluated directly without the legacy follow-camera
smoothing. Remaining camera work is validation against captured original
frames.
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.
## 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
+6
View File
@@ -12,6 +12,12 @@ 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`;
+14 -6
View File
@@ -90,9 +90,11 @@ 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` 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.
`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.
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
@@ -137,9 +139,15 @@ actual formulas and spacings:
- `(7 - count) * 0.5 * 68`, with the row anchored at `426 + 16`;
- `(9 - count) * 0.5 * 52`, with the row anchored at `433 + 16`.
The remaining visual work is outside this recovered static scene snapshot:
the executable-owned player/status HUD in the blank upper band, continuous
timeline interpolation, and the exact transition timing between task states.
`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.
## Common and navigator layers
+3 -2
View File
@@ -26,7 +26,8 @@ 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;
- genre filtering from the catalog genre byte;
- the eight original sort tabs, with genre headings and catalog-backed title,
difficulty, newest and random ordering;
- 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;
@@ -48,7 +49,7 @@ Controls:
```text
Up / Down, W / S previous / next song
PageUp / PageDown jump by eight songs
Q / E or Tab genre
Q / E or Tab song order
Enter or Space enter difficulty screen
Escape close selector
+40 -26
View File
@@ -47,10 +47,22 @@ generated samples remain uniform over corners; it is not the normal route
renderer.
The player now mirrors `DrawWay`: it uploads the original authored points plus
the two exact timestamp intersections and draws separate behind/current and
current/ahead line strips with endpoint color gradients. The stage values
`backwardsDrawDist` and `forwardDrawDist` behave as seconds and are converted
to milliseconds before comparison with track and note timestamps.
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.
Dynamic `TrackDrawDist` records are step changes, not interpolation keys. The
StageConfig forward distance remains active until the timestamp of the first
@@ -64,18 +76,22 @@ For `ac_10pt8tion_hard.dat`:
```text
first track key: 0 ms, (0, 0, 0)
second track key: 6486 ms, (0, 0, 199.985)
draw behind: 10 s
draw ahead: 7 s
first range field: 10
active range: 7 beats
```
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.
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.
## Track colors
The stage config stores two RGBA colors directly after the draw-range values.
They are used for the forward and already-travelled portions of the rail. For
`10pt8tion_hard` they are `(255,0,128)` ahead and `(255,255,255)` behind.
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.
## Base background
@@ -88,15 +104,14 @@ rgba top_left
rgba bottom_right
rgba bottom_left
u8 interpolate_to_next
u8 audio_reactive_color
u8 rhythm_reactive_color
```
`FUN_00642390` holds the active colors unless `interpolate_to_next` is set; in
that case it linearly interpolates all four RGBA values to the following key.
`audio_reactive_color` applies `FUN_005d9650` to each active color using the
runtime analyser value. The Linux player now implements the exact hold versus
interpolate selection and keeps the second mode at its neutral color factor
until the analyser feeding `stage renderer +0x24` is ported.
`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.
`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
@@ -105,7 +120,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 `particles`,
The remaining original scene is produced by the stage FlowItem,
`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,
@@ -115,14 +130,13 @@ keys, 19 visualizer keys, 46 model names and 316 object instances; 10pt8tion
contains 2, 28, 37 and 352 respectively. The Vectorail level data retains this
decoded scene for the model-rendering pass.
The PSP package now retains all three timelines. The particle constructor at
`FUN_005f0940` creates a 64-instance pool for every configured particle key.
`FUN_005f0130` scales both `repeatMeasure` and `lifespanMeasure` by the active
beat duration. Recovered spawn layouts are: type 1, a deterministic/random
point; type 2, a screen/grid group using `groupShapeSize`; type 3, six points
at 60-degree intervals around a circle. The PSP implementation follows these
timing and layout rules, but substitutes geometry for the original particle
texture resource until that resource binding is mapped.
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 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,
+18 -1
View File
@@ -186,6 +186,21 @@ 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;
@@ -327,6 +342,8 @@ 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;
ColorTable2Array colorTable2 @ hdr.colors2;
// Header word 11 points to offset slot 2, one word into this table.
ColorTable2Array colorTable2 @ hdr.colors2 - 4;
+58
View File
@@ -0,0 +1,58 @@
// 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
View File
@@ -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.audioReactive ? 2u : 0u);
(source.rhythmReactive ? 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.fadeOut ? static_cast<std::uint8_t>(kObjectKeyLoop) : 0u) |
(source.fadeIn ? static_cast<std::uint8_t>(kObjectKeyInterpolate) : 0u));
(source.repeat ? static_cast<std::uint8_t>(kObjectKeyLoop) : 0u) |
(source.interpolate ? 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.tweenTowards ? static_cast<std::uint8_t>(kObjectKeyLoop) : 0u) |
(source.tweenAway ? static_cast<std::uint8_t>(kObjectKeyInterpolate) : 0u));
(source.repeat ? static_cast<std::uint8_t>(kObjectKeyLoop) : 0u) |
(source.interpolate ? 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.tweenTowards ? static_cast<std::uint8_t>(kObjectKeyLoop) : 0u) |
(source.tweenAway ? static_cast<std::uint8_t>(kObjectKeyInterpolate) : 0u));
(source.repeat ? static_cast<std::uint8_t>(kObjectKeyLoop) : 0u) |
(source.interpolate ? static_cast<std::uint8_t>(kObjectKeyInterpolate) : 0u));
output.rgba = rgba(source.color);
return output;
}
@@ -0,0 +1,51 @@
// 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);
}
}
}
}
+10
View File
@@ -72,6 +72,9 @@ 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:
@@ -89,6 +92,12 @@ 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}")
@@ -98,6 +107,7 @@ 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())