forked from tsuki/openroller
1208 lines
53 KiB
C++
1208 lines
53 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 <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();
|
|
}
|
|
|
|
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) {
|
|
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, not their
|
|
// left-to-right tab order. Internal kind 0 (genre) maps through the
|
|
// "34621857" table to visual tab 3.
|
|
frame["/imc_sort"] = "jf_sort3_ini";
|
|
// Child clips carry each tab's enabled label independently of the active
|
|
// chevron. The yellow 40x18 NEW marker is a separate root child authored
|
|
// into both jf_sort3_ini and jf_sort3, so it is intentionally preserved.
|
|
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_ini", "jf_m_normal_ini", "jf_m_hard_ini", "jf_m_extra_ini"
|
|
};
|
|
static constexpr std::array<const char*, 4> visible{
|
|
"jf_m_simple_on", "jf_m_normal_on", "jf_m_hard_on", "jf_m_extra_on"
|
|
};
|
|
static constexpr std::array<const char*, 4> unavailable{
|
|
"jf_m_simple_off", "jf_m_normal_off", "jf_m_hard_off", "jf_m_extra_off"
|
|
};
|
|
for (size_t i = 0; i < paths.size(); ++i) {
|
|
const bool exists = !song || !song->stages[i].empty();
|
|
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;
|
|
}
|
|
|
|
const char* genreName(int genre) {
|
|
switch (genre) {
|
|
case 1: return "ANIME AND POPS";
|
|
case 2: return "VOCALOID";
|
|
case 3: return "RHYTHM GAME";
|
|
case 4: return "GAME";
|
|
case 5: return "VARIETY";
|
|
case 6: return "ORIGINAL";
|
|
case 7: return "TOUHOU";
|
|
default: return "ALL SONGS";
|
|
}
|
|
}
|
|
|
|
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;
|
|
|
|
const std::array<int, 8> genres{-1, 1, 2, 7, 3, 4, 5, 6};
|
|
size_t genreSlot = 0;
|
|
std::vector<size_t> visible;
|
|
std::vector<CarouselEntry> carousel;
|
|
auto rebuildVisible = [&] {
|
|
visible.clear();
|
|
carousel.clear();
|
|
const int genre = genres[genreSlot];
|
|
for (size_t i = 0; i < songs.size(); ++i) {
|
|
if (genre < 0 || songs[i].catalog.genre == genre) visible.push_back(i);
|
|
}
|
|
int previousGenre = -1;
|
|
for (const size_t songIndex : visible) {
|
|
const int songGenre = songs[songIndex].catalog.genre;
|
|
if (songGenre != previousGenre) {
|
|
carousel.push_back({true, 0, songGenre});
|
|
previousGenre = songGenre;
|
|
}
|
|
carousel.push_back({false, songIndex, songGenre});
|
|
}
|
|
};
|
|
rebuildVisible();
|
|
|
|
size_t selection = 0;
|
|
int difficulty = firstPlayableDifficulty(songs[visible[selection]]);
|
|
auto refreshOriginalScenes = [&] {
|
|
if (visible.empty()) return;
|
|
const Song& song = songs[visible[selection]];
|
|
if (originalMusic.ready()) originalMusic.setState(selectMusicSceneState(&song));
|
|
if (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 changeGenre = [&](int delta) {
|
|
int next = (static_cast<int>(genreSlot) + delta) % static_cast<int>(genres.size());
|
|
if (next < 0) next += static_cast<int>(genres.size());
|
|
genreSlot = static_cast<size_t>(next);
|
|
selection = 0;
|
|
rebuildVisible();
|
|
if (!visible.empty()) difficulty = firstPlayableDifficulty(songs[visible[selection]]);
|
|
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: changeGenre(-1); break;
|
|
case SDLK_E: case SDLK_TAB: changeGenre(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 (loadDdsTexture(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);
|
|
|
|
const int activeGenre = genres[genreSlot];
|
|
const glm::vec4 activeColor = genreColor(activeGenre);
|
|
ui.rect(18, 136, 684, 48, activeColor);
|
|
ui.text(38, 149, 3, "Q < " + std::string(genreName(activeGenre)) + " > 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 (loadDdsTexture(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 (loadDdsTexture(songs[songIndex].menuTexture.string(), loaded, nullptr)) {
|
|
texture = textures.emplace(songIndex, loaded).first;
|
|
}
|
|
}
|
|
if (texture == textures.end()) continue;
|
|
const float width = 374.0f * scale;
|
|
const float height = 34.0f * scale;
|
|
ui.texture(texture->second, 201.0f - 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();
|
|
}
|