forked from tsuki/openroller
Initial public source release
Split reusable rendering and format support into vectorail-core and vectorail-gc.
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user