Files
openroller/apps/desktop/src/SongSelect.cpp
T

1269 lines
56 KiB
C++

#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 <algorithm>
#include <array>
#include <cctype>
#include <cstdint>
#include <cstdlib>
#include <fstream>
#include <iostream>
#include <random>
#include <unordered_map>
#include <unordered_set>
#include <vector>
#include <glm/gtc/matrix_transform.hpp>
namespace fs = std::filesystem;
namespace {
std::vector<glm::vec3> toGlmVertices(const std::vector<gc::TumoVertex>& source) {
std::vector<glm::vec3> 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<fs::path, 4> 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<void*>(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<UiVertex> 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<float>(texture.width);
if (sourceHeight < 0.0f) sourceHeight = static_cast<float>(texture.height);
std::vector<UiVertex> 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<std::array<float, 2>, 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<UiVertex> 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<char>(std::toupper(raw));
const std::array<uint8_t, 7> 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<uint8_t, 7> 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<UiVertex>& 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<UiVertex>& 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<GLsizei>(vertices.size()));
}
Shader shader_;
unsigned int vao_ = 0;
unsigned int vbo_ = 0;
std::vector<UiVertex> 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<float>(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<glm::vec3> vertices = toGlmVertices(geometry.triangles);
gpu.triangleCount = static_cast<GLsizei>(vertices.size());
gpu.lineFirst = static_cast<GLint>(vertices.size());
const std::vector<glm::vec3> solidLines = toGlmVertices(geometry.solidLines);
vertices.insert(vertices.end(), solidLines.begin(), solidLines.end());
gpu.lineCount = static_cast<GLsizei>(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<uint8_t>* 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_t>(size));
if (size > 0) file.read(reinterpret_cast<char*>(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<unsigned char>(symbol[i]))) return SIZE_MAX;
number = number * 10 + static_cast<size_t>(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<gc::RvbImageDraw> 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<gc::RvbImageDraw> 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<gc::RvbImageDraw> nextDraws, std::string* error) {
std::unordered_set<std::string> 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<uint8_t> 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<gc::RvbImageDraw> draws_;
std::unordered_map<std::string, DdsTexture> textures_;
std::vector<uint8_t> rvbBytes_;
std::vector<uint8_t> 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<const char*, 4> 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<const char*, 4> visible{
"jf_simple_on", "jf_normal_on", "jf_hard_on", "jf_extra_on"
};
static constexpr std::array<const char*, 4> 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<const char*, 4> 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<const char*, 4> selected{
"jf_m_simple_on", "jf_m_normal_on", "jf_m_hard_on", "jf_m_extra_on"
};
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
? selected[i] : (exists ? visible[i] : unavailable[i]);
}
static constexpr std::array<const char*, 4> 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<const char*, 4> 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<uint8_t> pixels(static_cast<size_t>(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<size_t>(width) * 3;
for (int row = height - 1; row >= 0; --row) {
file.write(reinterpret_cast<const char*>(pixels.data() + row * rowBytes),
static_cast<std::streamsize>(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<uint8_t> bytes;
std::vector<gc::StageCatalogEntry> 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<Song> 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<size_t, DdsTexture> textures;
// Visual order in selectmusic2: New, Monthly, Genre, Difficulty,
// Score Average, Title, Favorite, At random.
constexpr size_t kGenreSort = 2;
size_t sortSlot = kGenreSort;
std::vector<size_t> visible;
std::vector<CarouselEntry> carousel;
std::mt19937 random(std::random_device{}());
auto rebuildVisible = [&] {
visible.clear();
carousel.clear();
visible.reserve(songs.size());
for (size_t i = 0; i < songs.size(); ++i) visible.push_back(i);
const auto titleLess = [&](size_t lhs, size_t rhs) {
const auto& a = songs[lhs].catalog;
const auto& b = songs[rhs].catalog;
const std::string& aKey = a.sortKey.empty() ? a.title : a.sortKey;
const std::string& bKey = b.sortKey.empty() ? b.title : b.sortKey;
return aKey == bKey ? a.id < b.id : aKey < bKey;
};
switch (sortSlot) {
case 0: // Newest catalog entries first.
std::stable_sort(visible.begin(), visible.end(), [&](size_t lhs, size_t rhs) {
return songs[lhs].catalog.id > songs[rhs].catalog.id;
});
break;
case kGenreSort:
std::stable_sort(visible.begin(), visible.end(), [&](size_t lhs, size_t rhs) {
const int a = genreLabelRow(songs[lhs].catalog.genre);
const int b = genreLabelRow(songs[rhs].catalog.genre);
return a == b ? titleLess(lhs, rhs) : a < b;
});
break;
case 3: // Highest chart rating first.
std::stable_sort(visible.begin(), visible.end(), [&](size_t lhs, size_t rhs) {
const auto& a = songs[lhs].catalog.difficultyRatings;
const auto& b = songs[rhs].catalog.difficultyRatings;
const uint8_t aMax = *std::max_element(a.begin(), a.end());
const uint8_t bMax = *std::max_element(b.begin(), b.end());
return aMax == bMax ? titleLess(lhs, rhs) : aMax > bMax;
});
break;
case 5:
std::stable_sort(visible.begin(), visible.end(), titleLess);
break;
case 7:
std::shuffle(visible.begin(), visible.end(), random);
break;
default:
// Theme, score and favorite data live in the arcade profile
// services. Preserve catalog order when no profile is loaded.
break;
}
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<int>(sortSlot + 1)));
}
if (originalDifficulty.ready()) {
originalDifficulty.setState(selectModeSceneState(&song, difficulty));
}
};
refreshOriginalScenes();
auto changeSong = [&](int delta) {
if (visible.empty()) return;
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();
};
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();
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();
};
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<size_t>(
std::distance(carousel.begin(), selectedEntry));
}
}
const auto carouselAtOffset = [&](int offset) -> const CarouselEntry& {
int logical = static_cast<int>(selectedCarouselEntry) + offset;
const int count = static_cast<int>(carousel.size());
logical %= count;
if (logical < 0) logical += count;
return carousel[static_cast<size_t>(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<int, 12> offsets{
-5, -4, -3, -2, -1, 0, 0, 1, 2, 3, 4, 5
};
static constexpr std::array<float, 12> rowY{
175, 228, 281, 334, 387, 440, 701, 754, 807, 860, 913, 966
};
static constexpr std::array<float, 12> indexY{
189, 242, 295, 348, 401, 454, 715, 768, 821, 874, 927, 980
};
static constexpr std::array<float, 12> rowAlpha{
0.0f, 0.7f, 0.8f, 0.9f, 1.0f, 0.0f,
0.0f, 1.0f, 0.9f, 0.8f, 0.7f, 0.0f
};
for (size_t slot = 0; slot < rowY.size(); ++slot) {
if (rowAlpha[slot] <= 0.0f || carousel.empty()) continue;
const CarouselEntry& entry = carouselAtOffset(offsets[slot]);
if (!entry.category && originalMusicRow.ready()) {
originalMusicRow.render(ui, 8.0f, 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<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(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<int>(selection) + row - half;
while (logical < 0) logical += static_cast<int>(visible.size());
logical %= static_cast<int>(visible.size());
const size_t songIndex = visible[static_cast<size_t>(logical)];
const bool isSelected = logical == static_cast<int>(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<int, 12> offsets{
-5, -4, -3, -2, -1, 0, 0, 1, 2, 3, 4, 5
};
static constexpr std::array<float, 12> centersY{
209, 262, 315, 368, 421, 474, 735, 788, 841, 894, 947, 1000
};
static constexpr std::array<float, 12> scales{
0.0f, 0.7f, 0.8f, 0.9f, 1.0f, 0.0f,
0.0f, 1.0f, 0.9f, 0.8f, 0.7f, 0.0f
};
for (size_t slot = 0; slot < offsets.size(); ++slot) {
const float scale = scales[slot];
if (scale <= 0.0f || carousel.empty()) continue;
const CarouselEntry& entry = carouselAtOffset(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<int>(std::distance(visible.begin(), visibleIt));
if (std::abs(position - static_cast<int>(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();
}