Initial public source release

Split reusable rendering and format support into vectorail-core and vectorail-gc.
This commit is contained in:
2026-08-02 17:05:27 +02:00
commit 831d96e562
109 changed files with 20558 additions and 0 deletions
+218
View File
@@ -0,0 +1,218 @@
#include "gc/StageCatalog.hpp"
#include "openroller/psp/SongCatalog.hpp"
#include <algorithm>
#include <cctype>
#include <cstdint>
#include <cstring>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <string>
#include <vector>
namespace {
bool readFile(const std::filesystem::path& path, std::vector<std::uint8_t>* bytes) {
if (!bytes) return false;
std::ifstream input(path, std::ios::binary);
if (!input) return false;
input.seekg(0, std::ios::end);
const std::streamoff size = input.tellg();
if (size < 0) return false;
input.seekg(0, std::ios::beg);
bytes->resize(static_cast<std::size_t>(size));
if (!bytes->empty()) input.read(reinterpret_cast<char*>(bytes->data()), size);
return static_cast<bool>(input);
}
template <std::size_t N>
void copyDisplay(char (&output)[N], const std::string& input) {
std::size_t written = 0;
for (std::size_t index = 0; index < input.size();) {
const unsigned char value = static_cast<unsigned char>(input[index++]);
if (written + 1 >= N) break;
if (value >= 0x20 && value < 0x7f) {
output[written++] = static_cast<char>(value);
continue;
}
output[written++] = '?';
while (index < input.size() &&
(static_cast<unsigned char>(input[index]) & 0xc0u) == 0x80u) ++index;
}
output[written] = '\0';
}
const gc::StageCatalogEntry* findSong(
const std::vector<gc::StageCatalogEntry>& entries,
const std::string& token) {
const std::string easy = "ac_" + token + "_easy";
if (const auto* found = gc::FindStageCatalogEntryByChart(entries, easy)) return found;
for (const auto& entry : entries) {
if (entry.imageKey == token) return &entry;
}
return nullptr;
}
std::string chartAt(const gc::StageCatalogEntry& entry, std::size_t difficulty) {
std::string chart = entry.chartIds[difficulty];
if (chart.empty()) chart = entry.chartGroup0[difficulty];
return chart;
}
struct AsciiMetadata {
const char* token;
const char* title;
const char* artist;
};
const AsciiMetadata* asciiMetadata(const std::string& token) {
// The PSP port currently uses a compact built-in ASCII font. Keep the
// original catalog as the source of gameplay data, but provide readable
// metadata instead of locale-dependent mojibake/question marks.
static constexpr AsciiMetadata overrides[] = {
{"altale", "Altale", "Sakuzyo"},
{"7days", "7 days a week", "Silver Forest feat. Aki"},
{"mikumiku", "Miku Miku ni Shite Ageru", "ika-mo"},
{"syositu2", "The Disappearance of Hatsune Miku", "cosMo@BousouP feat. Hatsune Miku"},
{"world2", "World's End Dancehall", "wowaka feat. Hatsune Miku & Megurine Luka"},
{"rollingirl2", "Rolling Girl", "wowaka feat. Hatsune Miku"},
{"uraomote", "Two-Faced Lovers", "wowaka"},
{"unknown", "Unknown Mother-Goose", "wowaka feat. Hatsune Miku"},
{"redial", "Redial", "livetune feat. Hatsune Miku"},
{"tellyour", "Tell Your World", "livetune feat. Hatsune Miku"},
{"umiyuri", "Tale of the Deep-sea Lily", "n-buna feat. Hatsune Miku"},
{"karakuri", "Karakuri Pierrot", "40mP feat. Hatsune Miku"},
{"dappo", "Law-evading Rock", "Neru feat. Kagamine Len"},
{"vampire", "The Vampire", "DECO*27 feat. Hatsune Miku"},
{"pa3", "PaIII.SENSATION", "Yunosuke feat. Miku, GUMI & Rin"},
};
for (const AsciiMetadata& metadata : overrides) {
if (token == metadata.token) return &metadata;
}
return nullptr;
}
std::string resolveAudioFilename(
const std::filesystem::path& soundDirectory,
const std::string& authoredName) {
const std::filesystem::path exact = soundDirectory / authoredName;
if (std::filesystem::is_regular_file(exact)) return exact.filename().string();
std::string lower = authoredName;
std::transform(lower.begin(), lower.end(), lower.begin(), [](unsigned char value) {
return static_cast<char>(std::tolower(value));
});
for (const auto& entry : std::filesystem::directory_iterator(soundDirectory)) {
if (!entry.is_regular_file()) continue;
std::string candidate = entry.path().filename().string();
std::transform(candidate.begin(), candidate.end(), candidate.begin(), [](unsigned char value) {
return static_cast<char>(std::tolower(value));
});
if (candidate == lower) return entry.path().filename().string();
}
return authoredName;
}
} // namespace
int main(int argc, char** argv) {
const bool listMode = argc == 3 && std::string(argv[2]) == "--list";
if ((!listMode && argc < 4) || argc < 3) {
std::cerr << "usage: openroller-psp-catalog <stage_param.dat> <catalog.orpc> <song-token>...\n"
<< " openroller-psp-catalog <stage_param.dat> --list\n";
return 2;
}
std::vector<std::uint8_t> bytes;
std::vector<gc::StageCatalogEntry> entries;
std::string error;
if (!readFile(argv[1], &bytes) || !gc::ParseStageCatalog(bytes, &entries, &error)) {
std::cerr << argv[1] << ": " << (error.empty() ? "could not read catalog" : error) << '\n';
return 1;
}
if (listMode) {
for (const gc::StageCatalogEntry& entry : entries) {
std::cout << entry.id << '\t' << entry.title << '\t'
<< entry.artist << '\t' << entry.imageKey << '\t'
<< entry.bgmBase;
for (const std::string& chart : entry.chartIds) {
std::cout << '\t' << chart;
}
std::cout << '\n';
}
return 0;
}
const std::filesystem::path stageDirectory =
std::filesystem::path(argv[1]).parent_path().parent_path() / "stage";
const std::filesystem::path soundDirectory = stageDirectory / "sound";
std::vector<openroller::psp::SongCatalogRecord> records;
for (int argument = 3; argument < argc; ++argument) {
const std::string token = argv[argument];
if (token.size() >= 32u) {
std::cerr << token << ": song token is too long\n";
return 1;
}
const gc::StageCatalogEntry* entry = findSong(entries, token);
if (!entry) {
std::cerr << token << ": no stage_param.dat record\n";
return 1;
}
openroller::psp::SongCatalogRecord record{};
const AsciiMetadata* metadata = asciiMetadata(token);
copyDisplay(record.key, token);
copyDisplay(record.title, metadata ? metadata->title : entry->title);
copyDisplay(record.artist, metadata ? metadata->artist : entry->artist);
copyDisplay(record.duration, entry->duration);
copyDisplay(record.bpm, entry->bpm);
record.genre = entry->genre;
std::copy(entry->difficultyRatings.begin(), entry->difficultyRatings.end(), record.ratings);
for (std::size_t difficulty = 0; difficulty < 4; ++difficulty) {
const std::string chart = chartAt(*entry, difficulty);
const bool exists = !chart.empty() &&
std::filesystem::is_regular_file(stageDirectory / (chart + ".dat"));
if (exists) record.availableMask |= static_cast<std::uint8_t>(1u << difficulty);
if (exists) {
static constexpr const char* names[4] = {
"easy", "normal", "hard", "extra",
};
const std::string bgmName =
entry->bgmBase + entry->chartGroup0[difficulty] + "_BGM.wav";
const std::string shotName =
entry->bgmBase + entry->chartSuffixes[difficulty] + "_SHOT.wav";
std::cout << token << '\t' << entry->imageKey << '\t'
<< names[difficulty] << '\t' << chart << '\t'
<< resolveAudioFilename(soundDirectory, bgmName) << '\t'
<< resolveAudioFilename(soundDirectory, shotName) << '\t'
<< static_cast<unsigned>(entry->bgmVolumes[difficulty]) << '\t'
<< static_cast<unsigned>(entry->shotVolumes[difficulty]) << '\n';
}
}
if (record.availableMask == 0) {
std::cerr << token << ": catalog record has no local charts\n";
return 1;
}
records.push_back(record);
}
if (records.size() > openroller::psp::kMaximumCatalogSongs) {
std::cerr << "PSP catalog exceeds its song budget\n";
return 1;
}
openroller::psp::SongCatalogHeader header{};
std::copy(std::begin(openroller::psp::kSongCatalogMagic),
std::end(openroller::psp::kSongCatalogMagic), header.magic);
header.version = openroller::psp::kSongCatalogVersion;
header.headerSize = sizeof(header);
header.songCount = static_cast<std::uint32_t>(records.size());
header.recordSize = sizeof(openroller::psp::SongCatalogRecord);
header.fileSize = static_cast<std::uint32_t>(
sizeof(header) + records.size() * sizeof(openroller::psp::SongCatalogRecord));
std::ofstream output(argv[2], std::ios::binary | std::ios::trunc);
output.write(reinterpret_cast<const char*>(&header), sizeof(header));
output.write(reinterpret_cast<const char*>(records.data()),
static_cast<std::streamsize>(records.size() * sizeof(records.front())));
if (!output) {
std::cerr << argv[2] << ": could not write catalog\n";
return 1;
}
return 0;
}