#include "openroller/desktop/LevelLoader.hpp" #include "gc/StageCatalog.hpp" #include #include #include #include #include #include #include #include #include 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 miss{236.0f, 202.0f, 168.0f, 168.0f}; std::array unmute{202.0f, 168.0f, 134.0f, 134.0f}; std::array limit{202.0f, 168.0f, 134.0f, 134.0f}; std::array 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& out) { for (char& c : value) { if (c == '(' || c == ')' || c == ',') c = ' '; } std::stringstream values(value); std::array 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(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& b, size_t off) { return static_cast((static_cast(b[off]) << 8) | static_cast(b[off + 1])); } uint32_t u32be(const std::vector& b, size_t off) { return (static_cast(b[off + 0]) << 24) | (static_cast(b[off + 1]) << 16) | (static_cast(b[off + 2]) << 8) | static_cast(b[off + 3]); } float f32be(const std::vector& 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& 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), 0); if (!out.empty()) file.read(reinterpret_cast(out.data()), static_cast(out.size())); return static_cast(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 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(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& 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(bytes.data() + off), len); off += len; while (!s.empty() && s.back() == '\0') s.pop_back(); return s; } std::vector decodeGcNotes(const std::vector& bytes, size_t start, size_t end) { constexpr size_t recordSize = 99; std::vector 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(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(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(std::max(1, bpm)); } float gcTimingAt(const std::vector& 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& track, const std::vector& dists, float noteT, float minT, float maxT) { if (track.empty() || dists.empty()) return 0.0f; const float lastMs = static_cast(std::max(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(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(track[i].timeMs); const float b = static_cast(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& track, float timeMs) { if (track.empty()) return 0.0f; if (timeMs <= static_cast(track.front().timeMs)) return 0.0f; if (timeMs >= static_cast(track.back().timeMs)) return static_cast(track.size() - 1); for (size_t i = 0; i + 1 < track.size(); ++i) { const float a = static_cast(track[i].timeMs); const float b = static_cast(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(i) + u; } } return static_cast(track.size() - 1); } std::string lower(std::string s) { std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) { return static_cast(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 catalogBytes; std::vector 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(entry->bgmVolumes[difficultyIndex]) / 100.0f; result.shotGain = static_cast(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 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 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 trackAheadColor{0, 204, 255, 255}; std::array trackBehindColor{0, 102, 160, 255}; std::array, 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(bpmCount) * 8; bool timingListsValid = off <= cfgEnd; for (std::vector& list : noteTimings) { if (!timingListsValid || off + 2 > cfgEnd) { timingListsValid = false; break; } const uint16_t count = u16be(bytes, off); off += 2; if (static_cast(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 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(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(p.timeMs)}); if (gcTrack.size() < 2) return data; data.config["gc_duration_ms"] = static_cast(std::max(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(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(timeMs)), "gc_draw_ahead", std::max(0.0f, distance)}); } } } } std::vector 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(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(count, capacity); ++i, off += colorRecordSize) { const uint32_t timeMs = u32be(bytes, off); const float t = trackParamAtTimeMs(gcTrack, static_cast(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(std::min(count, capacity)); } } std::vector gcNotes = decodeGcNotes(bytes, notesOff, cameraOff); if (!gcNotes.empty()) { std::array typeCounts{}; const float minT = static_cast(gcNotes.front().timeMs); const float maxT = static_cast(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(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(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(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(type)) << '=' << typeCounts[type]; } std::cout << std::endl; } // FUN_005ed4c0 loads _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(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=") << (data.audioPath.empty() ? " audio=" : " bgm=" + data.audioPath) << (data.audioShotPath.empty() ? " shot=" : " shot=" + data.audioShotPath) << " audioGain=" << data.audioBgmGain << '/' << data.audioShotGain << (data.gameplaySoundPaths[1].empty() ? " tapSE=" : " tapSE=Ver.3") << (data.backgroundPath.empty() ? " background=" : " 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); }