#include "gc/StageCatalog.hpp" #include "openroller/psp/SongCatalog.hpp" #include #include #include #include #include #include #include #include #include namespace { bool readFile(const std::filesystem::path& path, std::vector* 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(size)); if (!bytes->empty()) input.read(reinterpret_cast(bytes->data()), size); return static_cast(input); } template 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(input[index++]); if (written + 1 >= N) break; if (value >= 0x20 && value < 0x7f) { output[written++] = static_cast(value); continue; } output[written++] = '?'; while (index < input.size() && (static_cast(input[index]) & 0xc0u) == 0x80u) ++index; } output[written] = '\0'; } const gc::StageCatalogEntry* findSong( const std::vector& 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(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(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 ...\n" << " openroller-psp-catalog --list\n"; return 2; } std::vector bytes; std::vector 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 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(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(entry->bgmVolumes[difficulty]) << '\t' << static_cast(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(records.size()); header.recordSize = sizeof(openroller::psp::SongCatalogRecord); header.fileSize = static_cast( sizeof(header) + records.size() * sizeof(openroller::psp::SongCatalogRecord)); std::ofstream output(argv[2], std::ios::binary | std::ios::trunc); output.write(reinterpret_cast(&header), sizeof(header)); output.write(reinterpret_cast(records.data()), static_cast(records.size() * sizeof(records.front()))); if (!output) { std::cerr << argv[2] << ": could not write catalog\n"; return 1; } return 0; }