#include "openroller/desktop/SongSelect.hpp" #include "vectorail/core/DdsTexture.hpp" #include "vectorail/core/Shader.hpp" #include "openroller/desktop/ServiceMenu.hpp" #include "openroller/desktop/CabinetBackend.hpp" #include "gc/StageCatalog.hpp" #include "gc/MtxArchive.hpp" #include "gc/RvbLayout.hpp" #include "gc/RvbScene.hpp" #include "vectorail/core/gl_loader.hpp" #include "gc/TumoModel.hpp" #include #include #include #include #include #include #include #include #include #include #include #include namespace fs = std::filesystem; namespace { std::vector toGlmVertices(const std::vector& source) { std::vector result; result.reserve(source.size()); for (const gc::TumoVertex& vertex : source) { result.emplace_back(vertex.x, vertex.y, vertex.z); } return result; } constexpr int kUiWidth = 720; constexpr int kUiHeight = 1280; enum class SelectTask { Music, Difficulty, }; struct Song { gc::StageCatalogEntry catalog; fs::path menuTexture; std::array stages{}; }; struct CarouselEntry { bool category = false; size_t songIndex = 0; int genre = 0; }; struct UiVertex { float x; float y; float u; float v; }; class UiRenderer { public: UiRenderer() : shader_("shaders/ui.vert", "shaders/ui.frag") { glGenVertexArrays(1, &vao_); glGenBuffers(1, &vbo_); glBindVertexArray(vao_); glBindBuffer(GL_ARRAY_BUFFER, vbo_); glBufferData(GL_ARRAY_BUFFER, sizeof(UiVertex) * 6 * 8192, nullptr, GL_DYNAMIC_DRAW); glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, sizeof(UiVertex), nullptr); glEnableVertexAttribArray(0); glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, sizeof(UiVertex), reinterpret_cast(sizeof(float) * 2)); glEnableVertexAttribArray(1); shader_.use(); shader_.setBool("uTexture", false); shader_.setBool("uUseGradient", false); } void rect(float x, float y, float width, float height, const glm::vec4& color) { if (solidColor_ != color && !solid_.empty()) flush(); solidColor_ = color; appendQuad(solid_, x, y, width, height, 0.0f, 0.0f, 1.0f, 1.0f); } void outline(float x, float y, float width, float height, float thickness, const glm::vec4& color) { rect(x, y, width, thickness, color); rect(x, y + height - thickness, width, thickness, color); rect(x, y, thickness, height, color); rect(x + width - thickness, y, thickness, height, color); } void verticalGradient(float x, float y, float width, float height, const glm::vec4& top, const glm::vec4& bottom) { flush(); std::vector vertices; vertices.reserve(6); appendQuad(vertices, x, y, width, height, 0.0f, 0.0f, 1.0f, 1.0f); shader_.use(); shader_.setBool("uUseTexture", false); shader_.setBool("uUseGradient", true); shader_.setVec4("uGradientTop", top); shader_.setVec4("uGradientBottom", bottom); glBindTexture(GL_TEXTURE_2D, 0); uploadAndDraw(vertices); shader_.setBool("uUseGradient", false); } void texture(const DdsTexture& texture, float x, float y, float width, float height, float sourceX = 0.0f, float sourceY = 0.0f, float sourceWidth = -1.0f, float sourceHeight = -1.0f, const glm::vec4& tint = glm::vec4(1.0f)) { if (texture.id == 0 || texture.width <= 0 || texture.height <= 0) return; flush(); if (sourceWidth < 0.0f) sourceWidth = static_cast(texture.width); if (sourceHeight < 0.0f) sourceHeight = static_cast(texture.height); std::vector vertices; vertices.reserve(6); appendQuad(vertices, x, y, width, height, sourceX / texture.width, sourceY / texture.height, (sourceX + sourceWidth) / texture.width, (sourceY + sourceHeight) / texture.height); shader_.use(); shader_.setBool("uUseTexture", true); shader_.setBool("uUseGradient", false); shader_.setVec4("uColor", tint); glBindTexture(GL_TEXTURE_2D, texture.id); uploadAndDraw(vertices); glBindTexture(GL_TEXTURE_2D, 0); } void textureQuad(const DdsTexture& texture, const std::array, 4>& corners, const glm::vec4& tint = glm::vec4(1.0f)) { if (texture.id == 0) return; flush(); const float leftU = 0.0f, rightU = 1.0f, topV = 0.0f, bottomV = 1.0f; const auto vertex = [&](size_t index, float u, float v) { return UiVertex{ndcX(corners[index][0]), ndcY(corners[index][1]), u, v}; }; const std::vector vertices{ vertex(0, leftU, topV), vertex(1, rightU, topV), vertex(2, leftU, bottomV), vertex(2, leftU, bottomV), vertex(1, rightU, topV), vertex(3, rightU, bottomV) }; shader_.use(); shader_.setBool("uUseTexture", true); shader_.setBool("uUseGradient", false); shader_.setVec4("uColor", tint); glBindTexture(GL_TEXTURE_2D, texture.id); uploadAndDraw(vertices); glBindTexture(GL_TEXTURE_2D, 0); } void text(float x, float y, float scale, const std::string& value, const glm::vec4& color) { const float advance = scale * 6.0f; for (unsigned char raw : value) { const char c = static_cast(std::toupper(raw)); const std::array rows = glyph(c); for (int row = 0; row < 7; ++row) { for (int col = 0; col < 5; ++col) { if ((rows[row] & (1u << (4 - col))) != 0) { rect(x + col * scale, y + row * scale, scale, scale, color); } } } x += advance; } } void flush() { if (solid_.empty()) return; shader_.use(); shader_.setBool("uUseTexture", false); shader_.setBool("uUseGradient", false); shader_.setVec4("uColor", solidColor_); glBindTexture(GL_TEXTURE_2D, 0); uploadAndDraw(solid_); solid_.clear(); } private: static std::array glyph(char c) { switch (c) { case 'A': return {14,17,17,31,17,17,17}; case 'B': return {30,17,17,30,17,17,30}; case 'C': return {14,17,16,16,16,17,14}; case 'D': return {30,17,17,17,17,17,30}; case 'E': return {31,16,16,30,16,16,31}; case 'F': return {31,16,16,30,16,16,16}; case 'G': return {14,17,16,23,17,17,15}; case 'H': return {17,17,17,31,17,17,17}; case 'I': return {31,4,4,4,4,4,31}; case 'J': return {7,2,2,2,18,18,12}; case 'K': return {17,18,20,24,20,18,17}; case 'L': return {16,16,16,16,16,16,31}; case 'M': return {17,27,21,21,17,17,17}; case 'N': return {17,25,21,19,17,17,17}; case 'O': return {14,17,17,17,17,17,14}; case 'P': return {30,17,17,30,16,16,16}; case 'Q': return {14,17,17,17,21,18,13}; case 'R': return {30,17,17,30,20,18,17}; case 'S': return {15,16,16,14,1,1,30}; case 'T': return {31,4,4,4,4,4,4}; case 'U': return {17,17,17,17,17,17,14}; case 'V': return {17,17,17,17,17,10,4}; case 'W': return {17,17,17,21,21,21,10}; case 'X': return {17,17,10,4,10,17,17}; case 'Y': return {17,17,10,4,4,4,4}; case 'Z': return {31,1,2,4,8,16,31}; case '0': return {14,17,19,21,25,17,14}; case '1': return {4,12,4,4,4,4,14}; case '2': return {14,17,1,2,4,8,31}; case '3': return {30,1,1,14,1,1,30}; case '4': return {2,6,10,18,31,2,2}; case '5': return {31,16,16,30,1,1,30}; case '6': return {14,16,16,30,17,17,14}; case '7': return {31,1,2,4,8,8,8}; case '8': return {14,17,17,14,17,17,14}; case '9': return {14,17,17,15,1,1,14}; case '-': return {0,0,0,31,0,0,0}; case ':': return {0,4,4,0,4,4,0}; case '/': return {1,2,2,4,8,8,16}; case '<': return {2,4,8,16,8,4,2}; case '>': return {8,4,2,1,2,4,8}; case '.': return {0,0,0,0,0,12,12}; case '[': return {14,8,8,8,8,8,14}; case ']': return {14,2,2,2,2,2,14}; default: return {0,0,0,0,0,0,0}; } } static float ndcX(float x) { return x / (kUiWidth * 0.5f) - 1.0f; } static float ndcY(float y) { return 1.0f - y / (kUiHeight * 0.5f); } static void appendQuad(std::vector& out, float x, float y, float width, float height, float u0, float v0, float u1, float v1) { const float left = ndcX(x), right = ndcX(x + width); const float top = ndcY(y), bottom = ndcY(y + height); out.insert(out.end(), { {left, top, u0, v0}, {right, top, u1, v0}, {left, bottom, u0, v1}, {left, bottom, u0, v1}, {right, top, u1, v0}, {right, bottom, u1, v1} }); } void uploadAndDraw(const std::vector& vertices) { glBindVertexArray(vao_); glBindBuffer(GL_ARRAY_BUFFER, vbo_); glBufferSubData(GL_ARRAY_BUFFER, 0, vertices.size() * sizeof(UiVertex), vertices.data()); glDrawArrays(GL_TRIANGLES, 0, static_cast(vertices.size())); } Shader shader_; unsigned int vao_ = 0; unsigned int vbo_ = 0; std::vector solid_; glm::vec4 solidColor_{1.0f}; }; struct MenuGpuModel { unsigned int vao = 0; unsigned int vbo = 0; GLsizei triangleCount = 0; GLint lineFirst = 0; GLsizei lineCount = 0; }; class OriginalMenuModels { public: OriginalMenuModels() : shader_("shaders/model.vert", "shaders/model.frag") {} bool load(const fs::path& modelDir, std::string* error) { return loadModel(modelDir / "menu_obj_05.tumo", decoration_, error) && loadModel(modelDir / "obj_sphere06.tumo", sphere_, error); } void render(float elapsedSeconds) const { if (!ready()) return; // CCommon3DCamera reset by FUN_0063de50: eye=(0,0,~2.25), // target=(0,0,eye.z+1), up=(0,1,0), FOV=pi/3. The executable uses // D3D's LH convention; GLM's LH/NO projection preserves that view // while producing OpenGL clip depth. const glm::mat4 projection = glm::perspectiveLH_NO( glm::radians(60.0f), static_cast(kUiWidth) / kUiHeight, 0.1f, 1000.0f); const glm::mat4 view = glm::lookAtLH(glm::vec3(0, 0, 2.25f), glm::vec3(0, 0, 3.25f), glm::vec3(0, 1, 0)); shader_.use(); shader_.setMat4("uProjection", projection); shader_.setMat4("uView", view); glEnable(GL_DEPTH_TEST); glDepthMask(GL_FALSE); // FUN_00577fb0 draws menu_obj_05 twice at y=+/-13.75. The two // material colours are read literally from 006fcdc4..006fcdc8 and // 006fcdb8..006fcdc0. shader_.setVec4("uColor", glm::vec4(0.3216f, 0.0f, 0.7020f, 0.18f)); glBindVertexArray(decoration_.vao); shader_.setMat4("uModel", glm::translate(glm::mat4(1.0f), glm::vec3(0, 13.75f, 0))); glDrawArrays(GL_TRIANGLES, 0, decoration_.triangleCount); shader_.setVec4("uColor", glm::vec4(0.8863f, 0.2118f, 0.5490f, 0.15f)); shader_.setMat4("uModel", glm::translate(glm::mat4(1.0f), glm::vec3(0, -13.75f, 0))); glDrawArrays(GL_TRIANGLES, 0, decoration_.triangleCount); // obj_sphere06: translation Z=100, scale=5, equal XYZ rotation at // time*0.125. The small two-frequency Y drift is also present in // FUN_00577fb0 (constants 0.221, 0.433, 2.5, 0.75). const float y = (std::sin(elapsedSeconds * 0.221f) + std::sin(elapsedSeconds * 0.433f) * 2.5f) * 0.75f; glm::mat4 sphereModel = glm::translate(glm::mat4(1.0f), glm::vec3(0, y, 100)); const float angle = elapsedSeconds * 0.125f; sphereModel = glm::rotate(sphereModel, angle, glm::vec3(1, 0, 0)); sphereModel = glm::rotate(sphereModel, angle, glm::vec3(0, 1, 0)); sphereModel = glm::rotate(sphereModel, angle, glm::vec3(0, 0, 1)); sphereModel = glm::scale(sphereModel, glm::vec3(5.0f)); shader_.setMat4("uModel", sphereModel); shader_.setVec4("uColor", glm::vec4(0.8157f, 0.7216f, 0.8235f, 0.48f)); glBindVertexArray(sphere_.vao); glLineWidth(1.0f); glDrawArrays(GL_LINES, sphere_.lineFirst, sphere_.lineCount); glBindVertexArray(0); glDepthMask(GL_TRUE); glDisable(GL_DEPTH_TEST); } bool ready() const { return decoration_.vao != 0 && sphere_.vao != 0; } void clear() { clearModel(decoration_); clearModel(sphere_); } private: static bool loadModel(const fs::path& path, MenuGpuModel& gpu, std::string* error) { gc::TumoGeometry geometry; if (!gc::LoadTumoGeometry(path.string(), &geometry, error)) return false; std::vector vertices = toGlmVertices(geometry.triangles); gpu.triangleCount = static_cast(vertices.size()); gpu.lineFirst = static_cast(vertices.size()); const std::vector solidLines = toGlmVertices(geometry.solidLines); vertices.insert(vertices.end(), solidLines.begin(), solidLines.end()); gpu.lineCount = static_cast(geometry.solidLines.size()); glGenVertexArrays(1, &gpu.vao); glGenBuffers(1, &gpu.vbo); glBindVertexArray(gpu.vao); glBindBuffer(GL_ARRAY_BUFFER, gpu.vbo); glBufferData(GL_ARRAY_BUFFER, vertices.size() * sizeof(glm::vec3), vertices.data(), GL_STATIC_DRAW); glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(glm::vec3), nullptr); glEnableVertexAttribArray(0); return true; } static void clearModel(MenuGpuModel& gpu) { if (gpu.vbo != 0) glDeleteBuffers(1, &gpu.vbo); if (gpu.vao != 0) glDeleteVertexArrays(1, &gpu.vao); gpu = {}; } Shader shader_; MenuGpuModel decoration_; MenuGpuModel sphere_; }; bool readFile(const fs::path& path, std::vector* bytes) { if (!bytes) return false; std::ifstream file(path, std::ios::binary); if (!file) return false; file.seekg(0, std::ios::end); const std::streamoff size = file.tellg(); file.seekg(0, std::ios::beg); if (size < 0) return false; bytes->resize(static_cast(size)); if (size > 0) file.read(reinterpret_cast(bytes->data()), size); return file.good() || file.eof(); } 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; for (size_t i = 5; i < symbol.size(); ++i) { if (!std::isdigit(static_cast(symbol[i]))) return SIZE_MAX; number = number * 10 + static_cast(symbol[i] - '0'); } return number == 0 ? SIZE_MAX : number - 1; } class OriginalMenuLayer { public: bool load(const fs::path& rvbPath, const fs::path& mtxPath, const gc::RvbSnapshotState& state, std::string* error) { if (!readFile(rvbPath, &rvbBytes_) || !readFile(mtxPath, &mtxBytes_)) { if (error) *error = "could not read original RVB/MTX scene"; return false; } if (!gc::ParseRvbScene(rvbBytes_, &scene_, error) || !gc::ParseMtxArchive(mtxBytes_, &archive_, error)) { return false; } return setState(state, error); } bool loadSymbol(const fs::path& rvbPath, const fs::path& mtxPath, const std::string& symbol, const gc::RvbSnapshotState& state, std::string* error) { if (!readFile(rvbPath, &rvbBytes_) || !readFile(mtxPath, &mtxBytes_)) { if (error) *error = "could not read original RVB/MTX scene"; return false; } if (!gc::ParseRvbScene(rvbBytes_, &scene_, error) || !gc::ParseMtxArchive(mtxBytes_, &archive_, error)) { return false; } return setSymbolState(symbol, state, error); } bool setState(const gc::RvbSnapshotState& state, std::string* error = nullptr) { std::vector nextDraws; if (!gc::BuildRvbSnapshot(rvbBytes_, scene_, state, &nextDraws, error)) return false; return acceptDraws(std::move(nextDraws), error); } bool setSymbolState(const std::string& symbol, const gc::RvbSnapshotState& state, std::string* error = nullptr) { std::vector nextDraws; if (!gc::BuildRvbSymbolSnapshot(rvbBytes_, scene_, symbol, state, &nextDraws, error)) { return false; } return acceptDraws(std::move(nextDraws), error); } void render(UiRenderer& ui, float dx = 0.0f, float dy = 0.0f, float alpha = 1.0f) const { for (const gc::RvbImageDraw& draw : draws_) { const auto found = textures_.find(draw.imageSymbol); if (found == textures_.end()) continue; auto corners = draw.corners; for (auto& corner : corners) { corner[0] += dx; corner[1] += dy; } ui.textureQuad(found->second, corners, glm::vec4(draw.color[0], draw.color[1], draw.color[2], draw.alpha * alpha)); } } bool ready() const { return !textures_.empty(); } void translateSymbol(const std::string& symbol, float dx, float dy) { for (gc::RvbImageDraw& draw : draws_) { if (draw.imageSymbol != symbol) continue; for (auto& corner : draw.corners) { corner[0] += dx; corner[1] += dy; } } } void clear() { for (auto& [_, texture] : textures_) { if (texture.id != 0) glDeleteTextures(1, &texture.id); } textures_.clear(); draws_.clear(); rvbBytes_.clear(); mtxBytes_.clear(); scene_ = {}; archive_ = {}; } private: bool acceptDraws(std::vector nextDraws, std::string* error) { std::unordered_set required; for (const gc::RvbImageDraw& draw : nextDraws) required.insert(draw.imageSymbol); for (const std::string& symbol : required) { if (textures_.contains(symbol)) continue; const size_t index = imageTextureIndex(symbol); if (index >= archive_.textures.size()) continue; std::vector dds; DdsTexture texture; if (!gc::ExtractMtxTextureDds(mtxBytes_, archive_.textures[index], &dds, error) || !loadDdsTextureBytes(dds, texture, error)) { return false; } textures_.emplace(symbol, texture); } draws_ = std::move(nextDraws); return !draws_.empty() && !textures_.empty(); } std::vector draws_; std::unordered_map textures_; std::vector rvbBytes_; std::vector mtxBytes_; gc::RvbScene scene_; gc::MtxArchive archive_; }; gc::RvbSnapshotState selectMusicRowState() { gc::RvbSnapshotState state; auto& frame = state.frameByPath; frame["/"] = "jf_music_exoff"; frame["/imc_tag_first_s/imc_music_ex"] = "jf_tag_ex_off"; frame["/imc_tag_first_s/imc_music_new"] = "jf_tag_new_off"; frame["/imc_tag_first_s/imc_music_no"] = "jf_tag_noxx"; frame["/imc_tag_first_s"] = "jf_tag_first_off"; frame["/imc_unlock_key_small"] = "jf_keysmall_off"; frame["/imc_rate_smpl"] = "jf_rs_x"; frame["/imc_rate_nrml"] = "jf_rs_x"; frame["/imc_rate_hard"] = "jf_rs_x"; return state; } gc::RvbSnapshotState selectMusicSceneState(const Song* song = nullptr, int activeSort = 3) { gc::RvbSnapshotState state; auto& frame = state.frameByPath; frame["/"] = "jf_slmusic_start"; frame["/imc_navi"] = "tg_navi_start"; frame["/imc_navi/imc_tx"] = "jf_ope_tx_off"; frame["/imc_title"] = "lf_title_selectmusic_start"; frame["/imc_focus"] = "jf_focus_start"; frame["/imc_focus/imc_fd_jacket_anim"] = "jf_fd_jacket_on"; frame["/imc_focus/imc_fd_jacket_anim/imc_fd_jacket_off"] = "jf_jacket_first"; frame["/imc_focus/imc_fd_jacket_anim/imc_fd_jacket_on"] = "jf_jacket_first"; // FUN_005adbb0 only enables the key/total/ranking tags from persistent // profile state. OpenRoller currently has no imported NESiCA profile. frame["/imc_focus/imc_unlock_key_star"] = "jf_keystar_off"; const bool extra = song && !song->stages[3].empty(); frame["/imc_focus/imc_diff"] = extra ? "jf_diff_exon" : "jf_diff_exoff"; static constexpr std::array paths{ "/imc_focus/imc_diff/imc_n_smpl", "/imc_focus/imc_diff/imc_n_nrml", "/imc_focus/imc_diff/imc_n_hard", "/imc_focus/imc_diff/imc_n_extra" }; static constexpr std::array visible{ "jf_simple_on", "jf_normal_on", "jf_hard_on", "jf_extra_on" }; static constexpr std::array unavailable{ "jf_simple_not", "jf_normal_not", "jf_hard_not", "jf_extra_not" }; for (size_t i = 0; i < paths.size(); ++i) { frame[paths[i]] = !song || !song->stages[i].empty() ? visible[i] : unavailable[i]; } frame["/imc_focus/imc_total"] = "jf_total_off"; frame["/imc_focus/imc_music_ex"] = extra ? "jf_tag_ex_on" : "jf_tag_ex_off"; frame["/imc_focus/imc_tag_no"] = "jf_tag_noxx"; frame["/imc_focus/imc_tag_new"] = "jf_tag_new_on"; frame["/imc_focus/imc_tri_btm"] = "lf_tri_off"; frame["/imc_focus/imc_tri_top"] = "lf_tri_off"; // CSelectMusicTask stores sort kinds in executable order 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. frame["/imc_sort/imc_sort1"] = "jf_sort1_on"; frame["/imc_sort/imc_sort2"] = "jf_sort2_on"; frame["/imc_sort/imc_sort3"] = "jf_sort3_on"; frame["/imc_sort/imc_sort4"] = "jf_sort4_on"; frame["/imc_sort/imc_sort5"] = "jf_sort5_on"; frame["/imc_sort/imc_sort6"] = "jf_sort6_on"; frame["/imc_sort/imc_sort7"] = "jf_sort7_on"; frame["/imc_sort/imc_sort8"] = "jf_sort8_on"; return state; } gc::RvbSnapshotState selectModeSceneState(const Song* song = nullptr, int difficulty = 0) { gc::RvbSnapshotState state; auto& frame = state.frameByPath; frame["/"] = "jf_slmode_start"; frame["/imc_navi"] = "tg_navi_start"; frame["/imc_navi/UNIQUE_155"] = "jf_tx_mode"; frame["/imc_title"] = "jf_title_mode"; frame["/imc_slmode"] = "jf_mode_fi"; const bool extra = song && !song->stages[3].empty(); frame["/imc_slmode/imc_mode"] = extra ? "jf_mode_exon" : "jf_mode_exoff"; static constexpr std::array paths{ "/imc_slmode/imc_mode/imc_m_smpl", "/imc_slmode/imc_mode/imc_m_nrml", "/imc_slmode/imc_mode/imc_m_hard", "/imc_slmode/imc_mode/imc_m_extra" }; static constexpr std::array selected{ "jf_m_simple_on", "jf_m_normal_on", "jf_m_hard_on", "jf_m_extra_on" }; static constexpr std::array visible{ "jf_m_simple_off", "jf_m_normal_off", "jf_m_hard_off", "jf_m_extra_off" }; static constexpr std::array 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(i) == difficulty && exists ? selected[i] : (exists ? visible[i] : unavailable[i]); } static constexpr std::array labels{ "lf_simple", "lf_normal", "lf_hard", "lf_extra" }; frame[extra ? "/imc_slmode/imc_mode/imc_tri_set_exon" : "/imc_slmode/imc_mode/imc_tri_set_exoff"] = labels[difficulty]; frame["/imc_tab"] = "lf_tab_mode"; static constexpr std::array marks{ "jf_mk_simple", "jf_mk_normal", "jf_mk_hard", "jf_mk_extra" }; frame["/imc_tab/imc_info_m_tab"] = marks[difficulty]; return state; } gc::RvbSnapshotState commonSelectSceneState() { gc::RvbSnapshotState state; auto& frame = state.frameByPath; frame["/"] = "jf_com_all"; frame["/imc_ctrl_anim"] = "jf_ctrl_start"; frame["/imc_ctrl_anim/imc_ctrl"] = "jf_ctrl_3"; frame["/imc_ctrl_anim/imc_ctrl/imc_ctrl_label1"] = "jf_ctrl_tx02"; frame["/imc_ctrl_anim/imc_ctrl/imc_ctrl_label2"] = "jf_ctrl_tx05"; frame["/imc_ctrl_anim/imc_ctrl/imc_ctrl_label3"] = "jf_ctrl_tx08"; frame["/imc_head"] = "jf_head_fi"; frame["/imc_head/imc_head_back"] = "jf_hback_black"; frame["/imc_head/UNIQUE_225"] = "jf_line_on"; frame["/imc_head/imc_head_time"] = "jf_time_on"; frame["/imc_head/imc_ico_l"] = "jf_local_on"; frame["/imc_head/imc_ico_n"] = "jf_nesys_on"; frame["/imc_foot"] = "jf_foot_fi"; return state; } gc::RvbSnapshotState navigatorSelectSceneState() { gc::RvbSnapshotState state; auto& frame = state.frameByPath; frame["/"] = "jf_ope_start"; frame["/imc_ope"] = "jf_ope00"; frame["/imc_ope/imc_ope_mouth"] = "jf_ope_mouth_stay"; frame["/imc_ope/imc_ope_mouth/imc_ope_mouth_anim"] = "lf_mouth_bc"; return state; } int genreLabelRow(int genre) { // s_j[_eng].dds is the executable-owned 256x256 genre-label atlas. // Each pseudo song ID 50000+n selects one 256x32 row. switch (genre) { case 1: return 1; // Anime & Pops case 2: return 2; // VOCALOID case 7: return 3; // Touhou arrangements case 3: return 4; // Rhythm Game case 4: return 5; // Game case 5: return 6; // Variety case 6: return 7; // Original default: return 0; // Recommended for beginners } } void captureFrameForReverse(const char* prefix, const char* suffix, int width, int height) { if (!prefix || !*prefix || width <= 0 || height <= 0) return; std::vector pixels(static_cast(width) * height * 3); glPixelStorei(GL_PACK_ALIGNMENT, 1); glReadPixels(0, 0, width, height, GL_RGB, GL_UNSIGNED_BYTE, pixels.data()); std::ofstream file(std::string(prefix) + "-" + suffix + ".ppm", std::ios::binary); if (!file) return; file << "P6\n" << width << ' ' << height << "\n255\n"; const size_t rowBytes = static_cast(width) * 3; for (int row = height - 1; row >= 0; --row) { file.write(reinterpret_cast(pixels.data() + row * rowBytes), static_cast(rowBytes)); } } glm::vec4 genreColor(int genre) { switch (genre) { case 1: return {0.95f, 0.42f, 0.50f, 1.0f}; case 2: return {0.15f, 0.70f, 0.86f, 1.0f}; case 3: return {0.94f, 0.72f, 0.08f, 1.0f}; case 4: return {0.43f, 0.72f, 0.05f, 1.0f}; case 5: return {0.13f, 0.49f, 0.72f, 1.0f}; case 6: return {0.64f, 0.32f, 0.62f, 1.0f}; case 7: return {0.25f, 0.58f, 0.36f, 1.0f}; default: return {0.96f, 0.34f, 0.16f, 1.0f}; } } int firstPlayableDifficulty(const Song& song, int preferred = 2) { if (preferred >= 0 && preferred < 4 && !song.stages[preferred].empty()) return preferred; for (int distance = 1; distance < 4; ++distance) { const int lower = preferred - distance; const int upper = preferred + distance; if (lower >= 0 && !song.stages[lower].empty()) return lower; if (upper < 4 && !song.stages[upper].empty()) return upper; } return 0; } } // namespace bool runSongSelect(SDL_Window* window, const fs::path& gcRoot, std::string* selectedStagePath) { if (!window || !selectedStagePath) return false; const fs::path stageParam = gcRoot / "data" / "boot" / "stage_param.dat"; const fs::path stageDir = gcRoot / "data" / "stage"; std::vector bytes; std::vector catalog; std::string error; if (!readFile(stageParam, &bytes) || !gc::ParseStageCatalog(bytes, &catalog, &error)) { std::cerr << "Song select: could not load " << stageParam << ": " << error << std::endl; return false; } std::vector songs; for (gc::StageCatalogEntry& entry : catalog) { const fs::path menu = stageDir / "2d" / (entry.imageKey + "_menu.dds"); if (entry.imageKey.empty() || !fs::is_regular_file(menu)) continue; Song song; song.catalog = std::move(entry); song.menuTexture = menu; bool playable = false; for (size_t difficulty = 0; difficulty < 4; ++difficulty) { std::string chart = song.catalog.chartIds[difficulty]; if (chart.empty()) chart = song.catalog.chartGroup0[difficulty]; if (chart.empty()) continue; const fs::path stage = stageDir / (chart + ".dat"); if (fs::is_regular_file(stage)) { song.stages[difficulty] = stage; playable = true; } } if (playable) songs.push_back(std::move(song)); } if (songs.empty()) { std::cerr << "Song select: catalog has no playable local charts" << std::endl; return false; } UiRenderer ui; OriginalMenuModels originalMenuModels; OriginalMenuLayer originalMusic; OriginalMenuLayer originalMusicRow; OriginalMenuLayer originalMusicIndex; OriginalMenuLayer originalDifficulty; OriginalMenuLayer originalCommon; OriginalMenuLayer originalNavigator; std::string originalError; if (!originalMenuModels.load(gcRoot / "data" / "model", &originalError)) { std::cerr << "Song select: original 3D menu models unavailable: " << originalError << std::endl; } if (!originalMusic.load(gcRoot / "data" / "2d_boost" / "selectmusic2_eng.rvb", gcRoot / "data" / "2d_boost" / "selectmusic2_eng.mtx", selectMusicSceneState(), &originalError)) { std::cerr << "Song select: original music scene unavailable: " << originalError << std::endl; } if (!originalMusicRow.loadSymbol( gcRoot / "data" / "2d_boost" / "selectmusic2_eng.rvb", gcRoot / "data" / "2d_boost" / "selectmusic2_eng.mtx", "mc_music_link", selectMusicRowState(), &originalError)) { std::cerr << "Song select: original carousel row unavailable: " << originalError << std::endl; } if (!originalMusicIndex.loadSymbol( gcRoot / "data" / "2d_boost" / "selectmusic2_eng.rvb", gcRoot / "data" / "2d_boost" / "selectmusic2_eng.mtx", "mc_index_link", {}, &originalError)) { std::cerr << "Song select: original category row unavailable: " << originalError << std::endl; } if (!originalDifficulty.load(gcRoot / "data" / "2d_boost" / "selectmode2_eng.rvb", gcRoot / "data" / "2d_boost" / "selectmode2_eng.mtx", selectModeSceneState(), &originalError)) { std::cerr << "Song select: original difficulty scene unavailable: " << originalError << std::endl; } if (!originalCommon.load(gcRoot / "data" / "2d_boost" / "common_eng.rvb", gcRoot / "data" / "2d_boost" / "common_eng.mtx", commonSelectSceneState(), &originalError)) { std::cerr << "Song select: original common scene unavailable: " << originalError << std::endl; } else { // The common controller clip reuses alternate label frames authored // on the button centreline. When composing those normally separate // task states into the three-button selector, align their captions to // the baseline used by the first slot. originalCommon.translateSymbol("Image8", 0.0f, 59.0f); originalCommon.translateSymbol("Image11", 0.0f, 59.0f); } if (!originalNavigator.load( gcRoot / "data" / "2d_boost" / "navigator" / "navi_001_yume.rvb", gcRoot / "data" / "2d_boost" / "navigator" / "navi_001_yume.mtx", navigatorSelectSceneState(), &originalError)) { std::cerr << "Song select: original navigator scene unavailable: " << originalError << std::endl; } DdsTexture navigator; DdsTexture menuBalloon; DdsTexture genreLabels; std::string textureError; loadDdsTexture((gcRoot / "data" / "2d_boost" / "navigator" / "001_yume" / "base.dds").string(), navigator, &textureError); if (!loadDdsTexture((gcRoot / "data" / "2d_boost" / "menu" / "balloon.dds").string(), menuBalloon, &textureError)) { std::cerr << "Song select: original balloon unavailable: " << textureError << std::endl; } if (!loadDdsTexture((gcRoot / "data" / "2d_boost" / "menu" / "s_j_eng.dds").string(), genreLabels, &textureError)) { std::cerr << "Song select: original genre labels unavailable: " << textureError << std::endl; } std::unordered_map textures; // 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 visible; std::vector carousel; std::mt19937 random(std::random_device{}()); auto rebuildVisible = [&] { visible.clear(); carousel.clear(); visible.reserve(songs.size()); for (size_t i = 0; i < songs.size(); ++i) visible.push_back(i); const auto titleLess = [&](size_t lhs, size_t rhs) { const auto& a = songs[lhs].catalog; const auto& b = songs[rhs].catalog; const std::string& aKey = a.sortKey.empty() ? a.title : a.sortKey; const std::string& bKey = b.sortKey.empty() ? b.title : b.sortKey; return aKey == bKey ? a.id < b.id : aKey < bKey; }; switch (sortSlot) { case 0: // Newest catalog entries first. std::stable_sort(visible.begin(), visible.end(), [&](size_t lhs, size_t rhs) { return songs[lhs].catalog.id > songs[rhs].catalog.id; }); break; case kGenreSort: std::stable_sort(visible.begin(), visible.end(), [&](size_t lhs, size_t rhs) { const int a = genreLabelRow(songs[lhs].catalog.genre); const int b = genreLabelRow(songs[rhs].catalog.genre); return a == b ? titleLess(lhs, rhs) : a < b; }); break; case 3: // Highest chart rating first. std::stable_sort(visible.begin(), visible.end(), [&](size_t lhs, size_t rhs) { const auto& a = songs[lhs].catalog.difficultyRatings; const auto& b = songs[rhs].catalog.difficultyRatings; const uint8_t aMax = *std::max_element(a.begin(), a.end()); const uint8_t bMax = *std::max_element(b.begin(), b.end()); return aMax == bMax ? titleLess(lhs, rhs) : aMax > bMax; }); break; case 5: std::stable_sort(visible.begin(), visible.end(), titleLess); break; case 7: std::shuffle(visible.begin(), visible.end(), random); break; default: // Theme, score and favorite data live in the arcade profile // services. Preserve catalog order when no profile is loaded. break; } 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}); } } }; rebuildVisible(); size_t selection = 0; int difficulty = firstPlayableDifficulty(songs[visible[selection]]); auto refreshOriginalScenes = [&] { if (visible.empty()) return; const Song& song = songs[visible[selection]]; if (originalMusic.ready()) { originalMusic.setState(selectMusicSceneState(&song, static_cast(sortSlot + 1))); } if (originalDifficulty.ready()) { originalDifficulty.setState(selectModeSceneState(&song, difficulty)); } }; refreshOriginalScenes(); auto changeSong = [&](int delta) { if (visible.empty()) return; const int count = static_cast(visible.size()); int next = (static_cast(selection) + delta) % count; if (next < 0) next += count; selection = static_cast(next); difficulty = firstPlayableDifficulty(songs[visible[selection]], difficulty); refreshOriginalScenes(); }; auto changeSort = [&](int delta) { const size_t selectedSong = visible.empty() ? 0 : visible[selection]; int next = (static_cast(sortSlot) + delta) % 8; if (next < 0) next += 8; sortSlot = static_cast(next); rebuildVisible(); const auto selected = std::find(visible.begin(), visible.end(), selectedSong); selection = selected == visible.end() ? 0 : static_cast(std::distance(visible.begin(), selected)); if (!visible.empty()) { difficulty = firstPlayableDifficulty(songs[visible[selection]], difficulty); } refreshOriginalScenes(); }; auto changeDifficulty = [&](int delta) { const Song& song = songs[visible[selection]]; for (int step = 1; step <= 4; ++step) { int next = (difficulty + delta * step) % 4; if (next < 0) next += 4; if (!song.stages[next].empty()) { difficulty = next; refreshOriginalScenes(); return; } } }; bool running = true; bool confirmed = false; SelectTask task = SelectTask::Music; bool capturedMusic = false; bool capturedDifficulty = false; const char* capturePrefix = std::getenv("OPENROLLER_CAPTURE_PREFIX"); const bool captureBoth = std::getenv("OPENROLLER_CAPTURE_BOTH") != nullptr; const Uint64 menuStartTicks = SDL_GetTicks(); while (running) { SDL_Event event; while (SDL_PollEvent(&event)) { if (event.type == SDL_EVENT_QUIT) running = false; if (event.type != SDL_EVENT_KEY_DOWN || event.key.repeat) continue; if (event.key.key == SDLK_CAPSLOCK) { runServiceMenu(window, defaultCabinetBackend()); SDL_SetWindowTitle(window, "OpenRoller"); continue; } if (task == SelectTask::Music) { switch (event.key.key) { case SDLK_ESCAPE: running = false; break; case SDLK_UP: case SDLK_W: changeSong(-1); break; case SDLK_DOWN: case SDLK_S: changeSong(1); break; case SDLK_PAGEUP: changeSong(-8); break; case SDLK_PAGEDOWN: changeSong(8); break; case SDLK_Q: changeSort(-1); break; case SDLK_E: case SDLK_TAB: changeSort(1); break; case SDLK_RETURN: case SDLK_SPACE: task = SelectTask::Difficulty; break; default: break; } } else { switch (event.key.key) { case SDLK_ESCAPE: task = SelectTask::Music; break; case SDLK_UP: case SDLK_W: case SDLK_LEFT: case SDLK_A: changeDifficulty(-1); break; case SDLK_DOWN: case SDLK_S: case SDLK_RIGHT: case SDLK_D: changeDifficulty(1); break; case SDLK_RETURN: case SDLK_SPACE: confirmed = true; running = false; break; default: break; } } } if (visible.empty()) continue; const Song& selected = songs[visible[selection]]; SDL_SetWindowTitle(window, ("OpenRoller - " + selected.catalog.imageKey).c_str()); int pixelWidth = 0, pixelHeight = 0; SDL_GetWindowSizeInPixels(window, &pixelWidth, &pixelHeight); glViewport(0, 0, pixelWidth, pixelHeight); glDisable(GL_DEPTH_TEST); glClearColor(0.1882353f, 0.1882353f, 0.6078432f, 1.0f); glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); // CMenuBackgroundTask::FUN_00577fb0 emits a full-screen four-vertex // D3D strip. Its top pair is ARGB FF30309B and its bottom pair is // ARGB FFE57386. This pass sits behind every RVB menu scene. ui.verticalGradient(0, 0, kUiWidth, kUiHeight, glm::vec4(0x30 / 255.0f, 0x30 / 255.0f, 0x9b / 255.0f, 1.0f), glm::vec4(0xe5 / 255.0f, 0x73 / 255.0f, 0x86 / 255.0f, 1.0f)); originalMenuModels.render((SDL_GetTicks() - menuStartTicks) / 1000.0f); // FUN_005b73a0 reconstructs the first 768x256 texels of balloon.dds // as a 12x4 grid of 64px quads at y=1000. Drawing the same source // rectangle once is pixel-equivalent (the final 48px are clipped). if (menuBalloon.id != 0) { ui.texture(menuBalloon, 0, 1000, 768, 256, 0, 0, 768, 256); } if (task == SelectTask::Music && originalMusic.ready()) originalMusic.render(ui); if (task == SelectTask::Difficulty && originalDifficulty.ready()) originalDifficulty.render(ui); size_t selectedCarouselEntry = 0; if (task == SelectTask::Music && !carousel.empty()) { const size_t selectedSongIndex = visible[selection]; const auto selectedEntry = std::find_if( carousel.begin(), carousel.end(), [selectedSongIndex](const CarouselEntry& entry) { return !entry.category && entry.songIndex == selectedSongIndex; }); if (selectedEntry != carousel.end()) { selectedCarouselEntry = static_cast( std::distance(carousel.begin(), selectedEntry)); } } const auto carouselAtOffset = [&](int offset) -> const CarouselEntry& { int logical = static_cast(selectedCarouselEntry) + offset; const int count = static_cast(carousel.size()); logical %= count; if (logical < 0) logical += count; return carousel[static_cast(logical)]; }; if (task == SelectTask::Music && (originalMusicRow.ready() || originalMusicIndex.ready())) { // FUN_00447170 supplies the raw MovieClip translation, while // FUN_00447620 is applied as opacity to these fixed-width rows. // FUN_00446cb0 switches each slot between mc_music_link and // mc_index_link for pseudo IDs >= 50000. static constexpr std::array offsets{ -5, -4, -3, -2, -1, 0, 0, 1, 2, 3, 4, 5 }; static constexpr std::array rowY{ 175, 228, 281, 334, 387, 440, 701, 754, 807, 860, 913, 966 }; static constexpr std::array indexY{ 189, 242, 295, 348, 401, 454, 715, 768, 821, 874, 927, 980 }; static constexpr std::array rowAlpha{ 0.0f, 0.7f, 0.8f, 0.9f, 1.0f, 0.0f, 0.0f, 1.0f, 0.9f, 0.8f, 0.7f, 0.0f }; for (size_t slot = 0; slot < rowY.size(); ++slot) { if (rowAlpha[slot] <= 0.0f || carousel.empty()) continue; const CarouselEntry& entry = carouselAtOffset(offsets[slot]); if (!entry.category && originalMusicRow.ready()) { originalMusicRow.render(ui, 8.0f, rowY[slot], rowAlpha[slot]); } else if (entry.category && originalMusicIndex.ready()) { originalMusicIndex.render(ui, 88.0f, indexY[slot], rowAlpha[slot]); if (genreLabels.id != 0) { const float sourceY = 32.0f * genreLabelRow(entry.genre); // FUN_005aca40: index X/Y plus 53 and 2; FUN_005b3fc0 // copies a 256x32 cell from s_j_eng.dds. ui.texture(genreLabels, 141.0f, indexY[slot] + 2.0f, 256.0f, 32.0f, 0.0f, sourceY, 256.0f, 32.0f, glm::vec4(1.0f, 1.0f, 1.0f, rowAlpha[slot])); } } } } const bool usingOriginal = task == SelectTask::Music ? originalMusic.ready() : originalDifficulty.ready(); const size_t selectedSongIndex = visible[selection]; auto selectedTexture = textures.find(selectedSongIndex); if (selectedTexture == textures.end()) { DdsTexture loaded; if (loadSongMenuTexture(selected.menuTexture.string(), loaded, nullptr)) { selectedTexture = textures.emplace(selectedSongIndex, loaded).first; } } if (!usingOriginal) { const glm::vec4 orange{1.0f, 0.31f, 0.08f, 1.0f}; const glm::vec4 dark{0.08f, 0.12f, 0.14f, 1.0f}; const glm::vec4 paleBlue{0.76f, 0.91f, 0.95f, 0.94f}; ui.rect(0, 0, 720, 18, orange); ui.rect(0, 18, 720, 100, glm::vec4(1.0f, 0.78f, 0.67f, 1.0f)); ui.rect(0, 118, 720, 5, orange); ui.text(28, 40, 6, task == SelectTask::Music ? "SELECT MUSIC" : "SELECT DIFFICULTY", dark); ui.text(550, 32, 3, "LOCAL", dark); ui.text(550, 62, 5, std::to_string(selection + 1) + "/" + std::to_string(visible.size()), dark); static constexpr std::array sortNames{ "NEW", "MONTHLY THEME", "GENRE", "DIFFICULTY", "SCORE AVERAGE", "TITLE", "FAVORITE", "AT RANDOM" }; const glm::vec4 activeColor = genreColor(static_cast(sortSlot)); ui.rect(18, 136, 684, 48, activeColor); ui.text(38, 149, 3, "Q < " + std::string(sortNames[sortSlot]) + " > E", glm::vec4(1.0f)); constexpr int rows = 8; constexpr float rowY = 200.0f; constexpr float rowHeight = 49.0f; const int half = rows / 2; for (int row = 0; row < rows; ++row) { int logical = static_cast(selection) + row - half; while (logical < 0) logical += static_cast(visible.size()); logical %= static_cast(visible.size()); const size_t songIndex = visible[static_cast(logical)]; const bool isSelected = logical == static_cast(selection); const float y = rowY + row * rowHeight; ui.rect(34, y, isSelected ? 615.0f : 535.0f, rowHeight - 5.0f, isSelected ? glm::vec4(1.0f, 0.67f, 0.56f, 0.98f) : paleBlue); ui.rect(34, y, 9, rowHeight - 5.0f, isSelected ? orange : genreColor(songs[songIndex].catalog.genre)); auto found = textures.find(songIndex); if (found == textures.end()) { DdsTexture loaded; if (loadSongMenuTexture(songs[songIndex].menuTexture.string(), loaded, nullptr)) { found = textures.emplace(songIndex, loaded).first; } } if (found != textures.end()) { // Every *_menu.dds already contains a rendered title in its // upper-right atlas cell; use it exactly as the arcade does. ui.texture(found->second, 55, y + 4, 455, 35, 250, 0, 262, 48); } if (isSelected) ui.outline(29, y - 3, 625, rowHeight, 3, orange); } // The navigator is an original local asset and occupies the same // lower-right visual layer as the arcade selection screen. if (navigator.id != 0) ui.texture(navigator, 345, 750, 390, 390); ui.rect(34, 610, 640, 455, glm::vec4(0.90f, 0.97f, 0.91f, 0.91f)); ui.outline(34, 610, 640, 455, 3, orange); if (selectedTexture != textures.end()) { // Native atlas positioning preserves jacket, title, source and // artist fragments without synthesizing localized glyphs. ui.texture(selectedTexture->second, 54, 632, 512, 256); } static constexpr const char* names[] = {"SIMPLE", "NORMAL", "HARD", "EXTRA"}; static const glm::vec4 colors[] = { {0.15f, 0.74f, 0.91f, 1.0f}, {0.93f, 0.70f, 0.08f, 1.0f}, {0.93f, 0.24f, 0.53f, 1.0f}, {0.54f, 0.26f, 0.77f, 1.0f} }; for (int i = 0; i < 4; ++i) { const float x = 52.0f + i * 157.0f; const bool exists = !selected.stages[i].empty(); glm::vec4 color = colors[i]; if (!exists) color *= glm::vec4(0.35f, 0.35f, 0.35f, 0.55f); ui.rect(x, 920, 142, 84, color); if (task == SelectTask::Difficulty && i == difficulty) { ui.outline(x - 5, 915, 152, 94, 5, orange); } ui.text(x + 10, 933, 2, names[i], glm::vec4(1.0f)); ui.text(x + 57, 963, 4, exists ? std::to_string(selected.catalog.difficultyRatings[i]) : "-", glm::vec4(1.0f)); } ui.text(52, 1030, 2, "BPM " + selected.catalog.bpm + " TIME " + selected.catalog.duration, dark); ui.rect(0, 1165, 720, 115, dark); if (task == SelectTask::Music) { ui.text(28, 1183, 3, "UP DOWN: SONG Q E: GENRE", glm::vec4(1.0f)); ui.text(28, 1225, 3, "ENTER: DIFFICULTY ESC: EXIT", glm::vec4(1.0f)); } else { ui.text(28, 1183, 3, "ARROWS: DIFFICULTY", glm::vec4(1.0f)); ui.text(28, 1225, 3, "ENTER: PLAY ESC: BACK", glm::vec4(1.0f)); } } else if (selectedTexture != textures.end()) { const DdsTexture& atlas = selectedTexture->second; if (task == SelectTask::Music) { // FUN_005aca40 + FUN_005b34f0: fixed focus fragments from the // selected song's original 512x256 menu atlas. ui.texture(atlas, 39, 497, 196, 196, 1, 1, 196, 196); ui.texture(atlas, 251, 467, 374, 34, 0, 197, 374, 34); ui.texture(atlas, 262, 500, 374, 24, 0, 232, 374, 24); ui.texture(atlas, 262, 522, 314, 16, 198, 180, 314, 16); // FUN_00447170/FUN_00447620: the twelve real carousel slots. static constexpr std::array offsets{ -5, -4, -3, -2, -1, 0, 0, 1, 2, 3, 4, 5 }; static constexpr std::array centersY{ 209, 262, 315, 368, 421, 474, 735, 788, 841, 894, 947, 1000 }; static constexpr std::array scales{ 0.0f, 0.7f, 0.8f, 0.9f, 1.0f, 0.0f, 0.0f, 1.0f, 0.9f, 0.8f, 0.7f, 0.0f }; for (size_t slot = 0; slot < offsets.size(); ++slot) { const float scale = scales[slot]; if (scale <= 0.0f || carousel.empty()) continue; const CarouselEntry& entry = carouselAtOffset(offsets[slot]); if (entry.category) continue; const size_t songIndex = entry.songIndex; auto texture = textures.find(songIndex); if (texture == textures.end()) { DdsTexture loaded; if (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); } } else { // Fixed selected-song fragments in CDifficultyTask's renderer // (FUN_005be2d0), using its executable constants. // FUN_005b33a0 treats the executable constants as sprite // centres and subtracts half of the scaled source size. ui.texture(atlas, 105, 167, 98, 98, 1, 1, 196, 196); ui.texture(atlas, 209, 178, 374, 34, 0, 197, 374, 34); ui.texture(atlas, 220, 214, 374, 24, 0, 232, 374, 24); ui.texture(atlas, 220, 239, 314, 16, 198, 180, 314, 16); } } // The navigator and common HUD are later compositing passes in // CSelectMusicTask; the character must cover the lower carousel rows. if (originalNavigator.ready()) originalNavigator.render(ui); if (originalCommon.ready()) originalCommon.render(ui); ui.flush(); if (capturePrefix && task == SelectTask::Music && !capturedMusic) { captureFrameForReverse(capturePrefix, "music", pixelWidth, pixelHeight); capturedMusic = true; if (captureBoth) task = SelectTask::Difficulty; } else if (capturePrefix && task == SelectTask::Difficulty && !capturedDifficulty) { captureFrameForReverse(capturePrefix, "difficulty", pixelWidth, pixelHeight); capturedDifficulty = true; } SDL_GL_SwapWindow(window); SDL_Delay(8); // Keep only a small moving texture window instead of uploading all // ~900 jackets to VRAM. if (textures.size() > 20) { for (auto it = textures.begin(); it != textures.end();) { const auto visibleIt = std::find(visible.begin(), visible.end(), it->first); const int position = visibleIt == visible.end() ? 100000 : static_cast(std::distance(visible.begin(), visibleIt)); if (std::abs(position - static_cast(selection)) > 12) { glDeleteTextures(1, &it->second.id); it = textures.erase(it); } else { ++it; } } } } if (confirmed && !visible.empty()) { const Song& song = songs[visible[selection]]; if (!song.stages[difficulty].empty()) *selectedStagePath = song.stages[difficulty].string(); } for (auto& [_, texture] : textures) glDeleteTextures(1, &texture.id); if (navigator.id != 0) glDeleteTextures(1, &navigator.id); if (menuBalloon.id != 0) glDeleteTextures(1, &menuBalloon.id); if (genreLabels.id != 0) glDeleteTextures(1, &genreLabels.id); originalMenuModels.clear(); originalMusic.clear(); originalMusicRow.clear(); originalMusicIndex.clear(); originalDifficulty.clear(); originalCommon.clear(); originalNavigator.clear(); SDL_SetWindowTitle(window, "OpenRoller"); glEnable(GL_DEPTH_TEST); return confirmed && !selectedStagePath->empty(); }