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,827 @@
|
||||
#include "openroller/desktop/LevelLoader.hpp"
|
||||
#include "gc/StageCatalog.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cctype>
|
||||
#include <cmath>
|
||||
#include <cstring>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
#include <sstream>
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
namespace {
|
||||
|
||||
struct GcTrackPiece {
|
||||
uint32_t timeMs = 0;
|
||||
glm::vec3 pos{0.0f};
|
||||
};
|
||||
|
||||
struct GcNote {
|
||||
uint32_t timeMs = 0;
|
||||
uint8_t rawType = 0;
|
||||
uint8_t effectiveType = 0;
|
||||
bool adlib = false;
|
||||
int16_t markEffectId = -1;
|
||||
float appearanceLeadBeats = 0.0f;
|
||||
float durationBeats = 0.0f;
|
||||
uint32_t packedColor = 0xffffffffu;
|
||||
uint32_t merryCount = 0;
|
||||
float merrySpacingBeats = 0.0f;
|
||||
glm::vec3 directionVector{0.0f};
|
||||
};
|
||||
|
||||
struct GcTimingEntry {
|
||||
uint32_t timeMs = 0;
|
||||
uint32_t mode = 0;
|
||||
float value = 0.0f;
|
||||
};
|
||||
|
||||
struct GcSystemTimingConfig {
|
||||
bool missMarkOverride = false;
|
||||
float greatMinTimeMs = 32.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};
|
||||
float scratchEnableTimeMs = 250.0f;
|
||||
float beatEnableTimeMs = 200.0f;
|
||||
};
|
||||
|
||||
// RotateHPB::ToVector_Deg converts (heading, pitch, bank) to a quaternion and
|
||||
// transforms (0, 0, distance). Directional notes author bank as zero, but keep
|
||||
// the complete original formula here.
|
||||
glm::vec3 gcDirectionVector(float distance, float heading, float pitch, float bank = 0.0f) {
|
||||
const float a = glm::radians(-pitch) * 0.5f;
|
||||
const float b = glm::radians(heading) * 0.5f;
|
||||
const float c = glm::radians(bank) * 0.5f;
|
||||
const float ca = std::cos(a), cb = std::cos(b), cc = std::cos(c);
|
||||
const float sa = std::sin(a), sb = std::sin(b), sc = std::sin(c);
|
||||
|
||||
const float qw = sc * sa * sb + cc * ca * cb;
|
||||
const float qx = sc * ca * sb + cc * sa * cb;
|
||||
const float qy = cc * ca * sb - sc * sa * cb;
|
||||
const float qz = cc * sa * sb - sc * ca * cb;
|
||||
return distance * glm::vec3(
|
||||
2.0f * (qz * qx + qw * qy),
|
||||
2.0f * (qy * qz - qw * qx),
|
||||
1.0f - 2.0f * (qx * qx + qy * qy));
|
||||
}
|
||||
|
||||
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>& out) {
|
||||
for (char& c : value) {
|
||||
if (c == '(' || c == ')' || c == ',') c = ' ';
|
||||
}
|
||||
std::stringstream values(value);
|
||||
std::array<float, 4> parsed{};
|
||||
if (!(values >> parsed[0] >> parsed[1] >> parsed[2] >> parsed[3])) return false;
|
||||
out = parsed;
|
||||
return true;
|
||||
}
|
||||
|
||||
GcSystemTimingConfig loadGcSystemTimingConfig(const fs::path& stageFile) {
|
||||
GcSystemTimingConfig config;
|
||||
const fs::path systemCfg = stageFile.parent_path().parent_path() / "system.cfg";
|
||||
std::ifstream file(systemCfg);
|
||||
if (!file.is_open()) return config;
|
||||
|
||||
std::string line;
|
||||
bool inBlockComment = false;
|
||||
while (std::getline(file, line)) {
|
||||
if (inBlockComment) {
|
||||
const size_t end = line.find("*/");
|
||||
if (end == std::string::npos) continue;
|
||||
line.erase(0, end + 2);
|
||||
inBlockComment = false;
|
||||
}
|
||||
for (;;) {
|
||||
const size_t begin = line.find("/*");
|
||||
if (begin == std::string::npos) break;
|
||||
const size_t end = line.find("*/", begin + 2);
|
||||
if (end == std::string::npos) {
|
||||
line.resize(begin);
|
||||
inBlockComment = true;
|
||||
break;
|
||||
}
|
||||
line.erase(begin, end + 2 - begin);
|
||||
}
|
||||
const size_t comment = line.find("//");
|
||||
if (comment != std::string::npos) line.resize(comment);
|
||||
const 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.greatMinTimeMs = 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);
|
||||
else if (key == "ScratchEnableTime") config.scratchEnableTimeMs = std::stof(value);
|
||||
else if (key == "BeatEnableTime") config.beatEnableTimeMs = std::stof(value);
|
||||
} catch (const std::exception&) {
|
||||
// Preserve the shipped defaults if a local config line is malformed.
|
||||
}
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
size_t gcDifficultyIndex(const fs::path& stageFile) {
|
||||
std::string stem = stageFile.stem().string();
|
||||
std::transform(stem.begin(), stem.end(), stem.begin(),
|
||||
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
|
||||
if (stem.find("_normal") != std::string::npos) return 1;
|
||||
if (stem.find("_hard") != std::string::npos) return 2;
|
||||
if (stem.find("_extra") != std::string::npos ||
|
||||
(stem.size() >= 3 && stem.compare(stem.size() - 3, 3, "_ex") == 0) ||
|
||||
stem.find("_ex_") != std::string::npos) return 3;
|
||||
return 0; // _easy and old charts without a suffix
|
||||
}
|
||||
|
||||
const char* gcNoteTypeName(uint8_t type) {
|
||||
static constexpr const char* names[] = {
|
||||
"NONE", "NORMAL", "FLICK", "HOLD", "SCRATCH", "BEAT", "MERRY GO ROUND", "HIDDEN",
|
||||
"HIDDEN2", "CRITICAL", "SLIDE HOLD", "SLIDE COUNTER", "TURN", "SPIN", "FINISH", "DUAL HOLD",
|
||||
};
|
||||
return type < (sizeof(names) / sizeof(names[0])) ? names[type] : "UNKNOWN";
|
||||
}
|
||||
|
||||
uint16_t u16be(const std::vector<uint8_t>& b, size_t off) {
|
||||
return static_cast<uint16_t>((static_cast<uint16_t>(b[off]) << 8) | static_cast<uint16_t>(b[off + 1]));
|
||||
}
|
||||
|
||||
uint32_t u32be(const std::vector<uint8_t>& b, size_t off) {
|
||||
return (static_cast<uint32_t>(b[off + 0]) << 24) |
|
||||
(static_cast<uint32_t>(b[off + 1]) << 16) |
|
||||
(static_cast<uint32_t>(b[off + 2]) << 8) |
|
||||
static_cast<uint32_t>(b[off + 3]);
|
||||
}
|
||||
|
||||
float f32be(const std::vector<uint8_t>& b, size_t off) {
|
||||
const uint32_t u = u32be(b, off);
|
||||
float f = 0.0f;
|
||||
std::memcpy(&f, &u, sizeof(float));
|
||||
return f;
|
||||
}
|
||||
|
||||
bool readFile(const std::string& path, std::vector<uint8_t>& out) {
|
||||
std::ifstream file(path, std::ios::binary);
|
||||
if (!file.is_open()) return false;
|
||||
file.seekg(0, std::ios::end);
|
||||
const std::streamoff size = file.tellg();
|
||||
if (size < 0) return false;
|
||||
file.seekg(0, std::ios::beg);
|
||||
out.assign(static_cast<size_t>(size), 0);
|
||||
if (!out.empty()) file.read(reinterpret_cast<char*>(out.data()), static_cast<std::streamsize>(out.size()));
|
||||
return static_cast<bool>(file) || file.eof();
|
||||
}
|
||||
|
||||
void loadGcObjectClipTable(const fs::path& stagePath, LevelData& data) {
|
||||
if (!data.gcStage) return;
|
||||
|
||||
const fs::path clipPath =
|
||||
stagePath.parent_path() / (stagePath.stem().string() + "_clip.dat");
|
||||
std::vector<uint8_t> bytes;
|
||||
if (!readFile(clipPath.string(), bytes) || bytes.size() < 8) return;
|
||||
|
||||
const uint32_t objectCount = u32be(bytes, 0);
|
||||
const uint32_t frameCount = u32be(bytes, 4);
|
||||
if (objectCount != data.gcStage->objects.size() || frameCount == 0) return;
|
||||
if (objectCount > (bytes.size() - 8) / frameCount) return;
|
||||
|
||||
const size_t payloadSize = static_cast<size_t>(objectCount) * frameCount;
|
||||
if (8 + payloadSize > bytes.size()) return;
|
||||
data.gcObjectClipFrameCount = frameCount;
|
||||
data.gcObjectClipVisibility.assign(bytes.begin() + 8,
|
||||
bytes.begin() + 8 + payloadSize);
|
||||
}
|
||||
|
||||
bool saneFloat(float v, float limit = 1000000.0f) {
|
||||
return std::isfinite(v) && std::fabs(v) <= limit;
|
||||
}
|
||||
|
||||
std::string readSizedString16(const std::vector<uint8_t>& bytes, size_t& off, size_t end) {
|
||||
if (off + 2 > end) return {};
|
||||
const uint16_t len = u16be(bytes, off);
|
||||
off += 2;
|
||||
if (off + len > end) return {};
|
||||
std::string s(reinterpret_cast<const char*>(bytes.data() + off), len);
|
||||
off += len;
|
||||
while (!s.empty() && s.back() == '\0') s.pop_back();
|
||||
return s;
|
||||
}
|
||||
|
||||
std::vector<GcNote> decodeGcNotes(const std::vector<uint8_t>& bytes, size_t start, size_t end) {
|
||||
constexpr size_t recordSize = 99;
|
||||
std::vector<GcNote> notes;
|
||||
if (start + 8 > end || end > bytes.size()) return notes;
|
||||
|
||||
size_t off = start;
|
||||
const uint32_t nameCount = u32be(bytes, off);
|
||||
off += 4;
|
||||
for (uint32_t i = 0; i < nameCount; ++i) {
|
||||
if (off >= end) return {};
|
||||
const size_t len = bytes[off++];
|
||||
if (len > end - off) return {};
|
||||
off += len;
|
||||
}
|
||||
if (off + 4 > end) return {};
|
||||
const uint32_t count = u32be(bytes, off);
|
||||
off += 4;
|
||||
const size_t payloadBytes = static_cast<size_t>(count) * recordSize;
|
||||
if (payloadBytes != end - off) return notes;
|
||||
|
||||
notes.reserve(count);
|
||||
for (uint32_t i = 0; i < count; ++i, off += recordSize) {
|
||||
const uint8_t rawType = bytes[off + 4];
|
||||
uint8_t effectiveType = bytes[off + 5] ? 1 : rawType;
|
||||
if (!bytes[off + 5]) {
|
||||
if (rawType == 0x0b) effectiveType = 0x0a;
|
||||
else if (rawType == 0x0c || rawType == 0x0e) effectiveType = 0x09;
|
||||
else if (rawType == 0x0d) effectiveType = 0x04;
|
||||
}
|
||||
float directionLength = f32be(bytes, off + 25);
|
||||
// LoadTuneMarkDataOne substitutes 1.0 for directional types whose
|
||||
// authored vector length is zero (notably SLIDE HOLD charts).
|
||||
if ((effectiveType == 2 || effectiveType == 10 || rawType == 0x10) &&
|
||||
directionLength <= 0.0f) {
|
||||
directionLength = 1.0f;
|
||||
}
|
||||
notes.push_back({
|
||||
u32be(bytes, off), rawType, effectiveType, bytes[off + 5] != 0,
|
||||
static_cast<int16_t>(u16be(bytes, off + 6)),
|
||||
f32be(bytes, off + 39),
|
||||
f32be(bytes, off + 51),
|
||||
u32be(bytes, off + 55),
|
||||
u32be(bytes, off + 71),
|
||||
f32be(bytes, off + 75),
|
||||
gcDirectionVector(directionLength,
|
||||
f32be(bytes, off + 29),
|
||||
f32be(bytes, off + 33)),
|
||||
});
|
||||
}
|
||||
return notes;
|
||||
}
|
||||
|
||||
float gcBeatDurationAt(const gc::StageConfig* config, uint32_t timeMs) {
|
||||
uint32_t bpm = 120;
|
||||
if (config) {
|
||||
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<uint32_t>(1, bpm));
|
||||
}
|
||||
|
||||
float gcTimingAt(const std::vector<GcTimingEntry>& entries, uint32_t timeMs,
|
||||
float beatMs, float nextSpacingMs) {
|
||||
if (entries.empty()) return beatMs * 0.5f;
|
||||
const GcTimingEntry* active = &entries.front();
|
||||
for (const GcTimingEntry& entry : entries) {
|
||||
if (entry.timeMs > timeMs) break;
|
||||
active = &entry;
|
||||
}
|
||||
if (active->mode == 1) return active->value;
|
||||
if (active->mode == 3) return nextSpacingMs;
|
||||
return active->value * beatMs;
|
||||
}
|
||||
|
||||
float cumulativeDistanceAtTime(const std::vector<GcTrackPiece>& track, const std::vector<float>& dists, float noteT, float minT, float maxT) {
|
||||
if (track.empty() || dists.empty()) return 0.0f;
|
||||
|
||||
const float lastMs = static_cast<float>(std::max<uint32_t>(1, track.back().timeMs));
|
||||
float targetMs = 0.0f;
|
||||
if (maxT - minT > 0.001f && maxT * 1000.0f <= lastMs * 1.25f) {
|
||||
targetMs = noteT * 1000.0f;
|
||||
} else if (maxT - minT > 0.001f && maxT <= lastMs * 1.25f) {
|
||||
targetMs = noteT;
|
||||
} else {
|
||||
const float u = (maxT > minT) ? ((noteT - minT) / (maxT - minT)) : 0.0f;
|
||||
targetMs = u * lastMs;
|
||||
}
|
||||
|
||||
if (targetMs <= static_cast<float>(track.front().timeMs)) return dists.front();
|
||||
if (targetMs >= lastMs) return dists.back();
|
||||
|
||||
for (size_t i = 0; i + 1 < track.size(); ++i) {
|
||||
const float a = static_cast<float>(track[i].timeMs);
|
||||
const float b = static_cast<float>(track[i + 1].timeMs);
|
||||
if (targetMs >= a && targetMs <= b) {
|
||||
const float span = b - a;
|
||||
const float u = span > 0.001f ? (targetMs - a) / span : 0.0f;
|
||||
return dists[i] + (dists[i + 1] - dists[i]) * u;
|
||||
}
|
||||
}
|
||||
return dists.back();
|
||||
}
|
||||
|
||||
float trackParamAtTimeMs(const std::vector<GcTrackPiece>& track, float timeMs) {
|
||||
if (track.empty()) return 0.0f;
|
||||
if (timeMs <= static_cast<float>(track.front().timeMs)) return 0.0f;
|
||||
if (timeMs >= static_cast<float>(track.back().timeMs)) return static_cast<float>(track.size() - 1);
|
||||
for (size_t i = 0; i + 1 < track.size(); ++i) {
|
||||
const float a = static_cast<float>(track[i].timeMs);
|
||||
const float b = static_cast<float>(track[i + 1].timeMs);
|
||||
if (timeMs >= a && timeMs <= b) {
|
||||
const float span = b - a;
|
||||
const float u = span > 0.001f ? (timeMs - a) / span : 0.0f;
|
||||
return static_cast<float>(i) + u;
|
||||
}
|
||||
}
|
||||
return static_cast<float>(track.size() - 1);
|
||||
}
|
||||
|
||||
std::string lower(std::string s) {
|
||||
std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) { return static_cast<char>(std::tolower(c)); });
|
||||
return s;
|
||||
}
|
||||
|
||||
std::string gcStageToken(const fs::path& stagePath, const std::string& chartName) {
|
||||
std::string token = lower(chartName.empty() ? stagePath.stem().string() : chartName);
|
||||
if (token.rfind("ac_", 0) == 0) token = token.substr(3);
|
||||
for (const std::string suffix : {"_hard", "_normal", "_easy"}) {
|
||||
if (token.size() > suffix.size() && token.compare(token.size() - suffix.size(), suffix.size(), suffix) == 0) {
|
||||
token.resize(token.size() - suffix.size());
|
||||
}
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
std::string inferGcBgmPath(const fs::path& stagePath, const std::string& chartName, const std::string& bgmName) {
|
||||
const fs::path soundDir = stagePath.parent_path() / "sound";
|
||||
if (!fs::is_directory(soundDir)) return {};
|
||||
|
||||
const std::string token = gcStageToken(stagePath, chartName);
|
||||
|
||||
const std::string bgm = lower(bgmName);
|
||||
for (const auto& entry : fs::directory_iterator(soundDir)) {
|
||||
if (!entry.is_regular_file()) continue;
|
||||
const std::string name = lower(entry.path().filename().string());
|
||||
if (entry.path().extension() != ".wav") continue;
|
||||
if (name.find("_bgm") == std::string::npos) continue;
|
||||
if ((!token.empty() && name.find(token) != std::string::npos) ||
|
||||
(!bgm.empty() && name.find(bgm) != std::string::npos)) {
|
||||
return entry.path().string();
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
struct GcStageAudio {
|
||||
std::string bgmPath;
|
||||
std::string shotPath;
|
||||
float bgmGain = 1.0f;
|
||||
float shotGain = 1.0f;
|
||||
};
|
||||
|
||||
GcStageAudio resolveGcStageAudio(const fs::path& stagePath,
|
||||
const std::string& chartName,
|
||||
const std::string& bgmName,
|
||||
size_t difficultyIndex) {
|
||||
GcStageAudio result;
|
||||
const fs::path dataDir = stagePath.parent_path().parent_path();
|
||||
const fs::path stageParam = dataDir / "boot" / "stage_param.dat";
|
||||
std::vector<uint8_t> catalogBytes;
|
||||
std::vector<gc::StageCatalogEntry> entries;
|
||||
std::string catalogError;
|
||||
if (readFile(stageParam.string(), catalogBytes) &&
|
||||
gc::ParseStageCatalog(catalogBytes, &entries, &catalogError)) {
|
||||
const std::string stageId = stagePath.stem().string();
|
||||
const gc::StageCatalogEntry* entry = gc::FindStageCatalogEntryByChart(entries, stageId);
|
||||
if (!entry && !chartName.empty()) {
|
||||
entry = gc::FindStageCatalogEntryByChart(entries, chartName);
|
||||
}
|
||||
if (entry) {
|
||||
for (size_t i = 0; i < entry->chartIds.size(); ++i) {
|
||||
if (entry->chartIds[i] == stageId || entry->chartIds[i] == chartName) {
|
||||
difficultyIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
difficultyIndex = std::min(difficultyIndex, entry->chartIds.size() - 1);
|
||||
const fs::path soundDir = dataDir / "stage" / "sound";
|
||||
const fs::path bgm = soundDir /
|
||||
(entry->bgmBase + entry->chartGroup0[difficultyIndex] + "_BGM.wav");
|
||||
const fs::path shot = soundDir /
|
||||
(entry->bgmBase + entry->chartSuffixes[difficultyIndex] + "_SHOT.wav");
|
||||
result.bgmGain = static_cast<float>(entry->bgmVolumes[difficultyIndex]) / 100.0f;
|
||||
result.shotGain = static_cast<float>(entry->shotVolumes[difficultyIndex]) / 100.0f;
|
||||
|
||||
// LoadStageBGM requires the pair. Its compatibility path is the
|
||||
// old one-file layout under data/sound.
|
||||
if (fs::is_regular_file(bgm) && fs::is_regular_file(shot)) {
|
||||
result.bgmPath = bgm.string();
|
||||
result.shotPath = shot.string();
|
||||
return result;
|
||||
}
|
||||
const fs::path legacy = dataDir / "sound" / (entry->bgmBase + ".wav");
|
||||
if (fs::is_regular_file(legacy)) {
|
||||
result.bgmPath = legacy.string();
|
||||
return result;
|
||||
}
|
||||
// Keep a damaged/incomplete dump playable when its BGM survived.
|
||||
if (fs::is_regular_file(bgm)) {
|
||||
result.bgmPath = bgm.string();
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.bgmPath = inferGcBgmPath(stagePath, chartName, bgmName);
|
||||
return result;
|
||||
}
|
||||
|
||||
std::string inferGcBackgroundPath(const fs::path& stagePath, const std::string& chartName) {
|
||||
const fs::path image = stagePath.parent_path() / "2d" / (gcStageToken(stagePath, chartName) + "_menu.dds");
|
||||
return fs::is_regular_file(image) ? image.string() : std::string{};
|
||||
}
|
||||
|
||||
LevelData loadGcStageDat(const std::string& stageFile) {
|
||||
LevelData data;
|
||||
std::vector<uint8_t> bytes;
|
||||
if (!readFile(stageFile, bytes) || bytes.size() < 52) return data;
|
||||
|
||||
// Keep the complete clean-room decode alongside the compact player data.
|
||||
// Rendering can consume sections incrementally without teaching this
|
||||
// loader a second, diverging copy of every variable-length object record.
|
||||
gc::StageDat stageDat;
|
||||
gc::ParsedStagePattern parsedStage;
|
||||
std::string stageError;
|
||||
if (gc::StageDat::LoadFromFile(stageFile, stageDat, &stageError) &&
|
||||
gc::ParseStagePattern(stageDat, &parsedStage, &stageError)) {
|
||||
data.gcStage = std::move(parsedStage);
|
||||
}
|
||||
|
||||
std::vector<uint32_t> header;
|
||||
header.reserve(13);
|
||||
for (size_t off = 0; off < 52; off += 4) header.push_back(u32be(bytes, off));
|
||||
|
||||
const uint32_t cfgOff = header[0];
|
||||
const uint32_t trackOff = header[2];
|
||||
const uint32_t notesOff = header[3];
|
||||
const uint32_t cameraOff = header[4];
|
||||
if (cfgOff >= bytes.size() || trackOff >= bytes.size() || notesOff >= bytes.size() || cameraOff >= bytes.size()) return data;
|
||||
|
||||
std::string chartName;
|
||||
std::string bgmName;
|
||||
float backwardsDrawDist = 10.0f;
|
||||
float forwardDrawDist = 7.0f;
|
||||
std::array<uint8_t, 4> trackAheadColor{0, 204, 255, 255};
|
||||
std::array<uint8_t, 4> trackBehindColor{0, 102, 160, 255};
|
||||
std::array<std::vector<GcTimingEntry>, 4> noteTimings;
|
||||
const GcSystemTimingConfig systemTiming = loadGcSystemTimingConfig(fs::path(stageFile));
|
||||
const size_t difficultyIndex = gcDifficultyIndex(fs::path(stageFile));
|
||||
{
|
||||
const size_t cfgEnd = header[1];
|
||||
size_t off = cfgOff + 12;
|
||||
if (off + 2 <= cfgEnd) {
|
||||
const uint16_t bpmCount = u16be(bytes, off);
|
||||
off += 2 + static_cast<size_t>(bpmCount) * 8;
|
||||
bool timingListsValid = off <= cfgEnd;
|
||||
for (std::vector<GcTimingEntry>& list : noteTimings) {
|
||||
if (!timingListsValid || off + 2 > cfgEnd) {
|
||||
timingListsValid = false;
|
||||
break;
|
||||
}
|
||||
const uint16_t count = u16be(bytes, off);
|
||||
off += 2;
|
||||
if (static_cast<size_t>(count) > (cfgEnd - off) / 12) {
|
||||
timingListsValid = false;
|
||||
break;
|
||||
}
|
||||
list.reserve(count);
|
||||
for (uint16_t i = 0; i < count; ++i, off += 12) {
|
||||
list.push_back({u32be(bytes, off), u32be(bytes, off + 4), f32be(bytes, off + 8)});
|
||||
}
|
||||
}
|
||||
if (timingListsValid) {
|
||||
chartName = readSizedString16(bytes, off, cfgEnd);
|
||||
(void)readSizedString16(bytes, off, cfgEnd);
|
||||
bgmName = readSizedString16(bytes, off, cfgEnd);
|
||||
(void)readSizedString16(bytes, off, cfgEnd);
|
||||
if (off + 16 <= cfgEnd) {
|
||||
backwardsDrawDist = f32be(bytes, off);
|
||||
forwardDrawDist = f32be(bytes, off + 4);
|
||||
for (size_t i = 0; i < 4; ++i) trackAheadColor[i] = bytes[off + 8 + i];
|
||||
for (size_t i = 0; i < 4; ++i) trackBehindColor[i] = bytes[off + 12 + i];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data.title = chartName.empty() ? fs::path(stageFile).stem().string() : chartName;
|
||||
data.author = "Groove Coaster";
|
||||
const GcStageAudio stageAudio = resolveGcStageAudio(
|
||||
fs::path(stageFile), chartName, bgmName, difficultyIndex);
|
||||
data.audioPath = stageAudio.bgmPath;
|
||||
data.audioShotPath = stageAudio.shotPath;
|
||||
data.audioBgmGain = stageAudio.bgmGain;
|
||||
data.audioShotGain = stageAudio.shotGain;
|
||||
const fs::path soundDir = fs::path(stageFile).parent_path().parent_path() / "sound";
|
||||
// The selected SE id lives in the arcade profile. OpenRoller currently
|
||||
// starts with the shipped default (se0000, "Ver.3 Set") from se.dat.
|
||||
data.gameplaySoundPaths = {
|
||||
(soundDir / "SE_ARRANGE.wav").string(),
|
||||
(soundDir / "TAP_SE1.wav").string(),
|
||||
(soundDir / "TAP_SE2.wav").string(),
|
||||
};
|
||||
// Exact entries in data/sound/SEList.csv for the three default slots.
|
||||
data.gameplaySoundGains = {0.87f, 0.80f, 0.77f};
|
||||
data.backgroundPath = inferGcBackgroundPath(fs::path(stageFile), chartName);
|
||||
data.config["speed"] = 1.0f;
|
||||
data.config["gc_camera"] = 1.0f;
|
||||
// game471.exe uses this fixed vertical FOV in its gameplay projection.
|
||||
data.config["gc_fov"] = 75.0f;
|
||||
data.config["gc_draw_behind"] = saneFloat(backwardsDrawDist) ? std::max(0.0f, backwardsDrawDist) : 10.0f;
|
||||
data.config["gc_draw_ahead"] = saneFloat(forwardDrawDist) ? std::max(0.0f, forwardDrawDist) : 7.0f;
|
||||
data.config["gc_track_ahead_r"] = trackAheadColor[0] / 255.0f;
|
||||
data.config["gc_track_ahead_g"] = trackAheadColor[1] / 255.0f;
|
||||
data.config["gc_track_ahead_b"] = trackAheadColor[2] / 255.0f;
|
||||
data.config["gc_track_behind_r"] = trackBehindColor[0] / 255.0f;
|
||||
data.config["gc_track_behind_g"] = trackBehindColor[1] / 255.0f;
|
||||
data.config["gc_track_behind_b"] = trackBehindColor[2] / 255.0f;
|
||||
data.config["gc_great_min_ms"] = std::max(0.0f, systemTiming.greatMinTimeMs);
|
||||
data.config["gc_scratch_enable_ms"] = std::max(0.0f, systemTiming.scratchEnableTimeMs);
|
||||
data.config["gc_beat_enable_ms"] = std::max(0.0f, systemTiming.beatEnableTimeMs);
|
||||
|
||||
std::vector<GcTrackPiece> gcTrack;
|
||||
{
|
||||
const size_t trackEnd = notesOff;
|
||||
if (trackOff + 4 > trackEnd) return data;
|
||||
const uint32_t count = u32be(bytes, trackOff);
|
||||
size_t off = trackOff + 4;
|
||||
const size_t capacity = (trackEnd - off) / 16;
|
||||
const size_t n = std::min<size_t>(count, capacity);
|
||||
gcTrack.reserve(n);
|
||||
for (size_t i = 0; i < n; ++i, off += 16) {
|
||||
GcTrackPiece p;
|
||||
p.timeMs = u32be(bytes, off + 0);
|
||||
p.pos.x = f32be(bytes, off + 4);
|
||||
p.pos.y = f32be(bytes, off + 8);
|
||||
p.pos.z = f32be(bytes, off + 12);
|
||||
if (saneFloat(p.pos.x) && saneFloat(p.pos.y) && saneFloat(p.pos.z)) gcTrack.push_back(p);
|
||||
}
|
||||
}
|
||||
|
||||
// game471's FUN_005e9690 interpolates adjacent track keys linearly by
|
||||
// timestamp. Type 1 selects the matching straight-segment path here.
|
||||
for (const auto& p : gcTrack) data.trackPoints.push_back({p.pos, 1, true, static_cast<float>(p.timeMs)});
|
||||
if (gcTrack.size() < 2) return data;
|
||||
data.config["gc_duration_ms"] = static_cast<float>(std::max<uint32_t>(1, gcTrack.back().timeMs));
|
||||
|
||||
{
|
||||
const size_t drawOff = header[1];
|
||||
if (drawOff + 4 <= trackOff) {
|
||||
const uint32_t count = u32be(bytes, drawOff);
|
||||
const size_t capacity = (trackOff - drawOff - 4) / 8;
|
||||
size_t off = drawOff + 4;
|
||||
for (size_t i = 0; i < std::min<size_t>(count, capacity); ++i, off += 8) {
|
||||
const uint32_t timeMs = u32be(bytes, off);
|
||||
const float distance = f32be(bytes, off + 4);
|
||||
if (saneFloat(distance)) {
|
||||
data.timeline.push_back({trackParamAtTimeMs(gcTrack, static_cast<float>(timeMs)),
|
||||
"gc_draw_ahead", std::max(0.0f, distance)});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<float> cumulative;
|
||||
cumulative.reserve(gcTrack.size());
|
||||
cumulative.push_back(0.0f);
|
||||
for (size_t i = 1; i < gcTrack.size(); ++i) {
|
||||
cumulative.push_back(cumulative.back() + glm::distance(gcTrack[i - 1].pos, gcTrack[i].pos));
|
||||
}
|
||||
|
||||
{
|
||||
const size_t cameraEnd = header[5];
|
||||
if (cameraOff + 4 <= cameraEnd) {
|
||||
const uint32_t count = u32be(bytes, cameraOff);
|
||||
size_t off = cameraOff + 4;
|
||||
const size_t recordSize = 59;
|
||||
const size_t capacity = (cameraEnd - off) / recordSize;
|
||||
const size_t n = std::min<size_t>(count, capacity);
|
||||
data.gcCameraKeys.reserve(n);
|
||||
for (size_t i = 0; i < n; ++i, off += recordSize) {
|
||||
GcCameraKey key;
|
||||
key.timeMs = u32be(bytes, off + 0);
|
||||
key.aMode = bytes[off + 4];
|
||||
key.fMode = bytes[off + 5];
|
||||
key.dist = f32be(bytes, off + 6);
|
||||
key.rotationA = {f32be(bytes, off + 10), f32be(bytes, off + 14), 0.0f};
|
||||
key.originOff = {f32be(bytes, off + 18), f32be(bytes, off + 22), f32be(bytes, off + 26)};
|
||||
key.projType = bytes[off + 30];
|
||||
key.fieldFar = {f32be(bytes, off + 31), f32be(bytes, off + 35), f32be(bytes, off + 39)};
|
||||
key.fieldNear = {f32be(bytes, off + 43), f32be(bytes, off + 47), f32be(bytes, off + 51)};
|
||||
key.rotationB = f32be(bytes, off + 55);
|
||||
|
||||
// game471.exe FUN_005ed4c0 copies all 59 wire bytes into the
|
||||
// 0x44-byte runtime key without finite-value filtering. The
|
||||
// first camera in the three comet charts deliberately carries
|
||||
// NaN rotations, so dropping the whole key changes their intro.
|
||||
data.gcCameraKeys.push_back(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Full-screen stage background: four RGBA corners keyed by chart time.
|
||||
// The two trailing bytes are fade flags; linear interpolation matches the
|
||||
// common fade-enabled records and will be specialized once both modes are
|
||||
// mapped from the renderer.
|
||||
if (header.size() > 9) {
|
||||
const size_t colorsOff = header[8];
|
||||
const size_t objectsOff = header[9];
|
||||
constexpr size_t colorRecordSize = 22;
|
||||
if (colorsOff + 4 <= objectsOff && objectsOff <= bytes.size()) {
|
||||
const uint32_t count = u32be(bytes, colorsOff);
|
||||
const size_t capacity = (objectsOff - colorsOff - 4) / colorRecordSize;
|
||||
size_t off = colorsOff + 4;
|
||||
static constexpr const char* corners[] = {"tr", "tl", "br", "bl"};
|
||||
for (size_t i = 0; i < std::min<size_t>(count, capacity); ++i, off += colorRecordSize) {
|
||||
const uint32_t timeMs = u32be(bytes, off);
|
||||
const float t = trackParamAtTimeMs(gcTrack, static_cast<float>(timeMs));
|
||||
for (size_t corner = 0; corner < 4; ++corner) {
|
||||
const size_t colorOff = off + 4 + corner * 4;
|
||||
data.timeline.push_back({t, std::string("gc_bg_") + corners[corner] + "_r", bytes[colorOff + 0] / 255.0f});
|
||||
data.timeline.push_back({t, std::string("gc_bg_") + corners[corner] + "_g", bytes[colorOff + 1] / 255.0f});
|
||||
data.timeline.push_back({t, std::string("gc_bg_") + corners[corner] + "_b", bytes[colorOff + 2] / 255.0f});
|
||||
}
|
||||
}
|
||||
data.config["gc_background_keys"] = static_cast<float>(std::min<size_t>(count, capacity));
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<GcNote> gcNotes = decodeGcNotes(bytes, notesOff, cameraOff);
|
||||
if (!gcNotes.empty()) {
|
||||
std::array<size_t, 256> typeCounts{};
|
||||
const float minT = static_cast<float>(gcNotes.front().timeMs);
|
||||
const float maxT = static_cast<float>(gcNotes.back().timeMs);
|
||||
for (size_t noteIndex = 0; noteIndex < gcNotes.size(); ++noteIndex) {
|
||||
const GcNote& n = gcNotes[noteIndex];
|
||||
++typeCounts[n.rawType];
|
||||
const float timeMs = static_cast<float>(n.timeMs);
|
||||
const float beatMs = gcBeatDurationAt(data.gcStage ? &data.gcStage->config : nullptr, n.timeMs);
|
||||
const bool durationType = n.effectiveType == 3 || n.effectiveType == 4 ||
|
||||
n.effectiveType == 5 || n.effectiveType == 10 ||
|
||||
n.effectiveType == 15;
|
||||
const float durationBeats = n.effectiveType == 6
|
||||
? static_cast<float>(n.merryCount) * std::max(0.0f, n.merrySpacingBeats)
|
||||
: std::max(0.0f, n.durationBeats);
|
||||
const float appearTimeMs = std::max(0.0f, timeMs - std::max(0.0f, n.appearanceLeadBeats) * beatMs);
|
||||
const float endTimeMs = (durationType || n.effectiveType == 6)
|
||||
? std::max(timeMs, timeMs + durationBeats * beatMs)
|
||||
: timeMs;
|
||||
const float nextSpacingMs = noteIndex + 1 < gcNotes.size()
|
||||
? std::max(0.0f, static_cast<float>(gcNotes[noteIndex + 1].timeMs) - timeMs)
|
||||
: beatMs * 2.0f;
|
||||
float missWindowMs = std::max(0.0f, gcTimingAt(noteTimings[0], n.timeMs, beatMs, nextSpacingMs));
|
||||
float earlyWindowMs = std::max(0.0f, gcTimingAt(noteTimings[1], n.timeMs, beatMs, nextSpacingMs));
|
||||
float lateWindowMs = std::max(0.0f, gcTimingAt(noteTimings[2], n.timeMs, beatMs, nextSpacingMs));
|
||||
float muteTimingMs = std::max(0.0f, gcTimingAt(noteTimings[3], n.timeMs, beatMs, nextSpacingMs));
|
||||
// FUN_005ed4c0 replaces the authored timing-list results with the
|
||||
// four per-difficulty arrays from data/system.cfg when this flag is
|
||||
// enabled. Runtime +0x98/+0x9c/+0xa0 are miss, early and late.
|
||||
if (systemTiming.missMarkOverride) {
|
||||
missWindowMs = std::max(0.0f, systemTiming.miss[difficultyIndex]);
|
||||
earlyWindowMs = std::max(0.0f, systemTiming.unmute[difficultyIndex]);
|
||||
lateWindowMs = std::max(0.0f, systemTiming.limit[difficultyIndex]);
|
||||
muteTimingMs = std::max(0.0f, systemTiming.mute[difficultyIndex]);
|
||||
}
|
||||
// BuildTimingDataSub adds this literal for FLICK and the otherwise
|
||||
// unclassified type 0x10 before constructing runtime +0xc4.
|
||||
if (n.effectiveType == 2 || n.rawType == 0x10) {
|
||||
earlyWindowMs += beatMs * 0.2f;
|
||||
lateWindowMs += beatMs * 0.2f;
|
||||
}
|
||||
const float fadeAnchorMs = (durationType || n.effectiveType == 6)
|
||||
? endTimeMs
|
||||
: timeMs + lateWindowMs;
|
||||
const float markerFadeEndTimeMs = fadeAnchorMs + beatMs * 4.0f;
|
||||
data.notes.push_back({
|
||||
cumulativeDistanceAtTime(gcTrack, cumulative, timeMs, minT, maxT),
|
||||
timeMs,
|
||||
appearTimeMs,
|
||||
endTimeMs,
|
||||
cumulativeDistanceAtTime(gcTrack, cumulative, endTimeMs, minT, maxT),
|
||||
n.rawType,
|
||||
n.effectiveType,
|
||||
n.adlib,
|
||||
n.markEffectId,
|
||||
n.packedColor,
|
||||
n.merryCount,
|
||||
n.directionVector,
|
||||
beatMs,
|
||||
earlyWindowMs,
|
||||
lateWindowMs,
|
||||
missWindowMs,
|
||||
muteTimingMs,
|
||||
markerFadeEndTimeMs,
|
||||
true,
|
||||
});
|
||||
}
|
||||
std::cout << "GC note types:";
|
||||
for (size_t type = 0; type < typeCounts.size(); ++type) {
|
||||
if (typeCounts[type] == 0) continue;
|
||||
std::cout << " 0x" << std::hex << type << std::dec << '/' << gcNoteTypeName(static_cast<uint8_t>(type))
|
||||
<< '=' << typeCounts[type];
|
||||
}
|
||||
std::cout << std::endl;
|
||||
}
|
||||
|
||||
// FUN_005ed4c0 loads <stage>_clip.dat as an objectCount x frameCount byte
|
||||
// matrix. FUN_006445b0 indexes object first and round(timeMs / (1000/60))
|
||||
// second, and skips the object when the stored byte is zero.
|
||||
loadGcObjectClipTable(fs::path(stageFile), data);
|
||||
|
||||
std::cout << "GC stage loaded: " << data.title << " track=" << data.trackPoints.size()
|
||||
<< " notes=" << data.notes.size()
|
||||
<< " cameras=" << data.gcCameraKeys.size()
|
||||
<< " clipFrames=" << data.gcObjectClipFrameCount
|
||||
<< " bgKeys=" << static_cast<size_t>(data.config.count("gc_background_keys")
|
||||
? data.config.at("gc_background_keys") : 0.0f)
|
||||
<< (data.gcStage ? " particles=" + std::to_string(data.gcStage->particles.size()) +
|
||||
" visualizers=" + std::to_string(data.gcStage->visualizer.size()) +
|
||||
" models=" + std::to_string(data.gcStage->modelNames.size()) +
|
||||
" objects=" + std::to_string(data.gcStage->objects.size())
|
||||
: " backgroundScene=<decode-failed>")
|
||||
<< (data.audioPath.empty() ? " audio=<none>" : " bgm=" + data.audioPath)
|
||||
<< (data.audioShotPath.empty() ? " shot=<none>" : " shot=" + data.audioShotPath)
|
||||
<< " audioGain=" << data.audioBgmGain << '/' << data.audioShotGain
|
||||
<< (data.gameplaySoundPaths[1].empty() ? " tapSE=<none>" : " tapSE=Ver.3")
|
||||
<< (data.backgroundPath.empty() ? " background=<none>" : " background=" + data.backgroundPath)
|
||||
<< std::endl;
|
||||
return data;
|
||||
}
|
||||
|
||||
LevelData loadLegacyFolder(const std::string& mapFolder) {
|
||||
LevelData data;
|
||||
std::string metaPath = mapFolder + "/map.txt";
|
||||
std::ifstream metaFile(metaPath);
|
||||
if (!metaFile.is_open()) return data;
|
||||
|
||||
std::string line, trackFile, notesFile, timelineFile;
|
||||
while (std::getline(metaFile, line)) {
|
||||
if (line.empty() || line[0] == '#') continue;
|
||||
std::stringstream ss(line);
|
||||
std::string key, value; ss >> key; std::getline(ss, value);
|
||||
if (!value.empty() && value[0] == ' ') value.erase(0, 1);
|
||||
if (key == "title") data.title = value;
|
||||
else if (key == "author") data.author = value;
|
||||
else if (key == "audio") data.audioPath = mapFolder + "/" + value;
|
||||
else if (key == "track") trackFile = mapFolder + "/" + value;
|
||||
else if (key == "notes") notesFile = mapFolder + "/" + value;
|
||||
else if (key == "timeline") timelineFile = mapFolder + "/" + value;
|
||||
else { try { data.config[key] = std::stof(value); } catch(...) {} }
|
||||
}
|
||||
|
||||
std::ifstream tFile(trackFile);
|
||||
if (tFile.is_open()) {
|
||||
while (std::getline(tFile, line)) {
|
||||
std::stringstream ss(line);
|
||||
float x, y, z; int type = 0; int visible = 1;
|
||||
if (ss >> x >> y >> z) {
|
||||
if (!(ss >> type)) type = 0;
|
||||
if (!(ss >> visible)) visible = 1;
|
||||
data.trackPoints.push_back({{x, y, z}, type, visible != 0});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::ifstream nFile(notesFile);
|
||||
if (nFile.is_open()) {
|
||||
float distance = 0.0f;
|
||||
while (nFile >> distance) data.notes.push_back({distance, 0.0f, 0, 0});
|
||||
}
|
||||
|
||||
std::ifstream tlFile(timelineFile);
|
||||
if (tlFile.is_open()) {
|
||||
while (std::getline(tlFile, line)) {
|
||||
if (line.empty() || line[0] == '#') continue;
|
||||
std::stringstream ss(line);
|
||||
float t, val; std::string param;
|
||||
if (ss >> t >> param >> val) data.timeline.push_back({t, param, val});
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
LevelData LevelLoader::load(const std::string& path) {
|
||||
const fs::path p(path);
|
||||
if (fs::is_regular_file(p) && p.extension() == ".dat") return loadGcStageDat(path);
|
||||
return loadLegacyFolder(path);
|
||||
}
|
||||
Reference in New Issue
Block a user