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,27 @@
|
||||
add_executable(openroller-desktop
|
||||
src/AudioManager.cpp
|
||||
src/CabinetBackend.cpp
|
||||
src/LevelLoader.cpp
|
||||
src/ServiceMenu.cpp
|
||||
src/SongSelect.cpp
|
||||
src/main.cpp
|
||||
)
|
||||
|
||||
set_target_properties(openroller-desktop PROPERTIES OUTPUT_NAME OpenRoller)
|
||||
|
||||
target_include_directories(openroller-desktop PRIVATE include)
|
||||
target_link_libraries(openroller-desktop
|
||||
PRIVATE
|
||||
Vectorail::Core
|
||||
Vectorail::GC
|
||||
Vectorail::GCEffects
|
||||
)
|
||||
|
||||
if(MSVC)
|
||||
target_compile_options(openroller-desktop PRIVATE /W4)
|
||||
else()
|
||||
target_compile_options(openroller-desktop PRIVATE -Wall -Wextra -Wpedantic)
|
||||
endif()
|
||||
|
||||
configure_file(openroller.cfg openroller.cfg COPYONLY)
|
||||
file(COPY shaders DESTINATION "${CMAKE_CURRENT_BINARY_DIR}")
|
||||
@@ -0,0 +1,58 @@
|
||||
#pragma once
|
||||
#include <SDL3/SDL.h>
|
||||
#include <array>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
class AudioManager {
|
||||
public:
|
||||
enum class GameplaySound : size_t {
|
||||
Adlib = 0,
|
||||
Tap1 = 1,
|
||||
Tap2 = 2,
|
||||
};
|
||||
|
||||
AudioManager();
|
||||
~AudioManager();
|
||||
|
||||
bool loadMusic(const std::string& path, float gain = 1.0f);
|
||||
bool loadMusicPair(const std::string& bgmPath, const std::string& shotPath,
|
||||
float bgmGain, float shotGain);
|
||||
bool loadGameplaySounds(const std::array<std::string, 3>& paths,
|
||||
const std::array<float, 3>& gains);
|
||||
void playGameplaySound(GameplaySound sound);
|
||||
void play();
|
||||
void pause();
|
||||
void resume();
|
||||
void setShotMuted(bool muted);
|
||||
|
||||
double getTime() const;
|
||||
double getDuration() const; // Длина песни в секундах
|
||||
|
||||
bool isPlaying() const { return playing; }
|
||||
|
||||
private:
|
||||
struct GameplaySoundSlot {
|
||||
std::vector<Uint8> data;
|
||||
std::array<SDL_AudioStream*, 2> voices{nullptr, nullptr};
|
||||
size_t nextVoice = 0;
|
||||
};
|
||||
|
||||
void clear();
|
||||
void clearGameplaySounds();
|
||||
bool openStreams(const SDL_AudioSpec& bgmSpec, const Uint8* bgmData, Uint32 bgmLen,
|
||||
float bgmGain, const SDL_AudioSpec* shotSpec = nullptr,
|
||||
const Uint8* shotData = nullptr, Uint32 shotLen = 0,
|
||||
float shotGain = 1.0f);
|
||||
|
||||
SDL_AudioDeviceID device;
|
||||
SDL_AudioStream* bgmStream;
|
||||
SDL_AudioStream* shotStream;
|
||||
std::array<GameplaySoundSlot, 3> gameplaySounds;
|
||||
double duration; // Предрассчитанная длительность
|
||||
float shotBaseGain;
|
||||
bool shotMuted;
|
||||
bool playing;
|
||||
Uint64 startTime;
|
||||
Uint64 accumulatedTicks;
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
#pragma once
|
||||
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
enum class CabinetInput : std::size_t {
|
||||
Test,
|
||||
Service,
|
||||
Coin,
|
||||
Select,
|
||||
Enter,
|
||||
LeftUp,
|
||||
LeftDown,
|
||||
LeftLeft,
|
||||
LeftRight,
|
||||
LeftButton,
|
||||
RightUp,
|
||||
RightDown,
|
||||
RightLeft,
|
||||
RightRight,
|
||||
RightButton,
|
||||
Count,
|
||||
};
|
||||
|
||||
struct CabinetRgb {
|
||||
std::uint8_t r = 0;
|
||||
std::uint8_t g = 0;
|
||||
std::uint8_t b = 0;
|
||||
};
|
||||
|
||||
enum class CardReaderStatus {
|
||||
Ready,
|
||||
Disconnected,
|
||||
Unformatted,
|
||||
ReadError,
|
||||
Timeout,
|
||||
};
|
||||
|
||||
struct CardReaderState {
|
||||
CardReaderStatus status = CardReaderStatus::Disconnected;
|
||||
std::string cardId;
|
||||
};
|
||||
|
||||
// Hardware boundary shared by gameplay and test mode. The software backend
|
||||
// is deliberately useful on its own; an ACM implementation can replace it
|
||||
// without teaching the test-mode forms about packets or device paths.
|
||||
class CabinetBackend {
|
||||
public:
|
||||
virtual ~CabinetBackend() = default;
|
||||
|
||||
virtual void poll() = 0;
|
||||
virtual bool input(CabinetInput input) const = 0;
|
||||
|
||||
virtual void setLed(std::size_t logicalIndex, CabinetRgb color) = 0;
|
||||
virtual void clearLeds() = 0;
|
||||
virtual void commitOutputs() = 0;
|
||||
virtual const std::array<CabinetRgb, 118>& leds() const = 0;
|
||||
|
||||
virtual int headphoneVolume() const { return 0; }
|
||||
virtual bool headphoneConnected() const { return false; }
|
||||
virtual bool grooveStageConnected() const { return false; }
|
||||
virtual CardReaderState cardReader() const { return {}; }
|
||||
};
|
||||
|
||||
// Keyboard + in-memory outputs. This keeps service mode fully testable before
|
||||
// the cabinet ACM and RFID transports are attached.
|
||||
CabinetBackend& defaultCabinetBackend();
|
||||
@@ -0,0 +1,105 @@
|
||||
#pragma once
|
||||
#include <array>
|
||||
#include <vector>
|
||||
#include <string>
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
#include <map>
|
||||
#include <optional>
|
||||
#include <cstdint>
|
||||
#include <glm/glm.hpp>
|
||||
#include "gc/StagePattern.hpp"
|
||||
|
||||
struct TrackPoint {
|
||||
glm::vec3 position;
|
||||
int type; // 0=Smooth, 1=Straight
|
||||
bool visible = true; // NEW: Render rail or not
|
||||
float timeMs = 0.0f;
|
||||
};
|
||||
|
||||
struct Keyframe {
|
||||
float t;
|
||||
std::string param;
|
||||
float value;
|
||||
};
|
||||
|
||||
struct LevelNote {
|
||||
float distance = 0.0f;
|
||||
float timeMs = 0.0f;
|
||||
float appearTimeMs = 0.0f;
|
||||
float endTimeMs = 0.0f;
|
||||
float endDistance = 0.0f;
|
||||
unsigned int rawType = 0;
|
||||
unsigned int effectiveType = 0;
|
||||
// Wire +5 becomes runtime marker +8 in FUN_005ea800. Any non-zero value
|
||||
// makes FUN_005e9410 select the ALB slot of the active gameplay SE set.
|
||||
bool adlib = false;
|
||||
// First signed 16-bit field at wire +6. DrawMark passes this value minus
|
||||
// one as the UV-list selector for game effect 3.
|
||||
int markEffectId = -1;
|
||||
// game471 wire +55. The stage authors use this packed RRGGBBAA value for
|
||||
// the path/body colour of duration targets.
|
||||
uint32_t packedColor = 0xffffffffu;
|
||||
uint32_t merryCount = 0;
|
||||
// BuildTimingDataSub converts wire +25 (length) and +29/+33 (HPB angles)
|
||||
// to this world-space vector. GameScene::buildGameData later projects it
|
||||
// through the camera active at timeMs to obtain a fixed screen angle.
|
||||
glm::vec3 directionVector{0.0f};
|
||||
// Runtime +0x20 and +0xc4 in game471's marker object.
|
||||
float beatDurationMs = 500.0f;
|
||||
float earlyTimingMs = 250.0f;
|
||||
float lateTimingMs = 250.0f;
|
||||
float missTimingMs = 250.0f;
|
||||
float muteTimingMs = 0.0f;
|
||||
float markerFadeEndTimeMs = 0.0f;
|
||||
bool gcTiming = false;
|
||||
};
|
||||
|
||||
// Exact 59-byte stage camera record after decoding from big endian. The
|
||||
// original game expands this to a 0x44-byte runtime key by adding rotationA.z
|
||||
// = 0; keeping the wire fields here lets the player reproduce aMode/fMode
|
||||
// instead of flattening them into the legacy editor timeline.
|
||||
struct GcCameraKey {
|
||||
uint32_t timeMs = 0;
|
||||
uint8_t aMode = 0;
|
||||
uint8_t fMode = 0;
|
||||
float dist = 0.0f;
|
||||
glm::vec3 rotationA{0.0f};
|
||||
glm::vec3 originOff{0.0f};
|
||||
uint8_t projType = 0;
|
||||
glm::vec3 fieldFar{0.0f};
|
||||
glm::vec3 fieldNear{0.0f};
|
||||
float rotationB = 0.0f;
|
||||
};
|
||||
|
||||
struct LevelData {
|
||||
std::string title;
|
||||
std::string author;
|
||||
std::string audioPath;
|
||||
std::string audioShotPath;
|
||||
float audioBgmGain = 1.0f;
|
||||
float audioShotGain = 1.0f;
|
||||
// game471 SE set slots: ALB, TP1 and TP2. The arcade runtime allocates
|
||||
// two playback channels for each slot so repeated taps can overlap.
|
||||
std::array<std::string, 3> gameplaySoundPaths;
|
||||
std::array<float, 3> gameplaySoundGains{1.0f, 1.0f, 1.0f};
|
||||
std::string backgroundPath;
|
||||
std::vector<TrackPoint> trackPoints;
|
||||
std::vector<LevelNote> notes;
|
||||
std::vector<GcCameraKey> gcCameraKeys;
|
||||
// Optional arcade *_clip.dat table. It is object-major and contains one
|
||||
// byte per 60 Hz frame; a zero byte suppresses that stage object for the
|
||||
// frame. game471 consumes this before evaluating authored visibility.
|
||||
uint32_t gcObjectClipFrameCount = 0;
|
||||
std::vector<uint8_t> gcObjectClipVisibility;
|
||||
// Complete decoded stage background: particle/visualizer keys, exact
|
||||
// gradient fade flags and the animated 3D object scene.
|
||||
std::optional<gc::ParsedStagePattern> gcStage;
|
||||
std::vector<Keyframe> timeline;
|
||||
std::map<std::string, float> config;
|
||||
};
|
||||
|
||||
class LevelLoader {
|
||||
public:
|
||||
static LevelData load(const std::string& path);
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
#pragma once
|
||||
|
||||
#include <SDL3/SDL.h>
|
||||
|
||||
#include <filesystem>
|
||||
|
||||
class CabinetBackend;
|
||||
|
||||
// Runs the cabinet operator/test mode until Test/Escape returns to the caller.
|
||||
// The existing OpenGL context remains owned by the caller.
|
||||
void runServiceMenu(SDL_Window* window, CabinetBackend& cabinet,
|
||||
const std::filesystem::path& contentPath = {});
|
||||
@@ -0,0 +1,12 @@
|
||||
#pragma once
|
||||
|
||||
#include <SDL3/SDL.h>
|
||||
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
|
||||
// Runs the Groove Coaster-style song and difficulty selector. Returns false
|
||||
// when the window is closed/cancelled, otherwise writes the chosen stage .dat.
|
||||
bool runSongSelect(SDL_Window* window,
|
||||
const std::filesystem::path& gcRoot,
|
||||
std::string* selectedStagePath);
|
||||
@@ -0,0 +1,6 @@
|
||||
# OpenRoller machine configuration.
|
||||
#
|
||||
# Mirrors HKLM\SOFTWARE\taito\typex\Country from the original Type X system.
|
||||
# 0 = original eight coin/song presets
|
||||
# other = extended 39-entry coin/song table
|
||||
Country=0
|
||||
@@ -0,0 +1,39 @@
|
||||
#version 450 core
|
||||
out vec4 FragColor;
|
||||
in vec2 vUv;
|
||||
uniform float uTime;
|
||||
uniform vec3 uResolution;
|
||||
uniform sampler2D uBackdrop;
|
||||
uniform bool uHasBackdrop;
|
||||
uniform float uBackdropAspect;
|
||||
uniform vec3 uBgTopRight;
|
||||
uniform vec3 uBgTopLeft;
|
||||
uniform vec3 uBgBottomRight;
|
||||
uniform vec3 uBgBottomLeft;
|
||||
|
||||
void main() {
|
||||
vec3 top = mix(uBgTopLeft, uBgTopRight, vUv.x);
|
||||
vec3 bottom = mix(uBgBottomLeft, uBgBottomRight, vUv.x);
|
||||
vec3 color = mix(bottom, top, vUv.y);
|
||||
|
||||
if (uHasBackdrop) {
|
||||
// The useful jacket cell occupies the left square of GC's 512x256
|
||||
// *_menu.dds atlas. Crop that cell and cover the portrait viewport.
|
||||
float screenAspect = uResolution.x / uResolution.y;
|
||||
vec2 imageUv = vUv - 0.5;
|
||||
if (screenAspect < uBackdropAspect) {
|
||||
imageUv.x *= screenAspect / uBackdropAspect;
|
||||
} else {
|
||||
imageUv.y *= uBackdropAspect / screenAspect;
|
||||
}
|
||||
imageUv += 0.5;
|
||||
// Jacket cell bounds are 197x197 in the original 512x256 atlas.
|
||||
vec2 atlasUv = vec2(imageUv.x * (197.0 / 512.0),
|
||||
(1.0 - imageUv.y) * (197.0 / 256.0));
|
||||
vec4 backdrop = texture(uBackdrop, atlasUv);
|
||||
float vignette = 1.0 - smoothstep(0.18, 0.82, length(vUv - 0.5));
|
||||
color = mix(color, backdrop.rgb * (0.08 + 0.05 * vignette), backdrop.a * 0.45);
|
||||
}
|
||||
|
||||
FragColor = vec4(color, 1.0);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
#version 450 core
|
||||
layout (location = 0) in vec2 aPos;
|
||||
out vec2 vUv;
|
||||
uniform vec3 uGameplayFlip;
|
||||
|
||||
void main() {
|
||||
vUv = aPos * 0.5 + 0.5;
|
||||
gl_Position = vec4(aPos * uGameplayFlip.xy, 0.0, 1.0);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
#version 450 core
|
||||
in float vSide;
|
||||
in float vDist;
|
||||
in float vTrackTimeMs;
|
||||
out vec4 FragColor;
|
||||
|
||||
uniform vec3 uColor;
|
||||
uniform vec3 uBehindColor;
|
||||
uniform bool uIsAvatar;
|
||||
uniform bool uClipTrack;
|
||||
uniform float uCurrentTimeMs;
|
||||
uniform float uDrawBehindMs;
|
||||
uniform float uDrawAheadMs;
|
||||
uniform float uAlpha;
|
||||
|
||||
void main() {
|
||||
float dist = abs(vSide);
|
||||
|
||||
// Мягкий туман: трасса видна очень далеко
|
||||
float fog = exp(-vDist * 0.001);
|
||||
|
||||
if (uClipTrack &&
|
||||
(vTrackTimeMs < uCurrentTimeMs - uDrawBehindMs ||
|
||||
vTrackTimeMs > uCurrentTimeMs + uDrawAheadMs)) {
|
||||
discard;
|
||||
}
|
||||
|
||||
if (uIsAvatar) {
|
||||
FragColor = vec4(1.0, 1.0, 1.0, 1.0);
|
||||
} else {
|
||||
float intensity = pow(1.0 - dist, 4.0);
|
||||
float core = pow(1.0 - dist, 20.0);
|
||||
vec3 railColor = (uClipTrack && vTrackTimeMs < uCurrentTimeMs) ? uBehindColor : uColor;
|
||||
vec3 finalColor = mix(railColor, vec3(1.0), core);
|
||||
|
||||
// Цвет затухает в темноту, а не просто обрезается
|
||||
FragColor = vec4(finalColor * fog, intensity * fog * uAlpha);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
#version 450 core
|
||||
layout (location = 0) in vec3 aPos;
|
||||
layout (location = 1) in float aSide;
|
||||
layout (location = 2) in vec3 aRight;
|
||||
layout (location = 3) in float aTimeMs;
|
||||
|
||||
uniform mat4 uProjection;
|
||||
uniform mat4 uView;
|
||||
uniform float uWidth;
|
||||
|
||||
out float vSide;
|
||||
out float vDist;
|
||||
out float vTrackTimeMs;
|
||||
|
||||
void main() {
|
||||
vSide = aSide;
|
||||
vTrackTimeMs = aTimeMs;
|
||||
vec3 finalPos = aPos + aRight * aSide * uWidth;
|
||||
|
||||
// Считаем позицию в пространстве камеры
|
||||
vec4 viewPos = uView * vec4(finalPos, 1.0);
|
||||
vDist = -viewPos.z; // Глубина (дистанция от камеры)
|
||||
|
||||
gl_Position = uProjection * viewPos;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
#version 450 core
|
||||
out vec4 FragColor;
|
||||
|
||||
uniform vec4 uColor;
|
||||
|
||||
void main() {
|
||||
FragColor = uColor;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
#version 450 core
|
||||
layout (location = 0) in vec3 aPos;
|
||||
|
||||
uniform mat4 uProjection;
|
||||
uniform mat4 uView;
|
||||
uniform mat4 uModel;
|
||||
|
||||
void main() {
|
||||
gl_Position = uProjection * uView * uModel * vec4(aPos, 1.0);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
#version 450 core
|
||||
in vec2 vUv;
|
||||
out vec4 FragColor;
|
||||
|
||||
uniform sampler2D uTexture;
|
||||
uniform vec4 uColor;
|
||||
|
||||
void main() {
|
||||
vec4 texel = texture(uTexture, vUv);
|
||||
if (texel.a < 0.01) discard;
|
||||
FragColor = vec4(texel.rgb * uColor.rgb, texel.a * uColor.a);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
#version 450 core
|
||||
layout (location = 0) in vec3 aPos;
|
||||
layout (location = 1) in vec2 aUv;
|
||||
|
||||
uniform mat4 uProjection;
|
||||
uniform mat4 uView;
|
||||
uniform vec4 uUvRect;
|
||||
|
||||
out vec2 vUv;
|
||||
|
||||
void main() {
|
||||
vUv = uUvRect.xy + aUv * uUvRect.zw;
|
||||
gl_Position = uProjection * uView * vec4(aPos, 1.0);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
#version 450 core
|
||||
in float vLife;
|
||||
out vec4 FragColor;
|
||||
uniform vec3 uColor;
|
||||
|
||||
void main() {
|
||||
// В кваде vLife достаточно для затухания
|
||||
FragColor = vec4(uColor, vLife);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
#version 450 core
|
||||
layout (location = 0) in vec3 aPos;
|
||||
layout (location = 1) in vec2 aOffset; // Смещение углов квадрата
|
||||
layout (location = 2) in float aLife;
|
||||
|
||||
uniform mat4 uProjection;
|
||||
uniform mat4 uView;
|
||||
|
||||
out float vLife;
|
||||
|
||||
void main() {
|
||||
vLife = aLife;
|
||||
vec4 viewPos = uView * vec4(aPos, 1.0);
|
||||
// Billboard: квадрат всегда смотрит на камеру
|
||||
viewPos.xy += aOffset * aLife * 0.5;
|
||||
gl_Position = uProjection * viewPos;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
#version 450 core
|
||||
in vec4 vColor;
|
||||
out vec4 FragColor;
|
||||
|
||||
void main() {
|
||||
FragColor = vColor;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
#version 450 core
|
||||
layout (location = 0) in vec3 aPos;
|
||||
layout (location = 1) in vec4 aColor;
|
||||
|
||||
uniform mat4 uProjection;
|
||||
uniform mat4 uView;
|
||||
|
||||
out vec4 vColor;
|
||||
|
||||
void main() {
|
||||
vColor = aColor;
|
||||
gl_Position = uProjection * uView * vec4(aPos, 1.0);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
#version 450 core
|
||||
in vec2 vUv;
|
||||
out vec4 FragColor;
|
||||
uniform sampler2D uTexture;
|
||||
uniform bool uUseTexture;
|
||||
uniform bool uUseGradient;
|
||||
uniform vec4 uColor;
|
||||
uniform vec4 uGradientTop;
|
||||
uniform vec4 uGradientBottom;
|
||||
|
||||
void main() {
|
||||
if (uUseTexture) {
|
||||
FragColor = texture(uTexture, vUv) * uColor;
|
||||
} else if (uUseGradient) {
|
||||
FragColor = mix(uGradientTop, uGradientBottom, vUv.y);
|
||||
} else {
|
||||
FragColor = uColor;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
#version 450 core
|
||||
layout (location = 0) in vec2 aPos;
|
||||
layout (location = 1) in vec2 aUv;
|
||||
out vec2 vUv;
|
||||
|
||||
void main() {
|
||||
vUv = aUv;
|
||||
gl_Position = vec4(aPos, 0.0, 1.0);
|
||||
}
|
||||
@@ -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