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
+827
View File
@@ -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);
}