forked from tsuki/openroller
2998 lines
126 KiB
C++
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, ¬es, &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, ¬es, &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, ¬es, &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, ¬es, &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, ¬es, &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, ¬es, &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;
|
|
}
|