From 72c59bf4101c9d11e05f67aab0c26003e3901a5e Mon Sep 17 00:00:00 2001 From: Kiyooru Date: Sat, 15 Aug 2026 16:25:11 +0200 Subject: [PATCH] Reproduce arcade menu transitions --- apps/desktop/src/SongSelect.cpp | 494 ++++++++++++++++++++++++++++---- docs/re_gc_menu_exe.md | 20 +- 2 files changed, 448 insertions(+), 66 deletions(-) diff --git a/apps/desktop/src/SongSelect.cpp b/apps/desktop/src/SongSelect.cpp index 84536e1..4736d83 100644 --- a/apps/desktop/src/SongSelect.cpp +++ b/apps/desktop/src/SongSelect.cpp @@ -17,6 +17,7 @@ #include #include #include +#include #include #include #include @@ -45,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 kCarouselOffsets{ + -5, -4, -3, -2, -1, 0, 0, 1, 2, 3, 4, 5 +}; +constexpr std::array kCarouselRowY{ + 175, 228, 281, 334, 387, 440, 701, 754, 807, 860, 913, 966 +}; +constexpr std::array kCarouselIndexY{ + 189, 242, 295, 348, 401, 454, 715, 768, 821, 874, 927, 980 +}; +constexpr std::array kCarouselCentersY{ + 209, 262, 315, 368, 421, 474, 735, 788, 841, 894, 947, 1000 +}; +constexpr std::array 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(target) - static_cast(origin); + const int wrappedCount = static_cast(count); + if (distance > wrappedCount / 2) distance -= wrappedCount; + if (distance < -wrappedCount / 2) distance += wrappedCount; + return distance; +} + struct Song { gc::StageCatalogEntry catalog; fs::path menuTexture; @@ -433,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], @@ -446,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() { @@ -460,6 +523,7 @@ public: if (texture.id != 0) glDeleteTextures(1, &texture.id); } textures_.clear(); + symbolTranslations_.clear(); draws_.clear(); rvbBytes_.clear(); mtxBytes_.clear(); @@ -488,6 +552,7 @@ private: } std::vector draws_; std::unordered_map textures_; + std::unordered_map> symbolTranslations_; std::vector rvbBytes_; std::vector mtxBytes_; gc::RvbScene scene_; @@ -563,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; @@ -604,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; @@ -623,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; @@ -633,6 +733,21 @@ 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 paths) { + for (const char* path : paths) { + state.animationFrameByPath[path] = animationFrame; + } + return state; +} + int genreLabelRow(int genre) { // s_j[_eng].dds is the executable-owned 256x256 genre-label atlas. // Each pseudo song ID 50000+n selects one 256x32 row. @@ -874,6 +989,14 @@ bool runSongSelect(SDL_Window* window, const fs::path& gcRoot, std::string* sele 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; @@ -888,12 +1011,15 @@ 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(visible.size()); int next = (static_cast(selection) + delta) % count; if (next < 0) next += count; selection = static_cast(next); difficulty = firstPlayableDifficulty(songs[visible[selection]], difficulty); refreshOriginalScenes(); + startMotion(MenuMotion::SongScroll); }; auto changeSort = [&](int delta) { const size_t selectedSong = visible.empty() ? 0 : visible[selection]; @@ -908,6 +1034,7 @@ bool runSongSelect(SDL_Window* window, const fs::path& gcRoot, std::string* sele difficulty = firstPlayableDifficulty(songs[visible[selection]], difficulty); } refreshOriginalScenes(); + startMotion(MenuMotion::SortChange); }; auto changeDifficulty = [&](int delta) { const Song& song = songs[visible[selection]]; @@ -917,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; } } @@ -927,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(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; @@ -940,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; @@ -950,14 +1147,14 @@ bool runSongSelect(SDL_Window* window, const fs::path& gcRoot, std::string* sele 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); @@ -966,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; } @@ -976,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(SDL_GetTicks() - motionStartTicks) / 1000.0f; + const uint32_t rvbFrame = static_cast(activeMotionSeconds * 60.0f); + if (motion == MenuMotion::MusicEnter) { + originalMusic.setState(animateRvbState( + selectMusicSceneState(&selected, static_cast(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(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(sortSlot + 1)), rvbFrame, + {"/imc_sort"})); + } else if (motion == MenuMotion::MusicDecision) { + originalMusic.setState(animateRvbState( + selectMusicDecisionSceneState(&selected, static_cast(sortSlot + 1)), + rvbFrame, {"/imc_focus"})); + } else if (motion == MenuMotion::MusicExit) { + originalMusic.setState(animateRvbState( + selectMusicExitSceneState(&selected, static_cast(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 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(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); @@ -1020,39 +1296,90 @@ bool runSongSelect(SDL_Window* window, const fs::path& gcRoot, std::string* sele if (logical < 0) logical += count; return carousel[static_cast(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( + 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(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(kUiWidth) * eased; + musicLayerAlpha = 1.0f - progress; + } + const auto animatedSlot = [&](size_t targetSlot) { + std::array geometry{ + kCarouselRowY[targetSlot], kCarouselIndexY[targetSlot], + kCarouselCentersY[targetSlot], kCarouselAlpha[targetSlot] + }; + if (motion != MenuMotion::SongScroll || carousel.empty()) return geometry; + + int targetLogical = static_cast(selectedCarouselEntry) + + kCarouselOffsets[targetSlot]; + const int count = static_cast(carousel.size()); + targetLogical %= count; + if (targetLogical < 0) targetLogical += count; + const int sourceOffset = wrappedCarouselDistance( + previousCarouselEntry, static_cast(targetLogical), carousel.size()); + const int sourceSlot = carouselSlotForOffset( + sourceOffset, sourceOffset == 0 && scrollDelta < 0); + if (sourceSlot >= 0) { + geometry[0] = lerp(kCarouselRowY[static_cast(sourceSlot)], + geometry[0], scrollProgress); + geometry[1] = lerp(kCarouselIndexY[static_cast(sourceSlot)], + geometry[1], scrollProgress); + geometry[2] = lerp(kCarouselCentersY[static_cast(sourceSlot)], + geometry[2], scrollProgress); + geometry[3] = lerp(kCarouselAlpha[static_cast(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 offsets{ - -5, -4, -3, -2, -1, 0, 0, 1, 2, 3, 4, 5 - }; - static constexpr std::array rowY{ - 175, 228, 281, 334, 387, 440, 701, 754, 807, 860, 913, 966 - }; - static constexpr std::array indexY{ - 189, 242, 295, 348, 401, 454, 715, 768, 821, 874, 927, 980 - }; - static constexpr std::array rowAlpha{ - 0.0f, 0.7f, 0.8f, 0.9f, 1.0f, 0.0f, - 0.0f, 1.0f, 0.9f, 0.8f, 0.7f, 0.0f - }; - for (size_t slot = 0; slot < rowY.size(); ++slot) { - if (rowAlpha[slot] <= 0.0f || carousel.empty()) continue; - const CarouselEntry& entry = carouselAtOffset(offsets[slot]); + 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)); } } } @@ -1068,6 +1395,18 @@ bool runSongSelect(SDL_Window* window, const fs::path& gcRoot, std::string* sele 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}; @@ -1165,26 +1504,35 @@ 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 offsets{ - -5, -4, -3, -2, -1, 0, 0, 1, 2, 3, 4, 5 - }; - static constexpr std::array centersY{ - 209, 262, 315, 368, 421, 474, 735, 788, 841, 894, 947, 1000 - }; - static constexpr std::array scales{ - 0.0f, 0.7f, 0.8f, 0.9f, 1.0f, 0.0f, - 0.0f, 1.0f, 0.9f, 0.8f, 0.7f, 0.0f - }; - for (size_t slot = 0; slot < offsets.size(); ++slot) { - const float scale = scales[slot]; + 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); @@ -1197,19 +1545,28 @@ bool runSongSelect(SDL_Window* window, const fs::path& gcRoot, std::string* sele 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 @@ -1218,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; } diff --git a/docs/re_gc_menu_exe.md b/docs/re_gc_menu_exe.md index c36954a..6c482dc 100644 --- a/docs/re_gc_menu_exe.md +++ b/docs/re_gc_menu_exe.md @@ -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