forked from tsuki/openroller
Initial public source release
Split reusable rendering and format support into vectorail-core and vectorail-gc.
This commit is contained in:
@@ -0,0 +1,233 @@
|
||||
#include "openroller/desktop/AudioManager.hpp"
|
||||
#include <algorithm>
|
||||
#include <iostream>
|
||||
|
||||
AudioManager::AudioManager()
|
||||
: device(0), bgmStream(nullptr), shotStream(nullptr), duration(0),
|
||||
shotBaseGain(1.0f), shotMuted(false), playing(false), startTime(0),
|
||||
accumulatedTicks(0) {}
|
||||
|
||||
AudioManager::~AudioManager() {
|
||||
clear();
|
||||
}
|
||||
|
||||
void AudioManager::clear() {
|
||||
clearGameplaySounds();
|
||||
if (bgmStream) SDL_DestroyAudioStream(bgmStream);
|
||||
if (shotStream) SDL_DestroyAudioStream(shotStream);
|
||||
if (device) SDL_CloseAudioDevice(device);
|
||||
device = 0;
|
||||
bgmStream = nullptr;
|
||||
shotStream = nullptr;
|
||||
duration = 0.0;
|
||||
shotBaseGain = 1.0f;
|
||||
shotMuted = false;
|
||||
playing = false;
|
||||
startTime = 0;
|
||||
accumulatedTicks = 0;
|
||||
}
|
||||
|
||||
void AudioManager::clearGameplaySounds() {
|
||||
for (GameplaySoundSlot& slot : gameplaySounds) {
|
||||
for (SDL_AudioStream*& voice : slot.voices) {
|
||||
if (voice) SDL_DestroyAudioStream(voice);
|
||||
voice = nullptr;
|
||||
}
|
||||
slot.data.clear();
|
||||
slot.nextVoice = 0;
|
||||
}
|
||||
}
|
||||
|
||||
bool AudioManager::openStreams(const SDL_AudioSpec& bgmSpec, const Uint8* bgmData,
|
||||
Uint32 bgmLen, float bgmGain,
|
||||
const SDL_AudioSpec* shotSpec, const Uint8* shotData,
|
||||
Uint32 shotLen, float requestedShotGain) {
|
||||
const int bytesPerSample = SDL_AUDIO_BITSIZE(bgmSpec.format) / 8;
|
||||
if (bytesPerSample <= 0 || bgmSpec.channels <= 0 || bgmSpec.freq <= 0) return false;
|
||||
duration = static_cast<double>(bgmLen) /
|
||||
(static_cast<double>(bgmSpec.channels) * bytesPerSample * bgmSpec.freq);
|
||||
|
||||
device = SDL_OpenAudioDevice(SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK, nullptr);
|
||||
if (!device) {
|
||||
std::cerr << "Audio device open error: " << SDL_GetError() << std::endl;
|
||||
return false;
|
||||
}
|
||||
if (!SDL_PauseAudioDevice(device)) {
|
||||
std::cerr << "Audio device pause error: " << SDL_GetError() << std::endl;
|
||||
return false;
|
||||
}
|
||||
|
||||
bgmStream = SDL_CreateAudioStream(&bgmSpec, nullptr);
|
||||
if (shotSpec) shotStream = SDL_CreateAudioStream(shotSpec, nullptr);
|
||||
if (!bgmStream || (shotSpec && !shotStream)) {
|
||||
std::cerr << "Audio stream create error: " << SDL_GetError() << std::endl;
|
||||
return false;
|
||||
}
|
||||
SDL_AudioStream* streams[] = {bgmStream, shotStream};
|
||||
const int streamCount = shotStream ? 2 : 1;
|
||||
if (!SDL_BindAudioStreams(device, streams, streamCount)) {
|
||||
std::cerr << "Audio stream bind error: " << SDL_GetError() << std::endl;
|
||||
return false;
|
||||
}
|
||||
|
||||
bgmGain = std::clamp(bgmGain, 0.0f, 1.0f);
|
||||
shotBaseGain = std::clamp(requestedShotGain, 0.0f, 1.0f);
|
||||
if (!SDL_SetAudioStreamGain(bgmStream, bgmGain) ||
|
||||
(shotStream && !SDL_SetAudioStreamGain(shotStream, shotBaseGain))) {
|
||||
std::cerr << "Audio gain error: " << SDL_GetError() << std::endl;
|
||||
return false;
|
||||
}
|
||||
if (!SDL_PutAudioStreamData(bgmStream, bgmData, static_cast<int>(bgmLen)) ||
|
||||
(shotStream && !SDL_PutAudioStreamData(
|
||||
shotStream, shotData, static_cast<int>(shotLen)))) {
|
||||
std::cerr << "Audio queue error: " << SDL_GetError() << std::endl;
|
||||
return false;
|
||||
}
|
||||
shotMuted = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool AudioManager::loadMusic(const std::string& path, float gain) {
|
||||
clear();
|
||||
SDL_AudioSpec loadedSpec{};
|
||||
Uint8* loadedBuf = nullptr;
|
||||
Uint32 loadedLen = 0;
|
||||
if (!SDL_LoadWAV(path.c_str(), &loadedSpec, &loadedBuf, &loadedLen)) {
|
||||
std::cerr << "WAV Load Error: " << SDL_GetError() << std::endl;
|
||||
return false;
|
||||
}
|
||||
const bool opened = openStreams(loadedSpec, loadedBuf, loadedLen, gain);
|
||||
SDL_free(loadedBuf);
|
||||
if (opened) return true;
|
||||
clear();
|
||||
return false;
|
||||
}
|
||||
|
||||
bool AudioManager::loadMusicPair(const std::string& bgmPath, const std::string& shotPath,
|
||||
float bgmGain, float shotGain) {
|
||||
clear();
|
||||
|
||||
SDL_AudioSpec bgmSpec{};
|
||||
SDL_AudioSpec shotSpec{};
|
||||
Uint8* bgmBuf = nullptr;
|
||||
Uint8* shotBuf = nullptr;
|
||||
Uint32 bgmLen = 0;
|
||||
Uint32 shotLen = 0;
|
||||
if (!SDL_LoadWAV(bgmPath.c_str(), &bgmSpec, &bgmBuf, &bgmLen)) {
|
||||
std::cerr << "BGM WAV load error: " << SDL_GetError() << std::endl;
|
||||
return false;
|
||||
}
|
||||
if (!SDL_LoadWAV(shotPath.c_str(), &shotSpec, &shotBuf, &shotLen)) {
|
||||
std::cerr << "SHOT WAV load error: " << SDL_GetError()
|
||||
<< "; playing BGM only" << std::endl;
|
||||
SDL_free(bgmBuf);
|
||||
return loadMusic(bgmPath, bgmGain);
|
||||
}
|
||||
|
||||
const bool opened = openStreams(bgmSpec, bgmBuf, bgmLen, bgmGain,
|
||||
&shotSpec, shotBuf, shotLen, shotGain);
|
||||
SDL_free(bgmBuf);
|
||||
SDL_free(shotBuf);
|
||||
if (opened) return true;
|
||||
clear();
|
||||
return false;
|
||||
}
|
||||
|
||||
bool AudioManager::loadGameplaySounds(const std::array<std::string, 3>& paths,
|
||||
const std::array<float, 3>& gains) {
|
||||
clearGameplaySounds();
|
||||
if (!device) return false;
|
||||
|
||||
std::array<SDL_AudioSpec, 3> specs{};
|
||||
for (size_t i = 0; i < gameplaySounds.size(); ++i) {
|
||||
Uint8* wavData = nullptr;
|
||||
Uint32 wavLength = 0;
|
||||
if (paths[i].empty() ||
|
||||
!SDL_LoadWAV(paths[i].c_str(), &specs[i], &wavData, &wavLength)) {
|
||||
std::cerr << "Gameplay SE WAV load error: " << paths[i] << ": "
|
||||
<< SDL_GetError() << std::endl;
|
||||
if (wavData) SDL_free(wavData);
|
||||
clearGameplaySounds();
|
||||
return false;
|
||||
}
|
||||
gameplaySounds[i].data.assign(wavData, wavData + wavLength);
|
||||
SDL_free(wavData);
|
||||
}
|
||||
|
||||
std::array<SDL_AudioStream*, 6> voices{};
|
||||
size_t voiceIndex = 0;
|
||||
for (size_t i = 0; i < gameplaySounds.size(); ++i) {
|
||||
GameplaySoundSlot& slot = gameplaySounds[i];
|
||||
for (SDL_AudioStream*& voice : slot.voices) {
|
||||
voice = SDL_CreateAudioStream(&specs[i], nullptr);
|
||||
if (!voice || !SDL_SetAudioStreamGain(voice, std::clamp(gains[i], 0.0f, 1.0f))) {
|
||||
std::cerr << "Gameplay SE stream error: " << SDL_GetError() << std::endl;
|
||||
clearGameplaySounds();
|
||||
return false;
|
||||
}
|
||||
voices[voiceIndex++] = voice;
|
||||
}
|
||||
}
|
||||
if (!SDL_BindAudioStreams(device, voices.data(), static_cast<int>(voices.size()))) {
|
||||
std::cerr << "Gameplay SE bind error: " << SDL_GetError() << std::endl;
|
||||
clearGameplaySounds();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void AudioManager::playGameplaySound(GameplaySound sound) {
|
||||
GameplaySoundSlot& slot = gameplaySounds[static_cast<size_t>(sound)];
|
||||
if (slot.data.empty()) return;
|
||||
|
||||
SDL_AudioStream* voice = slot.voices[slot.nextVoice];
|
||||
slot.nextVoice = (slot.nextVoice + 1) % slot.voices.size();
|
||||
if (!voice) return;
|
||||
if (!SDL_ClearAudioStream(voice) ||
|
||||
!SDL_PutAudioStreamData(voice, slot.data.data(), static_cast<int>(slot.data.size())) ||
|
||||
!SDL_FlushAudioStream(voice)) {
|
||||
std::cerr << "Gameplay SE playback error: " << SDL_GetError() << std::endl;
|
||||
}
|
||||
}
|
||||
|
||||
void AudioManager::play() {
|
||||
if (device) {
|
||||
SDL_ResumeAudioDevice(device);
|
||||
playing = true;
|
||||
startTime = SDL_GetTicks();
|
||||
accumulatedTicks = 0;
|
||||
}
|
||||
}
|
||||
|
||||
void AudioManager::pause() {
|
||||
if (!device || !playing) return;
|
||||
const Uint64 now = SDL_GetTicks();
|
||||
accumulatedTicks += now - startTime;
|
||||
SDL_PauseAudioDevice(device);
|
||||
playing = false;
|
||||
}
|
||||
|
||||
void AudioManager::resume() {
|
||||
if (!device || playing) return;
|
||||
SDL_ResumeAudioDevice(device);
|
||||
startTime = SDL_GetTicks();
|
||||
playing = true;
|
||||
}
|
||||
|
||||
void AudioManager::setShotMuted(bool muted) {
|
||||
if (!shotStream || shotMuted == muted) return;
|
||||
if (SDL_SetAudioStreamGain(shotStream, muted ? 0.0f : shotBaseGain)) {
|
||||
shotMuted = muted;
|
||||
} else {
|
||||
std::cerr << "SHOT gain error: " << SDL_GetError() << std::endl;
|
||||
}
|
||||
}
|
||||
|
||||
double AudioManager::getTime() const {
|
||||
const Uint64 liveTicks = playing ? SDL_GetTicks() - startTime : 0;
|
||||
return static_cast<double>(accumulatedTicks + liveTicks) / 1000.0;
|
||||
}
|
||||
|
||||
double AudioManager::getDuration() const {
|
||||
return duration;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
#include "openroller/desktop/CabinetBackend.hpp"
|
||||
|
||||
#include <SDL3/SDL.h>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace {
|
||||
|
||||
class SoftwareCabinetBackend final : public CabinetBackend {
|
||||
public:
|
||||
void poll() override {
|
||||
const bool* keys = SDL_GetKeyboardState(nullptr);
|
||||
if (!keys) {
|
||||
inputs_.fill(false);
|
||||
return;
|
||||
}
|
||||
const auto down = [&](SDL_Scancode key) { return keys[key]; };
|
||||
set(CabinetInput::Test, down(SDL_SCANCODE_CAPSLOCK));
|
||||
set(CabinetInput::Service, down(SDL_SCANCODE_F1));
|
||||
set(CabinetInput::Coin, down(SDL_SCANCODE_F2));
|
||||
set(CabinetInput::Select, down(SDL_SCANCODE_F3));
|
||||
set(CabinetInput::Enter,
|
||||
down(SDL_SCANCODE_RIGHTBRACKET) || down(SDL_SCANCODE_RETURN));
|
||||
set(CabinetInput::LeftUp, down(SDL_SCANCODE_Q));
|
||||
set(CabinetInput::LeftDown, down(SDL_SCANCODE_A));
|
||||
set(CabinetInput::LeftLeft, down(SDL_SCANCODE_LCTRL));
|
||||
set(CabinetInput::LeftRight, down(SDL_SCANCODE_S));
|
||||
set(CabinetInput::LeftButton, down(SDL_SCANCODE_LALT));
|
||||
set(CabinetInput::RightUp, down(SDL_SCANCODE_UP));
|
||||
set(CabinetInput::RightDown, down(SDL_SCANCODE_DOWN));
|
||||
set(CabinetInput::RightLeft, down(SDL_SCANCODE_LEFT));
|
||||
set(CabinetInput::RightRight, down(SDL_SCANCODE_RIGHT));
|
||||
set(CabinetInput::RightButton, down(SDL_SCANCODE_SPACE));
|
||||
}
|
||||
|
||||
bool input(CabinetInput input) const override {
|
||||
return inputs_[static_cast<std::size_t>(input)];
|
||||
}
|
||||
|
||||
void setLed(std::size_t logicalIndex, CabinetRgb color) override {
|
||||
if (logicalIndex < leds_.size()) leds_[logicalIndex] = color;
|
||||
}
|
||||
|
||||
void clearLeds() override {
|
||||
leds_.fill({});
|
||||
}
|
||||
|
||||
void commitOutputs() override {
|
||||
// The software backend intentionally retains the last committed frame
|
||||
// so the LED test can render exactly what a hardware backend receives.
|
||||
}
|
||||
|
||||
const std::array<CabinetRgb, 118>& leds() const override {
|
||||
return leds_;
|
||||
}
|
||||
|
||||
private:
|
||||
void set(CabinetInput input, bool value) {
|
||||
inputs_[static_cast<std::size_t>(input)] = value;
|
||||
}
|
||||
|
||||
std::array<bool, static_cast<std::size_t>(CabinetInput::Count)> inputs_{};
|
||||
std::array<CabinetRgb, 118> leds_{};
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
CabinetBackend& defaultCabinetBackend() {
|
||||
static SoftwareCabinetBackend backend;
|
||||
return backend;
|
||||
}
|
||||
@@ -0,0 +1,827 @@
|
||||
#include "openroller/desktop/LevelLoader.hpp"
|
||||
#include "gc/StageCatalog.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cctype>
|
||||
#include <cmath>
|
||||
#include <cstring>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
#include <sstream>
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
namespace {
|
||||
|
||||
struct GcTrackPiece {
|
||||
uint32_t timeMs = 0;
|
||||
glm::vec3 pos{0.0f};
|
||||
};
|
||||
|
||||
struct GcNote {
|
||||
uint32_t timeMs = 0;
|
||||
uint8_t rawType = 0;
|
||||
uint8_t effectiveType = 0;
|
||||
bool adlib = false;
|
||||
int16_t markEffectId = -1;
|
||||
float appearanceLeadBeats = 0.0f;
|
||||
float durationBeats = 0.0f;
|
||||
uint32_t packedColor = 0xffffffffu;
|
||||
uint32_t merryCount = 0;
|
||||
float merrySpacingBeats = 0.0f;
|
||||
glm::vec3 directionVector{0.0f};
|
||||
};
|
||||
|
||||
struct GcTimingEntry {
|
||||
uint32_t timeMs = 0;
|
||||
uint32_t mode = 0;
|
||||
float value = 0.0f;
|
||||
};
|
||||
|
||||
struct GcSystemTimingConfig {
|
||||
bool missMarkOverride = false;
|
||||
float greatMinTimeMs = 32.0f;
|
||||
std::array<float, 4> miss{236.0f, 202.0f, 168.0f, 168.0f};
|
||||
std::array<float, 4> unmute{202.0f, 168.0f, 134.0f, 134.0f};
|
||||
std::array<float, 4> limit{202.0f, 168.0f, 134.0f, 134.0f};
|
||||
std::array<float, 4> mute{0.0f, 0.0f, 0.0f, 0.0f};
|
||||
float scratchEnableTimeMs = 250.0f;
|
||||
float beatEnableTimeMs = 200.0f;
|
||||
};
|
||||
|
||||
// RotateHPB::ToVector_Deg converts (heading, pitch, bank) to a quaternion and
|
||||
// transforms (0, 0, distance). Directional notes author bank as zero, but keep
|
||||
// the complete original formula here.
|
||||
glm::vec3 gcDirectionVector(float distance, float heading, float pitch, float bank = 0.0f) {
|
||||
const float a = glm::radians(-pitch) * 0.5f;
|
||||
const float b = glm::radians(heading) * 0.5f;
|
||||
const float c = glm::radians(bank) * 0.5f;
|
||||
const float ca = std::cos(a), cb = std::cos(b), cc = std::cos(c);
|
||||
const float sa = std::sin(a), sb = std::sin(b), sc = std::sin(c);
|
||||
|
||||
const float qw = sc * sa * sb + cc * ca * cb;
|
||||
const float qx = sc * ca * sb + cc * sa * cb;
|
||||
const float qy = cc * ca * sb - sc * sa * cb;
|
||||
const float qz = cc * sa * sb - sc * ca * cb;
|
||||
return distance * glm::vec3(
|
||||
2.0f * (qz * qx + qw * qy),
|
||||
2.0f * (qy * qz - qw * qx),
|
||||
1.0f - 2.0f * (qx * qx + qy * qy));
|
||||
}
|
||||
|
||||
std::string trim(std::string value) {
|
||||
const auto first = std::find_if_not(value.begin(), value.end(),
|
||||
[](unsigned char c) { return std::isspace(c); });
|
||||
const auto last = std::find_if_not(value.rbegin(), value.rend(),
|
||||
[](unsigned char c) { return std::isspace(c); }).base();
|
||||
return first < last ? std::string(first, last) : std::string{};
|
||||
}
|
||||
|
||||
bool parseFloatTuple4(std::string value, std::array<float, 4>& out) {
|
||||
for (char& c : value) {
|
||||
if (c == '(' || c == ')' || c == ',') c = ' ';
|
||||
}
|
||||
std::stringstream values(value);
|
||||
std::array<float, 4> parsed{};
|
||||
if (!(values >> parsed[0] >> parsed[1] >> parsed[2] >> parsed[3])) return false;
|
||||
out = parsed;
|
||||
return true;
|
||||
}
|
||||
|
||||
GcSystemTimingConfig loadGcSystemTimingConfig(const fs::path& stageFile) {
|
||||
GcSystemTimingConfig config;
|
||||
const fs::path systemCfg = stageFile.parent_path().parent_path() / "system.cfg";
|
||||
std::ifstream file(systemCfg);
|
||||
if (!file.is_open()) return config;
|
||||
|
||||
std::string line;
|
||||
bool inBlockComment = false;
|
||||
while (std::getline(file, line)) {
|
||||
if (inBlockComment) {
|
||||
const size_t end = line.find("*/");
|
||||
if (end == std::string::npos) continue;
|
||||
line.erase(0, end + 2);
|
||||
inBlockComment = false;
|
||||
}
|
||||
for (;;) {
|
||||
const size_t begin = line.find("/*");
|
||||
if (begin == std::string::npos) break;
|
||||
const size_t end = line.find("*/", begin + 2);
|
||||
if (end == std::string::npos) {
|
||||
line.resize(begin);
|
||||
inBlockComment = true;
|
||||
break;
|
||||
}
|
||||
line.erase(begin, end + 2 - begin);
|
||||
}
|
||||
const size_t comment = line.find("//");
|
||||
if (comment != std::string::npos) line.resize(comment);
|
||||
const size_t equals = line.find('=');
|
||||
if (equals == std::string::npos) continue;
|
||||
const std::string key = trim(line.substr(0, equals));
|
||||
const std::string value = trim(line.substr(equals + 1));
|
||||
try {
|
||||
if (key == "MissMarkOverride") config.missMarkOverride = std::stoi(value) != 0;
|
||||
else if (key == "GreatMinTime") config.greatMinTimeMs = std::stof(value);
|
||||
else if (key == "MissTimingOverride") parseFloatTuple4(value, config.miss);
|
||||
else if (key == "UnmuteTimingOverride") parseFloatTuple4(value, config.unmute);
|
||||
else if (key == "LimitTimingOverride") parseFloatTuple4(value, config.limit);
|
||||
else if (key == "MuteTimingOverride") parseFloatTuple4(value, config.mute);
|
||||
else if (key == "ScratchEnableTime") config.scratchEnableTimeMs = std::stof(value);
|
||||
else if (key == "BeatEnableTime") config.beatEnableTimeMs = std::stof(value);
|
||||
} catch (const std::exception&) {
|
||||
// Preserve the shipped defaults if a local config line is malformed.
|
||||
}
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
size_t gcDifficultyIndex(const fs::path& stageFile) {
|
||||
std::string stem = stageFile.stem().string();
|
||||
std::transform(stem.begin(), stem.end(), stem.begin(),
|
||||
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
|
||||
if (stem.find("_normal") != std::string::npos) return 1;
|
||||
if (stem.find("_hard") != std::string::npos) return 2;
|
||||
if (stem.find("_extra") != std::string::npos ||
|
||||
(stem.size() >= 3 && stem.compare(stem.size() - 3, 3, "_ex") == 0) ||
|
||||
stem.find("_ex_") != std::string::npos) return 3;
|
||||
return 0; // _easy and old charts without a suffix
|
||||
}
|
||||
|
||||
const char* gcNoteTypeName(uint8_t type) {
|
||||
static constexpr const char* names[] = {
|
||||
"NONE", "NORMAL", "FLICK", "HOLD", "SCRATCH", "BEAT", "MERRY GO ROUND", "HIDDEN",
|
||||
"HIDDEN2", "CRITICAL", "SLIDE HOLD", "SLIDE COUNTER", "TURN", "SPIN", "FINISH", "DUAL HOLD",
|
||||
};
|
||||
return type < (sizeof(names) / sizeof(names[0])) ? names[type] : "UNKNOWN";
|
||||
}
|
||||
|
||||
uint16_t u16be(const std::vector<uint8_t>& b, size_t off) {
|
||||
return static_cast<uint16_t>((static_cast<uint16_t>(b[off]) << 8) | static_cast<uint16_t>(b[off + 1]));
|
||||
}
|
||||
|
||||
uint32_t u32be(const std::vector<uint8_t>& b, size_t off) {
|
||||
return (static_cast<uint32_t>(b[off + 0]) << 24) |
|
||||
(static_cast<uint32_t>(b[off + 1]) << 16) |
|
||||
(static_cast<uint32_t>(b[off + 2]) << 8) |
|
||||
static_cast<uint32_t>(b[off + 3]);
|
||||
}
|
||||
|
||||
float f32be(const std::vector<uint8_t>& b, size_t off) {
|
||||
const uint32_t u = u32be(b, off);
|
||||
float f = 0.0f;
|
||||
std::memcpy(&f, &u, sizeof(float));
|
||||
return f;
|
||||
}
|
||||
|
||||
bool readFile(const std::string& path, std::vector<uint8_t>& out) {
|
||||
std::ifstream file(path, std::ios::binary);
|
||||
if (!file.is_open()) return false;
|
||||
file.seekg(0, std::ios::end);
|
||||
const std::streamoff size = file.tellg();
|
||||
if (size < 0) return false;
|
||||
file.seekg(0, std::ios::beg);
|
||||
out.assign(static_cast<size_t>(size), 0);
|
||||
if (!out.empty()) file.read(reinterpret_cast<char*>(out.data()), static_cast<std::streamsize>(out.size()));
|
||||
return static_cast<bool>(file) || file.eof();
|
||||
}
|
||||
|
||||
void loadGcObjectClipTable(const fs::path& stagePath, LevelData& data) {
|
||||
if (!data.gcStage) return;
|
||||
|
||||
const fs::path clipPath =
|
||||
stagePath.parent_path() / (stagePath.stem().string() + "_clip.dat");
|
||||
std::vector<uint8_t> bytes;
|
||||
if (!readFile(clipPath.string(), bytes) || bytes.size() < 8) return;
|
||||
|
||||
const uint32_t objectCount = u32be(bytes, 0);
|
||||
const uint32_t frameCount = u32be(bytes, 4);
|
||||
if (objectCount != data.gcStage->objects.size() || frameCount == 0) return;
|
||||
if (objectCount > (bytes.size() - 8) / frameCount) return;
|
||||
|
||||
const size_t payloadSize = static_cast<size_t>(objectCount) * frameCount;
|
||||
if (8 + payloadSize > bytes.size()) return;
|
||||
data.gcObjectClipFrameCount = frameCount;
|
||||
data.gcObjectClipVisibility.assign(bytes.begin() + 8,
|
||||
bytes.begin() + 8 + payloadSize);
|
||||
}
|
||||
|
||||
bool saneFloat(float v, float limit = 1000000.0f) {
|
||||
return std::isfinite(v) && std::fabs(v) <= limit;
|
||||
}
|
||||
|
||||
std::string readSizedString16(const std::vector<uint8_t>& bytes, size_t& off, size_t end) {
|
||||
if (off + 2 > end) return {};
|
||||
const uint16_t len = u16be(bytes, off);
|
||||
off += 2;
|
||||
if (off + len > end) return {};
|
||||
std::string s(reinterpret_cast<const char*>(bytes.data() + off), len);
|
||||
off += len;
|
||||
while (!s.empty() && s.back() == '\0') s.pop_back();
|
||||
return s;
|
||||
}
|
||||
|
||||
std::vector<GcNote> decodeGcNotes(const std::vector<uint8_t>& bytes, size_t start, size_t end) {
|
||||
constexpr size_t recordSize = 99;
|
||||
std::vector<GcNote> notes;
|
||||
if (start + 8 > end || end > bytes.size()) return notes;
|
||||
|
||||
size_t off = start;
|
||||
const uint32_t nameCount = u32be(bytes, off);
|
||||
off += 4;
|
||||
for (uint32_t i = 0; i < nameCount; ++i) {
|
||||
if (off >= end) return {};
|
||||
const size_t len = bytes[off++];
|
||||
if (len > end - off) return {};
|
||||
off += len;
|
||||
}
|
||||
if (off + 4 > end) return {};
|
||||
const uint32_t count = u32be(bytes, off);
|
||||
off += 4;
|
||||
const size_t payloadBytes = static_cast<size_t>(count) * recordSize;
|
||||
if (payloadBytes != end - off) return notes;
|
||||
|
||||
notes.reserve(count);
|
||||
for (uint32_t i = 0; i < count; ++i, off += recordSize) {
|
||||
const uint8_t rawType = bytes[off + 4];
|
||||
uint8_t effectiveType = bytes[off + 5] ? 1 : rawType;
|
||||
if (!bytes[off + 5]) {
|
||||
if (rawType == 0x0b) effectiveType = 0x0a;
|
||||
else if (rawType == 0x0c || rawType == 0x0e) effectiveType = 0x09;
|
||||
else if (rawType == 0x0d) effectiveType = 0x04;
|
||||
}
|
||||
float directionLength = f32be(bytes, off + 25);
|
||||
// LoadTuneMarkDataOne substitutes 1.0 for directional types whose
|
||||
// authored vector length is zero (notably SLIDE HOLD charts).
|
||||
if ((effectiveType == 2 || effectiveType == 10 || rawType == 0x10) &&
|
||||
directionLength <= 0.0f) {
|
||||
directionLength = 1.0f;
|
||||
}
|
||||
notes.push_back({
|
||||
u32be(bytes, off), rawType, effectiveType, bytes[off + 5] != 0,
|
||||
static_cast<int16_t>(u16be(bytes, off + 6)),
|
||||
f32be(bytes, off + 39),
|
||||
f32be(bytes, off + 51),
|
||||
u32be(bytes, off + 55),
|
||||
u32be(bytes, off + 71),
|
||||
f32be(bytes, off + 75),
|
||||
gcDirectionVector(directionLength,
|
||||
f32be(bytes, off + 29),
|
||||
f32be(bytes, off + 33)),
|
||||
});
|
||||
}
|
||||
return notes;
|
||||
}
|
||||
|
||||
float gcBeatDurationAt(const gc::StageConfig* config, uint32_t timeMs) {
|
||||
uint32_t bpm = 120;
|
||||
if (config) {
|
||||
for (const gc::BpmChange& change : config->bpmChanges) {
|
||||
if (change.timeMs > timeMs) break;
|
||||
if (change.bpm != 0) bpm = change.bpm;
|
||||
}
|
||||
}
|
||||
return 60000.0f / static_cast<float>(std::max<uint32_t>(1, bpm));
|
||||
}
|
||||
|
||||
float gcTimingAt(const std::vector<GcTimingEntry>& entries, uint32_t timeMs,
|
||||
float beatMs, float nextSpacingMs) {
|
||||
if (entries.empty()) return beatMs * 0.5f;
|
||||
const GcTimingEntry* active = &entries.front();
|
||||
for (const GcTimingEntry& entry : entries) {
|
||||
if (entry.timeMs > timeMs) break;
|
||||
active = &entry;
|
||||
}
|
||||
if (active->mode == 1) return active->value;
|
||||
if (active->mode == 3) return nextSpacingMs;
|
||||
return active->value * beatMs;
|
||||
}
|
||||
|
||||
float cumulativeDistanceAtTime(const std::vector<GcTrackPiece>& track, const std::vector<float>& dists, float noteT, float minT, float maxT) {
|
||||
if (track.empty() || dists.empty()) return 0.0f;
|
||||
|
||||
const float lastMs = static_cast<float>(std::max<uint32_t>(1, track.back().timeMs));
|
||||
float targetMs = 0.0f;
|
||||
if (maxT - minT > 0.001f && maxT * 1000.0f <= lastMs * 1.25f) {
|
||||
targetMs = noteT * 1000.0f;
|
||||
} else if (maxT - minT > 0.001f && maxT <= lastMs * 1.25f) {
|
||||
targetMs = noteT;
|
||||
} else {
|
||||
const float u = (maxT > minT) ? ((noteT - minT) / (maxT - minT)) : 0.0f;
|
||||
targetMs = u * lastMs;
|
||||
}
|
||||
|
||||
if (targetMs <= static_cast<float>(track.front().timeMs)) return dists.front();
|
||||
if (targetMs >= lastMs) return dists.back();
|
||||
|
||||
for (size_t i = 0; i + 1 < track.size(); ++i) {
|
||||
const float a = static_cast<float>(track[i].timeMs);
|
||||
const float b = static_cast<float>(track[i + 1].timeMs);
|
||||
if (targetMs >= a && targetMs <= b) {
|
||||
const float span = b - a;
|
||||
const float u = span > 0.001f ? (targetMs - a) / span : 0.0f;
|
||||
return dists[i] + (dists[i + 1] - dists[i]) * u;
|
||||
}
|
||||
}
|
||||
return dists.back();
|
||||
}
|
||||
|
||||
float trackParamAtTimeMs(const std::vector<GcTrackPiece>& track, float timeMs) {
|
||||
if (track.empty()) return 0.0f;
|
||||
if (timeMs <= static_cast<float>(track.front().timeMs)) return 0.0f;
|
||||
if (timeMs >= static_cast<float>(track.back().timeMs)) return static_cast<float>(track.size() - 1);
|
||||
for (size_t i = 0; i + 1 < track.size(); ++i) {
|
||||
const float a = static_cast<float>(track[i].timeMs);
|
||||
const float b = static_cast<float>(track[i + 1].timeMs);
|
||||
if (timeMs >= a && timeMs <= b) {
|
||||
const float span = b - a;
|
||||
const float u = span > 0.001f ? (timeMs - a) / span : 0.0f;
|
||||
return static_cast<float>(i) + u;
|
||||
}
|
||||
}
|
||||
return static_cast<float>(track.size() - 1);
|
||||
}
|
||||
|
||||
std::string lower(std::string s) {
|
||||
std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) { return static_cast<char>(std::tolower(c)); });
|
||||
return s;
|
||||
}
|
||||
|
||||
std::string gcStageToken(const fs::path& stagePath, const std::string& chartName) {
|
||||
std::string token = lower(chartName.empty() ? stagePath.stem().string() : chartName);
|
||||
if (token.rfind("ac_", 0) == 0) token = token.substr(3);
|
||||
for (const std::string suffix : {"_hard", "_normal", "_easy"}) {
|
||||
if (token.size() > suffix.size() && token.compare(token.size() - suffix.size(), suffix.size(), suffix) == 0) {
|
||||
token.resize(token.size() - suffix.size());
|
||||
}
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
std::string inferGcBgmPath(const fs::path& stagePath, const std::string& chartName, const std::string& bgmName) {
|
||||
const fs::path soundDir = stagePath.parent_path() / "sound";
|
||||
if (!fs::is_directory(soundDir)) return {};
|
||||
|
||||
const std::string token = gcStageToken(stagePath, chartName);
|
||||
|
||||
const std::string bgm = lower(bgmName);
|
||||
for (const auto& entry : fs::directory_iterator(soundDir)) {
|
||||
if (!entry.is_regular_file()) continue;
|
||||
const std::string name = lower(entry.path().filename().string());
|
||||
if (entry.path().extension() != ".wav") continue;
|
||||
if (name.find("_bgm") == std::string::npos) continue;
|
||||
if ((!token.empty() && name.find(token) != std::string::npos) ||
|
||||
(!bgm.empty() && name.find(bgm) != std::string::npos)) {
|
||||
return entry.path().string();
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
struct GcStageAudio {
|
||||
std::string bgmPath;
|
||||
std::string shotPath;
|
||||
float bgmGain = 1.0f;
|
||||
float shotGain = 1.0f;
|
||||
};
|
||||
|
||||
GcStageAudio resolveGcStageAudio(const fs::path& stagePath,
|
||||
const std::string& chartName,
|
||||
const std::string& bgmName,
|
||||
size_t difficultyIndex) {
|
||||
GcStageAudio result;
|
||||
const fs::path dataDir = stagePath.parent_path().parent_path();
|
||||
const fs::path stageParam = dataDir / "boot" / "stage_param.dat";
|
||||
std::vector<uint8_t> catalogBytes;
|
||||
std::vector<gc::StageCatalogEntry> entries;
|
||||
std::string catalogError;
|
||||
if (readFile(stageParam.string(), catalogBytes) &&
|
||||
gc::ParseStageCatalog(catalogBytes, &entries, &catalogError)) {
|
||||
const std::string stageId = stagePath.stem().string();
|
||||
const gc::StageCatalogEntry* entry = gc::FindStageCatalogEntryByChart(entries, stageId);
|
||||
if (!entry && !chartName.empty()) {
|
||||
entry = gc::FindStageCatalogEntryByChart(entries, chartName);
|
||||
}
|
||||
if (entry) {
|
||||
for (size_t i = 0; i < entry->chartIds.size(); ++i) {
|
||||
if (entry->chartIds[i] == stageId || entry->chartIds[i] == chartName) {
|
||||
difficultyIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
difficultyIndex = std::min(difficultyIndex, entry->chartIds.size() - 1);
|
||||
const fs::path soundDir = dataDir / "stage" / "sound";
|
||||
const fs::path bgm = soundDir /
|
||||
(entry->bgmBase + entry->chartGroup0[difficultyIndex] + "_BGM.wav");
|
||||
const fs::path shot = soundDir /
|
||||
(entry->bgmBase + entry->chartSuffixes[difficultyIndex] + "_SHOT.wav");
|
||||
result.bgmGain = static_cast<float>(entry->bgmVolumes[difficultyIndex]) / 100.0f;
|
||||
result.shotGain = static_cast<float>(entry->shotVolumes[difficultyIndex]) / 100.0f;
|
||||
|
||||
// LoadStageBGM requires the pair. Its compatibility path is the
|
||||
// old one-file layout under data/sound.
|
||||
if (fs::is_regular_file(bgm) && fs::is_regular_file(shot)) {
|
||||
result.bgmPath = bgm.string();
|
||||
result.shotPath = shot.string();
|
||||
return result;
|
||||
}
|
||||
const fs::path legacy = dataDir / "sound" / (entry->bgmBase + ".wav");
|
||||
if (fs::is_regular_file(legacy)) {
|
||||
result.bgmPath = legacy.string();
|
||||
return result;
|
||||
}
|
||||
// Keep a damaged/incomplete dump playable when its BGM survived.
|
||||
if (fs::is_regular_file(bgm)) {
|
||||
result.bgmPath = bgm.string();
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.bgmPath = inferGcBgmPath(stagePath, chartName, bgmName);
|
||||
return result;
|
||||
}
|
||||
|
||||
std::string inferGcBackgroundPath(const fs::path& stagePath, const std::string& chartName) {
|
||||
const fs::path image = stagePath.parent_path() / "2d" / (gcStageToken(stagePath, chartName) + "_menu.dds");
|
||||
return fs::is_regular_file(image) ? image.string() : std::string{};
|
||||
}
|
||||
|
||||
LevelData loadGcStageDat(const std::string& stageFile) {
|
||||
LevelData data;
|
||||
std::vector<uint8_t> bytes;
|
||||
if (!readFile(stageFile, bytes) || bytes.size() < 52) return data;
|
||||
|
||||
// Keep the complete clean-room decode alongside the compact player data.
|
||||
// Rendering can consume sections incrementally without teaching this
|
||||
// loader a second, diverging copy of every variable-length object record.
|
||||
gc::StageDat stageDat;
|
||||
gc::ParsedStagePattern parsedStage;
|
||||
std::string stageError;
|
||||
if (gc::StageDat::LoadFromFile(stageFile, stageDat, &stageError) &&
|
||||
gc::ParseStagePattern(stageDat, &parsedStage, &stageError)) {
|
||||
data.gcStage = std::move(parsedStage);
|
||||
}
|
||||
|
||||
std::vector<uint32_t> header;
|
||||
header.reserve(13);
|
||||
for (size_t off = 0; off < 52; off += 4) header.push_back(u32be(bytes, off));
|
||||
|
||||
const uint32_t cfgOff = header[0];
|
||||
const uint32_t trackOff = header[2];
|
||||
const uint32_t notesOff = header[3];
|
||||
const uint32_t cameraOff = header[4];
|
||||
if (cfgOff >= bytes.size() || trackOff >= bytes.size() || notesOff >= bytes.size() || cameraOff >= bytes.size()) return data;
|
||||
|
||||
std::string chartName;
|
||||
std::string bgmName;
|
||||
float backwardsDrawDist = 10.0f;
|
||||
float forwardDrawDist = 7.0f;
|
||||
std::array<uint8_t, 4> trackAheadColor{0, 204, 255, 255};
|
||||
std::array<uint8_t, 4> trackBehindColor{0, 102, 160, 255};
|
||||
std::array<std::vector<GcTimingEntry>, 4> noteTimings;
|
||||
const GcSystemTimingConfig systemTiming = loadGcSystemTimingConfig(fs::path(stageFile));
|
||||
const size_t difficultyIndex = gcDifficultyIndex(fs::path(stageFile));
|
||||
{
|
||||
const size_t cfgEnd = header[1];
|
||||
size_t off = cfgOff + 12;
|
||||
if (off + 2 <= cfgEnd) {
|
||||
const uint16_t bpmCount = u16be(bytes, off);
|
||||
off += 2 + static_cast<size_t>(bpmCount) * 8;
|
||||
bool timingListsValid = off <= cfgEnd;
|
||||
for (std::vector<GcTimingEntry>& list : noteTimings) {
|
||||
if (!timingListsValid || off + 2 > cfgEnd) {
|
||||
timingListsValid = false;
|
||||
break;
|
||||
}
|
||||
const uint16_t count = u16be(bytes, off);
|
||||
off += 2;
|
||||
if (static_cast<size_t>(count) > (cfgEnd - off) / 12) {
|
||||
timingListsValid = false;
|
||||
break;
|
||||
}
|
||||
list.reserve(count);
|
||||
for (uint16_t i = 0; i < count; ++i, off += 12) {
|
||||
list.push_back({u32be(bytes, off), u32be(bytes, off + 4), f32be(bytes, off + 8)});
|
||||
}
|
||||
}
|
||||
if (timingListsValid) {
|
||||
chartName = readSizedString16(bytes, off, cfgEnd);
|
||||
(void)readSizedString16(bytes, off, cfgEnd);
|
||||
bgmName = readSizedString16(bytes, off, cfgEnd);
|
||||
(void)readSizedString16(bytes, off, cfgEnd);
|
||||
if (off + 16 <= cfgEnd) {
|
||||
backwardsDrawDist = f32be(bytes, off);
|
||||
forwardDrawDist = f32be(bytes, off + 4);
|
||||
for (size_t i = 0; i < 4; ++i) trackAheadColor[i] = bytes[off + 8 + i];
|
||||
for (size_t i = 0; i < 4; ++i) trackBehindColor[i] = bytes[off + 12 + i];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data.title = chartName.empty() ? fs::path(stageFile).stem().string() : chartName;
|
||||
data.author = "Groove Coaster";
|
||||
const GcStageAudio stageAudio = resolveGcStageAudio(
|
||||
fs::path(stageFile), chartName, bgmName, difficultyIndex);
|
||||
data.audioPath = stageAudio.bgmPath;
|
||||
data.audioShotPath = stageAudio.shotPath;
|
||||
data.audioBgmGain = stageAudio.bgmGain;
|
||||
data.audioShotGain = stageAudio.shotGain;
|
||||
const fs::path soundDir = fs::path(stageFile).parent_path().parent_path() / "sound";
|
||||
// The selected SE id lives in the arcade profile. OpenRoller currently
|
||||
// starts with the shipped default (se0000, "Ver.3 Set") from se.dat.
|
||||
data.gameplaySoundPaths = {
|
||||
(soundDir / "SE_ARRANGE.wav").string(),
|
||||
(soundDir / "TAP_SE1.wav").string(),
|
||||
(soundDir / "TAP_SE2.wav").string(),
|
||||
};
|
||||
// Exact entries in data/sound/SEList.csv for the three default slots.
|
||||
data.gameplaySoundGains = {0.87f, 0.80f, 0.77f};
|
||||
data.backgroundPath = inferGcBackgroundPath(fs::path(stageFile), chartName);
|
||||
data.config["speed"] = 1.0f;
|
||||
data.config["gc_camera"] = 1.0f;
|
||||
// game471.exe uses this fixed vertical FOV in its gameplay projection.
|
||||
data.config["gc_fov"] = 75.0f;
|
||||
data.config["gc_draw_behind"] = saneFloat(backwardsDrawDist) ? std::max(0.0f, backwardsDrawDist) : 10.0f;
|
||||
data.config["gc_draw_ahead"] = saneFloat(forwardDrawDist) ? std::max(0.0f, forwardDrawDist) : 7.0f;
|
||||
data.config["gc_track_ahead_r"] = trackAheadColor[0] / 255.0f;
|
||||
data.config["gc_track_ahead_g"] = trackAheadColor[1] / 255.0f;
|
||||
data.config["gc_track_ahead_b"] = trackAheadColor[2] / 255.0f;
|
||||
data.config["gc_track_behind_r"] = trackBehindColor[0] / 255.0f;
|
||||
data.config["gc_track_behind_g"] = trackBehindColor[1] / 255.0f;
|
||||
data.config["gc_track_behind_b"] = trackBehindColor[2] / 255.0f;
|
||||
data.config["gc_great_min_ms"] = std::max(0.0f, systemTiming.greatMinTimeMs);
|
||||
data.config["gc_scratch_enable_ms"] = std::max(0.0f, systemTiming.scratchEnableTimeMs);
|
||||
data.config["gc_beat_enable_ms"] = std::max(0.0f, systemTiming.beatEnableTimeMs);
|
||||
|
||||
std::vector<GcTrackPiece> gcTrack;
|
||||
{
|
||||
const size_t trackEnd = notesOff;
|
||||
if (trackOff + 4 > trackEnd) return data;
|
||||
const uint32_t count = u32be(bytes, trackOff);
|
||||
size_t off = trackOff + 4;
|
||||
const size_t capacity = (trackEnd - off) / 16;
|
||||
const size_t n = std::min<size_t>(count, capacity);
|
||||
gcTrack.reserve(n);
|
||||
for (size_t i = 0; i < n; ++i, off += 16) {
|
||||
GcTrackPiece p;
|
||||
p.timeMs = u32be(bytes, off + 0);
|
||||
p.pos.x = f32be(bytes, off + 4);
|
||||
p.pos.y = f32be(bytes, off + 8);
|
||||
p.pos.z = f32be(bytes, off + 12);
|
||||
if (saneFloat(p.pos.x) && saneFloat(p.pos.y) && saneFloat(p.pos.z)) gcTrack.push_back(p);
|
||||
}
|
||||
}
|
||||
|
||||
// game471's FUN_005e9690 interpolates adjacent track keys linearly by
|
||||
// timestamp. Type 1 selects the matching straight-segment path here.
|
||||
for (const auto& p : gcTrack) data.trackPoints.push_back({p.pos, 1, true, static_cast<float>(p.timeMs)});
|
||||
if (gcTrack.size() < 2) return data;
|
||||
data.config["gc_duration_ms"] = static_cast<float>(std::max<uint32_t>(1, gcTrack.back().timeMs));
|
||||
|
||||
{
|
||||
const size_t drawOff = header[1];
|
||||
if (drawOff + 4 <= trackOff) {
|
||||
const uint32_t count = u32be(bytes, drawOff);
|
||||
const size_t capacity = (trackOff - drawOff - 4) / 8;
|
||||
size_t off = drawOff + 4;
|
||||
for (size_t i = 0; i < std::min<size_t>(count, capacity); ++i, off += 8) {
|
||||
const uint32_t timeMs = u32be(bytes, off);
|
||||
const float distance = f32be(bytes, off + 4);
|
||||
if (saneFloat(distance)) {
|
||||
data.timeline.push_back({trackParamAtTimeMs(gcTrack, static_cast<float>(timeMs)),
|
||||
"gc_draw_ahead", std::max(0.0f, distance)});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<float> cumulative;
|
||||
cumulative.reserve(gcTrack.size());
|
||||
cumulative.push_back(0.0f);
|
||||
for (size_t i = 1; i < gcTrack.size(); ++i) {
|
||||
cumulative.push_back(cumulative.back() + glm::distance(gcTrack[i - 1].pos, gcTrack[i].pos));
|
||||
}
|
||||
|
||||
{
|
||||
const size_t cameraEnd = header[5];
|
||||
if (cameraOff + 4 <= cameraEnd) {
|
||||
const uint32_t count = u32be(bytes, cameraOff);
|
||||
size_t off = cameraOff + 4;
|
||||
const size_t recordSize = 59;
|
||||
const size_t capacity = (cameraEnd - off) / recordSize;
|
||||
const size_t n = std::min<size_t>(count, capacity);
|
||||
data.gcCameraKeys.reserve(n);
|
||||
for (size_t i = 0; i < n; ++i, off += recordSize) {
|
||||
GcCameraKey key;
|
||||
key.timeMs = u32be(bytes, off + 0);
|
||||
key.aMode = bytes[off + 4];
|
||||
key.fMode = bytes[off + 5];
|
||||
key.dist = f32be(bytes, off + 6);
|
||||
key.rotationA = {f32be(bytes, off + 10), f32be(bytes, off + 14), 0.0f};
|
||||
key.originOff = {f32be(bytes, off + 18), f32be(bytes, off + 22), f32be(bytes, off + 26)};
|
||||
key.projType = bytes[off + 30];
|
||||
key.fieldFar = {f32be(bytes, off + 31), f32be(bytes, off + 35), f32be(bytes, off + 39)};
|
||||
key.fieldNear = {f32be(bytes, off + 43), f32be(bytes, off + 47), f32be(bytes, off + 51)};
|
||||
key.rotationB = f32be(bytes, off + 55);
|
||||
|
||||
// game471.exe FUN_005ed4c0 copies all 59 wire bytes into the
|
||||
// 0x44-byte runtime key without finite-value filtering. The
|
||||
// first camera in the three comet charts deliberately carries
|
||||
// NaN rotations, so dropping the whole key changes their intro.
|
||||
data.gcCameraKeys.push_back(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Full-screen stage background: four RGBA corners keyed by chart time.
|
||||
// The two trailing bytes are fade flags; linear interpolation matches the
|
||||
// common fade-enabled records and will be specialized once both modes are
|
||||
// mapped from the renderer.
|
||||
if (header.size() > 9) {
|
||||
const size_t colorsOff = header[8];
|
||||
const size_t objectsOff = header[9];
|
||||
constexpr size_t colorRecordSize = 22;
|
||||
if (colorsOff + 4 <= objectsOff && objectsOff <= bytes.size()) {
|
||||
const uint32_t count = u32be(bytes, colorsOff);
|
||||
const size_t capacity = (objectsOff - colorsOff - 4) / colorRecordSize;
|
||||
size_t off = colorsOff + 4;
|
||||
static constexpr const char* corners[] = {"tr", "tl", "br", "bl"};
|
||||
for (size_t i = 0; i < std::min<size_t>(count, capacity); ++i, off += colorRecordSize) {
|
||||
const uint32_t timeMs = u32be(bytes, off);
|
||||
const float t = trackParamAtTimeMs(gcTrack, static_cast<float>(timeMs));
|
||||
for (size_t corner = 0; corner < 4; ++corner) {
|
||||
const size_t colorOff = off + 4 + corner * 4;
|
||||
data.timeline.push_back({t, std::string("gc_bg_") + corners[corner] + "_r", bytes[colorOff + 0] / 255.0f});
|
||||
data.timeline.push_back({t, std::string("gc_bg_") + corners[corner] + "_g", bytes[colorOff + 1] / 255.0f});
|
||||
data.timeline.push_back({t, std::string("gc_bg_") + corners[corner] + "_b", bytes[colorOff + 2] / 255.0f});
|
||||
}
|
||||
}
|
||||
data.config["gc_background_keys"] = static_cast<float>(std::min<size_t>(count, capacity));
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<GcNote> gcNotes = decodeGcNotes(bytes, notesOff, cameraOff);
|
||||
if (!gcNotes.empty()) {
|
||||
std::array<size_t, 256> typeCounts{};
|
||||
const float minT = static_cast<float>(gcNotes.front().timeMs);
|
||||
const float maxT = static_cast<float>(gcNotes.back().timeMs);
|
||||
for (size_t noteIndex = 0; noteIndex < gcNotes.size(); ++noteIndex) {
|
||||
const GcNote& n = gcNotes[noteIndex];
|
||||
++typeCounts[n.rawType];
|
||||
const float timeMs = static_cast<float>(n.timeMs);
|
||||
const float beatMs = gcBeatDurationAt(data.gcStage ? &data.gcStage->config : nullptr, n.timeMs);
|
||||
const bool durationType = n.effectiveType == 3 || n.effectiveType == 4 ||
|
||||
n.effectiveType == 5 || n.effectiveType == 10 ||
|
||||
n.effectiveType == 15;
|
||||
const float durationBeats = n.effectiveType == 6
|
||||
? static_cast<float>(n.merryCount) * std::max(0.0f, n.merrySpacingBeats)
|
||||
: std::max(0.0f, n.durationBeats);
|
||||
const float appearTimeMs = std::max(0.0f, timeMs - std::max(0.0f, n.appearanceLeadBeats) * beatMs);
|
||||
const float endTimeMs = (durationType || n.effectiveType == 6)
|
||||
? std::max(timeMs, timeMs + durationBeats * beatMs)
|
||||
: timeMs;
|
||||
const float nextSpacingMs = noteIndex + 1 < gcNotes.size()
|
||||
? std::max(0.0f, static_cast<float>(gcNotes[noteIndex + 1].timeMs) - timeMs)
|
||||
: beatMs * 2.0f;
|
||||
float missWindowMs = std::max(0.0f, gcTimingAt(noteTimings[0], n.timeMs, beatMs, nextSpacingMs));
|
||||
float earlyWindowMs = std::max(0.0f, gcTimingAt(noteTimings[1], n.timeMs, beatMs, nextSpacingMs));
|
||||
float lateWindowMs = std::max(0.0f, gcTimingAt(noteTimings[2], n.timeMs, beatMs, nextSpacingMs));
|
||||
float muteTimingMs = std::max(0.0f, gcTimingAt(noteTimings[3], n.timeMs, beatMs, nextSpacingMs));
|
||||
// FUN_005ed4c0 replaces the authored timing-list results with the
|
||||
// four per-difficulty arrays from data/system.cfg when this flag is
|
||||
// enabled. Runtime +0x98/+0x9c/+0xa0 are miss, early and late.
|
||||
if (systemTiming.missMarkOverride) {
|
||||
missWindowMs = std::max(0.0f, systemTiming.miss[difficultyIndex]);
|
||||
earlyWindowMs = std::max(0.0f, systemTiming.unmute[difficultyIndex]);
|
||||
lateWindowMs = std::max(0.0f, systemTiming.limit[difficultyIndex]);
|
||||
muteTimingMs = std::max(0.0f, systemTiming.mute[difficultyIndex]);
|
||||
}
|
||||
// BuildTimingDataSub adds this literal for FLICK and the otherwise
|
||||
// unclassified type 0x10 before constructing runtime +0xc4.
|
||||
if (n.effectiveType == 2 || n.rawType == 0x10) {
|
||||
earlyWindowMs += beatMs * 0.2f;
|
||||
lateWindowMs += beatMs * 0.2f;
|
||||
}
|
||||
const float fadeAnchorMs = (durationType || n.effectiveType == 6)
|
||||
? endTimeMs
|
||||
: timeMs + lateWindowMs;
|
||||
const float markerFadeEndTimeMs = fadeAnchorMs + beatMs * 4.0f;
|
||||
data.notes.push_back({
|
||||
cumulativeDistanceAtTime(gcTrack, cumulative, timeMs, minT, maxT),
|
||||
timeMs,
|
||||
appearTimeMs,
|
||||
endTimeMs,
|
||||
cumulativeDistanceAtTime(gcTrack, cumulative, endTimeMs, minT, maxT),
|
||||
n.rawType,
|
||||
n.effectiveType,
|
||||
n.adlib,
|
||||
n.markEffectId,
|
||||
n.packedColor,
|
||||
n.merryCount,
|
||||
n.directionVector,
|
||||
beatMs,
|
||||
earlyWindowMs,
|
||||
lateWindowMs,
|
||||
missWindowMs,
|
||||
muteTimingMs,
|
||||
markerFadeEndTimeMs,
|
||||
true,
|
||||
});
|
||||
}
|
||||
std::cout << "GC note types:";
|
||||
for (size_t type = 0; type < typeCounts.size(); ++type) {
|
||||
if (typeCounts[type] == 0) continue;
|
||||
std::cout << " 0x" << std::hex << type << std::dec << '/' << gcNoteTypeName(static_cast<uint8_t>(type))
|
||||
<< '=' << typeCounts[type];
|
||||
}
|
||||
std::cout << std::endl;
|
||||
}
|
||||
|
||||
// FUN_005ed4c0 loads <stage>_clip.dat as an objectCount x frameCount byte
|
||||
// matrix. FUN_006445b0 indexes object first and round(timeMs / (1000/60))
|
||||
// second, and skips the object when the stored byte is zero.
|
||||
loadGcObjectClipTable(fs::path(stageFile), data);
|
||||
|
||||
std::cout << "GC stage loaded: " << data.title << " track=" << data.trackPoints.size()
|
||||
<< " notes=" << data.notes.size()
|
||||
<< " cameras=" << data.gcCameraKeys.size()
|
||||
<< " clipFrames=" << data.gcObjectClipFrameCount
|
||||
<< " bgKeys=" << static_cast<size_t>(data.config.count("gc_background_keys")
|
||||
? data.config.at("gc_background_keys") : 0.0f)
|
||||
<< (data.gcStage ? " particles=" + std::to_string(data.gcStage->particles.size()) +
|
||||
" visualizers=" + std::to_string(data.gcStage->visualizer.size()) +
|
||||
" models=" + std::to_string(data.gcStage->modelNames.size()) +
|
||||
" objects=" + std::to_string(data.gcStage->objects.size())
|
||||
: " backgroundScene=<decode-failed>")
|
||||
<< (data.audioPath.empty() ? " audio=<none>" : " bgm=" + data.audioPath)
|
||||
<< (data.audioShotPath.empty() ? " shot=<none>" : " shot=" + data.audioShotPath)
|
||||
<< " audioGain=" << data.audioBgmGain << '/' << data.audioShotGain
|
||||
<< (data.gameplaySoundPaths[1].empty() ? " tapSE=<none>" : " tapSE=Ver.3")
|
||||
<< (data.backgroundPath.empty() ? " background=<none>" : " background=" + data.backgroundPath)
|
||||
<< std::endl;
|
||||
return data;
|
||||
}
|
||||
|
||||
LevelData loadLegacyFolder(const std::string& mapFolder) {
|
||||
LevelData data;
|
||||
std::string metaPath = mapFolder + "/map.txt";
|
||||
std::ifstream metaFile(metaPath);
|
||||
if (!metaFile.is_open()) return data;
|
||||
|
||||
std::string line, trackFile, notesFile, timelineFile;
|
||||
while (std::getline(metaFile, line)) {
|
||||
if (line.empty() || line[0] == '#') continue;
|
||||
std::stringstream ss(line);
|
||||
std::string key, value; ss >> key; std::getline(ss, value);
|
||||
if (!value.empty() && value[0] == ' ') value.erase(0, 1);
|
||||
if (key == "title") data.title = value;
|
||||
else if (key == "author") data.author = value;
|
||||
else if (key == "audio") data.audioPath = mapFolder + "/" + value;
|
||||
else if (key == "track") trackFile = mapFolder + "/" + value;
|
||||
else if (key == "notes") notesFile = mapFolder + "/" + value;
|
||||
else if (key == "timeline") timelineFile = mapFolder + "/" + value;
|
||||
else { try { data.config[key] = std::stof(value); } catch(...) {} }
|
||||
}
|
||||
|
||||
std::ifstream tFile(trackFile);
|
||||
if (tFile.is_open()) {
|
||||
while (std::getline(tFile, line)) {
|
||||
std::stringstream ss(line);
|
||||
float x, y, z; int type = 0; int visible = 1;
|
||||
if (ss >> x >> y >> z) {
|
||||
if (!(ss >> type)) type = 0;
|
||||
if (!(ss >> visible)) visible = 1;
|
||||
data.trackPoints.push_back({{x, y, z}, type, visible != 0});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::ifstream nFile(notesFile);
|
||||
if (nFile.is_open()) {
|
||||
float distance = 0.0f;
|
||||
while (nFile >> distance) data.notes.push_back({distance, 0.0f, 0, 0});
|
||||
}
|
||||
|
||||
std::ifstream tlFile(timelineFile);
|
||||
if (tlFile.is_open()) {
|
||||
while (std::getline(tlFile, line)) {
|
||||
if (line.empty() || line[0] == '#') continue;
|
||||
std::stringstream ss(line);
|
||||
float t, val; std::string param;
|
||||
if (ss >> t >> param >> val) data.timeline.push_back({t, param, val});
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
LevelData LevelLoader::load(const std::string& path) {
|
||||
const fs::path p(path);
|
||||
if (fs::is_regular_file(p) && p.extension() == ".dat") return loadGcStageDat(path);
|
||||
return loadLegacyFolder(path);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user