forked from tsuki/openroller
219 lines
9.3 KiB
C++
219 lines
9.3 KiB
C++
#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;
|
|
}
|