Files
openroller/src/main.cpp
T
tsuki 831d96e562 Initial public source release
Split reusable rendering and format support into vectorail-core and vectorail-gc.
2026-08-02 17:05:27 +02:00

2998 lines
126 KiB
C++

#include "gc/EventStream.hpp"
#include "gc/NoteTypes.hpp"
#include "gc/StageCatalog.hpp"
#include "gc/StageDat.hpp"
#include "gc/StagePattern.hpp"
#include <algorithm>
#include <array>
#include <cerrno>
#include <cmath>
#include <cstring>
#include <cstdlib>
#include <fstream>
#include <iomanip>
#include <iostream>
#include <limits>
#include <map>
#include <filesystem>
#include <sstream>
#include <set>
#include <string>
#include <unordered_set>
#include <vector>
enum class RenderTimeMode {
Auto,
Time,
Fit,
Index,
};
enum class ExportAtMode {
Auto, // pick bars if time looks meaningful, else index
Beats, // raw timestamp as beats (string)
Bars, // bar:fracOfBar (string)
Seconds, // not implemented yet (needs BPM map); falls back to auto
Index, // uniform by index
};
static bool parseExportAtMode(const std::string& s, ExportAtMode* out) {
if (!out) return false;
if (s == "auto") { *out = ExportAtMode::Auto; return true; }
if (s == "beats") { *out = ExportAtMode::Beats; return true; }
if (s == "bars") { *out = ExportAtMode::Bars; return true; }
if (s == "seconds") { *out = ExportAtMode::Seconds; return true; }
if (s == "index") { *out = ExportAtMode::Index; return true; }
return false;
}
static bool parseRenderTimeMode(const std::string& s, RenderTimeMode* out) {
if (!out) return false;
if (s == "auto") { *out = RenderTimeMode::Auto; return true; }
if (s == "time") { *out = RenderTimeMode::Time; return true; }
if (s == "fit") { *out = RenderTimeMode::Fit; return true; }
if (s == "index") { *out = RenderTimeMode::Index; return true; }
return false;
}
static void printUsage(const char* argv0) {
std::cerr
<< "Usage:\n"
<< " " << argv0 << " <stage_dat_path> [--dump N] [--section IDX] [--align N] [--svg OUT.svg] [--svg-y value|type]\n"
<< " [--type-max N] [--no-filter] [--stats] [--stats-top N] [--stats-all] [--stats-keep-zero] [--find-align]\n"
<< " [--survey] [--strings] [--strings-section IDX] [--strings-min N] [--strings-max N] [--strings-all]\n"
<< " [--meta] [--meta-section IDX] (scan u16-length ASCII tokens; useful for backgrounds/ids)\n"
<< " [--stats-section IDX] [--stats-rs auto|12|16]\n"
<< " [--raw] (treat input as a single section, for files like *_ext.dat)\n"
<< " [--rs auto|12|16] (force event record size for --dump/--svg section picking)\n"
<< " [--find-align-section IDX] [--find-align-rs 12|16]\n"
<< " [--render OUT.svg] [--track-section IDX] [--note-section IDX] [--note-align N]\n"
<< " [--render-time auto|time|fit|index] [--render-keep-type0] [--render-notes-only] [--render-max-notes N]\n"
<< " " << argv0 << " <stage_param.dat> --track-info <ac_track_id>\n"
<< " " << argv0 << " <stage_param.dat> --play <ac_track_id> [--play-what bgm|shot] [--play-tool auto|ffplay|aplay|paplay|mpv]\n"
<< " " << argv0 << " <stage_param.dat> --viz <ac_track_id> <out.html> [--viz-what bgm|shot] [--viz-notes-only]\n"
<< " " << argv0 << " <stage_param.dat> --export-json <ac_track_id> <out.json>\n"
<< " [--export-at auto|bars|beats|index|seconds] [--export-relative] [--export-notes-only]\n"
<< " " << argv0 << " <stage_param.dat> --export-gcsim <ac_track_id> <out_dir>\n"
<< " [--gcsim-what bgm|shot] [--gcsim-bpm N] [--gcsim-title TITLE]\n"
<< " " << argv0 << " <stage_dat_path> --export-gcsim-project <out_dir>\n"
<< " [--stage-param PATH] [--ac-id ID] [--music WAV] [--gcsim-what bgm|shot] [--gcsim-bpm N] [--gcsim-title TITLE]\n"
<< " " << argv0 << " <stage_dat_path> --export-vectomapper <out_dir>\n"
<< " [--note-section IDX] [--note-align N] (track/camera from docs/stage.pat; notes still heuristic)\n"
<< "\n"
<< "Example:\n"
<< " " << argv0 << " GC/data/stage/ac_10pt8tion_easy.dat --dump 20\n"
<< " " << argv0 << " GC/data/stage/ac_10pt8tion_easy.dat --dump 20 --section 4\n"
<< " " << argv0 << " GC/data/stage/ac_10pt8tion_easy.dat --svg out.svg\n"
<< " " << argv0 << " GC/data/stage/ac_10pt8tion_hard.dat --stats\n"
<< " " << argv0 << " GC/data/stage/ac_10pt8tion_hard.dat --find-align\n"
<< " " << argv0 << " GC/data/stage/ac_10pt8tion_hard.dat --render out.svg\n"
<< " " << argv0 << " GC/data/boot/stage_param.dat --track-info ac_10pt8tion_hard\n"
<< " " << argv0 << " GC/data/boot/stage_param.dat --play ac_10pt8tion_hard\n"
<< " " << argv0 << " GC/data/boot/stage_param.dat --viz ac_10pt8tion_hard /tmp/viz.html\n"
<< " " << argv0 << " GC/data/boot/stage_param.dat --export-json ac_10pt8tion_hard /tmp/track.json\n";
}
static bool isFiniteF(float f) {
return std::isfinite(static_cast<double>(f));
}
static std::string rgbHexForType(uint32_t type) {
if (type == 0 || type == 0xFFFFFFFFu) return "#999999";
// Deterministic hash -> RGB, trying to avoid too-dark colors.
const uint32_t x = static_cast<uint32_t>(type) * 0x9e37u + 0x7f4a7c15u;
const uint8_t r = static_cast<uint8_t>(64 + ((x >> 0) & 0x7F));
const uint8_t g = static_cast<uint8_t>(64 + ((x >> 8) & 0x7F));
const uint8_t b = static_cast<uint8_t>(64 + ((x >> 16) & 0x7F));
std::ostringstream oss;
oss << "#"
<< std::hex << std::setw(2) << std::setfill('0') << static_cast<int>(r)
<< std::hex << std::setw(2) << std::setfill('0') << static_cast<int>(g)
<< std::hex << std::setw(2) << std::setfill('0') << static_cast<int>(b);
return oss.str();
}
static bool isGarbage12(const gc::GameEvent& e) {
if (!isFiniteF(e.timestamp)) return true;
if (e.timestamp < -1.0f || e.timestamp > 1.0e6f) return true;
if (e.type == 0xFFFFFFFFu) return true;
// value may legitimately be NaN for some opcodes, but as a default filter it helps a lot.
if (!isFiniteF(e.value)) return true;
if (std::fabs(static_cast<double>(e.value)) > 1.0e7) return true;
return false;
}
static bool parseU32(const char* s, uint32_t* out) {
if (!s || !out) return false;
errno = 0;
char* end = nullptr;
unsigned long v = std::strtoul(s, &end, 0); // accepts 123 or 0x7b
if (errno != 0 || end == s || *end != '\0') return false;
if (v > 0xFFFFFFFFul) return false;
*out = static_cast<uint32_t>(v);
return true;
}
static uint32_t u32be_bytes(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]) << 0);
}
static float f32be_bytes(const std::vector<uint8_t>& b, size_t off) {
const uint32_t u = u32be_bytes(b, off);
float f = 0.0f;
static_assert(sizeof(float) == sizeof(uint32_t), "float must be 32-bit");
std::memcpy(&f, &u, sizeof(float));
return f;
}
static bool readWholeFile(const std::string& path, std::vector<uint8_t>* out, std::string* err) {
if (!out) return false;
out->clear();
std::ifstream in(path, std::ios::binary);
if (!in.is_open()) {
if (err) *err = "could not open file";
return false;
}
in.seekg(0, std::ios::end);
std::streamoff sz = in.tellg();
in.seekg(0, std::ios::beg);
if (sz < 0) {
if (err) *err = "could not stat file size";
return false;
}
out->resize(static_cast<size_t>(sz));
if (!out->empty()) in.read(reinterpret_cast<char*>(out->data()), static_cast<std::streamsize>(out->size()));
if (!in.good() && !in.eof()) {
if (err) *err = "read failed";
return false;
}
return true;
}
static bool isAsciiPrintable(uint8_t c) {
return (c >= 0x20 && c <= 0x7E);
}
struct AsciiStringHit {
size_t off = 0; // absolute offset in file
std::string s;
};
static bool isLikelyInterestingToken(const std::string& s) {
if (s.size() < 4) return false;
if (s.rfind("ac_", 0) == 0) return true;
if (s.rfind("bgm_", 0) == 0) return true;
if (s.find('/') != std::string::npos) return true;
if (s.find('\\') != std::string::npos) return true;
if (s.find(".wav") != std::string::npos) return true;
if (s.find(".dat") != std::string::npos) return true;
if (s.find(".png") != std::string::npos) return true;
if (s.find(".dds") != std::string::npos) return true;
if (s.find(".tga") != std::string::npos) return true;
if (s.find("shader") != std::string::npos) return true;
if (s.find("tex") != std::string::npos) return true;
if (s.find("bg") != std::string::npos && s.size() <= 32) return true;
return false;
}
static bool isSaneAsciiToken(const std::string& s) {
if (s.size() < 4) return false;
size_t good = 0;
size_t alnum = 0;
for (unsigned char c : s) {
if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')) {
good++;
alnum++;
continue;
}
// Common separators in file ids / paths / keys.
if (c == '_' || c == '-' || c == '.' || c == '/' || c == '\\' || c == ':' || c == ' ') {
good++;
continue;
}
}
const double ratio = static_cast<double>(good) / static_cast<double>(s.size());
// Avoid random punctuation soup: require mostly "path-like" chars and at least some alnum.
return ratio >= 0.85 && alnum >= 3;
}
static std::vector<AsciiStringHit> scanAsciiStrings(
const std::vector<uint8_t>& bytes,
size_t start,
size_t end,
size_t minLen,
size_t maxHits,
bool keepAll) {
std::vector<AsciiStringHit> out;
if (start >= end || start >= bytes.size()) return out;
end = std::min(end, bytes.size());
if (minLen < 2) minLen = 2;
if (maxHits == 0) maxHits = 1;
std::unordered_set<std::string> seen;
size_t i = start;
while (i < end) {
while (i < end && !isAsciiPrintable(bytes[i])) i++;
size_t j = i;
while (j < end && isAsciiPrintable(bytes[j])) j++;
const size_t len = (j > i) ? (j - i) : 0;
if (len >= minLen) {
std::string s(reinterpret_cast<const char*>(&bytes[i]), len);
if (s.size() > 512) s.resize(512);
if (!keepAll && !isSaneAsciiToken(s)) {
i = (j > i) ? j : (i + 1);
continue;
}
if (keepAll || isLikelyInterestingToken(s)) {
if (seen.insert(s).second) {
out.push_back(AsciiStringHit{i, std::move(s)});
if (out.size() >= maxHits) break;
}
}
}
i = (j > i) ? j : (i + 1);
}
return out;
}
static double shannonEntropyBytes(const std::vector<uint8_t>& bytes, size_t start, size_t end) {
if (start >= end || start >= bytes.size()) return 0.0;
end = std::min(end, bytes.size());
const size_t n = end - start;
if (n == 0) return 0.0;
uint32_t hist[256];
std::memset(hist, 0, sizeof(hist));
for (size_t i = start; i < end; i++) hist[bytes[i]]++;
double ent = 0.0;
for (size_t c = 0; c < 256; c++) {
if (!hist[c]) continue;
const double p = static_cast<double>(hist[c]) / static_cast<double>(n);
ent -= p * (std::log(p) / std::log(2.0));
}
return ent; // bits per byte (0..8)
}
static uint64_t countVec3TriplesF32BE(const std::vector<uint8_t>& bytes, size_t start, size_t end) {
if (start >= end || start >= bytes.size()) return 0;
end = std::min(end, bytes.size());
if (end - start < 12) return 0;
uint64_t hits = 0;
// Scan in 4-byte steps; count a hit when 3 consecutive floats are finite and not absurd.
for (size_t off = start; off + 12 <= end; off += 4) {
const float a = f32be_bytes(bytes, off + 0);
const float b = f32be_bytes(bytes, off + 4);
const float c = f32be_bytes(bytes, off + 8);
if (!isFiniteF(a) || !isFiniteF(b) || !isFiniteF(c)) continue;
if (std::fabs(static_cast<double>(a)) > 1.0e5) continue;
if (std::fabs(static_cast<double>(b)) > 1.0e5) continue;
if (std::fabs(static_cast<double>(c)) > 1.0e5) continue;
hits++;
}
return hits;
}
static std::filesystem::path guessGcRootFromStageParamPath(const std::filesystem::path& stageParamPath) {
// stageParam.dat usually lives at: <GC>/data/boot/stage_param.dat
std::filesystem::path p = stageParamPath;
if (p.has_filename()) p = p.parent_path();
for (int i = 0; i < 8; i++) {
if (p.filename() == "data") return p.parent_path();
if (!p.has_parent_path()) break;
p = p.parent_path();
}
return std::filesystem::current_path();
}
static std::string shEscapeSingleQuotes(const std::string& s) {
// POSIX-ish /bin/sh escaping using single quotes.
// abc'd -> 'abc'"'"'d'
std::string out;
out.reserve(s.size() + 8);
out.push_back('\'');
for (char c : s) {
if (c == '\'') out += "'\"'\"'";
else out.push_back(c);
}
out.push_back('\'');
return out;
}
static bool tryReadWavDurationSec(const std::filesystem::path& wavPath, double* outSec, std::string* err) {
if (outSec) *outSec = 0.0;
std::ifstream in(wavPath, std::ios::binary);
if (!in.is_open()) {
if (err) *err = "could not open wav";
return false;
}
auto readU32le = [&](uint32_t* v) -> bool {
uint8_t b[4];
if (!in.read(reinterpret_cast<char*>(b), 4)) return false;
*v = (static_cast<uint32_t>(b[0]) << 0) |
(static_cast<uint32_t>(b[1]) << 8) |
(static_cast<uint32_t>(b[2]) << 16) |
(static_cast<uint32_t>(b[3]) << 24);
return true;
};
auto readU16le = [&](uint16_t* v) -> bool {
uint8_t b[2];
if (!in.read(reinterpret_cast<char*>(b), 2)) return false;
*v = static_cast<uint16_t>((static_cast<uint16_t>(b[0]) << 0) | (static_cast<uint16_t>(b[1]) << 8));
return true;
};
auto readFourCC = [&](char out[5]) -> bool {
char c[4];
if (!in.read(c, 4)) return false;
out[0] = c[0]; out[1] = c[1]; out[2] = c[2]; out[3] = c[3]; out[4] = '\0';
return true;
};
char riff[5] = {0}, wave[5] = {0};
uint32_t riffSize = 0;
if (!readFourCC(riff) || std::string(riff) != "RIFF") { if (err) *err = "not RIFF"; return false; }
if (!readU32le(&riffSize)) { if (err) *err = "short file"; return false; }
if (!readFourCC(wave) || std::string(wave) != "WAVE") { if (err) *err = "not WAVE"; return false; }
uint16_t fmtAudioFormat = 0;
uint16_t fmtNumChannels = 0;
uint32_t fmtSampleRate = 0;
uint32_t fmtByteRate = 0;
uint16_t fmtBlockAlign = 0;
uint16_t fmtBitsPerSample = 0;
uint32_t dataSize = 0;
bool haveFmt = false;
bool haveData = false;
// Walk chunks.
while (in.good() && (!haveFmt || !haveData)) {
char id[5] = {0};
uint32_t sz = 0;
if (!readFourCC(id)) break;
if (!readU32le(&sz)) break;
const std::string sid(id);
std::streamoff payloadStart = in.tellg();
if (sid == "fmt ") {
if (sz < 16) { if (err) *err = "fmt chunk too small"; return false; }
if (!readU16le(&fmtAudioFormat)) return false;
if (!readU16le(&fmtNumChannels)) return false;
if (!readU32le(&fmtSampleRate)) return false;
if (!readU32le(&fmtByteRate)) return false;
if (!readU16le(&fmtBlockAlign)) return false;
if (!readU16le(&fmtBitsPerSample)) return false;
haveFmt = true;
} else if (sid == "data") {
dataSize = sz;
haveData = true;
}
// Seek to end of chunk (account for what we already read in fmt)
in.seekg(payloadStart + static_cast<std::streamoff>(sz), std::ios::beg);
if (sz & 1) in.seekg(1, std::ios::cur); // pad byte
}
if (!haveFmt || !haveData) {
if (err) *err = "missing fmt/data chunk";
return false;
}
if (fmtSampleRate == 0 || fmtNumChannels == 0 || fmtBitsPerSample == 0) {
if (err) *err = "invalid fmt";
return false;
}
const double bytesPerSec = static_cast<double>(fmtSampleRate) *
static_cast<double>(fmtNumChannels) *
static_cast<double>(fmtBitsPerSample / 8.0);
if (bytesPerSec <= 0.0) {
if (err) *err = "invalid bytes/sec";
return false;
}
if (outSec) *outSec = static_cast<double>(dataSize) / bytesPerSec;
return true;
}
static uint32_t gcdU32(uint32_t a, uint32_t b) {
while (b) {
uint32_t t = a % b;
a = b;
b = t;
}
return a;
}
static bool approxToRational(double x, const std::vector<uint32_t>& dens, uint32_t* outNum, uint32_t* outDen, double tol = 1.0e-4) {
if (!outNum || !outDen) return false;
if (!std::isfinite(x)) return false;
if (x < 0.0) x = -x;
uint32_t bestN = 0, bestD = 1;
double bestErr = 1e100;
for (uint32_t d : dens) {
if (d == 0) continue;
const double n = std::round(x * static_cast<double>(d));
const double v = n / static_cast<double>(d);
const double e = std::fabs(v - x);
if (e < bestErr) {
bestErr = e;
bestN = static_cast<uint32_t>(std::max(0.0, n));
bestD = d;
}
}
if (bestErr > tol) return false;
const uint32_t g = gcdU32(bestN, bestD);
*outNum = bestN / (g ? g : 1);
*outDen = bestD / (g ? g : 1);
return true;
}
static std::string fmtFixed(double x, int digits) {
std::ostringstream oss;
oss << std::fixed << std::setprecision(digits) << x;
return oss.str();
}
static std::string jsonEscape(const std::string& s) {
std::ostringstream oss;
for (unsigned char c : s) {
switch (c) {
case '\\': oss << "\\\\"; break;
case '"': oss << "\\\""; break;
case '\n': oss << "\\n"; break;
case '\r': oss << "\\r"; break;
case '\t': oss << "\\t"; break;
default:
if (c < 0x20) {
oss << "\\u" << std::hex << std::setw(4) << std::setfill('0') << static_cast<int>(c) << std::dec << std::setfill(' ');
} else {
oss << static_cast<char>(c);
}
}
}
return oss.str();
}
static std::string typeToExportString(uint32_t type) {
switch (static_cast<gc::NoteType>(type)) {
case gc::NoteType::Tap: return "hit";
case gc::NoteType::Critical: return "critical";
case gc::NoteType::HoldStart: return "hold";
case gc::NoteType::HoldEnd: return "hold";
case gc::NoteType::DualHold: return "dualhold";
case gc::NoteType::Slide: return "slide";
case gc::NoteType::DualSlide: return "dualslide";
case gc::NoteType::SlideHold: return "slidehold";
default: break;
}
// Legacy / unknown in our hypothesis
if (type == 0x100u) return "hit2";
if (type == 0xA00u) return "slide";
return "event";
}
static std::string directionFromAngleRad(float a) {
if (!std::isfinite(static_cast<double>(a))) return {};
// Normalize to [-pi, pi)
const double pi = 3.14159265358979323846;
double x = static_cast<double>(a);
while (x >= pi) x -= 2.0 * pi;
while (x < -pi) x += 2.0 * pi;
// 8-way
const double deg = x * 180.0 / pi;
auto sector = [&](double d) -> int {
// center sectors at multiples of 45 degrees
int s = static_cast<int>(std::floor((d + 22.5) / 45.0));
s %= 8;
if (s < 0) s += 8;
return s;
};
switch (sector(deg)) {
case 0: return "right";
case 1: return "up_right";
case 2: return "up";
case 3: return "up_left";
case 4: return "left";
case 5: return "down_left";
case 6: return "down";
case 7: return "down_right";
default: return {};
}
}
struct TrackAssets {
std::filesystem::path gcRoot;
std::string trackId; // ac_...
std::string title;
std::string imageKey;
std::string artist;
std::string duration;
std::string bpm;
std::array<uint8_t, 4> difficultyRatings{};
std::array<std::string, 4> chartIds{};
std::string bgmBase; // bgm_...
std::filesystem::path stageDat;
std::filesystem::path stageExt;
std::filesystem::path stageClip;
std::filesystem::path menuDds;
std::filesystem::path menuDdsEnglish;
std::filesystem::path startDds;
std::filesystem::path startDdsEnglish;
std::filesystem::path wavBgm;
std::filesystem::path wavShot;
std::filesystem::path vibCsv;
};
static bool resolveTrackAssetsFromStageParam(
const std::string& stageParamPath,
const std::string& trackId,
TrackAssets* out,
std::string* err) {
if (!out) return false;
*out = TrackAssets{};
std::vector<uint8_t> bytes;
std::string ferr;
if (!readWholeFile(stageParamPath, &bytes, &ferr)) {
if (err) *err = "failed to read stage_param: " + ferr;
return false;
}
std::vector<gc::StageCatalogEntry> entries;
if (!gc::ParseStageCatalog(bytes, &entries, &ferr)) {
if (err) *err = "failed to parse stage_param: " + ferr;
return false;
}
const gc::StageCatalogEntry* entry = gc::FindStageCatalogEntryByChart(entries, trackId);
if (!entry) {
if (err) *err = "chart id not found in stage_param: " + trackId;
return false;
}
const std::filesystem::path root = guessGcRootFromStageParamPath(std::filesystem::path(stageParamPath));
const std::filesystem::path stageSoundDir = root / "data" / "stage" / "sound";
const std::filesystem::path stageDir = root / "data" / "stage";
const std::filesystem::path stage2dDir = stageDir / "2d";
out->gcRoot = root;
out->trackId = trackId;
out->title = entry->title;
out->imageKey = entry->imageKey;
out->artist = entry->artist;
out->duration = entry->duration;
out->bpm = entry->bpm;
out->difficultyRatings = entry->difficultyRatings;
out->chartIds = entry->chartIds;
out->bgmBase = entry->bgmBase;
out->stageDat = stageDir / (trackId + ".dat");
out->stageExt = stageDir / (trackId + "_ext.dat");
out->stageClip = stageDir / (trackId + "_clip.dat");
out->menuDds = stage2dDir / (entry->imageKey + "_menu.dds");
out->menuDdsEnglish = stage2dDir / "eng" / (entry->imageKey + "_menu.dds");
out->startDds = stage2dDir / (entry->imageKey + "_start.dds");
out->startDdsEnglish = stage2dDir / "eng" / (entry->imageKey + "_start.dds");
size_t difficultyIndex = 0;
for (size_t i = 0; i < entry->chartIds.size(); ++i) {
if (entry->chartIds[i] == trackId) {
difficultyIndex = i;
break;
}
}
out->wavBgm = stageSoundDir /
(entry->bgmBase + entry->chartGroup0[difficultyIndex] + "_BGM.wav");
out->wavShot = stageSoundDir /
(entry->bgmBase + entry->chartSuffixes[difficultyIndex] + "_SHOT.wav");
out->vibCsv = stageSoundDir / (entry->bgmBase + "_vib.csv");
return true;
}
struct TrackPt {
float t = 0.0f;
float v = 0.0f;
};
struct NoteEv {
float t = 0.0f;
uint32_t type = 0;
float value = 0.0f;
};
static float lerp(float a, float b, float t) { return a + (b - a) * t; }
static float clamp01(float t) {
if (t < 0.0f) return 0.0f;
if (t > 1.0f) return 1.0f;
return t;
}
static float trackValueAt(const std::vector<TrackPt>& pts, float t) {
if (pts.empty()) return 0.0f;
if (t <= pts.front().t) return pts.front().v;
if (t >= pts.back().t) return pts.back().v;
// upper_bound by time
size_t lo = 0, hi = pts.size();
while (lo + 1 < hi) {
const size_t mid = lo + (hi - lo) / 2;
if (pts[mid].t <= t) lo = mid;
else hi = mid;
}
const TrackPt& a = pts[lo];
const TrackPt& b = pts[std::min(lo + 1, pts.size() - 1)];
const float dt = (b.t - a.t);
const float u = (dt > 1.0e-6f) ? clamp01((t - a.t) / dt) : 0.0f;
return lerp(a.v, b.v, u);
}
static std::string colorForNote(uint32_t type) {
switch (static_cast<gc::NoteType>(type)) {
case gc::NoteType::Tap: return "#1f77b4";
case gc::NoteType::Critical: return "#ffbf00";
case gc::NoteType::HoldStart: return "#ff7f0e";
case gc::NoteType::HoldEnd: return "#ff7f0e";
case gc::NoteType::DualHold: return "#d62728";
case gc::NoteType::Slide: return "#2ca02c";
case gc::NoteType::DualSlide: return "#2ca02c";
case gc::NoteType::SlideHold: return "#9467bd";
default: break;
}
// fallback
return "#444444";
}
static bool buildTrackFromSection16(
const gc::StageDat& dat,
size_t sectionIndex,
std::vector<TrackPt>* outPts,
std::string* err) {
if (!outPts) return false;
outPts->clear();
if (sectionIndex >= dat.sections.size()) {
if (err) *err = "track section index out of range";
return false;
}
const auto& sec = dat.sections[sectionIndex];
// Force 16-byte decoding and alignment search.
const auto res = gc::TryDecodeEventStreamFixed(dat.bytes, sec.start, sec.end, 16);
const size_t start = sec.start + static_cast<size_t>(res.alignment);
std::vector<gc::GameEvent> events;
std::string tmpErr;
if (!gc::DecodeEventStream(dat.bytes, start, sec.end, 16, &events, &tmpErr)) {
if (err) *err = "DecodeEventStream(track) failed: " + tmpErr;
return false;
}
// Interpret (timestamp,value) as a 2D curve for now.
outPts->reserve(events.size());
for (const auto& e : events) {
if (!isFiniteF(e.timestamp) || !isFiniteF(e.value)) continue;
if (e.timestamp < -1.0f || e.timestamp > 1.0e6f) continue;
if (std::fabs(static_cast<double>(e.value)) > 1.0e7) continue;
outPts->push_back(TrackPt{e.timestamp, e.value});
}
std::sort(outPts->begin(), outPts->end(), [](const TrackPt& a, const TrackPt& b) { return a.t < b.t; });
// drop duplicates with same t to stabilize interpolation
outPts->erase(std::unique(outPts->begin(), outPts->end(), [](const TrackPt& a, const TrackPt& b) {
return std::fabs(static_cast<double>(a.t - b.t)) < 1.0e-6;
}), outPts->end());
if (outPts->size() < 2) {
if (err) *err = "not enough track points decoded";
return false;
}
return true;
}
static int pickBestNoteAlign12(const gc::StageDat& dat, const gc::Section& sec) {
// Score shifts by a mixture of markers and note-like opcodes.
const size_t rs = 12;
int bestShift = 0;
uint64_t bestScore = 0;
for (int shift = 0; shift < 12; shift++) {
const size_t start = sec.start + static_cast<size_t>(shift);
const size_t end = sec.end;
const size_t span = (end > start) ? (end - start) - ((end - start) % rs) : 0;
uint64_t noteLike = 0;
uint64_t bpmMarkers = 0;
uint64_t plausible = 0;
for (size_t rel = 0; rel + rs <= span; rel += rs) {
const size_t off = start + rel;
if (off + rs > dat.bytes.size()) break;
const float ts = f32be_bytes(dat.bytes, off + 0);
const uint32_t type = u32be_bytes(dat.bytes, off + 4);
const float val = f32be_bytes(dat.bytes, off + 8);
if (!isFiniteF(ts) || ts < -1.0f || ts > 1.0e6f) continue;
if (type == 0xFFFFFFFFu) continue;
if (!isFiniteF(val) || std::fabs(static_cast<double>(val)) > 1.0e7) continue;
if (type <= 0xFFFFu && gc::IsNote(type)) noteLike++;
if (type == 0 && std::fabs(static_cast<double>(val - 1.0f)) < 1.0e-6) bpmMarkers++;
plausible++;
}
const uint64_t score = noteLike * 100000 + bpmMarkers * 1000 + plausible;
if (score > bestScore) {
bestScore = score;
bestShift = shift;
}
}
return bestShift;
}
static bool buildNotesFromSection12(
const gc::StageDat& dat,
size_t sectionIndex,
int forcedAlign,
bool keepType0,
bool notesOnly,
std::vector<NoteEv>* outNotes,
std::string* err) {
if (!outNotes) return false;
outNotes->clear();
if (sectionIndex >= dat.sections.size()) {
if (err) *err = "note section index out of range";
return false;
}
const auto& sec = dat.sections[sectionIndex];
const int align = (forcedAlign >= 0) ? forcedAlign : pickBestNoteAlign12(dat, sec);
const size_t rs = 12;
const size_t start = sec.start + static_cast<size_t>(align);
const size_t end = sec.end;
const size_t span = (end > start) ? (end - start) - ((end - start) % rs) : 0;
outNotes->reserve(span / rs);
for (size_t rel = 0; rel + rs <= span; rel += rs) {
const size_t off = start + rel;
if (off + rs > dat.bytes.size()) break;
const float ts = f32be_bytes(dat.bytes, off + 0);
const uint32_t type = u32be_bytes(dat.bytes, off + 4);
const float val = f32be_bytes(dat.bytes, off + 8);
if (!isFiniteF(ts) || ts < -1.0f || ts > 1.0e6f) continue;
if (type == 0xFFFFFFFFu) continue;
if (!isFiniteF(val) || std::fabs(static_cast<double>(val)) > 1.0e7) continue;
if (type > 0xFFFFu) continue;
if (!keepType0 && type == 0) continue;
if (notesOnly && !gc::IsNote(type)) continue;
outNotes->push_back(NoteEv{ts, type, val});
}
std::sort(outNotes->begin(), outNotes->end(), [](const NoteEv& a, const NoteEv& b) { return a.t < b.t; });
if (outNotes->empty()) {
if (err) *err = "no events decoded from note section (try --note-align 0..11, or --render-keep-type0)";
return false;
}
return true;
}
static std::string mapperNoteType(uint32_t type) {
switch (static_cast<gc::NoteType>(type)) {
case gc::NoteType::Tap:
case gc::NoteType::Legacy_Tap:
return "tap";
case gc::NoteType::Critical:
return "critical";
case gc::NoteType::HoldStart:
case gc::NoteType::HoldEnd:
return "hold";
case gc::NoteType::DualHold:
return "dualhold";
case gc::NoteType::Slide:
case gc::NoteType::Legacy_Slide:
return "slide";
case gc::NoteType::DualSlide:
return "dualslide";
case gc::NoteType::SlideHold:
return "slidehold";
default:
break;
}
return "event";
}
static double dist2d(const gc::TrackPiece& a, const gc::TrackPiece& b) {
const double dx = static_cast<double>(b.x) - static_cast<double>(a.x);
const double dz = static_cast<double>(b.z) - static_cast<double>(a.z);
return std::sqrt(dx * dx + dz * dz);
}
static bool exportVectoMapperProject(
const std::filesystem::path& stageDatPath,
const gc::ParsedStagePattern& stage,
const std::vector<NoteEv>& notes,
const std::filesystem::path& outDir,
std::string* err) {
if (stage.track.size() < 2) {
if (err) *err = "stage pattern track has fewer than two points";
return false;
}
std::error_code ec;
std::filesystem::create_directories(outDir / "track", ec);
if (ec) {
if (err) *err = "failed to create track directory: " + ec.message();
return false;
}
std::filesystem::create_directories(outDir / "charts", ec);
if (ec) {
if (err) *err = "failed to create charts directory: " + ec.message();
return false;
}
std::filesystem::create_directories(outDir / "bg", ec);
if (ec) {
if (err) *err = "failed to create bg directory: " + ec.message();
return false;
}
const std::string title = !stage.config.chartName.empty() ? stage.config.chartName : stageDatPath.stem().string();
std::vector<double> cumulative;
cumulative.reserve(stage.track.size());
cumulative.push_back(0.0);
for (size_t i = 1; i < stage.track.size(); i++) {
cumulative.push_back(cumulative.back() + dist2d(stage.track[i - 1], stage.track[i]));
}
{
std::ofstream out(outDir / "track" / "track_graph.json", std::ios::binary);
if (!out.is_open()) {
if (err) *err = "failed to write track_graph.json";
return false;
}
out << "{\n";
out << " \"schema\": 1,\n";
out << " \"source\": {\"format\": \"gc_stage_dat\", \"file\": \"" << jsonEscape(stageDatPath.filename().string()) << "\"},\n";
out << " \"nodes\": [\n";
for (size_t i = 0; i < stage.track.size(); i++) {
const auto& p = stage.track[i];
out << " {\"id\": \"N" << i << "\", \"x\": " << fmtFixed(p.x, 6)
<< ", \"z\": " << fmtFixed(p.z, 6)
<< ", \"gc_time_ms\": " << p.timeMs
<< ", \"gc_y\": " << fmtFixed(p.y, 6) << "}";
out << (i + 1 < stage.track.size() ? "," : "") << "\n";
}
out << " ],\n";
out << " \"segments\": [\n";
for (size_t i = 0; i + 1 < stage.track.size(); i++) {
const auto& a = stage.track[i];
const auto& b = stage.track[i + 1];
const double c1x = static_cast<double>(a.x) + (static_cast<double>(b.x) - static_cast<double>(a.x)) / 3.0;
const double c1z = static_cast<double>(a.z) + (static_cast<double>(b.z) - static_cast<double>(a.z)) / 3.0;
const double c2x = static_cast<double>(a.x) + 2.0 * (static_cast<double>(b.x) - static_cast<double>(a.x)) / 3.0;
const double c2z = static_cast<double>(a.z) + 2.0 * (static_cast<double>(b.z) - static_cast<double>(a.z)) / 3.0;
out << " {\"id\": \"S" << i << "\", \"type\": \"bezier\", \"mode\": \"smooth\", "
<< "\"a\": \"N" << i << "\", \"b\": \"N" << (i + 1) << "\", "
<< "\"c1\": [" << fmtFixed(c1x, 6) << ", " << fmtFixed(c1z, 6) << "], "
<< "\"c2\": [" << fmtFixed(c2x, 6) << ", " << fmtFixed(c2z, 6) << "], "
<< "\"visible\": 1}";
out << (i + 2 < stage.track.size() ? "," : "") << "\n";
}
out << " ],\n";
out << " \"pieces\": [],\n";
out << " \"paths\": {\"main\": [";
for (size_t i = 0; i + 1 < stage.track.size(); i++) {
out << (i ? ", " : "") << "\"S" << i << "\"";
}
out << "]},\n";
out << " \"active_path\": \"main\"\n";
out << "}\n";
}
{
std::ofstream out(outDir / "track" / "camera_timeline.txt", std::ios::binary);
if (!out.is_open()) {
if (err) *err = "failed to write camera_timeline.txt";
return false;
}
const double lastMs = std::max(1.0, static_cast<double>(stage.track.back().timeMs));
const double maxParam = static_cast<double>(stage.track.size() - 1);
for (const auto& c : stage.cameras) {
const double t = std::max(0.0, std::min(maxParam, (static_cast<double>(c.timeMs) / lastMs) * maxParam));
const double fov = (std::isfinite(c.fieldNear[0]) && c.fieldNear[0] > 1.0f && c.fieldNear[0] < 179.0f)
? static_cast<double>(c.fieldNear[0])
: 90.0;
const double camY = (std::isfinite(c.originOff[2]) && std::fabs(static_cast<double>(c.originOff[2])) > 1.0e-6)
? static_cast<double>(c.originOff[2])
: 3.5;
const double camZ = (std::isfinite(c.dist) && std::fabs(static_cast<double>(c.dist)) > 1.0e-6)
? static_cast<double>(c.dist)
: 3.5;
out << fmtFixed(t, 6) << " fov " << fmtFixed(fov, 6) << "\n";
out << fmtFixed(t, 6) << " cam_y " << fmtFixed(camY, 6) << "\n";
out << fmtFixed(t, 6) << " cam_z " << fmtFixed(camZ, 6) << "\n";
}
}
{
std::ofstream out(outDir / "charts" / "normal.json", std::ios::binary);
if (!out.is_open()) {
if (err) *err = "failed to write charts/normal.json";
return false;
}
out << "{\n";
out << " \"schema\": 1,\n";
out << " \"name\": \"normal\",\n";
out << " \"source\": {\"format\": \"gc_stage_dat\", \"note_decode\": \"heuristic_section12\"},\n";
out << " \"avatar_move\": [\n";
for (size_t i = 0; i < stage.track.size(); i++) {
const double t = stage.track[i].timeMs ? (static_cast<double>(stage.track[i].timeMs) / 1000.0) : (cumulative[i] / 30.0);
out << " {\"t\": " << fmtFixed(t, 6) << ", \"d\": " << fmtFixed(cumulative[i], 6) << "}";
out << (i + 1 < stage.track.size() ? "," : "") << "\n";
}
out << " ],\n";
out << " \"notes\": [\n";
for (size_t i = 0; i < notes.size(); i++) {
const auto& n = notes[i];
out << " {\"t\": " << fmtFixed(n.t, 6)
<< ", \"type\": \"" << jsonEscape(mapperNoteType(n.type)) << "\""
<< ", \"raw_type\": \"0x" << std::hex << std::setw(8) << std::setfill('0') << n.type
<< std::dec << std::setfill(' ') << "\""
<< ", \"value\": " << fmtFixed(n.value, 6) << "}";
out << (i + 1 < notes.size() ? "," : "") << "\n";
}
out << " ]\n";
out << "}\n";
}
{
std::ofstream out(outDir / "bg" / "bg.json", std::ios::binary);
if (!out.is_open()) {
if (err) *err = "failed to write bg/bg.json";
return false;
}
out << "{\n";
out << " \"schema\": 1,\n";
out << " \"type\": \"color\",\n";
out << " \"params\": {\"color\": \"#0f1116\"}\n";
out << "}\n";
}
{
std::ofstream out(outDir / "level.json", std::ios::binary);
if (!out.is_open()) {
if (err) *err = "failed to write level.json";
return false;
}
out << "{\n";
out << " \"schema\": 1,\n";
out << " \"title\": \"" << jsonEscape(title) << "\",\n";
out << " \"audio\": \"\",\n";
out << " \"track\": \"track/track_graph.json\",\n";
out << " \"camera\": \"track/camera_timeline.txt\",\n";
out << " \"background\": \"bg/bg.json\",\n";
out << " \"charts\": {\"normal\": \"charts/normal.json\"},\n";
out << " \"timing\": {\"base_dps\": 30.0, \"playback_rate\": 1.0},\n";
out << " \"gc\": {\n";
out << " \"stage_file\": \"" << jsonEscape(stageDatPath.string()) << "\",\n";
out << " \"chart_name\": \"" << jsonEscape(stage.config.chartName) << "\",\n";
out << " \"bgm_name\": \"" << jsonEscape(stage.config.bgmName) << "\",\n";
out << " \"shot_name\": \"" << jsonEscape(stage.config.shotName) << "\",\n";
out << " \"track_points\": " << stage.track.size() << ",\n";
out << " \"camera_points\": " << stage.cameras.size() << ",\n";
out << " \"draw_distance_points\": " << stage.drawDistances.size() << "\n";
out << " }\n";
out << "}\n";
}
return true;
}
int main(int argc, char** argv) {
if (argc < 2) {
printUsage(argv[0]);
return 2;
}
std::string path = argv[1];
bool rawMode = false;
int dumpN = 0;
int dumpSection = -1;
int forcedAlign = -1;
std::string dumpRs = "auto"; // auto|12|16 (affects --dump/--svg candidate selection)
std::string svgOut;
std::string svgY = "value";
int svgMaxPoints = 6000;
bool svgKeepAll = false;
bool filterGarbage = true;
uint32_t typeMax = 0xFFFFFFFFu; // for rs=12, defaulted later to 0xFFFF when filtering
bool statsMode = false;
int statsTop = 50;
bool statsAll = false;
bool statsSkipZero = true;
int statsSection = -1;
std::string statsRs = "12"; // auto|12|16
bool findAlignMode = false;
int findAlignSection = -1;
std::string findAlignRs = "12"; // 12|16
bool surveyMode = false;
bool stringsMode = false;
int stringsSection = -1;
int stringsMinLen = 4;
int stringsMax = 250;
bool stringsAll = false;
bool metaMode = false;
int metaSection = 0;
std::string renderOut;
int trackSection = 2;
int noteSection = 4;
int noteAlign = -1;
RenderTimeMode renderTimeMode = RenderTimeMode::Auto;
bool renderKeepType0 = false;
bool renderNotesOnly = false;
int renderMaxNotes = 8000;
bool trackInfoMode = false;
std::string trackInfoId;
bool playMode = false;
std::string playId;
std::string playWhat = "bgm"; // bgm|shot
std::string playTool = "auto"; // auto|ffplay|aplay|paplay|mpv
bool vizMode = false;
std::string vizId;
std::string vizOutHtml;
std::string vizWhat = "bgm";
bool vizNotesOnly = false;
bool exportJsonMode = false;
std::string exportId;
std::string exportOutJson;
ExportAtMode exportAtMode = ExportAtMode::Auto;
bool exportRelative = false;
bool exportNotesOnly = false;
bool exportGcsimMode = false;
std::string gcsimId;
std::string gcsimOutDir;
std::string gcsimWhat = "bgm";
int gcsimBpm = 120;
std::string gcsimTitle;
bool exportGcsimProjectMode = false;
std::string gcsimProjectOutDir;
std::string gcsimProjectStageParam;
std::string gcsimProjectAcId;
std::string gcsimProjectMusic;
bool exportVectoMapperMode = false;
std::string exportVectoMapperOutDir;
for (int i = 2; i < argc; i++) {
std::string a = argv[i];
if (a == "--dump") {
if (i + 1 >= argc) {
std::cerr << "--dump requires an integer\n";
return 2;
}
dumpN = std::atoi(argv[i + 1]);
i++;
} else if (a == "--section") {
if (i + 1 >= argc) {
std::cerr << "--section requires an integer\n";
return 2;
}
dumpSection = std::atoi(argv[i + 1]);
i++;
} else if (a == "--align") {
if (i + 1 >= argc) {
std::cerr << "--align requires an integer\n";
return 2;
}
forcedAlign = std::atoi(argv[i + 1]);
if (forcedAlign < 0) forcedAlign = 0;
i++;
} else if (a == "--rs") {
if (i + 1 >= argc) {
std::cerr << "--rs requires one of: auto, 12, 16\n";
return 2;
}
dumpRs = argv[i + 1];
if (dumpRs != "auto" && dumpRs != "12" && dumpRs != "16") {
std::cerr << "--rs requires one of: auto, 12, 16\n";
return 2;
}
i++;
} else if (a == "--svg") {
if (i + 1 >= argc) {
std::cerr << "--svg requires a path\n";
return 2;
}
svgOut = argv[i + 1];
i++;
} else if (a == "--svg-y") {
if (i + 1 >= argc) {
std::cerr << "--svg-y requires one of: value, type\n";
return 2;
}
svgY = argv[i + 1];
if (svgY != "value" && svgY != "type") {
std::cerr << "--svg-y requires one of: value, type\n";
return 2;
}
i++;
} else if (a == "--type-max") {
if (i + 1 >= argc) {
std::cerr << "--type-max requires an integer (e.g. 65535 or 0xffff)\n";
return 2;
}
uint32_t v = 0;
if (!parseU32(argv[i + 1], &v)) {
std::cerr << "--type-max parse failed: " << argv[i + 1] << "\n";
return 2;
}
typeMax = v;
i++;
} else if (a == "--svg-max") {
if (i + 1 >= argc) {
std::cerr << "--svg-max requires an integer\n";
return 2;
}
svgMaxPoints = std::atoi(argv[i + 1]);
if (svgMaxPoints < 10) svgMaxPoints = 10;
i++;
} else if (a == "--svg-all") {
svgKeepAll = true;
} else if (a == "--no-filter") {
filterGarbage = false;
} else if (a == "--stats") {
statsMode = true;
} else if (a == "--stats-section") {
if (i + 1 >= argc) {
std::cerr << "--stats-section requires an integer\n";
return 2;
}
statsSection = std::atoi(argv[i + 1]);
i++;
} else if (a == "--stats-rs") {
if (i + 1 >= argc) {
std::cerr << "--stats-rs requires one of: auto, 12, 16\n";
return 2;
}
statsRs = argv[i + 1];
if (statsRs != "auto" && statsRs != "12" && statsRs != "16") {
std::cerr << "--stats-rs requires one of: auto, 12, 16\n";
return 2;
}
i++;
} else if (a == "--stats-top") {
if (i + 1 >= argc) {
std::cerr << "--stats-top requires an integer\n";
return 2;
}
statsTop = std::atoi(argv[i + 1]);
if (statsTop < 1) statsTop = 1;
i++;
} else if (a == "--stats-all") {
statsAll = true;
} else if (a == "--stats-keep-zero") {
statsSkipZero = false;
} else if (a == "--find-align") {
findAlignMode = true;
} else if (a == "--find-align-section") {
if (i + 1 >= argc) {
std::cerr << "--find-align-section requires an integer\n";
return 2;
}
findAlignSection = std::atoi(argv[i + 1]);
i++;
} else if (a == "--find-align-rs") {
if (i + 1 >= argc) {
std::cerr << "--find-align-rs requires one of: 12, 16\n";
return 2;
}
findAlignRs = argv[i + 1];
if (findAlignRs != "12" && findAlignRs != "16") {
std::cerr << "--find-align-rs requires one of: 12, 16\n";
return 2;
}
i++;
} else if (a == "--survey") {
surveyMode = true;
} else if (a == "--strings") {
stringsMode = true;
} else if (a == "--strings-section") {
if (i + 1 >= argc) {
std::cerr << "--strings-section requires an integer\n";
return 2;
}
stringsSection = std::atoi(argv[i + 1]);
i++;
} else if (a == "--strings-min") {
if (i + 1 >= argc) {
std::cerr << "--strings-min requires an integer\n";
return 2;
}
stringsMinLen = std::atoi(argv[i + 1]);
if (stringsMinLen < 2) stringsMinLen = 2;
i++;
} else if (a == "--strings-max") {
if (i + 1 >= argc) {
std::cerr << "--strings-max requires an integer\n";
return 2;
}
stringsMax = std::atoi(argv[i + 1]);
if (stringsMax < 1) stringsMax = 1;
i++;
} else if (a == "--strings-all") {
stringsAll = true;
} else if (a == "--meta") {
metaMode = true;
} else if (a == "--meta-section") {
if (i + 1 >= argc) {
std::cerr << "--meta-section requires an integer\n";
return 2;
}
metaSection = std::atoi(argv[i + 1]);
i++;
} else if (a == "--raw") {
rawMode = true;
} else if (a == "--render") {
if (i + 1 >= argc) {
std::cerr << "--render requires a path\n";
return 2;
}
renderOut = argv[i + 1];
i++;
} else if (a == "--render-time") {
if (i + 1 >= argc) {
std::cerr << "--render-time requires one of: auto, time, fit, index\n";
return 2;
}
if (!parseRenderTimeMode(argv[i + 1], &renderTimeMode)) {
std::cerr << "--render-time requires one of: auto, time, fit, index\n";
return 2;
}
i++;
} else if (a == "--render-keep-type0") {
renderKeepType0 = true;
} else if (a == "--render-notes-only") {
renderNotesOnly = true;
} else if (a == "--render-max-notes") {
if (i + 1 >= argc) {
std::cerr << "--render-max-notes requires an integer\n";
return 2;
}
renderMaxNotes = std::atoi(argv[i + 1]);
if (renderMaxNotes < 100) renderMaxNotes = 100;
i++;
} else if (a == "--track-info") {
if (i + 1 >= argc) {
std::cerr << "--track-info requires an id like: ac_10pt8tion_hard\n";
return 2;
}
trackInfoMode = true;
trackInfoId = argv[i + 1];
i++;
} else if (a == "--play") {
if (i + 1 >= argc) {
std::cerr << "--play requires an id like: ac_10pt8tion_hard\n";
return 2;
}
playMode = true;
playId = argv[i + 1];
i++;
} else if (a == "--play-what") {
if (i + 1 >= argc) {
std::cerr << "--play-what requires one of: bgm, shot\n";
return 2;
}
playWhat = argv[i + 1];
if (playWhat != "bgm" && playWhat != "shot") {
std::cerr << "--play-what requires one of: bgm, shot\n";
return 2;
}
i++;
} else if (a == "--play-tool") {
if (i + 1 >= argc) {
std::cerr << "--play-tool requires one of: auto, ffplay, aplay, paplay, mpv\n";
return 2;
}
playTool = argv[i + 1];
if (playTool != "auto" && playTool != "ffplay" && playTool != "aplay" && playTool != "paplay" && playTool != "mpv") {
std::cerr << "--play-tool requires one of: auto, ffplay, aplay, paplay, mpv\n";
return 2;
}
i++;
} else if (a == "--viz") {
if (i + 2 >= argc) {
std::cerr << "--viz requires: <ac_track_id> <out.html>\n";
return 2;
}
vizMode = true;
vizId = argv[i + 1];
vizOutHtml = argv[i + 2];
i += 2;
} else if (a == "--viz-what") {
if (i + 1 >= argc) {
std::cerr << "--viz-what requires one of: bgm, shot\n";
return 2;
}
vizWhat = argv[i + 1];
if (vizWhat != "bgm" && vizWhat != "shot") {
std::cerr << "--viz-what requires one of: bgm, shot\n";
return 2;
}
i++;
} else if (a == "--viz-notes-only") {
vizNotesOnly = true;
} else if (a == "--export-json") {
if (i + 2 >= argc) {
std::cerr << "--export-json requires: <ac_track_id> <out.json>\n";
return 2;
}
exportJsonMode = true;
exportId = argv[i + 1];
exportOutJson = argv[i + 2];
i += 2;
} else if (a == "--export-at") {
if (i + 1 >= argc) {
std::cerr << "--export-at requires one of: auto, bars, beats, index, seconds\n";
return 2;
}
if (!parseExportAtMode(argv[i + 1], &exportAtMode)) {
std::cerr << "--export-at requires one of: auto, bars, beats, index, seconds\n";
return 2;
}
i++;
} else if (a == "--export-relative") {
exportRelative = true;
} else if (a == "--export-notes-only") {
exportNotesOnly = true;
} else if (a == "--export-gcsim") {
if (i + 2 >= argc) {
std::cerr << "--export-gcsim requires: <ac_track_id> <out_dir>\n";
return 2;
}
exportGcsimMode = true;
gcsimId = argv[i + 1];
gcsimOutDir = argv[i + 2];
i += 2;
} else if (a == "--gcsim-what") {
if (i + 1 >= argc) {
std::cerr << "--gcsim-what requires one of: bgm, shot\n";
return 2;
}
gcsimWhat = argv[i + 1];
if (gcsimWhat != "bgm" && gcsimWhat != "shot") {
std::cerr << "--gcsim-what requires one of: bgm, shot\n";
return 2;
}
i++;
} else if (a == "--gcsim-bpm") {
if (i + 1 >= argc) {
std::cerr << "--gcsim-bpm requires an integer\n";
return 2;
}
gcsimBpm = std::atoi(argv[i + 1]);
if (gcsimBpm < 1) gcsimBpm = 1;
i++;
} else if (a == "--gcsim-title") {
if (i + 1 >= argc) {
std::cerr << "--gcsim-title requires a string\n";
return 2;
}
gcsimTitle = argv[i + 1];
i++;
} else if (a == "--export-gcsim-project") {
if (i + 1 >= argc) {
std::cerr << "--export-gcsim-project requires: <out_dir>\n";
return 2;
}
exportGcsimProjectMode = true;
gcsimProjectOutDir = argv[i + 1];
i++;
} else if (a == "--export-vectomapper") {
if (i + 1 >= argc) {
std::cerr << "--export-vectomapper requires: <out_dir>\n";
return 2;
}
exportVectoMapperMode = true;
exportVectoMapperOutDir = argv[i + 1];
i++;
} else if (a == "--stage-param") {
if (i + 1 >= argc) {
std::cerr << "--stage-param requires a path to stage_param.dat\n";
return 2;
}
gcsimProjectStageParam = argv[i + 1];
i++;
} else if (a == "--ac-id") {
if (i + 1 >= argc) {
std::cerr << "--ac-id requires an id like: ac_10pt8tion_hard\n";
return 2;
}
gcsimProjectAcId = argv[i + 1];
i++;
} else if (a == "--music") {
if (i + 1 >= argc) {
std::cerr << "--music requires a wav path\n";
return 2;
}
gcsimProjectMusic = argv[i + 1];
i++;
} else if (a == "--track-section") {
if (i + 1 >= argc) {
std::cerr << "--track-section requires an integer\n";
return 2;
}
trackSection = std::atoi(argv[i + 1]);
i++;
} else if (a == "--note-section") {
if (i + 1 >= argc) {
std::cerr << "--note-section requires an integer\n";
return 2;
}
noteSection = std::atoi(argv[i + 1]);
i++;
} else if (a == "--note-align") {
if (i + 1 >= argc) {
std::cerr << "--note-align requires an integer (0..11)\n";
return 2;
}
noteAlign = std::atoi(argv[i + 1]);
if (noteAlign < 0) noteAlign = 0;
if (noteAlign > 11) noteAlign = 11;
i++;
} else if (a == "--help" || a == "-h") {
printUsage(argv[0]);
return 0;
} else {
std::cerr << "Unknown arg: " << a << "\n";
return 2;
}
}
if (exportVectoMapperMode) {
const std::filesystem::path stageDatPath(path);
gc::StageDat dat;
std::string eerr;
if (!gc::StageDat::LoadFromFile(stageDatPath.string(), dat, &eerr)) {
std::cerr << "VectoMapper export: failed to load stage dat: " << stageDatPath.string() << "\n";
if (!eerr.empty()) std::cerr << eerr << "\n";
return 1;
}
gc::ParsedStagePattern stage;
if (!gc::ParseStagePattern(dat, &stage, &eerr)) {
std::cerr << "VectoMapper export: stage.pat parse failed: " << eerr << "\n";
return 1;
}
std::vector<NoteEv> notes;
if (!buildNotesFromSection12(dat, static_cast<size_t>(noteSection), noteAlign, /*keepType0*/false, /*notesOnly*/true, &notes, &eerr)) {
std::cerr << "VectoMapper export: note decode warning: " << eerr << "\n";
notes.clear();
}
if (!exportVectoMapperProject(stageDatPath, stage, notes, std::filesystem::path(exportVectoMapperOutDir), &eerr)) {
std::cerr << "VectoMapper export: write failed: " << eerr << "\n";
return 1;
}
std::cout << "Wrote VectoMapper project: " << exportVectoMapperOutDir << "\n";
std::cout << " track points: " << stage.track.size() << "\n";
std::cout << " camera points: " << stage.cameras.size() << "\n";
std::cout << " notes: " << notes.size() << " (heuristic)\n";
return 0;
}
if (playMode) {
TrackAssets ta;
std::string perr;
if (!resolveTrackAssetsFromStageParam(path, playId, &ta, &perr)) {
std::cerr << "Play: resolve failed: " << perr << "\n";
return 1;
}
std::filesystem::path wav;
if (playWhat == "bgm") wav = ta.wavBgm;
else wav = ta.wavShot;
if (!std::filesystem::exists(wav)) {
std::cerr << "Play: missing wav: " << wav.string() << "\n";
return 1;
}
// Choose a tool.
auto existsInPath = [&](const std::string& exe) -> bool {
const char* p = std::getenv("PATH");
if (!p) return false;
std::string cmd = "command -v " + exe + " >/dev/null 2>&1";
return std::system(cmd.c_str()) == 0;
};
std::string tool = playTool;
if (tool == "auto") {
if (existsInPath("ffplay")) tool = "ffplay";
else if (existsInPath("aplay")) tool = "aplay";
else if (existsInPath("paplay")) tool = "paplay";
else if (existsInPath("mpv")) tool = "mpv";
else tool.clear();
}
if (tool.empty()) {
std::cerr << "Play: no audio tool found. Install one of: ffplay, aplay(alsa-utils), paplay(pulseaudio), mpv\n";
return 1;
}
const std::string wavEsc = shEscapeSingleQuotes(wav.string());
std::string cmd;
if (tool == "ffplay") {
cmd = "ffplay -nodisp -autoexit -hide_banner -loglevel warning " + wavEsc;
} else if (tool == "aplay") {
cmd = "aplay " + wavEsc;
} else if (tool == "paplay") {
cmd = "paplay " + wavEsc;
} else if (tool == "mpv") {
cmd = "mpv --no-video --really-quiet " + wavEsc;
} else {
std::cerr << "Play: unsupported tool: " << tool << "\n";
return 1;
}
std::cout << "Track: " << ta.trackId << "\n";
std::cout << "BGM base: " << ta.bgmBase << "\n";
std::cout << "WAV (" << playWhat << "): " << wav.string() << "\n";
if (std::filesystem::exists(ta.vibCsv)) std::cout << "VIB: " << ta.vibCsv.string() << "\n";
std::cout << "Player: " << tool << "\n";
std::cout << "Running: " << cmd << "\n";
return std::system(cmd.c_str());
}
if (vizMode) {
TrackAssets ta;
std::string verr;
if (!resolveTrackAssetsFromStageParam(path, vizId, &ta, &verr)) {
std::cerr << "Viz: resolve failed: " << verr << "\n";
return 1;
}
std::filesystem::path wav;
if (vizWhat == "bgm") wav = ta.wavBgm;
else wav = ta.wavShot;
if (!std::filesystem::exists(wav)) {
std::cerr << "Viz: missing wav: " << wav.string() << "\n";
return 1;
}
double durSec = 0.0;
std::string werr;
if (!tryReadWavDurationSec(wav, &durSec, &werr)) {
std::cerr << "Viz: could not read wav duration: " << werr << "\n";
return 1;
}
// Load stage dat and decode.
gc::StageDat dat;
if (!gc::StageDat::LoadFromFile(ta.stageDat.string(), dat, &verr)) {
std::cerr << "Viz: failed to load stage dat: " << ta.stageDat.string() << "\n";
if (!verr.empty()) std::cerr << verr << "\n";
return 1;
}
std::vector<TrackPt> track;
std::vector<NoteEv> notes;
if (!buildTrackFromSection16(dat, static_cast<size_t>(trackSection), &track, &verr)) {
std::cerr << "Viz: track decode failed: " << verr << "\n";
return 1;
}
if (!buildNotesFromSection12(dat, static_cast<size_t>(noteSection), noteAlign, /*keepType0*/false, /*notesOnly*/vizNotesOnly, &notes, &verr)) {
std::cerr << "Viz: note decode failed: " << verr << "\n";
return 1;
}
// Downsample for DOM size.
const int maxDomNotes = 6000;
if (static_cast<int>(notes.size()) > maxDomNotes) {
const size_t stride = std::max<size_t>(1, notes.size() / static_cast<size_t>(maxDomNotes));
std::vector<NoteEv> ds;
ds.reserve(static_cast<size_t>(maxDomNotes));
for (size_t i = 0; i < notes.size(); i += stride) ds.push_back(notes[i]);
notes.swap(ds);
}
// Map note times to audio seconds: if note time range looks meaningful, fit it; else spread by index.
float nMinT = notes.front().t;
float nMaxT = notes.back().t;
const float nRange = (nMaxT > nMinT) ? (nMaxT - nMinT) : 0.0f;
uint64_t zeroTs = 0;
for (const auto& n : notes) if (std::fabs(static_cast<double>(n.t)) < 1.0e-9) zeroTs++;
const double pZero = notes.empty() ? 0.0 : (100.0 * static_cast<double>(zeroTs) / static_cast<double>(notes.size()));
const bool useIndex = (nRange <= 2.0f) || (pZero >= 80.0);
auto noteAudioSec = [&](const NoteEv& n, size_t idx, size_t total) -> double {
if (durSec <= 0.0) return 0.0;
if (!useIndex && nRange > 1.0e-6f) {
const double u = clamp01((n.t - nMinT) / nRange);
return u * durSec;
}
if (total <= 1) return 0.0;
const double u = static_cast<double>(idx) / static_cast<double>(total - 1);
return u * durSec;
};
// Prepare SVG coordinate mapping using track time.
float tMin = track.front().t;
float tMax = track.back().t;
float vMin = std::numeric_limits<float>::infinity();
float vMax = -std::numeric_limits<float>::infinity();
for (const auto& p : track) { vMin = std::min(vMin, p.v); vMax = std::max(vMax, p.v); }
const float tRange = (tMax > tMin) ? (tMax - tMin) : 1.0f;
const float vRange = (vMax > vMin) ? (vMax - vMin) : 1.0f;
const int W = 1600;
const int H = 900;
const int M = 70;
auto mapX = [&](float t) -> float {
const float u = (t - tMin) / tRange;
return static_cast<float>(M) + clamp01(u) * static_cast<float>(W - 2 * M);
};
auto mapY = [&](float v) -> float {
const float u = (v - vMin) / vRange;
return static_cast<float>(H - M) - clamp01(u) * static_cast<float>(H - 2 * M);
};
auto mapAudioToTrackT = [&](double sec) -> float {
const double u = (durSec > 1.0e-6) ? std::max(0.0, std::min(1.0, sec / durSec)) : 0.0;
return tMin + static_cast<float>(u) * tRange;
};
std::ofstream out(vizOutHtml, std::ios::binary);
if (!out.is_open()) {
std::cerr << "Viz: failed to open for write: " << vizOutHtml << "\n";
return 1;
}
// Use absolute file path for browser <audio src>.
const std::string audioSrc = std::filesystem::absolute(wav).string();
out << "<!doctype html>\n<html><head><meta charset=\"utf-8\"/>\n";
out << "<title>openroller viz: " << ta.trackId << "</title>\n";
out << "<style>\n"
"body{font-family:ui-monospace,Menlo,Consolas,monospace;margin:16px;background:#fff;color:#111;}\n"
".row{display:flex;gap:16px;align-items:center;flex-wrap:wrap;}\n"
".meta{font-size:12px;color:#333;}\n"
"svg{border:1px solid #eee;background:#fff;max-width:100%;height:auto;}\n"
".ph{stroke:#e11;stroke-width:2;opacity:.9}\n"
".note{stroke:#000;stroke-width:.5;opacity:.75}\n"
"</style>\n</head><body>\n";
out << "<div class=\"row\">\n";
out << "<audio id=\"a\" controls preload=\"metadata\" src=\"" << audioSrc << "\"></audio>\n";
out << "<div class=\"meta\">\n";
out << "<div><b>track</b>: " << ta.trackId << "</div>\n";
out << "<div><b>bgm</b>: " << ta.bgmBase << " (" << vizWhat << ")</div>\n";
out << "<div><b>wav</b>: " << wav.string() << "</div>\n";
out << "<div><b>duration</b>: " << std::fixed << std::setprecision(3) << durSec << " sec</div>\n";
out << "<div><b>noteTimeMode</b>: " << (useIndex ? "index" : "fit") << " (zeroT=" << zeroTs << "/" << notes.size() << ")</div>\n";
out << "</div>\n</div>\n";
out << "<svg id=\"s\" xmlns=\"http://www.w3.org/2000/svg\" width=\"" << W << "\" height=\"" << H
<< "\" viewBox=\"0 0 " << W << " " << H << "\">\n";
out << "<rect x=\"0\" y=\"0\" width=\"" << W << "\" height=\"" << H << "\" fill=\"#ffffff\"/>\n";
out << "<line x1=\"" << M << "\" y1=\"" << (H - M) << "\" x2=\"" << (W - M) << "\" y2=\"" << (H - M)
<< "\" stroke=\"#eeeeee\"/>\n";
out << "<line x1=\"" << M << "\" y1=\"" << M << "\" x2=\"" << M << "\" y2=\"" << (H - M)
<< "\" stroke=\"#eeeeee\"/>\n";
// Track polyline.
out << "<polyline fill=\"none\" stroke=\"#111111\" stroke-width=\"2\" opacity=\"0.55\" points=\"";
const size_t maxPts = 9000;
const size_t step = (track.size() > maxPts) ? (track.size() / maxPts) : 1;
for (size_t i = 0; i < track.size(); i += step) out << mapX(track[i].t) << "," << mapY(track[i].v) << " ";
out << "\"/>\n";
// Playhead.
out << "<line id=\"ph\" class=\"ph\" x1=\"" << M << "\" y1=\"" << M << "\" x2=\"" << M << "\" y2=\"" << (H - M) << "\"/>\n";
// Notes projected on track value at their mapped time.
for (size_t i = 0; i < notes.size(); i++) {
const auto& n = notes[i];
const double sec = noteAudioSec(n, i, notes.size());
const float tt = mapAudioToTrackT(sec);
const float vy = trackValueAt(track, tt);
const float cx = mapX(tt);
const float cy = mapY(vy);
const std::string fill = rgbHexForType(n.type);
out << "<circle class=\"note\" data-t=\"" << std::fixed << std::setprecision(6) << sec
<< "\" cx=\"" << cx << "\" cy=\"" << cy << "\" r=\"4.5\" fill=\"" << fill << "\"/>\n";
}
out << "</svg>\n";
out << "<script>\n"
"const a=document.getElementById('a');\n"
"const ph=document.getElementById('ph');\n"
"const W=" << W << ", H=" << H << ", M=" << M << ";\n"
"function tick(){\n"
" const d=a.duration||" << std::fixed << std::setprecision(6) << durSec << ";\n"
" const t=a.currentTime||0;\n"
" const u=d>0?Math.max(0,Math.min(1,t/d)):0;\n"
" const x=M+u*(W-2*M);\n"
" ph.setAttribute('x1',x); ph.setAttribute('x2',x);\n"
" requestAnimationFrame(tick);\n"
"}\n"
"requestAnimationFrame(tick);\n"
"</script>\n";
out << "</body></html>\n";
std::cout << "Wrote viz HTML: " << vizOutHtml << "\n";
return 0;
}
if (exportJsonMode) {
TrackAssets ta;
std::string eerr;
if (!resolveTrackAssetsFromStageParam(path, exportId, &ta, &eerr)) {
std::cerr << "Export: resolve failed: " << eerr << "\n";
return 1;
}
gc::StageDat dat;
if (!gc::StageDat::LoadFromFile(ta.stageDat.string(), dat, &eerr)) {
std::cerr << "Export: failed to load stage dat: " << ta.stageDat.string() << "\n";
if (!eerr.empty()) std::cerr << eerr << "\n";
return 1;
}
std::vector<TrackPt> track;
std::vector<NoteEv> notes;
if (!buildTrackFromSection16(dat, static_cast<size_t>(trackSection), &track, &eerr)) {
std::cerr << "Export: track decode failed: " << eerr << "\n";
return 1;
}
if (!buildNotesFromSection12(dat, static_cast<size_t>(noteSection), noteAlign, /*keepType0*/false, /*notesOnly*/exportNotesOnly, &notes, &eerr)) {
std::cerr << "Export: note decode failed: " << eerr << "\n";
return 1;
}
// Decide timing mode.
float nMinT = notes.front().t;
float nMaxT = notes.back().t;
const float nRange = (nMaxT > nMinT) ? (nMaxT - nMinT) : 0.0f;
uint64_t zeroTs = 0;
for (const auto& n : notes) if (std::fabs(static_cast<double>(n.t)) < 1.0e-9) zeroTs++;
const double pZero = notes.empty() ? 0.0 : (100.0 * static_cast<double>(zeroTs) / static_cast<double>(notes.size()));
ExportAtMode effective = exportAtMode;
if (effective == ExportAtMode::Seconds) {
// Needs BPM map; not yet implemented.
effective = ExportAtMode::Auto;
}
if (effective == ExportAtMode::Auto) {
if (nRange <= 2.0f || pZero >= 80.0) effective = ExportAtMode::Index;
else effective = ExportAtMode::Bars;
}
const float beatsPerBar = 4.0f; // TODO: detect time signature
const std::vector<uint32_t> denoms = {1, 2, 3, 4, 6, 8, 12, 16, 24, 32, 48, 64};
auto fmtAtAbs = [&](const NoteEv& n, size_t idx, size_t total) -> std::string {
if (effective == ExportAtMode::Index) {
// "bar:frac" using index-space bars
const double u = (total <= 1) ? 0.0 : (static_cast<double>(idx) / static_cast<double>(total - 1));
uint32_t num = 0, den = 1;
// quantize to nice fraction in [0,1]
if (!approxToRational(u, denoms, &num, &den, 5e-4)) { num = static_cast<uint32_t>(std::round(u * 64.0)); den = 64; }
if (num == 0) return "1:1/1";
return "1:" + std::to_string(num) + "/" + std::to_string(den);
}
if (effective == ExportAtMode::Beats) {
return fmtFixed(n.t, 6);
}
// Bars
const double beat = static_cast<double>(n.t);
if (!std::isfinite(beat) || beat < 0.0) return "1:1/1";
const double barF = std::floor(beat / beatsPerBar);
const int bar = static_cast<int>(barF) + 1;
const double inBarBeats = beat - barF * beatsPerBar;
const double frac = inBarBeats / beatsPerBar; // 0..1
uint32_t num = 0, den = 1;
if (!approxToRational(frac, denoms, &num, &den, 5e-4)) {
// fallback: 1/64 grid
num = static_cast<uint32_t>(std::round(frac * 64.0));
den = 64;
}
if (num == 0) return std::to_string(bar) + ":1/1";
return std::to_string(bar) + ":" + std::to_string(num) + "/" + std::to_string(den);
};
auto fmtDelta = [&](double dBars) -> std::string {
// dBars expressed in bars, format "+ N/D"
uint32_t num = 0, den = 1;
if (!approxToRational(dBars, denoms, &num, &den, 5e-4)) {
num = static_cast<uint32_t>(std::round(dBars * 64.0));
den = 64;
}
if (num == 0) { num = 0; den = 1; }
return "+ " + std::to_string(num) + "/" + std::to_string(den);
};
// Pair holds in a simple stack manner.
struct PendingHold {
size_t idx = 0;
uint32_t type = 0;
};
std::vector<PendingHold> holdStack;
std::vector<bool> skip(notes.size(), false);
struct ExportNote {
std::string type;
std::string at;
std::string endAt;
std::string direction;
std::string color;
};
std::vector<ExportNote> outNotes;
outNotes.reserve(notes.size());
// Precompute at-strings (abs) for pairing.
std::vector<std::string> absAt(notes.size());
for (size_t i = 0; i < notes.size(); i++) absAt[i] = fmtAtAbs(notes[i], i, notes.size());
for (size_t i = 0; i < notes.size(); i++) {
const uint32_t t = notes[i].type;
if (t == static_cast<uint32_t>(gc::NoteType::HoldStart) || t == static_cast<uint32_t>(gc::NoteType::DualHold)) {
holdStack.push_back(PendingHold{i, t});
} else if (t == static_cast<uint32_t>(gc::NoteType::HoldEnd)) {
if (!holdStack.empty()) {
PendingHold ph = holdStack.back();
holdStack.pop_back();
// Mark the end event to be skipped; we'll emit a combined hold note.
skip[i] = true;
ExportNote en;
en.type = "hold";
en.at = absAt[ph.idx];
// end_at as relative to start in bars if we can.
if (effective == ExportAtMode::Bars || effective == ExportAtMode::Index) {
double sBars = 0.0, eBars = 0.0;
if (effective == ExportAtMode::Index) {
sBars = (notes.size() <= 1) ? 0.0 : (static_cast<double>(ph.idx) / static_cast<double>(notes.size() - 1));
eBars = (notes.size() <= 1) ? 0.0 : (static_cast<double>(i) / static_cast<double>(notes.size() - 1));
} else {
sBars = static_cast<double>(notes[ph.idx].t) / static_cast<double>(beatsPerBar);
eBars = static_cast<double>(notes[i].t) / static_cast<double>(beatsPerBar);
}
const double d = std::max(0.0, eBars - sBars);
en.endAt = fmtDelta(d);
} else {
en.endAt = absAt[i];
}
en.color = "#ffcc00";
outNotes.push_back(std::move(en));
skip[ph.idx] = true;
}
}
}
// Emit remaining as hits/slides/etc.
for (size_t i = 0; i < notes.size(); i++) {
if (skip[i]) continue;
ExportNote en;
en.type = typeToExportString(notes[i].type);
if (!exportRelative || outNotes.empty()) {
en.at = absAt[i];
} else {
// Delta from previous emitted note in bars.
// We approximate based on the original idx spacing (good enough for a first exporter).
const size_t prevIdx = i ? (i - 1) : 0;
double dBars = 0.0;
if (effective == ExportAtMode::Index) {
const double a = (notes.size() <= 1) ? 0.0 : (static_cast<double>(prevIdx) / static_cast<double>(notes.size() - 1));
const double b = (notes.size() <= 1) ? 0.0 : (static_cast<double>(i) / static_cast<double>(notes.size() - 1));
dBars = std::max(0.0, b - a);
} else if (effective == ExportAtMode::Bars) {
const double a = static_cast<double>(notes[prevIdx].t) / static_cast<double>(beatsPerBar);
const double b = static_cast<double>(notes[i].t) / static_cast<double>(beatsPerBar);
dBars = std::max(0.0, b - a);
} else {
dBars = 0.0;
}
en.at = fmtDelta(dBars);
}
if (en.type == "slide") {
en.direction = directionFromAngleRad(notes[i].value);
}
en.color = rgbHexForType(notes[i].type);
outNotes.push_back(std::move(en));
}
std::ofstream out(exportOutJson, std::ios::binary);
if (!out.is_open()) {
std::cerr << "Export: failed to open for write: " << exportOutJson << "\n";
return 1;
}
out << "{\n";
out << " \"targets\": {\n";
out << " \"notes\": [\n";
for (size_t i = 0; i < outNotes.size(); i++) {
const auto& n = outNotes[i];
out << " {\n";
out << " \"type\": \"" << jsonEscape(n.type) << "\",\n";
out << " \"at\": \"" << jsonEscape(n.at) << "\",\n";
if (!n.endAt.empty()) out << " \"end_at\": \"" << jsonEscape(n.endAt) << "\",\n";
if (!n.direction.empty()) out << " \"direction\": \"" << jsonEscape(n.direction) << "\",\n";
out << " \"color\": \"" << jsonEscape(n.color) << "\"\n";
out << " }" << (i + 1 < outNotes.size() ? "," : "") << "\n";
}
out << " ]\n";
out << " }\n";
out << "}\n";
std::cout << "Wrote JSON: " << exportOutJson << " (notes=" << outNotes.size() << ", atMode="
<< (effective == ExportAtMode::Bars ? "bars" :
effective == ExportAtMode::Beats ? "beats" :
effective == ExportAtMode::Index ? "index" : "auto")
<< ")\n";
return 0;
}
if (exportGcsimMode) {
TrackAssets ta;
std::string eerr;
if (!resolveTrackAssetsFromStageParam(path, gcsimId, &ta, &eerr)) {
std::cerr << "GCSim export: resolve failed: " << eerr << "\n";
return 1;
}
std::filesystem::path wav;
if (gcsimWhat == "bgm") wav = ta.wavBgm;
else wav = ta.wavShot;
if (!std::filesystem::exists(wav)) {
std::cerr << "GCSim export: missing wav: " << wav.string() << "\n";
return 1;
}
gc::StageDat dat;
if (!gc::StageDat::LoadFromFile(ta.stageDat.string(), dat, &eerr)) {
std::cerr << "GCSim export: failed to load stage dat: " << ta.stageDat.string() << "\n";
if (!eerr.empty()) std::cerr << eerr << "\n";
return 1;
}
std::vector<NoteEv> notes;
if (!buildNotesFromSection12(dat, static_cast<size_t>(noteSection), noteAlign, /*keepType0*/false, /*notesOnly*/true, &notes, &eerr)) {
std::cerr << "GCSim export: note decode failed: " << eerr << "\n";
return 1;
}
// Convert to GCSim's notes.jsonc format: { header{time_signature,tempo}, target{notes:[...] } }
// Our current decode isn't fully understood; default to index-based placement to keep ordering stable.
const double durSec = [&]() -> double {
double s = 0.0; std::string werr;
if (!tryReadWavDurationSec(wav, &s, &werr)) return 0.0;
return s;
}();
std::filesystem::path outDir(gcsimOutDir);
std::error_code ec;
std::filesystem::create_directories(outDir, ec);
if (ec) {
std::cerr << "GCSim export: failed to create dir: " << outDir.string() << "\n";
return 1;
}
// Copy wav to music.wav (Unity project-friendly).
const std::filesystem::path outMusic = outDir / "music.wav";
std::filesystem::copy_file(wav, outMusic, std::filesystem::copy_options::overwrite_existing, ec);
if (ec) {
std::cerr << "GCSim export: failed to copy wav to: " << outMusic.string() << "\n";
return 1;
}
const std::filesystem::path outScore = outDir / "score.jsonc";
const std::filesystem::path outNotes = outDir / "notes.jsonc";
// notes.jsonc
{
std::ofstream out(outNotes, std::ios::binary);
if (!out.is_open()) {
std::cerr << "GCSim export: failed to write: " << outNotes.string() << "\n";
return 1;
}
out << "{\n";
out << " \"header\": {\n";
out << " \"time_signature\": [{\"at\": \"1\", \"value\": \"4/4\"}],\n";
out << " \"tempo\": [{\"at\": \"1:1/1\", \"value\": " << gcsimBpm << "}]\n";
out << " },\n";
out << " \"target\": {\n";
out << " \"notes\": [\n";
const std::vector<uint32_t> dens = {1,2,4,8,16,32,64};
auto fmtPosIndex = [&](size_t idx, size_t total) -> std::string {
if (total <= 1) return "1:1/1";
const double u = static_cast<double>(idx) / static_cast<double>(total - 1); // 0..1
// Use 64th grid within bar 1.
uint32_t num = static_cast<uint32_t>(std::round(u * 64.0));
if (num < 1) num = 1;
if (num > 64) num = 64;
return "1:" + std::to_string(num) + "/64";
};
for (size_t i = 0; i < notes.size(); i++) {
const uint32_t raw = notes[i].type;
std::string ty = typeToExportString(raw);
// Normalize to the type names used by GCSimulator (target_format.md)
if (ty == "hit") {}
else if (ty == "critical") {}
else if (ty == "hold") {}
else if (ty == "slide") {}
else if (ty == "slidehold") {}
else if (ty == "dualslide") {}
else if (ty == "dualhold") {}
else if (ty == "hit2") {}
else if (ty == "beat") {}
else if (ty == "scratch") {}
else if (ty == "adlib") {}
else {
// Keep the file loadable by the player: fall back to hit.
ty = "hit";
}
const std::string at = fmtPosIndex(i, notes.size());
out << " {\"type\": \"" << jsonEscape(ty) << "\", \"at\": \"" << jsonEscape(at)
<< "\", \"raw_type\": \"0x" << std::hex << std::setw(8) << std::setfill('0') << raw << std::dec << std::setfill(' ')
<< "\"}";
out << (i + 1 < notes.size() ? "," : "") << "\n";
}
out << " ]\n";
out << " }\n";
out << "}\n";
}
// score.jsonc (minimal)
{
std::ofstream out(outScore, std::ios::binary);
if (!out.is_open()) {
std::cerr << "GCSim export: failed to write: " << outScore.string() << "\n";
return 1;
}
const std::string title = !gcsimTitle.empty() ? gcsimTitle : ta.trackId;
out << "{\n";
out << " \"header\": {\n";
out << " \"version\": \"0.2.10\",\n";
out << " \"time_marker_file_name\": \"notes.jsonc\",\n";
out << " \"stage_start_at\": \"1\",\n";
out << " \"stage_end_at\": 999,\n";
out << " \"difficulty_class\": 2,\n";
out << " \"title\": \"" << jsonEscape(title) << "\",\n";
out << " \"music\": {\"path\": \"music.wav\", \"volume\": 0.8, \"play_start_delay\": 0.0},\n";
out << " \"high_speed\": 1.0,\n";
out << " \"default_skin_type\": \"brightness\"\n";
out << " },\n";
out << " \"target\": {\n";
out << " \"common_settings\": {\"appear_at\": \"-10s\"},\n";
out << " \"type_settings\": [\n";
out << " {\"type\":\"hit\", \"color\":\"rgbf(1,1,1)\"},\n";
out << " {\"type\":\"critical\", \"color\":\"rgbf(0,0.5,1)\"},\n";
out << " {\"type\":\"hold\", \"color\":\"rgbf(1,1,0)\"},\n";
out << " {\"type\":\"dualhold\", \"color\":\"rgbf(1,0.5,0)\"},\n";
out << " {\"type\":\"slide\", \"color\":\"rgbf(1,0,1)\"},\n";
out << " {\"type\":\"dualslide\", \"color\":\"rgbf(1,0,1)\"},\n";
out << " {\"type\":\"slidehold\", \"color\":\"rgbf(1,0,1)\"}\n";
out << " ]\n";
out << " }\n";
out << "}\n";
(void)durSec;
}
std::cout << "Wrote GCSimulator pack: " << outDir.string() << "\n";
std::cout << " " << outScore.string() << "\n";
std::cout << " " << outNotes.string() << "\n";
std::cout << " " << outMusic.string() << "\n";
return 0;
}
if (exportGcsimProjectMode) {
// argv[1] is the stage_dat_path in this mode.
const std::filesystem::path stageDatPath = std::filesystem::path(path);
if (!std::filesystem::exists(stageDatPath)) {
std::cerr << "GCSim project: missing stage dat: " << stageDatPath.string() << "\n";
return 1;
}
// Try to infer ac-id from filename if not provided.
std::string acId = gcsimProjectAcId;
if (acId.empty()) {
acId = stageDatPath.stem().string(); // ac_foo_hard
}
// Resolve music.
std::filesystem::path wav;
if (!gcsimProjectMusic.empty()) {
wav = std::filesystem::path(gcsimProjectMusic);
} else {
std::filesystem::path sp;
if (!gcsimProjectStageParam.empty()) {
sp = std::filesystem::path(gcsimProjectStageParam);
} else {
// Guess stage_param location from <GC>/data/stage/*.dat
// .../data/stage/<ac>.dat -> .../data/boot/stage_param.dat
std::filesystem::path p = stageDatPath;
for (int i = 0; i < 8; i++) {
if (p.filename() == "stage" && p.parent_path().filename() == "data") {
sp = p.parent_path() / "boot" / "stage_param.dat";
break;
}
if (!p.has_parent_path()) break;
p = p.parent_path();
}
}
if (!sp.empty() && std::filesystem::exists(sp)) {
TrackAssets ta;
std::string perr;
if (resolveTrackAssetsFromStageParam(sp.string(), acId, &ta, &perr)) {
if (gcsimWhat == "bgm") wav = ta.wavBgm;
else wav = ta.wavShot;
} else {
std::cerr << "GCSim project: stage_param resolve failed: " << perr << "\n";
}
}
}
if (wav.empty() || !std::filesystem::exists(wav)) {
std::cerr << "GCSim project: music wav not resolved. Provide --music WAV or --stage-param PATH.\n";
return 1;
}
// Load stage dat and decode notes.
gc::StageDat dat;
std::string derr;
if (!gc::StageDat::LoadFromFile(stageDatPath.string(), dat, &derr)) {
std::cerr << "GCSim project: failed to load stage dat: " << stageDatPath.string() << "\n";
if (!derr.empty()) std::cerr << derr << "\n";
return 1;
}
std::vector<NoteEv> notes;
if (!buildNotesFromSection12(dat, static_cast<size_t>(noteSection), noteAlign, /*keepType0*/false, /*notesOnly*/true, &notes, &derr)) {
std::cerr << "GCSim project: note decode failed: " << derr << "\n";
return 1;
}
std::filesystem::path outDir(gcsimProjectOutDir);
std::error_code ec;
std::filesystem::create_directories(outDir, ec);
if (ec) {
std::cerr << "GCSim project: failed to create dir: " << outDir.string() << "\n";
return 1;
}
const std::filesystem::path outMusic = outDir / "music.wav";
std::filesystem::copy_file(wav, outMusic, std::filesystem::copy_options::overwrite_existing, ec);
if (ec) {
std::cerr << "GCSim project: failed to copy wav to: " << outMusic.string() << "\n";
return 1;
}
const std::filesystem::path outScore = outDir / "score.jsonc";
const std::filesystem::path outNotes = outDir / "notes.jsonc";
auto normalizeGcsimType = [&](const std::string& t) -> std::string {
static const std::unordered_set<std::string> ok = {
"hit","hit2","critical","hold","slide","scratch","beat","dualhold","slidehold","dualslide","adlib"
};
if (ok.count(t)) return t;
return "hit";
};
// notes.jsonc (index-based placement for now)
{
std::ofstream out(outNotes, std::ios::binary);
if (!out.is_open()) {
std::cerr << "GCSim project: failed to write: " << outNotes.string() << "\n";
return 1;
}
out << "{\n";
out << " \"header\": {\n";
out << " \"time_signature\": [{\"at\": \"1\", \"value\": \"4/4\"}],\n";
out << " \"tempo\": [{\"at\": \"1:1/1\", \"value\": " << gcsimBpm << "}]\n";
out << " },\n";
// Provide both keys: wiki uses \"targets\", shipped examples use \"target\".
out << " \"targets\": {\n";
out << " \"notes\": [\n";
auto fmtPosIndex = [&](size_t idx, size_t total) -> std::string {
if (total <= 1) return "1:1/1";
const double u = static_cast<double>(idx) / static_cast<double>(total - 1); // 0..1
uint32_t num = static_cast<uint32_t>(std::round(u * 64.0));
if (num < 1) num = 1;
if (num > 64) num = 64;
return "1:" + std::to_string(num) + "/64";
};
for (size_t i = 0; i < notes.size(); i++) {
const uint32_t raw = notes[i].type;
std::string ty = normalizeGcsimType(typeToExportString(raw));
const std::string at = fmtPosIndex(i, notes.size());
out << " {\"type\": \"" << jsonEscape(ty) << "\", \"at\": \"" << jsonEscape(at)
<< "\", \"raw_type\": \"0x" << std::hex << std::setw(8) << std::setfill('0') << raw << std::dec << std::setfill(' ')
<< "\"}";
out << (i + 1 < notes.size() ? "," : "") << "\n";
}
out << " ]\n";
out << " },\n";
out << " \"target\": {\n";
out << " \"notes\": [\n";
for (size_t i = 0; i < notes.size(); i++) {
const uint32_t raw = notes[i].type;
std::string ty = normalizeGcsimType(typeToExportString(raw));
const std::string at = fmtPosIndex(i, notes.size());
out << " {\"type\": \"" << jsonEscape(ty) << "\", \"at\": \"" << jsonEscape(at)
<< "\", \"raw_type\": \"0x" << std::hex << std::setw(8) << std::setfill('0') << raw << std::dec << std::setfill(' ')
<< "\"}";
out << (i + 1 < notes.size() ? "," : "") << "\n";
}
out << " ]\n";
out << " }\n";
out << "}\n";
}
// score.jsonc (minimal)
{
std::ofstream out(outScore, std::ios::binary);
if (!out.is_open()) {
std::cerr << "GCSim project: failed to write: " << outScore.string() << "\n";
return 1;
}
const std::string title = !gcsimTitle.empty() ? gcsimTitle : acId;
out << "{\n";
out << " \"header\": {\n";
out << " \"version\": \"0.2.10\",\n";
out << " \"time_marker_file_name\": \"notes.jsonc\",\n";
out << " \"stage_start_at\": \"1\",\n";
out << " \"stage_end_at\": 999,\n";
out << " \"difficulty_class\": 2,\n";
out << " \"title\": \"" << jsonEscape(title) << "\",\n";
out << " \"music\": {\"path\": \"music.wav\", \"volume\": 0.8, \"play_start_delay\": 0.0},\n";
out << " \"high_speed\": 1.0,\n";
out << " \"default_skin_type\": \"brightness\"\n";
out << " },\n";
out << " \"target\": {\n";
out << " \"common_settings\": {\"appear_at\": \"-10s\"},\n";
out << " \"type_settings\": [\n";
out << " {\"type\":\"hit\", \"color\":\"rgbf(1,1,1)\"},\n";
out << " {\"type\":\"hit2\", \"color\":\"rgbf(0,1,1)\"},\n";
out << " {\"type\":\"critical\", \"color\":\"rgbf(0,0.5,1)\"},\n";
out << " {\"type\":\"hold\", \"color\":\"rgbf(1,1,0)\"},\n";
out << " {\"type\":\"dualhold\", \"color\":\"rgbf(1,0.5,0)\"},\n";
out << " {\"type\":\"slide\", \"color\":\"rgbf(1,0,1)\"},\n";
out << " {\"type\":\"dualslide\", \"color\":\"rgbf(1,0,1)\"},\n";
out << " {\"type\":\"slidehold\", \"color\":\"rgbf(1,0,1)\"},\n";
out << " {\"type\":\"beat\", \"color\":\"rgbf(0,1,1)\"}\n";
out << " ]\n";
out << " }\n";
out << "}\n";
}
std::cout << "Wrote GCSimulator project: " << outDir.string() << "\n";
std::cout << " " << outScore.string() << "\n";
std::cout << " " << outNotes.string() << "\n";
std::cout << " " << outMusic.string() << "\n";
return 0;
}
if (trackInfoMode) {
TrackAssets assets;
std::string error;
if (!resolveTrackAssetsFromStageParam(path, trackInfoId, &assets, &error)) {
std::cerr << "Track info: " << error << "\n";
return 1;
}
auto show = [&](const std::filesystem::path& assetPath) {
std::cout << " " << assetPath.string()
<< (std::filesystem::exists(assetPath) ? "" : " (missing)") << "\n";
};
std::cout << "stage_param: " << path << "\n";
std::cout << "catalog records: exact u16-count / variable-record decode\n";
std::cout << "track id: " << assets.trackId << "\n";
std::cout << "title: " << assets.title << "\n";
std::cout << "artist: " << assets.artist << "\n";
std::cout << "image key: " << assets.imageKey << "\n";
std::cout << "duration: " << assets.duration << "\n";
std::cout << "bpm: " << assets.bpm << "\n";
std::cout << "difficulty ratings: ";
for (size_t i = 0; i < assets.difficultyRatings.size(); ++i) {
if (i) std::cout << ", ";
std::cout << static_cast<unsigned>(assets.difficultyRatings[i]);
}
std::cout << "\nchart ids:\n";
for (const std::string& chart : assets.chartIds) {
if (!chart.empty()) std::cout << " " << chart << "\n";
}
std::cout << "bgm base: " << assets.bgmBase << "\n";
std::cout << "\nDerived asset paths:\n";
show(assets.stageDat);
show(assets.stageExt);
show(assets.stageClip);
show(assets.wavBgm);
show(assets.wavShot);
show(assets.vibCsv);
show(assets.menuDds);
show(assets.menuDdsEnglish);
show(assets.startDds);
show(assets.startDdsEnglish);
return 0;
}
gc::StageDat dat;
std::string err;
if (rawMode) {
if (!readWholeFile(path, &dat.bytes, &err)) {
std::cerr << "Failed to load: " << path << "\n";
if (!err.empty()) std::cerr << err << "\n";
return 1;
}
dat.headerSize = 0;
dat.headerWords.clear();
dat.offsets.clear();
dat.sections.clear();
dat.offsets.push_back(0);
dat.offsets.push_back(dat.bytes.size());
dat.sections.push_back(gc::Section{0, dat.bytes.size()});
} else {
if (!gc::StageDat::LoadFromFile(path, dat, &err)) {
std::cerr << "Failed to load: " << path << "\n";
if (!err.empty()) std::cerr << err << "\n";
return 1;
}
}
std::cout << "File: " << path << "\n";
std::cout << "Size: " << dat.bytes.size() << " bytes\n";
std::cout << "Header size: " << dat.headerSize << " bytes (" << (dat.headerSize / 4) << " u32)\n";
std::cout << "Sections: " << dat.sections.size() << "\n";
if (metaMode) {
const int secIdx = metaSection;
if (secIdx < 0 || static_cast<size_t>(secIdx) >= dat.sections.size()) {
std::cerr << "--meta-section out of range (file has " << dat.sections.size() << " sections)\n";
return 2;
}
const auto& sec = dat.sections[static_cast<size_t>(secIdx)];
std::cout << "\n=== META STRING SCAN (u16be length + ASCII) section #" << secIdx << " ===\n";
std::unordered_set<std::string> seen;
uint64_t found = 0;
for (size_t off = sec.start; off + 2 <= sec.end && off + 2 <= dat.bytes.size(); off++) {
auto tryEmit = [&](size_t at, size_t lenBytes, uint32_t len) {
if (len == 0 || len > 128) return;
const size_t s0 = at + lenBytes;
const size_t s1 = s0 + static_cast<size_t>(len);
if (s1 > sec.end || s1 > dat.bytes.size()) return;
std::string s(reinterpret_cast<const char*>(&dat.bytes[s0]), static_cast<size_t>(len));
if (!isSaneAsciiToken(s)) return;
// Most of these are NUL-terminated, but some have extra suffix chars; allow either.
const bool nulTerm = (s1 < dat.bytes.size() && dat.bytes[s1] == 0x00);
const bool suffixOkay = (s1 + 2 < dat.bytes.size() && dat.bytes[s1 + 2] == 0x00);
if (!nulTerm && !suffixOkay) return;
if (!seen.insert(s).second) return;
std::cout << "0x" << std::hex << at << std::dec << " len=" << len << " " << s << "\n";
found++;
};
// Pattern A: u16be length.
const uint16_t len16 = static_cast<uint16_t>((static_cast<uint16_t>(dat.bytes[off]) << 8) | dat.bytes[off + 1]);
tryEmit(off, 2, len16);
// Pattern B: 0x00 + u8 length (common in these stage headers).
if (dat.bytes[off] == 0x00) {
const uint8_t len8 = dat.bytes[off + 1];
tryEmit(off, 2, len8);
}
}
if (found == 0) std::cout << "(no u16-length ASCII strings found)\n";
return 0;
}
if (surveyMode) {
std::cout << "\n=== SECTION SURVEY ===\n";
std::cout << "idx start end len zero% ent vec3Hits best12(score/align) best16(score/align) strings\n";
std::cout << "---------------------------------------------------------------------------------------------------------------\n";
for (size_t i = 0; i < dat.sections.size(); i++) {
const auto& sec = dat.sections[i];
const size_t len = (sec.end > sec.start) ? (sec.end - sec.start) : 0;
uint64_t zeros = 0;
for (size_t off = sec.start; off < sec.end && off < dat.bytes.size(); off++) {
if (dat.bytes[off] == 0) zeros++;
}
const double pZero = len ? (100.0 * static_cast<double>(zeros) / static_cast<double>(len)) : 0.0;
const double ent = shannonEntropyBytes(dat.bytes, sec.start, sec.end);
const uint64_t vec3 = countVec3TriplesF32BE(dat.bytes, sec.start, sec.end);
const auto r12 = gc::TryDecodeEventStreamFixed(dat.bytes, sec.start, sec.end, 12);
const auto r16 = gc::TryDecodeEventStreamFixed(dat.bytes, sec.start, sec.end, 16);
auto hits = scanAsciiStrings(dat.bytes, sec.start, sec.end, 4, 3, false);
std::ostringstream ss;
for (size_t k = 0; k < hits.size(); k++) {
if (k) ss << " | ";
ss << hits[k].s;
}
std::cout
<< std::setw(3) << i
<< " 0x" << std::hex << std::setw(8) << std::setfill('0') << sec.start << std::dec << std::setfill(' ')
<< " 0x" << std::hex << std::setw(8) << std::setfill('0') << sec.end << std::dec << std::setfill(' ')
<< " " << std::setw(7) << len
<< " " << std::setw(5) << std::fixed << std::setprecision(1) << pZero
<< " " << std::setw(3) << std::fixed << std::setprecision(1) << ent
<< " " << std::setw(8) << vec3
<< " " << std::setw(6) << r12.score << "/" << std::setw(2) << r12.alignment
<< " " << std::setw(6) << r16.score << "/" << std::setw(2) << r16.alignment
<< " " << ss.str()
<< "\n";
}
return 0;
}
if (stringsMode) {
if (stringsSection >= 0) {
const size_t si = static_cast<size_t>(stringsSection);
if (si >= dat.sections.size()) {
std::cerr << "Invalid --strings-section " << stringsSection << " (max " << (dat.sections.size() - 1) << ")\n";
return 2;
}
const auto& sec = dat.sections[si];
auto hits = scanAsciiStrings(dat.bytes, sec.start, sec.end, static_cast<size_t>(stringsMinLen), static_cast<size_t>(stringsMax), stringsAll);
std::cout << "\n=== ASCII STRINGS (section #" << si << ", minLen=" << stringsMinLen << ", max=" << stringsMax
<< ", filter=" << (stringsAll ? "off" : "interesting") << ") ===\n";
for (const auto& h : hits) {
std::cout << "0x" << std::hex << h.off << std::dec << " " << h.s << "\n";
}
} else {
std::cout << "\n=== ASCII STRINGS (all sections, minLen=" << stringsMinLen << ", max/section=" << stringsMax
<< ", filter=" << (stringsAll ? "off" : "interesting") << ") ===\n";
for (size_t i = 0; i < dat.sections.size(); i++) {
const auto& sec = dat.sections[i];
auto hits = scanAsciiStrings(dat.bytes, sec.start, sec.end, static_cast<size_t>(stringsMinLen), static_cast<size_t>(stringsMax), stringsAll);
if (hits.empty()) continue;
std::cout << "\n-- section #" << i << " [0x" << std::hex << sec.start << "..0x" << sec.end << std::dec << "] --\n";
for (const auto& h : hits) {
std::cout << "0x" << std::hex << h.off << std::dec << " " << h.s << "\n";
}
}
}
return 0;
}
if (!renderOut.empty()) {
std::vector<TrackPt> track;
std::vector<NoteEv> notes;
std::string rerr;
if (trackSection < 0) trackSection = 0;
if (noteSection < 0) noteSection = 0;
if (!buildTrackFromSection16(dat, static_cast<size_t>(trackSection), &track, &rerr)) {
std::cerr << "Render: track decode failed: " << rerr << "\n";
return 1;
}
if (!buildNotesFromSection12(dat, static_cast<size_t>(noteSection), noteAlign, renderKeepType0, renderNotesOnly, &notes, &rerr)) {
std::cerr << "Render: note decode failed: " << rerr << "\n";
return 1;
}
float tMin = track.front().t;
float tMax = track.back().t;
float vMin = std::numeric_limits<float>::infinity();
float vMax = -std::numeric_limits<float>::infinity();
for (const auto& p : track) {
vMin = std::min(vMin, p.v);
vMax = std::max(vMax, p.v);
}
const float tRange = (tMax > tMin) ? (tMax - tMin) : 1.0f;
const float vRange = (vMax > vMin) ? (vMax - vMin) : 1.0f;
float nMinT = notes.front().t;
float nMaxT = notes.back().t;
const float nRange = (nMaxT > nMinT) ? (nMaxT - nMinT) : 0.0f;
uint64_t zeroTs = 0;
for (const auto& n : notes) {
if (std::fabs(static_cast<double>(n.t)) < 1.0e-9) zeroTs++;
}
RenderTimeMode effectiveMode = renderTimeMode;
if (effectiveMode == RenderTimeMode::Auto) {
// If note timestamps are mostly 0 or cover almost no range, fall back to index-based layout.
const double pZero = notes.empty() ? 0.0 : (100.0 * static_cast<double>(zeroTs) / static_cast<double>(notes.size()));
if (nRange <= 2.0f || pZero >= 80.0) effectiveMode = RenderTimeMode::Index;
else effectiveMode = RenderTimeMode::Fit;
}
auto mapNoteToTrackTime = [&](const NoteEv& n, size_t idx, size_t total) -> float {
if (effectiveMode == RenderTimeMode::Time) return n.t;
if (effectiveMode == RenderTimeMode::Fit) {
if (nRange <= 1.0e-6f) return n.t;
const float u = clamp01((n.t - nMinT) / nRange);
return tMin + u * tRange;
}
// Index: spread events uniformly across the track time range.
if (total <= 1) return tMin;
const float u = static_cast<float>(static_cast<double>(idx) / static_cast<double>(total - 1));
return tMin + clamp01(u) * tRange;
};
const int W = 1600;
const int H = 800;
const int M = 60;
auto mapX = [&](float t) -> float {
const float u = (t - tMin) / tRange;
return static_cast<float>(M) + clamp01(u) * static_cast<float>(W - 2 * M);
};
auto mapY = [&](float v) -> float {
const float u = (v - vMin) / vRange;
return static_cast<float>(H - M) - clamp01(u) * static_cast<float>(H - 2 * M);
};
std::ofstream out(renderOut, std::ios::binary);
if (!out.is_open()) {
std::cerr << "Render: failed to open for write: " << renderOut << "\n";
return 1;
}
out << "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n";
out << "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"" << W << "\" height=\"" << H
<< "\" viewBox=\"0 0 " << W << " " << H << "\">\n";
out << "<rect x=\"0\" y=\"0\" width=\"" << W << "\" height=\"" << H << "\" fill=\"#ffffff\"/>\n";
out << "<line x1=\"" << M << "\" y1=\"" << (H - M) << "\" x2=\"" << (W - M) << "\" y2=\"" << (H - M)
<< "\" stroke=\"#eeeeee\"/>\n";
out << "<line x1=\"" << M << "\" y1=\"" << M << "\" x2=\"" << M << "\" y2=\"" << (H - M)
<< "\" stroke=\"#eeeeee\"/>\n";
// Track polyline.
out << "<polyline fill=\"none\" stroke=\"#111111\" stroke-width=\"2\" opacity=\"0.55\" points=\"";
const size_t maxPts = 8000;
const size_t step = (track.size() > maxPts) ? (track.size() / maxPts) : 1;
for (size_t i = 0; i < track.size(); i += step) {
out << mapX(track[i].t) << "," << mapY(track[i].v) << " ";
}
out << "\"/>\n";
// Notes projected onto the track value at their time.
if (renderMaxNotes > 0 && static_cast<int>(notes.size()) > renderMaxNotes) {
// Downsample deterministically by stride.
const size_t stride = static_cast<size_t>(notes.size()) / static_cast<size_t>(renderMaxNotes);
std::vector<NoteEv> ds;
ds.reserve(static_cast<size_t>(renderMaxNotes));
for (size_t i = 0; i < notes.size(); i += std::max<size_t>(1, stride)) ds.push_back(notes[i]);
notes.swap(ds);
}
for (size_t i = 0; i < notes.size(); i++) {
const auto& n = notes[i];
const float tt = mapNoteToTrackTime(n, i, notes.size());
const float vy = trackValueAt(track, tt);
const float cx = mapX(tt);
const float cy = mapY(vy);
const std::string fill = renderNotesOnly ? colorForNote(n.type) : rgbHexForType(n.type);
out << "<circle cx=\"" << cx << "\" cy=\"" << cy << "\" r=\"5\" fill=\"" << fill
<< "\" stroke=\"#000000\" stroke-width=\"0.5\"/>\n";
}
out << "<text x=\"" << M << "\" y=\"" << (M - 18)
<< "\" font-family=\"monospace\" font-size=\"12\" fill=\"#333333\">";
out << "track: sec#" << trackSection << " (t=" << std::fixed << std::setprecision(3) << tMin << ".." << tMax
<< ", v=" << vMin << ".." << vMax << "), events: sec#" << noteSection << " count=" << notes.size()
<< " noteT=[" << nMinT << ".." << nMaxT << "]"
<< " timeMode="
<< (effectiveMode == RenderTimeMode::Time ? "time" :
effectiveMode == RenderTimeMode::Fit ? "fit" : "index")
<< " zeroT=" << zeroTs;
out << "</text>\n";
out << "</svg>\n";
std::cout << "\nWrote render SVG: " << renderOut << " (notes=" << notes.size() << ")\n";
return 0;
}
if (findAlignMode) {
const int secIdx = (findAlignSection >= 0) ? findAlignSection : ((dat.sections.size() > 4) ? 4 : 0);
if (secIdx < 0 || static_cast<size_t>(secIdx) >= dat.sections.size()) {
std::cerr << "--find-align section out of range (file has " << dat.sections.size() << " sections)\n";
return 2;
}
const auto& sec = dat.sections[static_cast<size_t>(secIdx)];
const size_t rs = (findAlignRs == "16") ? 16 : 12;
std::cout << "\nBrute-forcing alignment for section #" << secIdx << " (rs=" << rs << ")...\n";
std::cout << "Scanning offsets: [" << sec.start << ".." << sec.end << ")\n";
if (rs == 12) std::cout << "Heuristics (rs=12): count (type&0xffff)==0x29 and type==0 with value==1.0\n";
else std::cout << "Heuristics (rs=16): count type==0x29 and type==0 with value==1.0\n";
std::cout << "--------------------------------------------------------------------------------\n";
for (int shift = 0; shift < static_cast<int>(rs); shift++) {
uint64_t taps = 0;
uint64_t bpmMarkers = 0;
uint64_t finiteTriples = 0;
uint64_t plausibleTimes = 0;
uint64_t type16 = 0;
uint64_t total = 0;
const size_t start = sec.start + static_cast<size_t>(shift);
const size_t end = sec.end;
const size_t span = (end > start) ? (end - start) - ((end - start) % rs) : 0;
for (size_t rel = 0; rel + rs <= span; rel += rs) {
const size_t off = start + rel;
if (off + rs > dat.bytes.size()) break;
float ts = 0.0f;
uint32_t type = 0;
float val = 0.0f;
if (rs == 12) {
ts = f32be_bytes(dat.bytes, off + 0);
type = u32be_bytes(dat.bytes, off + 4);
val = f32be_bytes(dat.bytes, off + 8);
} else {
ts = f32be_bytes(dat.bytes, off + 2);
type = static_cast<uint32_t>((static_cast<uint32_t>(dat.bytes[off + 8]) << 8) | dat.bytes[off + 9]);
val = f32be_bytes(dat.bytes, off + 10);
}
total++;
if ((type & 0xFFFFu) == 0x29u) taps++;
if (type == 0x0u && isFiniteF(val) && std::fabs(static_cast<double>(val - 1.0f)) < 1.0e-6) bpmMarkers++;
if (isFiniteF(ts) && isFiniteF(val)) finiteTriples++;
if (isFiniteF(ts) && ts >= -1.0f && ts <= 1.0e6f) plausibleTimes++;
if (type <= 0xFFFFu) type16++;
}
const double pFinite = total ? (100.0 * static_cast<double>(finiteTriples) / static_cast<double>(total)) : 0.0;
const double pType16 = total ? (100.0 * static_cast<double>(type16) / static_cast<double>(total)) : 0.0;
const uint64_t score = taps * 1000 + bpmMarkers * 10 + plausibleTimes;
std::cout
<< "Shift +" << shift
<< ": score=" << score
<< " taps=" << taps
<< " bpm=" << bpmMarkers
<< " total=" << total
<< " finite=" << std::fixed << std::setprecision(1) << pFinite << "%"
<< " type<=0xffff=" << std::fixed << std::setprecision(1) << pType16 << "%"
<< " plausibleTime=" << plausibleTimes
<< "\n";
}
return 0;
}
if (statsMode) {
const int secIdx = (statsSection >= 0) ? statsSection : 4;
if (secIdx < 0 || static_cast<size_t>(secIdx) >= dat.sections.size()) {
std::cerr << "--stats section out of range (file has " << dat.sections.size() << " sections)\n";
return 2;
}
const auto& sec = dat.sections[static_cast<size_t>(secIdx)];
struct Stat {
uint64_t count = 0;
float minTs = std::numeric_limits<float>::infinity();
float maxTs = -std::numeric_limits<float>::infinity();
float minVal = std::numeric_limits<float>::infinity();
float maxVal = -std::numeric_limits<float>::infinity();
std::set<float> examples;
};
const size_t rs = (statsRs == "16") ? 16 : (statsRs == "12" ? 12 : 0);
gc::EventStreamDecodeResult autod;
if (rs == 0) {
autod = gc::TryDecodeEventStream(dat.bytes, sec.start, sec.end);
} else {
autod = gc::TryDecodeEventStreamFixed(dat.bytes, sec.start, sec.end, rs);
}
const size_t useRs = (rs == 0) ? autod.recordSize : rs;
const int align = (forcedAlign >= 0) ? forcedAlign : autod.alignment;
const size_t start = sec.start + static_cast<size_t>(align);
const size_t end = sec.end;
uint32_t tMax = typeMax;
if (filterGarbage && useRs == 12 && tMax == 0xFFFFFFFFu) tMax = 0xFFFFu;
std::map<uint32_t, Stat> stats;
uint64_t totalKept = 0;
const size_t span = (end > start) ? (end - start) - ((end - start) % useRs) : 0;
for (size_t rel = 0; rel + useRs <= span; rel += useRs) {
const size_t off = start + rel;
if (off + useRs > dat.bytes.size()) break;
float ts = 0.0f;
uint32_t type = 0;
float val = 0.0f;
if (useRs == 12) {
ts = f32be_bytes(dat.bytes, off + 0);
type = u32be_bytes(dat.bytes, off + 4);
val = f32be_bytes(dat.bytes, off + 8);
} else { // 16
ts = f32be_bytes(dat.bytes, off + 2);
type = static_cast<uint32_t>((static_cast<uint32_t>(dat.bytes[off + 8]) << 8) | dat.bytes[off + 9]);
val = f32be_bytes(dat.bytes, off + 10);
}
if (filterGarbage) {
if (!isFiniteF(ts) || ts < -1.0f || ts > 1.0e6f) continue;
if (type == 0xFFFFFFFFu || type > tMax) continue;
if (!isFiniteF(val) || std::fabs(static_cast<double>(val)) > 1.0e7) continue;
} else {
if (type == 0xFFFFFFFFu) continue;
}
if (statsSkipZero && type == 0 && ts == 0.0f && val == 0.0f) continue;
Stat& s = stats[type];
s.count++;
totalKept++;
s.minTs = std::min(s.minTs, ts);
s.maxTs = std::max(s.maxTs, ts);
s.minVal = std::min(s.minVal, val);
s.maxVal = std::max(s.maxVal, val);
if (s.examples.size() < 10) {
const float q = static_cast<float>(std::round(static_cast<double>(val) * 1.0e6) / 1.0e6);
s.examples.insert(q);
}
}
std::vector<std::pair<uint32_t, Stat>> rows(stats.begin(), stats.end());
std::sort(rows.begin(), rows.end(), [](const auto& a, const auto& b) {
return a.second.count > b.second.count;
});
std::cout << "\n=== STATISTICS FOR SECTION " << secIdx << " (rs=" << useRs << ") ===\n";
if (useRs == 12) std::cout << "Layout: [f32 time][u32 type][f32 value]\n";
else std::cout << "Layout: [u16 id][f32 time][u16 a][u16 type][f32 value][u16 b]\n";
std::cout << "Using start=" << start << " (align=" << align << "), filter=" << (filterGarbage ? "on" : "off")
<< ", typeMax=0x" << std::hex << std::setw(8) << std::setfill('0') << tMax << std::dec
<< std::setfill(' ') << "\n";
std::cout << "Events kept: " << totalKept << ", unique types: " << stats.size()
<< ", skipZeroTriple=" << (statsSkipZero ? "on" : "off") << "\n";
std::cout << "\nType (Hex) Count Time[min..max] Value[min..max] Example values\n";
std::cout << "------------------------------------------------------------------------------------\n";
const size_t maxRows = statsAll ? rows.size() : std::min<size_t>(rows.size(), static_cast<size_t>(statsTop));
for (size_t i = 0; i < maxRows; i++) {
const auto& kv = rows[i];
const uint32_t type = kv.first;
const Stat& s = kv.second;
std::cout << "0x" << std::hex << std::setw(8) << std::setfill('0') << type << std::dec << std::setfill(' ')
<< " " << std::setw(7) << s.count
<< " " << std::setw(10) << std::fixed << std::setprecision(3) << s.minTs
<< ".." << std::setw(10) << std::fixed << std::setprecision(3) << s.maxTs
<< " " << std::setw(10) << std::fixed << std::setprecision(3) << s.minVal
<< ".." << std::setw(10) << std::fixed << std::setprecision(3) << s.maxVal
<< " ";
int shown = 0;
for (float v : s.examples) {
std::cout << v;
shown++;
if (shown >= 3) break;
std::cout << ", ";
}
if (s.examples.size() > 3) std::cout << ", ...";
std::cout << "\n";
}
return 0;
}
// Score sections as potential event streams.
struct Row {
size_t sectionIndex;
gc::EventStreamDecodeResult res;
};
std::vector<Row> rows;
rows.reserve(dat.sections.size());
const size_t forcedRs = (dumpRs == "12") ? 12 : (dumpRs == "16" ? 16 : 0);
for (size_t i = 0; i < dat.sections.size(); i++) {
const auto& sec = dat.sections[i];
auto r = (forcedRs ? gc::TryDecodeEventStreamFixed(dat.bytes, sec.start, sec.end, forcedRs)
: gc::TryDecodeEventStream(dat.bytes, sec.start, sec.end));
rows.push_back(Row{i, r});
}
std::cout << "\nTop candidate sections (higher score is better):\n";
// Partial sort: n is small anyway.
std::sort(rows.begin(), rows.end(), [](const Row& a, const Row& b) {
return a.res.score > b.res.score;
});
const size_t showTop = std::min<size_t>(10, rows.size());
for (size_t i = 0; i < showTop; i++) {
const auto& row = rows[i];
const auto& sec = dat.sections[row.sectionIndex];
std::cout
<< " #" << row.sectionIndex
<< " off=" << sec.start
<< " len=" << (sec.end - sec.start)
<< " rs=" << row.res.recordSize
<< " align=" << row.res.alignment
<< " events=" << row.res.eventCount
<< " padZero=" << std::fixed << std::setprecision(2) << (row.res.padZeroRatio * 100.0) << "%"
<< " score=" << row.res.score
<< "\n";
}
if ((dumpN > 0 || !svgOut.empty()) && !rows.empty()) {
size_t chosenIdx = 0;
if (dumpSection >= 0) {
const size_t si = static_cast<size_t>(dumpSection);
if (si >= dat.sections.size()) {
std::cerr << "Invalid --section " << dumpSection << " (max " << (dat.sections.size() - 1) << ")\n";
return 2;
}
// Find the row for this section index (rows is sorted by score, so scan).
for (size_t i = 0; i < rows.size(); i++) {
if (rows[i].sectionIndex == si) {
chosenIdx = i;
break;
}
}
}
const auto& chosen = rows[chosenIdx];
const auto& sec = dat.sections[chosen.sectionIndex];
gc::EventStreamDecodeResult chosenRes = chosen.res;
// User request / known quirk: section #4 often isn't a 16-byte stream.
// Force 12-byte decoding for that section and re-find best alignment for that size.
if (forcedRs == 0 && chosen.sectionIndex == 4) {
chosenRes = gc::TryDecodeEventStreamFixed(dat.bytes, sec.start, sec.end, 12);
}
// Reasonable default for opcode space in 12-byte streams: keep only 16-bit types.
if (filterGarbage && chosenRes.recordSize == 12 && typeMax == 0xFFFFFFFFu) typeMax = 0xFFFFu;
const int align = (forcedAlign >= 0) ? forcedAlign : chosenRes.alignment;
const size_t streamStart = sec.start + static_cast<size_t>(align);
const size_t streamEnd = sec.end;
std::vector<gc::GameEvent> events;
if (!gc::DecodeEventStream(dat.bytes, streamStart, streamEnd, chosenRes.recordSize, &events, &err)) {
std::cerr << "Decode failed: " << err << "\n";
return 1;
}
if (dumpN > 0) {
std::cout << "\nDump first " << dumpN << " events from section #" << chosen.sectionIndex
<< " (start=" << streamStart << ", score=" << chosenRes.score << ", rs=" << chosenRes.recordSize
<< ", align=" << align << "):\n";
int shown = 0;
for (size_t i = 0; i < events.size() && shown < dumpN; i++) {
const auto& e = events[i];
if (filterGarbage && chosenRes.recordSize == 12) {
if (isGarbage12(e)) continue;
if (e.type > typeMax) continue;
}
std::cout << " [" << shown << "]"
<< " id=" << e.id
<< " ts=" << std::fixed << std::setprecision(6) << e.timestamp
<< " a=0x" << std::hex << std::setw(4) << std::setfill('0') << e.a << std::dec
<< " type=0x" << std::hex << std::setw(8) << std::setfill('0') << e.type << std::dec
<< " val=" << std::fixed << std::setprecision(6) << e.value
<< " b=0x" << std::hex << std::setw(4) << std::setfill('0') << e.b << std::dec
<< "\n";
shown++;
}
}
if (!svgOut.empty()) {
struct Pt {
float ts = 0.0f;
float v = 0.0f;
uint32_t type = 0;
};
std::vector<Pt> pts;
pts.reserve(events.size());
for (const auto& e : events) {
if (filterGarbage && chosenRes.recordSize == 12) {
if (isGarbage12(e)) continue;
if (e.type > typeMax) continue;
}
if (!isFiniteF(e.timestamp)) continue;
if (!svgKeepAll) {
const bool interesting = (e.type != 0 && e.type != 0xFFFF) || (e.timestamp != 0.0f) || (e.value != 0.0f);
if (!interesting) continue;
}
float y = 0.0f;
if (svgY == "type") {
y = static_cast<float>(static_cast<double>(e.type));
} else {
if (!isFiniteF(e.value)) continue;
y = e.value;
}
pts.push_back(Pt{e.timestamp, y, e.type});
}
if (pts.empty()) {
std::cerr << "No points to visualize (try --svg-all)\n";
return 1;
}
// Downsample for file size / speed.
if (svgMaxPoints > 0 && static_cast<int>(pts.size()) > svgMaxPoints) {
std::vector<Pt> ds;
ds.reserve(static_cast<size_t>(svgMaxPoints));
const double step = static_cast<double>(pts.size()) / static_cast<double>(svgMaxPoints);
for (int i = 0; i < svgMaxPoints; i++) {
const size_t idx = static_cast<size_t>(std::floor(i * step));
ds.push_back(pts[std::min(idx, pts.size() - 1)]);
}
pts.swap(ds);
}
float minTs = std::numeric_limits<float>::infinity();
float maxTs = -std::numeric_limits<float>::infinity();
float minV = std::numeric_limits<float>::infinity();
float maxV = -std::numeric_limits<float>::infinity();
for (const auto& p : pts) {
minTs = std::min(minTs, p.ts);
maxTs = std::max(maxTs, p.ts);
minV = std::min(minV, p.v);
maxV = std::max(maxV, p.v);
}
if (!isFiniteF(minTs) || !isFiniteF(maxTs) || !isFiniteF(minV) || !isFiniteF(maxV)) {
std::cerr << "Invalid bounds\n";
return 1;
}
const float tsRange = (maxTs > minTs) ? (maxTs - minTs) : 1.0f;
const float vRange = (maxV > minV) ? (maxV - minV) : 1.0f;
const int W = 1200;
const int H = 600;
const int M = 40;
auto mapX = [&](float ts) -> float {
const float t = (ts - minTs) / tsRange;
return static_cast<float>(M) + t * static_cast<float>(W - 2 * M);
};
auto mapY = [&](float v) -> float {
const float t = (v - minV) / vRange;
return static_cast<float>(H - M) - t * static_cast<float>(H - 2 * M);
};
std::ofstream out(svgOut, std::ios::binary);
if (!out.is_open()) {
std::cerr << "Failed to open for write: " << svgOut << "\n";
return 1;
}
out << "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n";
out << "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"" << W << "\" height=\"" << H
<< "\" viewBox=\"0 0 " << W << " " << H << "\">\n";
out << "<rect x=\"0\" y=\"0\" width=\"" << W << "\" height=\"" << H << "\" fill=\"#ffffff\"/>\n";
// Axes.
out << "<line x1=\"" << M << "\" y1=\"" << (H - M) << "\" x2=\"" << (W - M) << "\" y2=\"" << (H - M)
<< "\" stroke=\"#dddddd\"/>\n";
out << "<line x1=\"" << M << "\" y1=\"" << M << "\" x2=\"" << M << "\" y2=\"" << (H - M)
<< "\" stroke=\"#dddddd\"/>\n";
// Polyline (single stroke) for the overall shape.
out << "<polyline fill=\"none\" stroke=\"#111111\" stroke-width=\"1\" opacity=\"0.5\" points=\"";
for (const auto& p : pts) {
out << mapX(p.ts) << "," << mapY(p.v) << " ";
}
out << "\"/>\n";
// Colored points by type.
for (const auto& p : pts) {
out << "<circle cx=\"" << mapX(p.ts) << "\" cy=\"" << mapY(p.v) << "\" r=\"1.6\" fill=\""
<< rgbHexForType(p.type) << "\" opacity=\"0.9\"/>\n";
}
out << "<text x=\"" << M << "\" y=\"" << (M - 10)
<< "\" font-family=\"monospace\" font-size=\"12\" fill=\"#333333\">";
out << "section #" << chosen.sectionIndex
<< " points=" << pts.size()
<< " y=" << svgY
<< " ts=[" << std::fixed << std::setprecision(3) << minTs << ".." << maxTs << "]"
<< " val=[" << std::fixed << std::setprecision(3) << minV << ".." << maxV << "]";
out << "</text>\n";
out << "</svg>\n";
std::cout << "\nWrote SVG: " << svgOut << " (points=" << pts.size() << ")\n";
}
}
return 0;
}