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
+2997
View File
File diff suppressed because it is too large Load Diff
+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;
}
+600
View File
@@ -0,0 +1,600 @@
#include "gc/StageDat.hpp"
#include "gc/StagePattern.hpp"
#include "gc/TumoModel.hpp"
#include "openroller/psp/StagePackage.hpp"
#include <algorithm>
#include <array>
#include <cctype>
#include <cmath>
#include <cstdint>
#include <cstring>
#include <fstream>
#include <filesystem>
#include <iostream>
#include <limits>
#include <sstream>
#include <string>
#include <type_traits>
#include <vector>
#if defined(__BYTE_ORDER__) && __BYTE_ORDER__ != __ORDER_LITTLE_ENDIAN__
#error "openroller-psp-pack currently requires a little-endian host"
#endif
namespace {
using namespace openroller::psp;
struct SystemTimingConfig {
bool missMarkOverride = false;
float greatMinimumTimeMs = 32.0f;
float scratchEnableTimeMs = 250.0f;
float beatEnableTimeMs = 200.0f;
std::array<float, 4> miss{236.0f, 202.0f, 168.0f, 168.0f};
std::array<float, 4> unmute{202.0f, 168.0f, 134.0f, 134.0f};
std::array<float, 4> limit{202.0f, 168.0f, 134.0f, 134.0f};
std::array<float, 4> mute{0.0f, 0.0f, 0.0f, 0.0f};
};
std::string trim(std::string value) {
const auto first = std::find_if_not(value.begin(), value.end(),
[](unsigned char c) { return std::isspace(c); });
const auto last = std::find_if_not(value.rbegin(), value.rend(),
[](unsigned char c) { return std::isspace(c); }).base();
return first < last ? std::string(first, last) : std::string{};
}
bool parseFloatTuple4(std::string value, std::array<float, 4>* output) {
if (!output) return false;
for (char& c : value) {
if (c == '(' || c == ')' || c == ',') c = ' ';
}
std::stringstream stream(value);
std::array<float, 4> parsed{};
if (!(stream >> parsed[0] >> parsed[1] >> parsed[2] >> parsed[3])) return false;
*output = parsed;
return true;
}
SystemTimingConfig loadSystemTimingConfig(const std::string& sourcePath) {
SystemTimingConfig config;
const std::filesystem::path path =
std::filesystem::path(sourcePath).parent_path().parent_path() / "system.cfg";
std::ifstream file(path);
if (!file) return config;
std::string line;
bool blockComment = false;
while (std::getline(file, line)) {
if (blockComment) {
const std::size_t end = line.find("*/");
if (end == std::string::npos) continue;
line.erase(0, end + 2);
blockComment = false;
}
for (;;) {
const std::size_t begin = line.find("/*");
if (begin == std::string::npos) break;
const std::size_t end = line.find("*/", begin + 2);
if (end == std::string::npos) {
line.resize(begin);
blockComment = true;
break;
}
line.erase(begin, end + 2 - begin);
}
const std::size_t comment = line.find("//");
if (comment != std::string::npos) line.resize(comment);
const std::size_t equals = line.find('=');
if (equals == std::string::npos) continue;
const std::string key = trim(line.substr(0, equals));
const std::string value = trim(line.substr(equals + 1));
try {
if (key == "MissMarkOverride") config.missMarkOverride = std::stoi(value) != 0;
else if (key == "GreatMinTime") config.greatMinimumTimeMs = std::stof(value);
else if (key == "ScratchEnableTime") config.scratchEnableTimeMs = std::stof(value);
else if (key == "BeatEnableTime") config.beatEnableTimeMs = std::stof(value);
else if (key == "MissTimingOverride") parseFloatTuple4(value, &config.miss);
else if (key == "UnmuteTimingOverride") parseFloatTuple4(value, &config.unmute);
else if (key == "LimitTimingOverride") parseFloatTuple4(value, &config.limit);
else if (key == "MuteTimingOverride") parseFloatTuple4(value, &config.mute);
} catch (const std::exception&) {
}
}
return config;
}
PackageDifficulty inferDifficulty(const std::string& sourcePath) {
std::string lower = sourcePath;
std::transform(lower.begin(), lower.end(), lower.begin(), [](unsigned char value) {
return static_cast<char>(std::tolower(value));
});
if (lower.find("_extra.dat") != std::string::npos ||
lower.find("_ex.dat") != std::string::npos) return PackageDifficulty::Extra;
if (lower.find("_hard.dat") != std::string::npos) return PackageDifficulty::Hard;
if (lower.find("_normal.dat") != std::string::npos) return PackageDifficulty::Normal;
return PackageDifficulty::Easy;
}
float beatDurationAt(const gc::StageConfig& config, std::uint32_t timeMs) {
std::uint32_t bpm = 120;
for (const gc::BpmChange& change : config.bpmChanges) {
if (change.timeMs > timeMs) break;
if (change.bpm != 0) bpm = change.bpm;
}
return 60000.0f / static_cast<float>(std::max<std::uint32_t>(1, bpm));
}
float timingAt(
const std::vector<gc::NoteSetting>& settings,
std::uint32_t timeMs,
float beatMs,
float nextSpacingMs) {
if (settings.empty()) return beatMs * 0.5f;
const gc::NoteSetting* active = &settings.front();
for (const gc::NoteSetting& setting : settings) {
if (setting.timeMs > timeMs) break;
active = &setting;
}
if (active->mode == 1) return active->value;
if (active->mode == 3) return nextSpacingMs;
return active->value * beatMs;
}
std::uint8_t effectiveNoteType(const gc::StageNote& note) {
if (note.typeOverride) return 1;
const std::uint8_t raw = static_cast<std::uint8_t>(note.type);
if (raw == 0x0b) return 0x0a;
if (raw == 0x0c || raw == 0x0e) return 0x09;
if (raw == 0x0d) return 0x04;
return raw;
}
void directionVector(const gc::StageNote& note, std::uint8_t effectiveType, float output[3]) {
float distance = note.params25[0];
const std::uint8_t rawType = static_cast<std::uint8_t>(note.type);
if ((effectiveType == 2 || effectiveType == 10 || rawType == 0x10) && distance <= 0.0f) {
distance = 1.0f;
}
const float pi = 3.14159265358979323846f;
const float a = (-note.params25[2] * pi / 180.0f) * 0.5f;
const float b = (note.params25[1] * pi / 180.0f) * 0.5f;
const float ca = std::cos(a);
const float cb = std::cos(b);
const float sa = std::sin(a);
const float sb = std::sin(b);
const float qw = ca * cb;
const float qx = sa * cb;
const float qy = ca * sb;
const float qz = sa * sb;
output[0] = distance * 2.0f * (qz * qx + qw * qy);
output[1] = distance * 2.0f * (qy * qz - qw * qx);
output[2] = distance * (1.0f - 2.0f * (qx * qx + qy * qy));
}
std::uint32_t rgba(const gc::Color& color) {
return static_cast<std::uint32_t>(color.r) |
(static_cast<std::uint32_t>(color.g) << 8) |
(static_cast<std::uint32_t>(color.b) << 16) |
(static_cast<std::uint32_t>(color.a) << 24);
}
template <typename T>
void appendPod(std::vector<std::uint8_t>* bytes, const T& value) {
static_assert(std::is_trivially_copyable<T>::value, "package records must be POD");
const auto* first = reinterpret_cast<const std::uint8_t*>(&value);
bytes->insert(bytes->end(), first, first + sizeof(T));
}
void align16(std::vector<std::uint8_t>* bytes) {
bytes->resize((bytes->size() + 15u) & ~std::size_t(15u), 0);
}
template <typename Source, typename Packed, typename Convert>
PackageSection appendSection(
std::vector<std::uint8_t>* bytes,
const std::vector<Source>& source,
Convert&& convert) {
if (source.size() > std::numeric_limits<std::uint32_t>::max()) {
throw std::length_error("PSP package section exceeds 32-bit count");
}
align16(bytes);
PackageSection section{
static_cast<std::uint32_t>(bytes->size()),
static_cast<std::uint32_t>(source.size()),
};
for (const Source& item : source) {
const Packed packed = convert(item);
appendPod(bytes, packed);
}
return section;
}
std::uint32_t stageDuration(const gc::ParsedStagePattern& stage) {
std::uint32_t duration = 0;
const auto includeTimes = [&duration](const auto& values) {
for (const auto& value : values) duration = std::max(duration, value.timeMs);
};
includeTimes(stage.track);
includeTimes(stage.notes);
includeTimes(stage.cameras);
includeTimes(stage.drawDistances);
includeTimes(stage.backgroundColors);
return duration;
}
PackageTrackPoint packTrack(const gc::TrackPiece& source) {
return {source.timeMs, source.x, source.y, source.z};
}
PackageNote packNote(const gc::StageNote& source) {
PackageNote output{};
output.timeMs = source.timeMs;
output.type = static_cast<std::uint8_t>(source.type);
if (source.typeOverride) output.flags |= kNoteTypeOverride;
if (source.flag24) output.flags |= kNoteFlag24;
if (source.flag37) output.flags |= kNoteFlag37;
if (source.flag38) output.flags |= kNoteFlag38;
std::copy(source.params16.begin(), source.params16.end(), output.params16);
std::copy(source.params25.begin(), source.params25.end(), output.params25);
std::copy(source.params39.begin(), source.params39.end(), output.params39);
std::copy(source.params55.begin(), source.params55.end(), output.params55);
output.param67 = source.param67;
output.param71 = source.param71;
std::copy(source.params75.begin(), source.params75.end(), output.params75);
output.param95 = source.param95;
return output;
}
std::vector<PackageNote> buildNotes(
const gc::ParsedStagePattern& stage,
const std::string& sourcePath) {
std::vector<PackageNote> output;
output.reserve(stage.notes.size());
const SystemTimingConfig systemTiming = loadSystemTimingConfig(sourcePath);
const std::size_t difficulty =
static_cast<std::size_t>(inferDifficulty(sourcePath));
for (std::size_t i = 0; i < stage.notes.size(); ++i) {
const gc::StageNote& source = stage.notes[i];
PackageNote note = packNote(source);
note.effectiveType = effectiveNoteType(source);
note.markEffectId = source.params16[0];
note.beatDurationMs = beatDurationAt(stage.config, source.timeMs);
note.appearTimeMs = std::max(
0.0f,
static_cast<float>(source.timeMs) -
std::max(0.0f, source.params39[0]) * note.beatDurationMs);
const bool durationType =
note.effectiveType == 3 || note.effectiveType == 4 ||
note.effectiveType == 5 || note.effectiveType == 10 ||
note.effectiveType == 15;
const float durationBeats = note.effectiveType == 6
? static_cast<float>(source.param71) * std::max(0.0f, source.params75[0])
: std::max(0.0f, source.params39[3]);
note.endTimeMs = (durationType || note.effectiveType == 6)
? std::max(
static_cast<float>(source.timeMs),
static_cast<float>(source.timeMs) + durationBeats * note.beatDurationMs)
: static_cast<float>(source.timeMs);
const float nextSpacingMs = i + 1 < stage.notes.size()
? std::max(
0.0f,
static_cast<float>(stage.notes[i + 1].timeMs) -
static_cast<float>(source.timeMs))
: note.beatDurationMs * 2.0f;
note.missTimingMs = std::max(
0.0f,
timingAt(stage.config.noteSettings[0], source.timeMs,
note.beatDurationMs, nextSpacingMs));
note.earlyTimingMs = std::max(
0.0f,
timingAt(stage.config.noteSettings[1], source.timeMs,
note.beatDurationMs, nextSpacingMs));
note.lateTimingMs = std::max(
0.0f,
timingAt(stage.config.noteSettings[2], source.timeMs,
note.beatDurationMs, nextSpacingMs));
note.muteTimingMs = std::max(
0.0f,
timingAt(stage.config.noteSettings[3], source.timeMs,
note.beatDurationMs, nextSpacingMs));
if (systemTiming.missMarkOverride) {
note.missTimingMs = std::max(0.0f, systemTiming.miss[difficulty]);
note.earlyTimingMs = std::max(0.0f, systemTiming.unmute[difficulty]);
note.lateTimingMs = std::max(0.0f, systemTiming.limit[difficulty]);
note.muteTimingMs = std::max(0.0f, systemTiming.mute[difficulty]);
}
if (note.effectiveType == 2 || note.type == 0x10) {
note.earlyTimingMs += note.beatDurationMs * 0.2f;
note.lateTimingMs += note.beatDurationMs * 0.2f;
}
const float fadeAnchor = (durationType || note.effectiveType == 6)
? note.endTimeMs
: static_cast<float>(source.timeMs) + note.lateTimingMs;
note.markerFadeEndTimeMs = fadeAnchor + note.beatDurationMs * 4.0f;
note.packedColor = source.params55[0];
note.merryCount = source.param71;
directionVector(source, note.effectiveType, note.directionVector);
output.push_back(note);
}
return output;
}
PackageCameraPoint packCamera(const gc::CameraPoint& source) {
PackageCameraPoint output{};
output.timeMs = source.timeMs;
output.aMode = source.aMode;
output.fMode = source.fMode;
output.projectionType = source.projType;
output.distance = source.dist;
std::copy(std::begin(source.rotationA), std::end(source.rotationA), output.rotationA);
std::copy(std::begin(source.originOff), std::end(source.originOff), output.originOffset);
std::copy(std::begin(source.fieldFar), std::end(source.fieldFar), output.fieldFar);
std::copy(std::begin(source.fieldNear), std::end(source.fieldNear), output.fieldNear);
output.rotationB = source.rotationB;
return output;
}
PackageDrawDistancePoint packDrawDistance(const gc::DrawDistancePoint& source) {
return {source.timeMs, source.distance};
}
PackageBackgroundColorPoint packBackgroundColor(const gc::BackgroundColorPoint& source) {
PackageBackgroundColorPoint output{};
output.timeMs = source.timeMs;
output.topRightRgba = rgba(source.topRight);
output.topLeftRgba = rgba(source.topLeft);
output.bottomRightRgba = rgba(source.bottomRight);
output.bottomLeftRgba = rgba(source.bottomLeft);
output.flags = (source.interpolateToNext ? 1u : 0u) |
(source.audioReactive ? 2u : 0u);
return output;
}
template <typename Source, typename Packed, typename Convert>
PackageRange appendRange(
const std::vector<Source>& source,
std::vector<Packed>* destination,
Convert&& convert) {
if (!destination || destination->size() > std::numeric_limits<std::uint32_t>::max() ||
source.size() > std::numeric_limits<std::uint32_t>::max() - destination->size()) {
throw std::length_error("PSP background key array exceeds 32-bit range");
}
PackageRange range{
static_cast<std::uint32_t>(destination->size()),
static_cast<std::uint32_t>(source.size()),
};
destination->reserve(destination->size() + source.size());
for (const Source& item : source) destination->push_back(convert(item));
return range;
}
PackageVisibilityKey packVisibility(const gc::VisibilityPoint& source) {
PackageVisibilityKey output{};
output.timeMs = source.timeMs;
output.flags = static_cast<std::uint8_t>(
(source.fadeOut ? static_cast<std::uint8_t>(kObjectKeyLoop) : 0u) |
(source.fadeIn ? static_cast<std::uint8_t>(kObjectKeyInterpolate) : 0u));
output.visible = source.visible ? 1u : 0u;
return output;
}
PackageTransformKey packTransform(const gc::TransformPoint& source) {
PackageTransformKey output{};
output.timeMs = source.timeMs;
output.flags = static_cast<std::uint8_t>(
(source.tweenTowards ? static_cast<std::uint8_t>(kObjectKeyLoop) : 0u) |
(source.tweenAway ? static_cast<std::uint8_t>(kObjectKeyInterpolate) : 0u));
std::copy(std::begin(source.value), std::end(source.value), output.value);
return output;
}
PackageObjectColorKey packObjectColor(const gc::ObjectColorPoint& source) {
PackageObjectColorKey output{};
output.timeMs = source.timeMs;
output.flags = static_cast<std::uint8_t>(
(source.tweenTowards ? static_cast<std::uint8_t>(kObjectKeyLoop) : 0u) |
(source.tweenAway ? static_cast<std::uint8_t>(kObjectKeyInterpolate) : 0u));
output.rgba = rgba(source.color);
return output;
}
PackageParticlePoint packParticle(const gc::ParticlePoint& source) {
PackageParticlePoint output{};
output.timeMs = source.timeMs;
output.enabled = source.enabled;
output.shape = source.shape;
output.texture = source.texture;
output.rgba = rgba(source.color);
std::copy(std::begin(source.velocity), std::end(source.velocity), output.velocity);
output.repeatMeasure = source.repeatMeasure;
output.lifespanMeasure = source.lifespanMeasure;
output.groupShapeSize = source.groupShapeSize;
return output;
}
PackageVisualizerPoint packVisualizer(const gc::VisualizerPoint& source) {
return {source.timeMs, source.type, rgba(source.color)};
}
void appendGeometry(
const std::vector<gc::TumoVertex>& source,
std::vector<PackageBackgroundVertex>* vertices,
PackageRange* range) {
if (!vertices || !range || vertices->size() > std::numeric_limits<std::uint32_t>::max() ||
source.size() > std::numeric_limits<std::uint32_t>::max() - vertices->size()) {
throw std::length_error("PSP background vertex array exceeds 32-bit range");
}
range->first = static_cast<std::uint32_t>(vertices->size());
range->count = static_cast<std::uint32_t>(source.size());
vertices->reserve(vertices->size() + source.size());
for (const gc::TumoVertex& vertex : source) {
vertices->push_back({vertex.x, vertex.y, vertex.z});
}
}
bool writePackage(
const std::string& sourcePath,
const std::string& outputPath,
std::string* error) {
gc::StageDat dat;
if (!gc::StageDat::LoadFromFile(sourcePath, dat, error)) return false;
gc::ParsedStagePattern stage;
if (!gc::ParseStagePattern(dat, &stage, error)) return false;
if (stage.objects.size() > kMaximumPackageBackgroundObjects) {
if (error) *error = "stage exceeds the PSP background object budget";
return false;
}
StagePackageHeader header{};
const SystemTimingConfig systemTiming = loadSystemTimingConfig(sourcePath);
std::copy(std::begin(kStagePackageMagic), std::end(kStagePackageMagic), header.magic);
header.version = kStagePackageVersion;
header.headerSize = sizeof(StagePackageHeader);
header.flags = static_cast<std::uint32_t>(inferDifficulty(sourcePath));
header.durationMs = stageDuration(stage);
header.audioOffsetRaw = stage.config.audioOffset;
header.visualOffset = stage.config.visualOffset;
header.greatMinimumTimeMs = std::max(0.0f, systemTiming.greatMinimumTimeMs);
header.scratchEnableTimeMs = std::max(0.0f, systemTiming.scratchEnableTimeMs);
header.beatEnableTimeMs = std::max(0.0f, systemTiming.beatEnableTimeMs);
header.backwardsDrawDistance = stage.config.backwardsDrawDist;
header.forwardDrawDistance = stage.config.forwardDrawDist;
header.trackAheadRgba = rgba(stage.config.trackAheadColor);
header.trackBehindRgba = rgba(stage.config.trackBehindColor);
std::vector<PackageBackgroundModel> backgroundModels;
std::vector<PackageBackgroundVertex> backgroundVertices;
backgroundModels.reserve(stage.modelNames.size());
const std::filesystem::path modelDirectory =
std::filesystem::path(sourcePath).parent_path().parent_path() / "model";
for (const std::string& modelName : stage.modelNames) {
gc::TumoGeometry geometry;
std::string modelError;
const std::filesystem::path modelPath = modelDirectory / (modelName + ".tumo");
if (!gc::LoadTumoGeometry(modelPath.string(), &geometry, &modelError)) {
if (error) *error = modelPath.string() + ": " + modelError;
return false;
}
PackageBackgroundModel model{};
appendGeometry(geometry.triangles, &backgroundVertices, &model.triangles);
appendGeometry(geometry.solidLines, &backgroundVertices, &model.solidLines);
appendGeometry(geometry.wireframeLines, &backgroundVertices, &model.wireframeLines);
backgroundModels.push_back(model);
}
std::vector<PackageBackgroundObject> backgroundObjects;
std::vector<PackageVisibilityKey> visibilityKeys;
std::vector<PackageTransformKey> transformKeys;
std::vector<PackageObjectColorKey> objectColorKeys;
backgroundObjects.reserve(stage.objects.size());
for (const gc::StageObject& source : stage.objects) {
PackageBackgroundObject object{};
object.model = source.model;
object.parentIndex = source.parentIndex;
object.flags = (source.wireframe ? kBackgroundObjectWireframe : 0u) |
(source.flashing ? kBackgroundObjectFlashing : 0u) |
(source.unknownFlag ? kBackgroundObjectUnknown : 0u);
object.fragmentShader = source.fragmentShader;
std::copy(std::begin(source.position), std::end(source.position), object.position);
std::copy(std::begin(source.scale), std::end(source.scale), object.scale);
std::copy(std::begin(source.rotation), std::end(source.rotation), object.rotation);
std::copy(std::begin(source.color), std::end(source.color), object.color);
object.visibility = appendRange<gc::VisibilityPoint, PackageVisibilityKey>(
source.visibility, &visibilityKeys, packVisibility);
object.movement = appendRange<gc::TransformPoint, PackageTransformKey>(
source.movement, &transformKeys, packTransform);
object.scaling = appendRange<gc::TransformPoint, PackageTransformKey>(
source.scaling, &transformKeys, packTransform);
object.rotations = appendRange<gc::TransformPoint, PackageTransformKey>(
source.rotations, &transformKeys, packTransform);
object.colorChanges = appendRange<gc::ObjectColorPoint, PackageObjectColorKey>(
source.colorChanges, &objectColorKeys, packObjectColor);
backgroundObjects.push_back(object);
}
std::vector<std::uint8_t> bytes(sizeof(StagePackageHeader), 0);
const std::vector<PackageNote> notes = buildNotes(stage, sourcePath);
header.track = appendSection<gc::TrackPiece, PackageTrackPoint>(&bytes, stage.track, packTrack);
header.notes = appendSection<PackageNote, PackageNote>(
&bytes, notes, [](const PackageNote& item) { return item; });
header.cameras = appendSection<gc::CameraPoint, PackageCameraPoint>(&bytes, stage.cameras, packCamera);
header.drawDistances = appendSection<gc::DrawDistancePoint, PackageDrawDistancePoint>(
&bytes, stage.drawDistances, packDrawDistance);
header.backgroundColors = appendSection<gc::BackgroundColorPoint, PackageBackgroundColorPoint>(
&bytes, stage.backgroundColors, packBackgroundColor);
header.backgroundModels = appendSection<PackageBackgroundModel, PackageBackgroundModel>(
&bytes, backgroundModels, [](const PackageBackgroundModel& item) { return item; });
header.backgroundVertices = appendSection<PackageBackgroundVertex, PackageBackgroundVertex>(
&bytes, backgroundVertices, [](const PackageBackgroundVertex& item) { return item; });
header.backgroundObjects = appendSection<PackageBackgroundObject, PackageBackgroundObject>(
&bytes, backgroundObjects, [](const PackageBackgroundObject& item) { return item; });
header.visibilityKeys = appendSection<PackageVisibilityKey, PackageVisibilityKey>(
&bytes, visibilityKeys, [](const PackageVisibilityKey& item) { return item; });
header.transformKeys = appendSection<PackageTransformKey, PackageTransformKey>(
&bytes, transformKeys, [](const PackageTransformKey& item) { return item; });
header.objectColorKeys = appendSection<PackageObjectColorKey, PackageObjectColorKey>(
&bytes, objectColorKeys, [](const PackageObjectColorKey& item) { return item; });
header.particles = appendSection<gc::ParticlePoint, PackageParticlePoint>(
&bytes, stage.particles, packParticle);
header.visualizer = appendSection<gc::VisualizerPoint, PackageVisualizerPoint>(
&bytes, stage.visualizer, packVisualizer);
header.bpmChanges = appendSection<gc::BpmChange, PackageBpmPoint>(
&bytes, stage.config.bpmChanges,
[](const gc::BpmChange& item) { return PackageBpmPoint{item.timeMs, item.bpm}; });
align16(&bytes);
if (bytes.size() > std::numeric_limits<std::uint32_t>::max()) {
if (error) *error = "PSP stage package exceeds 4 GiB";
return false;
}
header.fileSize = static_cast<std::uint32_t>(bytes.size());
std::memcpy(bytes.data(), &header, sizeof(header));
std::ofstream output(outputPath, std::ios::binary | std::ios::trunc);
if (!output) {
if (error) *error = "could not create output file";
return false;
}
output.write(reinterpret_cast<const char*>(bytes.data()),
static_cast<std::streamsize>(bytes.size()));
if (!output) {
if (error) *error = "failed while writing output file";
return false;
}
std::cout << outputPath << ": " << bytes.size() << " bytes"
<< ", track=" << header.track.count
<< ", notes=" << header.notes.count
<< ", cameras=" << header.cameras.count
<< ", colors=" << header.backgroundColors.count
<< ", bgModels=" << header.backgroundModels.count
<< ", bgObjects=" << header.backgroundObjects.count
<< ", bgVertices=" << header.backgroundVertices.count
<< ", particles=" << header.particles.count
<< ", visualizer=" << header.visualizer.count
<< ", difficulty=" << (header.flags & kStageDifficultyMask) << '\n';
return true;
}
} // namespace
int main(int argc, char** argv) {
if (argc != 3) {
std::cerr << "usage: openroller-psp-pack <stage.dat> <stage.orps>\n";
return 2;
}
try {
std::string error;
if (!writePackage(argv[1], argv[2], &error)) {
std::cerr << argv[1] << ": " << error << '\n';
return 1;
}
} catch (const std::exception& exception) {
std::cerr << "openroller-psp-pack: " << exception.what() << '\n';
return 1;
}
return 0;
}
+89
View File
@@ -0,0 +1,89 @@
#include "StageRuntime.hpp"
#include "Gameplay.hpp"
#include <cmath>
#include <cstddef>
#include <iostream>
namespace {
bool finite(openroller::psp::Vec3 value) {
return std::isfinite(value.x) && std::isfinite(value.y) && std::isfinite(value.z);
}
} // namespace
int main(int argc, char** argv) {
if (argc != 2) {
std::cerr << "usage: openroller-psp-runtime-probe <stage.orps>\n";
return 2;
}
openroller::psp::StageView stage{};
char error[128]{};
if (!openroller::psp::loadStagePackage(argv[1], &stage, error, sizeof(error))) {
std::cerr << argv[1] << ": " << error << '\n';
return 1;
}
bool valid = true;
const float samples[] = {
0.0f,
static_cast<float>(stage.header->durationMs) * 0.5f,
static_cast<float>(stage.header->durationMs),
};
for (float timeMs : samples) {
const auto position = openroller::psp::trackPositionAt(stage, timeMs);
const auto camera = openroller::psp::evaluateCamera(stage, timeMs);
valid = valid && finite(position) && finite(camera.eye) && finite(camera.target) && finite(camera.up) &&
std::isfinite(camera.projectionBlend);
std::cout << "t=" << timeMs
<< " track=(" << position.x << ',' << position.y << ',' << position.z << ')'
<< " eye=(" << camera.eye.x << ',' << camera.eye.y << ',' << camera.eye.z << ')'
<< " target=(" << camera.target.x << ',' << camera.target.y << ',' << camera.target.z << ')'
<< " projection=" << camera.projectionBlend << '\n';
}
std::cout << "bytes=" << stage.storageSize
<< " track=" << stage.header->track.count
<< " notes=" << stage.header->notes.count
<< " cameras=" << stage.header->cameras.count
<< " bg_models=" << stage.header->backgroundModels.count
<< " bg_objects=" << stage.header->backgroundObjects.count
<< " bg_vertices=" << stage.header->backgroundVertices.count
<< " duration_ms=" << stage.header->durationMs << '\n';
openroller::psp::GameplayState gameplay{};
if (!openroller::psp::initializeGameplay(stage, &gameplay)) {
std::cerr << "could not initialize PSP gameplay state\n";
valid = false;
} else {
std::uint32_t tapIndex = 0;
while (tapIndex < stage.header->notes.count &&
stage.notes[tapIndex].effectiveType != 1 &&
stage.notes[tapIndex].effectiveType != 2) {
++tapIndex;
}
if (tapIndex == stage.header->notes.count) {
std::cerr << "stage has no single-tap note for gameplay probe\n";
valid = false;
tapIndex = 0;
}
const float firstNoteMs = static_cast<float>(stage.notes[tapIndex].timeMs);
const auto judgment = openroller::psp::pressGameplay(
stage, &gameplay, firstNoteMs, 1u);
valid = valid && judgment == openroller::psp::Judgment::Great && gameplay.combo == 1;
std::cout << "tap_at=" << firstNoteMs
<< " judgment=" << static_cast<int>(judgment)
<< " combo=" << gameplay.combo << '\n';
openroller::psp::seekGameplay(stage, &gameplay, 0.0f);
openroller::psp::updateGameplay(
stage, &gameplay,
firstNoteMs + stage.notes[tapIndex].lateTimingMs + 1.0f);
valid = valid && gameplay.missCount > 0;
std::cout << "misses_after_window=" << gameplay.missCount << '\n';
openroller::psp::destroyGameplay(&gameplay);
}
openroller::psp::unloadStagePackage(&stage);
return valid ? 0 : 1;
}