Initial public source release

Split reusable rendering and format support into vectorail-core and vectorail-gc.
This commit is contained in:
2026-08-02 17:05:27 +02:00
commit 831d96e562
109 changed files with 20558 additions and 0 deletions
+36
View File
@@ -0,0 +1,36 @@
TARGET = openroller_psp
OBJS = src/main.o src/StageRuntime.o src/AudioPlayer.o src/Gameplay.o src/SongMenu.o
INCDIR = include ../include
ifeq ($(DEBUG),1)
CFLAGS = -Og -g3 -G0 -Wall -Wextra
else
CFLAGS = -O2 -G0 -Wall -Wextra
endif
CXXFLAGS = $(CFLAGS) -std=gnu++17 -fno-exceptions -fno-rtti
CXXFLAGS += -DOPENROLLER_BUILD_TIMESTAMP=\"$(BUILD_TIMESTAMP)\"
ASFLAGS = $(CFLAGS)
LIBDIR =
LDFLAGS =
LIBS = -lpspgum -lpspgu -lpspaudio -lpspmp3
BUILD_PRX = 1
EXTRA_TARGETS = EBOOT.PBP
PSP_EBOOT_TITLE = OpenRoller PSP
# XMB media is optional so the public source tree can be built without
# redistributing project-specific artwork or music.
ifneq ($(wildcard assets/ICON0.PNG),)
PSP_EBOOT_ICON = assets/ICON0.PNG
endif
ifneq ($(wildcard assets/PIC1.PNG),)
PSP_EBOOT_PIC1 = assets/PIC1.PNG
endif
ifneq ($(wildcard assets/SND0.AT3),)
PSP_EBOOT_SND0 = assets/SND0.AT3
endif
PSPSDK := $(shell psp-config --pspsdk-path)
include $(PSPSDK)/lib/build.mak
+11
View File
@@ -0,0 +1,11 @@
# Optional XMB media
The public source tree builds without any files in this directory. To customize
the XMB entry for a local build, provide any of these conventional PSP assets:
- `ICON0.PNG` — application icon.
- `PIC1.PNG` — full-screen background image.
- `SND0.AT3` — short ATRAC3 background audio.
The GNUmakefile detects each file independently. These media files are ignored
by Git and are not covered by the OpenRoller source license.
+26
View File
@@ -0,0 +1,26 @@
#pragma once
#include <cstdint>
namespace openroller::psp {
enum class AudioEffect : std::uint8_t {
Adlib = 0,
Tap1 = 1,
Tap2 = 2,
};
bool startAudioPlayer(
const char* bgmPath,
const char* shotPath,
const char* effectDirectory);
void stopAudioPlayer();
bool audioPlayerRunning();
bool audioPlayerFinished();
std::uint32_t audioPlayerTimeMs();
void setAudioPlayerPaused(bool paused);
void seekAudioPlayer(std::uint32_t timeMs);
void setAudioPlayerShotMuted(bool muted);
void playAudioPlayerEffect(AudioEffect effect);
} // namespace openroller::psp
+63
View File
@@ -0,0 +1,63 @@
#pragma once
#include "StageRuntime.hpp"
#include <cstdint>
namespace openroller::psp {
enum class Judgment : std::uint8_t {
// Values 0..3 match the original game's recovered enum.
Miss = 0,
Good = 1,
Cool = 2,
Great = 3,
Pending = 0xff,
};
struct GameplayState {
struct NoteRuntime {
std::uint8_t judgment = static_cast<std::uint8_t>(Judgment::Pending);
std::uint8_t holding = 0;
std::uint8_t shotMuteApplied = 0;
std::uint8_t reserved = 0;
float inputStartTimeMs = -1.0f;
float lastInputTimeMs = -1.0f;
std::uint32_t inputMask = 0;
std::uint32_t lastInputBit = 0;
};
NoteRuntime* notes = nullptr;
std::uint32_t noteCount = 0;
std::uint32_t nextPending = 0;
std::uint32_t combo = 0;
std::uint32_t maximumCombo = 0;
std::uint32_t greatCount = 0;
std::uint32_t coolCount = 0;
std::uint32_t goodCount = 0;
std::uint32_t missCount = 0;
float previousClockMs = 0.0f;
float lastJudgmentClockMs = -10000.0f;
std::int32_t lastJudgedNote = -1;
Judgment lastJudgment = Judgment::Pending;
bool shotMuted = false;
};
bool initializeGameplay(const StageView& stage, GameplayState* gameplay);
void destroyGameplay(GameplayState* gameplay);
void seekGameplay(const StageView& stage, GameplayState* gameplay, float clockMs);
void updateGameplay(const StageView& stage, GameplayState* gameplay, float clockMs);
Judgment tapGameplay(const StageView& stage, GameplayState* gameplay, float clockMs);
Judgment pressGameplay(
const StageView& stage,
GameplayState* gameplay,
float clockMs,
std::uint32_t inputBit);
Judgment releaseGameplay(
const StageView& stage,
GameplayState* gameplay,
float clockMs,
std::uint32_t inputBit);
Judgment noteJudgment(const GameplayState& gameplay, std::uint32_t noteIndex);
} // namespace openroller::psp
+35
View File
@@ -0,0 +1,35 @@
#pragma once
#include "openroller/psp/SongCatalog.hpp"
#include <cstddef>
#include <cstdint>
namespace openroller::psp {
struct SongMenu {
void* catalogStorage = nullptr;
std::size_t catalogSize = 0;
const SongCatalogHeader* header = nullptr;
const SongCatalogRecord* songs = nullptr;
int selection = 0;
int difficulty = 0;
bool difficultyMode = false;
void* jacketStorage = nullptr;
std::size_t jacketSize = 0;
const std::uint16_t* jacketPixels = nullptr;
std::uint16_t jacketWidth = 0;
std::uint16_t jacketHeight = 0;
char rootPath[256]{};
};
bool loadSongMenu(const char* catalogPath, SongMenu* menu, char* error, std::size_t errorCapacity);
void unloadSongMenu(SongMenu* menu);
bool moveSongSelection(SongMenu* menu, int delta);
bool moveDifficultySelection(SongMenu* menu, int delta);
const SongCatalogRecord* selectedSong(const SongMenu& menu);
bool selectedSongPath(const SongMenu& menu, char* output, std::size_t capacity);
bool selectedAudioPath(const SongMenu& menu, char* output, std::size_t capacity);
bool selectedShotAudioPath(const SongMenu& menu, char* output, std::size_t capacity);
} // namespace openroller::psp
+60
View File
@@ -0,0 +1,60 @@
#pragma once
#include "openroller/psp/StagePackage.hpp"
#include <cstddef>
#include <cstdint>
namespace openroller::psp {
struct Vec3 {
float x = 0.0f;
float y = 0.0f;
float z = 0.0f;
};
struct CameraState {
Vec3 eye;
Vec3 target;
Vec3 up{0.0f, 1.0f, 0.0f};
float projectionBlend = 0.0f;
};
struct StageView {
void* storage = nullptr;
std::size_t storageSize = 0;
const StagePackageHeader* header = nullptr;
const PackageTrackPoint* track = nullptr;
const PackageNote* notes = nullptr;
const PackageCameraPoint* cameras = nullptr;
const PackageDrawDistancePoint* drawDistances = nullptr;
const PackageBackgroundColorPoint* backgroundColors = nullptr;
const PackageBackgroundModel* backgroundModels = nullptr;
const PackageBackgroundVertex* backgroundVertices = nullptr;
const PackageBackgroundObject* backgroundObjects = nullptr;
const PackageVisibilityKey* visibilityKeys = nullptr;
const PackageTransformKey* transformKeys = nullptr;
const PackageObjectColorKey* objectColorKeys = nullptr;
const PackageParticlePoint* particles = nullptr;
const PackageVisualizerPoint* visualizer = nullptr;
const PackageBpmPoint* bpmChanges = nullptr;
};
struct BackgroundColors {
std::uint32_t topRight = 0;
std::uint32_t topLeft = 0;
std::uint32_t bottomRight = 0;
std::uint32_t bottomLeft = 0;
};
bool loadStagePackage(const char* path, StageView* stage, char* error, std::size_t errorCapacity);
void unloadStagePackage(StageView* stage);
Vec3 trackPositionAt(const StageView& stage, float timeMs);
Vec3 trackTangentAt(const StageView& stage, float timeMs);
CameraState evaluateCamera(const StageView& stage, float timeMs);
BackgroundColors evaluateBackground(const StageView& stage, float timeMs);
float evaluateDrawAhead(const StageView& stage, float timeMs);
float evaluateBeatDurationMs(const StageView& stage, float timeMs);
} // namespace openroller::psp
+43
View File
@@ -0,0 +1,43 @@
#pragma once
namespace openroller::psp {
constexpr int kScreenWidth = 480;
constexpr int kScreenHeight = 272;
constexpr int kLogicalWidth = 720;
constexpr int kLogicalHeight = 1280;
constexpr float kLogicalScale = 3.0f / 8.0f;
constexpr int kScaledWidth = 270;
constexpr int kBorder = (kScreenHeight - kScaledWidth) / 2;
enum class TateSide {
Clockwise,
CounterClockwise,
};
struct Point {
float x;
float y;
};
inline Point toScreen(float logicalX, float logicalY, TateSide side) {
if (side == TateSide::Clockwise) {
return {
static_cast<float>(kScreenWidth) - logicalY * kLogicalScale,
static_cast<float>(kBorder) + logicalX * kLogicalScale,
};
}
return {
logicalY * kLogicalScale,
static_cast<float>(kBorder + kScaledWidth) - logicalX * kLogicalScale,
};
}
inline Point screenDirectionToLogical(float screenX, float screenY, TateSide side) {
if (side == TateSide::Clockwise) {
return {screenY, -screenX};
}
return {-screenY, screenX};
}
} // namespace openroller::psp
+418
View File
@@ -0,0 +1,418 @@
#include "AudioPlayer.hpp"
#include <pspaudio.h>
#include <pspiofilemgr.h>
#include <pspkernel.h>
#include <pspmp3.h>
#include <psputility.h>
#include <algorithm>
#include <cstdio>
#include <cstdlib>
#include <cstdint>
#include <cstring>
namespace openroller::psp {
namespace {
struct Mp3Decoder {
int file = -1;
int handle = -1;
alignas(64) unsigned char streamBuffer[16 * 1024];
alignas(64) unsigned char pcmBuffer[16 * (1152 / 2)];
};
struct AudioState {
struct Effect {
short* samples = nullptr;
std::uint32_t valueCount = 0;
volatile std::uint32_t voices[2]{};
volatile std::uint32_t nextVoice = 0;
};
Mp3Decoder bgm;
Effect effects[3];
int channel = -1;
int thread = -1;
int sampleRate = 0;
int channels = 0;
int lastDecodedBytes = 0;
volatile bool running = false;
volatile bool paused = false;
volatile bool finished = false;
volatile int requestedSeekMs = -1;
volatile std::uint32_t playedSamples = 0;
int consecutiveOutputFailures = 0;
bool resourceInitialized = false;
bool avcodecLoaded = false;
bool mp3ModuleLoaded = false;
alignas(64) std::int32_t mixAccumulator[1152 * 2];
// sceAudioSRCOutputBlocking waits until a buffer can be queued; it does
// not make the submitted memory immediately reusable. Keep the buffer
// currently consumed by the audio hardware separate from the one being
// prepared by the decoder/mixer.
alignas(64) short mixBuffers[2][1152 * 2];
std::uint32_t mixBufferIndex = 0;
};
AudioState gAudio;
short softLimit(std::int32_t sample) {
// Preserve the quiet 75% of the range, then progressively compress peaks.
// The old hard clamp produced flat-topped waves whenever BGM, SHOT and a
// hit sound crossed 0 dBFS, which is heard as continuous crackle.
const bool negative = sample < 0;
std::int32_t magnitude = negative ? -sample : sample;
if (magnitude > 24576) {
if (magnitude <= 32768) {
magnitude = 24576 + ((magnitude - 24576) >> 1);
} else if (magnitude <= 65528) {
magnitude = 28672 + ((magnitude - 32768) >> 3);
} else {
magnitude = 32767;
}
}
const std::int32_t limited = negative ? -magnitude : magnitude;
return static_cast<short>(std::clamp<std::int32_t>(
limited, static_cast<std::int32_t>(-32768),
static_cast<std::int32_t>(32767)));
}
bool loadEffect(const char* path, AudioState::Effect* effect) {
if (!path || !*path || !effect) return false;
const int file = sceIoOpen(path, PSP_O_RDONLY, 0777);
if (file < 0) return false;
const int length = sceIoLseek32(file, 0, PSP_SEEK_END);
if (length <= 0 || length > 1024 * 1024 || (length & 3) != 0 ||
sceIoLseek32(file, 0, PSP_SEEK_SET) < 0) {
sceIoClose(file);
return false;
}
short* samples = static_cast<short*>(std::malloc(static_cast<std::size_t>(length)));
if (!samples || sceIoRead(file, samples, length) != length) {
std::free(samples);
sceIoClose(file);
return false;
}
sceIoClose(file);
effect->samples = samples;
effect->valueCount = static_cast<std::uint32_t>(length / 2);
effect->voices[0] = effect->valueCount;
effect->voices[1] = effect->valueCount;
return true;
}
void closeEffects() {
for (AudioState::Effect& effect : gAudio.effects) {
std::free(effect.samples);
effect = {};
}
}
void closeDecoder(Mp3Decoder* decoder) {
if (decoder->handle >= 0) {
sceMp3ReleaseMp3Handle(decoder->handle);
decoder->handle = -1;
}
if (decoder->file >= 0) {
sceIoClose(decoder->file);
decoder->file = -1;
}
}
bool fillDecoder(Mp3Decoder* decoder) {
SceUChar8* destination = nullptr;
SceInt32 writable = 0;
SceInt32 sourcePosition = 0;
if (!decoder || decoder->handle < 0 ||
sceMp3GetInfoToAddStreamData(
decoder->handle, &destination, &writable, &sourcePosition) < 0) {
return false;
}
if (sceIoLseek32(decoder->file, sourcePosition, PSP_SEEK_SET) < 0) return false;
const int read = sceIoRead(decoder->file, destination, writable);
if (read <= 0) return false;
return sceMp3NotifyAddStreamData(decoder->handle, read) >= 0;
}
bool openDecoder(const char* path, Mp3Decoder* decoder) {
if (!path || !*path || !decoder) return false;
decoder->file = sceIoOpen(path, PSP_O_RDONLY, 0777);
if (decoder->file < 0) return false;
SceMp3InitArg arguments{};
arguments.mp3StreamStart = 0;
arguments.mp3StreamEnd = sceIoLseek32(decoder->file, 0, PSP_SEEK_END);
arguments.mp3Buf = decoder->streamBuffer;
arguments.mp3BufSize = sizeof(decoder->streamBuffer);
arguments.pcmBuf = decoder->pcmBuffer;
arguments.pcmBufSize = sizeof(decoder->pcmBuffer);
decoder->handle = sceMp3ReserveMp3Handle(&arguments);
if (decoder->handle < 0 || !fillDecoder(decoder) ||
sceMp3Init(decoder->handle) < 0 ||
// The firmware decoder retains an implicit looping mode on some PSP
// revisions. Zero means no repeats (play the stream exactly once).
// Without making this explicit, sceMp3Decode jumps back to frame zero
// instead of returning EOF, so the gameplay loop never sees
// audioPlayerFinished().
sceMp3SetLoopNum(decoder->handle, 0) < 0) {
closeDecoder(decoder);
return false;
}
return true;
}
void resetDecoder(Mp3Decoder* decoder, std::uint32_t frame) {
if (!decoder || decoder->handle < 0) return;
if (frame == 0) sceMp3ResetPlayPosition(decoder->handle);
else sceMp3ResetPlayPositionByFrame(decoder->handle, frame);
}
int decode(Mp3Decoder* decoder, short** samples) {
if (!decoder || decoder->handle < 0 || !samples) return 0;
if (sceMp3CheckStreamDataNeeded(decoder->handle) > 0) fillDecoder(decoder);
int decoded = sceMp3Decode(decoder->handle, samples);
if (decoded <= 0 &&
sceMp3CheckStreamDataNeeded(decoder->handle) > 0 &&
fillDecoder(decoder)) {
decoded = sceMp3Decode(decoder->handle, samples);
}
return decoded;
}
void resetPlayback(std::uint32_t timeMs) {
const std::uint64_t targetSamples =
static_cast<std::uint64_t>(timeMs) *
static_cast<std::uint32_t>(gAudio.sampleRate) / 1000u;
const std::uint32_t frame = static_cast<std::uint32_t>(targetSamples / 1152u);
resetDecoder(&gAudio.bgm, frame);
gAudio.playedSamples = frame * 1152u;
gAudio.finished = false;
gAudio.consecutiveOutputFailures = 0;
}
int outputSamples(const short* samples, int decodedBytes) {
if (decodedBytes <= 0 || !samples) return -1;
if (gAudio.channel < 0 || decodedBytes != gAudio.lastDecodedBytes) {
if (gAudio.channel >= 0) sceAudioSRCChRelease();
const int sampleCount = decodedBytes / (2 * gAudio.channels);
gAudio.channel = sceAudioSRCChReserve(
sampleCount, gAudio.sampleRate, gAudio.channels);
gAudio.lastDecodedBytes = decodedBytes;
}
if (gAudio.channel < 0) return gAudio.channel;
return sceAudioSRCOutputBlocking(
PSP_AUDIO_VOLUME_MAX, const_cast<short*>(samples));
}
int audioThread(SceSize, void*) {
while (gAudio.running) {
const int requestedSeek = gAudio.requestedSeekMs;
if (requestedSeek >= 0) {
resetPlayback(static_cast<std::uint32_t>(requestedSeek));
gAudio.requestedSeekMs = -1;
}
if (gAudio.paused) {
sceKernelDelayThread(5000);
continue;
}
short* bgmSamples = nullptr;
int bgmBytes = decode(&gAudio.bgm, &bgmSamples);
if (bgmBytes <= 0) {
// The compressed stream is authoritative. Stage DAT duration
// describes authored gameplay data and may end before or after
// the actual mix; do not synthesize silence to match it.
gAudio.finished = true;
sceKernelDelayThread(5000);
continue;
}
const int valueCount = bgmBytes / 2;
short* const mixBuffer = gAudio.mixBuffers[gAudio.mixBufferIndex];
for (int i = 0; i < valueCount; ++i) {
gAudio.mixAccumulator[i] = static_cast<std::int32_t>(bgmSamples[i]);
}
for (AudioState::Effect& effect : gAudio.effects) {
if (!effect.samples) continue;
for (volatile std::uint32_t& voice : effect.voices) {
std::uint32_t position = voice;
if (position >= effect.valueCount) continue;
const int available = std::min(
valueCount,
static_cast<int>(effect.valueCount - position));
for (int i = 0; i < available; ++i) {
gAudio.mixAccumulator[i] +=
static_cast<std::int32_t>(effect.samples[position + i]);
}
position += static_cast<std::uint32_t>(available);
voice = position;
}
}
for (int i = 0; i < valueCount; ++i) {
mixBuffer[i] = softLimit(gAudio.mixAccumulator[i]);
}
const int outputResult = outputSamples(mixBuffer, bgmBytes);
if (outputResult >= 0) {
// Some firmwares return the queued sample count and some audio
// implementations return zero on success. The submitted frame
// size is authoritative in either case.
const int sampleCount = bgmBytes / (2 * gAudio.channels);
gAudio.playedSamples += static_cast<std::uint32_t>(sampleCount);
gAudio.mixBufferIndex ^= 1u;
gAudio.consecutiveOutputFailures = 0;
} else {
if (++gAudio.consecutiveOutputFailures >= 8) {
// Do not leave the application trapped forever in a chart if
// the firmware loses its SRC channel.
gAudio.finished = true;
gAudio.paused = true;
}
sceKernelDelayThread(5000);
}
}
return 0;
}
void cleanupAudio() {
if (gAudio.channel >= 0) {
sceAudioSRCChRelease();
gAudio.channel = -1;
}
closeDecoder(&gAudio.bgm);
closeEffects();
if (gAudio.resourceInitialized) {
sceMp3TermResource();
gAudio.resourceInitialized = false;
}
if (gAudio.mp3ModuleLoaded) {
sceUtilityUnloadModule(PSP_MODULE_AV_MP3);
gAudio.mp3ModuleLoaded = false;
}
if (gAudio.avcodecLoaded) {
sceUtilityUnloadModule(PSP_MODULE_AV_AVCODEC);
gAudio.avcodecLoaded = false;
}
}
} // namespace
bool startAudioPlayer(
const char* bgmPath,
const char* shotPath,
const char* effectDirectory) {
stopAudioPlayer();
// PSP-1000 cannot reliably decode and mix two independent MP3 streams
// while the 3D stage is running. Release assets are pre-mixed, so SHOT is
// intentionally ignored here; hit/ad-lib PCM effects remain interactive.
(void)shotPath;
if (!bgmPath || !*bgmPath) return false;
if (sceUtilityLoadModule(PSP_MODULE_AV_AVCODEC) < 0) return false;
gAudio.avcodecLoaded = true;
if (sceUtilityLoadModule(PSP_MODULE_AV_MP3) < 0) {
cleanupAudio();
return false;
}
gAudio.mp3ModuleLoaded = true;
if (sceMp3InitResource() < 0) {
cleanupAudio();
return false;
}
gAudio.resourceInitialized = true;
if (!openDecoder(bgmPath, &gAudio.bgm)) {
cleanupAudio();
return false;
}
if (effectDirectory && *effectDirectory) {
static constexpr const char* names[3] = {
"adlib.pcm", "tap1.pcm", "tap2.pcm",
};
for (int i = 0; i < 3; ++i) {
char path[384]{};
std::snprintf(path, sizeof(path), "%s/%s", effectDirectory, names[i]);
loadEffect(path, &gAudio.effects[i]);
}
}
gAudio.sampleRate = sceMp3GetSamplingRate(gAudio.bgm.handle);
gAudio.channels = sceMp3GetMp3ChannelNum(gAudio.bgm.handle);
if (gAudio.sampleRate <= 0 ||
(gAudio.channels != 1 && gAudio.channels != 2)) {
cleanupAudio();
return false;
}
gAudio.playedSamples = 0;
gAudio.mixBufferIndex = 0;
gAudio.consecutiveOutputFailures = 0;
gAudio.requestedSeekMs = -1;
gAudio.paused = false;
gAudio.finished = false;
gAudio.running = true;
gAudio.thread = sceKernelCreateThread(
"OpenRoller audio", audioThread, 0x12, 0x5000,
PSP_THREAD_ATTR_USER, nullptr);
if (gAudio.thread < 0 ||
sceKernelStartThread(gAudio.thread, 0, nullptr) < 0) {
gAudio.running = false;
cleanupAudio();
return false;
}
return true;
}
void stopAudioPlayer() {
if (gAudio.running) {
gAudio.running = false;
if (gAudio.thread >= 0) sceKernelWaitThreadEnd(gAudio.thread, nullptr);
}
if (gAudio.thread >= 0) {
sceKernelDeleteThread(gAudio.thread);
gAudio.thread = -1;
}
cleanupAudio();
gAudio.sampleRate = 0;
gAudio.channels = 0;
gAudio.lastDecodedBytes = 0;
gAudio.playedSamples = 0;
gAudio.mixBufferIndex = 0;
gAudio.consecutiveOutputFailures = 0;
gAudio.requestedSeekMs = -1;
gAudio.paused = false;
gAudio.finished = false;
}
bool audioPlayerRunning() {
return gAudio.running && gAudio.sampleRate > 0;
}
bool audioPlayerFinished() {
return gAudio.finished;
}
std::uint32_t audioPlayerTimeMs() {
if (gAudio.sampleRate <= 0) return 0;
return static_cast<std::uint32_t>(
static_cast<std::uint64_t>(gAudio.playedSamples) * 1000u /
static_cast<std::uint32_t>(gAudio.sampleRate));
}
void setAudioPlayerPaused(bool paused) {
gAudio.paused = paused;
}
void seekAudioPlayer(std::uint32_t timeMs) {
if (gAudio.running) gAudio.requestedSeekMs = static_cast<int>(timeMs);
}
void setAudioPlayerShotMuted(bool muted) {
(void)muted;
}
void playAudioPlayerEffect(AudioEffect effect) {
const unsigned index = static_cast<unsigned>(effect);
if (index >= 3 || !gAudio.effects[index].samples) return;
AudioState::Effect& slot = gAudio.effects[index];
const std::uint32_t voice = slot.nextVoice++ & 1u;
slot.voices[voice] = 0;
}
} // namespace openroller::psp
+344
View File
@@ -0,0 +1,344 @@
#include "Gameplay.hpp"
#include <algorithm>
#include <cmath>
#include <cstdlib>
namespace openroller::psp {
namespace {
bool tapTarget(std::uint8_t type) {
return type == 1 || type == 2;
}
bool dualTapTarget(std::uint8_t type) {
return type == 9;
}
bool holdTarget(std::uint8_t type) {
return type == 3 || type == 15;
}
bool rhythmLongTarget(std::uint8_t type) {
return type == 4 || type == 5;
}
bool supportedTarget(std::uint8_t type) {
return tapTarget(type) || dualTapTarget(type) ||
holdTarget(type) || rhythmLongTarget(type);
}
Judgment tapJudgment(
const PackageNote& note,
float clockMs,
float greatMinimumMs) {
const float error = std::fabs(clockMs - static_cast<float>(note.timeMs));
const float outer = std::max(
0.0f,
clockMs > static_cast<float>(note.timeMs)
? note.lateTimingMs
: note.earlyTimingMs);
float great = outer * 0.25f;
float cool = outer * 0.50f;
if (great < greatMinimumMs) {
great = greatMinimumMs;
cool = std::min(outer, great + (outer - great) / 3.0f);
}
if (error < great) return Judgment::Great;
if (error < cool) return Judgment::Cool;
if (error < outer) return Judgment::Good;
return Judgment::Miss;
}
Judgment longJudgment(
const PackageNote& note,
float pressTimeMs,
float releaseTimeMs) {
const float durationMs = std::max(
1.0f,
note.endTimeMs - static_cast<float>(note.timeMs));
const float heldStartMs = std::max(static_cast<float>(note.timeMs), pressTimeMs);
const float heldEndMs = std::min(note.endTimeMs, releaseTimeMs);
float heldMs = std::max(0.0f, heldEndMs - heldStartMs);
const float finalTwentyPercentMs = durationMs * 0.20f;
if (finalTwentyPercentMs < 66.666664f) {
heldMs += 66.666664f - finalTwentyPercentMs;
}
const float heldPercent = heldMs * 100.0f / durationMs;
if (heldPercent > 80.0f) return Judgment::Great;
if (heldPercent > 60.0f) return Judgment::Cool;
if (heldPercent > 40.0f) return Judgment::Good;
return Judgment::Miss;
}
void resetRuntime(GameplayState::NoteRuntime* note) {
*note = {};
note->judgment = static_cast<std::uint8_t>(Judgment::Pending);
note->inputStartTimeMs = -1.0f;
note->lastInputTimeMs = -1.0f;
}
void advancePending(GameplayState* gameplay) {
while (gameplay->nextPending < gameplay->noteCount &&
gameplay->notes[gameplay->nextPending].judgment !=
static_cast<std::uint8_t>(Judgment::Pending)) {
++gameplay->nextPending;
}
}
void recordJudgment(
GameplayState* gameplay,
std::uint32_t noteIndex,
Judgment judgment,
float clockMs) {
GameplayState::NoteRuntime& runtime = gameplay->notes[noteIndex];
runtime.judgment = static_cast<std::uint8_t>(judgment);
runtime.holding = 0;
runtime.inputMask = 0;
gameplay->lastJudgment = judgment;
gameplay->lastJudgmentClockMs = clockMs;
gameplay->lastJudgedNote = static_cast<std::int32_t>(noteIndex);
gameplay->shotMuted = judgment == Judgment::Miss;
if (judgment == Judgment::Great) {
++gameplay->greatCount;
++gameplay->combo;
} else if (judgment == Judgment::Cool) {
++gameplay->coolCount;
++gameplay->combo;
} else if (judgment == Judgment::Good) {
++gameplay->goodCount;
++gameplay->combo;
} else {
++gameplay->missCount;
gameplay->combo = 0;
}
gameplay->maximumCombo = std::max(gameplay->maximumCombo, gameplay->combo);
if (noteIndex == gameplay->nextPending) advancePending(gameplay);
}
} // namespace
bool initializeGameplay(const StageView& stage, GameplayState* gameplay) {
if (!gameplay || !stage.header || stage.header->notes.count == 0) return false;
destroyGameplay(gameplay);
gameplay->notes = static_cast<GameplayState::NoteRuntime*>(
std::malloc(
static_cast<std::size_t>(stage.header->notes.count) *
sizeof(GameplayState::NoteRuntime)));
if (!gameplay->notes) return false;
gameplay->noteCount = stage.header->notes.count;
for (std::uint32_t i = 0; i < gameplay->noteCount; ++i) {
resetRuntime(&gameplay->notes[i]);
}
return true;
}
void destroyGameplay(GameplayState* gameplay) {
if (!gameplay) return;
std::free(gameplay->notes);
*gameplay = {};
}
void seekGameplay(const StageView& stage, GameplayState* gameplay, float clockMs) {
if (!gameplay || !gameplay->notes || !stage.header) return;
gameplay->nextPending = 0;
gameplay->combo = 0;
gameplay->maximumCombo = 0;
gameplay->greatCount = 0;
gameplay->coolCount = 0;
gameplay->goodCount = 0;
gameplay->missCount = 0;
gameplay->lastJudgment = Judgment::Pending;
gameplay->lastJudgmentClockMs = -10000.0f;
gameplay->lastJudgedNote = -1;
gameplay->previousClockMs = clockMs;
gameplay->shotMuted = false;
for (std::uint32_t i = 0; i < gameplay->noteCount; ++i) {
resetRuntime(&gameplay->notes[i]);
const PackageNote& note = stage.notes[i];
if (supportedTarget(note.effectiveType) &&
static_cast<float>(note.timeMs) + note.lateTimingMs < clockMs) {
gameplay->notes[i].judgment = static_cast<std::uint8_t>(Judgment::Miss);
}
}
advancePending(gameplay);
}
void updateGameplay(const StageView& stage, GameplayState* gameplay, float clockMs) {
if (!gameplay || !gameplay->notes || !stage.header) return;
if (clockMs + 1000.0f < gameplay->previousClockMs) {
seekGameplay(stage, gameplay, clockMs);
}
gameplay->previousClockMs = clockMs;
for (std::uint32_t i = 0; i < gameplay->noteCount; ++i) {
const PackageNote& note = stage.notes[i];
GameplayState::NoteRuntime& runtime = gameplay->notes[i];
if (!supportedTarget(note.effectiveType) ||
runtime.judgment != static_cast<std::uint8_t>(Judgment::Pending)) {
continue;
}
if (!runtime.holding && !runtime.shotMuteApplied &&
clockMs >= static_cast<float>(note.timeMs) + note.muteTimingMs) {
runtime.shotMuteApplied = 1;
gameplay->shotMuted = true;
}
if (rhythmLongTarget(note.effectiveType) && runtime.holding) {
const float enableMs = note.effectiveType == 4
? stage.header->scratchEnableTimeMs
: stage.header->beatEnableTimeMs;
const bool bodyEnded = clockMs >= note.endTimeMs;
const bool inputExpired = clockMs - runtime.lastInputTimeMs > enableMs;
if (bodyEnded || inputExpired) {
float coveredEndMs = runtime.lastInputTimeMs;
if (bodyEnded &&
note.endTimeMs - runtime.lastInputTimeMs < 1000.0f / 60.0f) {
coveredEndMs = note.endTimeMs;
}
recordJudgment(
gameplay,
i,
longJudgment(note, runtime.inputStartTimeMs, coveredEndMs),
clockMs);
}
continue;
}
if (holdTarget(note.effectiveType) && runtime.holding &&
clockMs >= note.endTimeMs) {
recordJudgment(
gameplay,
i,
longJudgment(note, runtime.inputStartTimeMs, note.endTimeMs),
clockMs);
continue;
}
if (!runtime.holding &&
clockMs > static_cast<float>(note.timeMs) + note.lateTimingMs) {
recordJudgment(gameplay, i, Judgment::Miss, clockMs);
}
}
}
Judgment pressGameplay(
const StageView& stage,
GameplayState* gameplay,
float clockMs,
std::uint32_t inputBit) {
if (!gameplay || !gameplay->notes || !stage.header || inputBit == 0) {
return Judgment::Pending;
}
updateGameplay(stage, gameplay, clockMs);
for (std::uint32_t i = 0; i < gameplay->noteCount; ++i) {
const PackageNote& note = stage.notes[i];
GameplayState::NoteRuntime& runtime = gameplay->notes[i];
if (runtime.judgment != static_cast<std::uint8_t>(Judgment::Pending) ||
!runtime.holding || !rhythmLongTarget(note.effectiveType)) {
continue;
}
if (note.effectiveType == 5 || inputBit != runtime.lastInputBit) {
runtime.lastInputTimeMs = clockMs;
runtime.lastInputBit = inputBit;
}
return Judgment::Pending;
}
for (std::uint32_t i = 0; i < gameplay->noteCount; ++i) {
const PackageNote& note = stage.notes[i];
GameplayState::NoteRuntime& runtime = gameplay->notes[i];
if (runtime.judgment != static_cast<std::uint8_t>(Judgment::Pending) ||
runtime.inputMask == 0 || (runtime.inputMask & inputBit) != 0 ||
!(dualTapTarget(note.effectiveType) ||
(note.effectiveType == 15 && !runtime.holding))) {
continue;
}
const float error = clockMs - static_cast<float>(note.timeMs);
if (error < -note.earlyTimingMs || error > note.lateTimingMs) continue;
runtime.inputMask |= inputBit;
if (dualTapTarget(note.effectiveType)) {
const Judgment result = tapJudgment(
note, runtime.inputStartTimeMs,
stage.header->greatMinimumTimeMs);
recordJudgment(gameplay, i, result, clockMs);
return result;
}
runtime.holding = 1;
runtime.inputStartTimeMs = clockMs;
gameplay->shotMuted = false;
return Judgment::Pending;
}
std::uint32_t candidate = gameplay->noteCount;
float candidateError = 0.0f;
for (std::uint32_t i = 0; i < gameplay->noteCount; ++i) {
const PackageNote& note = stage.notes[i];
const GameplayState::NoteRuntime& runtime = gameplay->notes[i];
if (runtime.judgment != static_cast<std::uint8_t>(Judgment::Pending) ||
runtime.inputMask != 0 || !supportedTarget(note.effectiveType)) {
continue;
}
const float error = clockMs - static_cast<float>(note.timeMs);
if (error < -note.earlyTimingMs || error > note.lateTimingMs) continue;
if (candidate == gameplay->noteCount || std::fabs(error) < candidateError) {
candidate = i;
candidateError = std::fabs(error);
}
}
if (candidate == gameplay->noteCount) return Judgment::Pending;
const PackageNote& note = stage.notes[candidate];
GameplayState::NoteRuntime& runtime = gameplay->notes[candidate];
if (tapTarget(note.effectiveType)) {
const Judgment result = tapJudgment(
note, clockMs, stage.header->greatMinimumTimeMs);
recordJudgment(gameplay, candidate, result, clockMs);
return result;
}
runtime.inputMask = inputBit;
runtime.inputStartTimeMs = clockMs;
runtime.lastInputTimeMs = clockMs;
runtime.lastInputBit = inputBit;
runtime.holding =
note.effectiveType == 3 || rhythmLongTarget(note.effectiveType);
if (runtime.holding) gameplay->shotMuted = false;
return Judgment::Pending;
}
Judgment releaseGameplay(
const StageView& stage,
GameplayState* gameplay,
float clockMs,
std::uint32_t inputBit) {
if (!gameplay || !gameplay->notes || !stage.header || inputBit == 0) {
return Judgment::Pending;
}
for (std::uint32_t i = 0; i < gameplay->noteCount; ++i) {
const PackageNote& note = stage.notes[i];
GameplayState::NoteRuntime& runtime = gameplay->notes[i];
if (runtime.judgment != static_cast<std::uint8_t>(Judgment::Pending) ||
!holdTarget(note.effectiveType) || (runtime.inputMask & inputBit) == 0) {
continue;
}
if (runtime.holding) {
const Judgment result =
longJudgment(note, runtime.inputStartTimeMs, clockMs);
recordJudgment(gameplay, i, result, clockMs);
return result;
}
runtime.inputMask &= ~inputBit;
if (runtime.inputMask == 0) runtime.inputStartTimeMs = -1.0f;
return Judgment::Pending;
}
return Judgment::Pending;
}
Judgment tapGameplay(const StageView& stage, GameplayState* gameplay, float clockMs) {
return pressGameplay(stage, gameplay, clockMs, 1u);
}
Judgment noteJudgment(const GameplayState& gameplay, std::uint32_t noteIndex) {
if (!gameplay.notes || noteIndex >= gameplay.noteCount) return Judgment::Pending;
return static_cast<Judgment>(gameplay.notes[noteIndex].judgment);
}
} // namespace openroller::psp
+201
View File
@@ -0,0 +1,201 @@
#include "SongMenu.hpp"
#include <pspkernel.h>
#include <cstdio>
#include <cstdlib>
#include <cstring>
namespace openroller::psp {
namespace {
void setError(char* output, std::size_t capacity, const char* message) {
if (output && capacity > 0) std::snprintf(output, capacity, "%s", message);
}
int firstDifficulty(const SongCatalogRecord& song, int preferred) {
if (preferred >= 0 && preferred < 4 && (song.availableMask & (1u << preferred)) != 0) {
return preferred;
}
for (int distance = 1; distance < 4; ++distance) {
const int lower = preferred - distance;
const int upper = preferred + distance;
if (lower >= 0 && (song.availableMask & (1u << lower)) != 0) return lower;
if (upper < 4 && (song.availableMask & (1u << upper)) != 0) return upper;
}
for (int difficulty = 0; difficulty < 4; ++difficulty) {
if ((song.availableMask & (1u << difficulty)) != 0) return difficulty;
}
return 0;
}
bool loadJacket(SongMenu* menu) {
std::free(menu->jacketStorage);
menu->jacketStorage = nullptr;
menu->jacketPixels = nullptr;
menu->jacketSize = 0;
const SongCatalogRecord* song = selectedSong(*menu);
if (!song) return false;
char path[384]{};
std::snprintf(path, sizeof(path), "%s/songs/%s/jacket.orpj", menu->rootPath, song->key);
std::FILE* file = std::fopen(path, "rb");
if (!file) return false;
std::fseek(file, 0, SEEK_END);
const long length = std::ftell(file);
std::fseek(file, 0, SEEK_SET);
if (length < static_cast<long>(sizeof(JacketHeader)) || length > 512 * 512 * 2 + 64) {
std::fclose(file);
return false;
}
void* storage = std::malloc(static_cast<std::size_t>(length));
if (!storage || std::fread(storage, 1, static_cast<std::size_t>(length), file) !=
static_cast<std::size_t>(length)) {
std::fclose(file);
std::free(storage);
return false;
}
std::fclose(file);
const auto* header = static_cast<const JacketHeader*>(storage);
const std::size_t expected = static_cast<std::size_t>(header->width) * header->height * 2;
if (std::memcmp(header->magic, kJacketMagic, sizeof(header->magic)) != 0 ||
header->version != kJacketVersion || header->pixelFormat != 0 ||
header->width == 0 || header->height == 0 || header->dataSize != expected ||
sizeof(JacketHeader) + expected != static_cast<std::size_t>(length)) {
std::free(storage);
return false;
}
menu->jacketStorage = storage;
menu->jacketSize = static_cast<std::size_t>(length);
menu->jacketPixels = reinterpret_cast<const std::uint16_t*>(
static_cast<const std::uint8_t*>(storage) + sizeof(JacketHeader));
menu->jacketWidth = header->width;
menu->jacketHeight = header->height;
sceKernelDcacheWritebackRange(storage, static_cast<std::size_t>(length));
return true;
}
} // namespace
bool loadSongMenu(const char* catalogPath, SongMenu* menu, char* error, std::size_t errorCapacity) {
if (!catalogPath || !menu) return false;
unloadSongMenu(menu);
std::FILE* file = std::fopen(catalogPath, "rb");
if (!file) {
setError(error, errorCapacity, "catalog.orpc not found");
return false;
}
std::fseek(file, 0, SEEK_END);
const long length = std::ftell(file);
std::fseek(file, 0, SEEK_SET);
if (length < static_cast<long>(sizeof(SongCatalogHeader)) || length > 64 * 1024) {
std::fclose(file);
setError(error, errorCapacity, "invalid catalog size");
return false;
}
void* storage = std::malloc(static_cast<std::size_t>(length));
if (!storage || std::fread(storage, 1, static_cast<std::size_t>(length), file) !=
static_cast<std::size_t>(length)) {
std::fclose(file);
std::free(storage);
setError(error, errorCapacity, "could not read catalog");
return false;
}
std::fclose(file);
const auto* header = static_cast<const SongCatalogHeader*>(storage);
const bool valid = std::memcmp(header->magic, kSongCatalogMagic, sizeof(header->magic)) == 0 &&
header->version == kSongCatalogVersion && header->headerSize == sizeof(SongCatalogHeader) &&
header->recordSize == sizeof(SongCatalogRecord) && header->songCount > 0 &&
header->songCount <= kMaximumCatalogSongs && header->fileSize == static_cast<std::uint32_t>(length) &&
sizeof(SongCatalogHeader) + header->songCount * sizeof(SongCatalogRecord) ==
static_cast<std::size_t>(length);
if (!valid) {
std::free(storage);
setError(error, errorCapacity, "invalid catalog header");
return false;
}
menu->catalogStorage = storage;
menu->catalogSize = static_cast<std::size_t>(length);
menu->header = header;
menu->songs = reinterpret_cast<const SongCatalogRecord*>(
static_cast<const std::uint8_t*>(storage) + sizeof(SongCatalogHeader));
std::snprintf(menu->rootPath, sizeof(menu->rootPath), "%s", catalogPath);
char* slash = std::strrchr(menu->rootPath, '/');
if (slash) *slash = '\0';
else std::snprintf(menu->rootPath, sizeof(menu->rootPath), ".");
menu->difficulty = firstDifficulty(menu->songs[0], 2);
loadJacket(menu);
setError(error, errorCapacity, "ok");
return true;
}
void unloadSongMenu(SongMenu* menu) {
if (!menu) return;
std::free(menu->jacketStorage);
std::free(menu->catalogStorage);
*menu = {};
}
const SongCatalogRecord* selectedSong(const SongMenu& menu) {
if (!menu.header || !menu.songs || menu.selection < 0 ||
static_cast<std::uint32_t>(menu.selection) >= menu.header->songCount) return nullptr;
return &menu.songs[menu.selection];
}
bool moveSongSelection(SongMenu* menu, int delta) {
if (!menu || !menu->header || menu->header->songCount == 0) return false;
const int count = static_cast<int>(menu->header->songCount);
menu->selection = (menu->selection + delta) % count;
if (menu->selection < 0) menu->selection += count;
menu->difficulty = firstDifficulty(menu->songs[menu->selection], menu->difficulty);
loadJacket(menu);
return true;
}
bool moveDifficultySelection(SongMenu* menu, int delta) {
const SongCatalogRecord* song = menu ? selectedSong(*menu) : nullptr;
if (!song) return false;
for (int step = 1; step <= 4; ++step) {
int candidate = (menu->difficulty + delta * step) % 4;
if (candidate < 0) candidate += 4;
if ((song->availableMask & (1u << candidate)) != 0) {
menu->difficulty = candidate;
return true;
}
}
return false;
}
bool selectedSongPath(const SongMenu& menu, char* output, std::size_t capacity) {
static constexpr const char* names[4] = {"easy", "normal", "hard", "extra"};
const SongCatalogRecord* song = selectedSong(menu);
if (!song || !output || capacity == 0 || menu.difficulty < 0 || menu.difficulty >= 4) return false;
const int written = std::snprintf(
output, capacity, "%s/songs/%s/%s.orps", menu.rootPath, song->key, names[menu.difficulty]);
return written > 0 && static_cast<std::size_t>(written) < capacity;
}
bool selectedAudioPath(const SongMenu& menu, char* output, std::size_t capacity) {
static constexpr const char* names[4] = {"easy", "normal", "hard", "extra"};
const SongCatalogRecord* song = selectedSong(menu);
if (!song || !output || capacity == 0 || menu.difficulty < 0 || menu.difficulty >= 4) {
return false;
}
const int written = std::snprintf(
output, capacity, "%s/songs/%s/%s_bgm.mp3",
menu.rootPath, song->key, names[menu.difficulty]);
return written > 0 && static_cast<std::size_t>(written) < capacity;
}
bool selectedShotAudioPath(const SongMenu& menu, char* output, std::size_t capacity) {
static constexpr const char* names[4] = {"easy", "normal", "hard", "extra"};
const SongCatalogRecord* song = selectedSong(menu);
if (!song || !output || capacity == 0 || menu.difficulty < 0 || menu.difficulty >= 4) {
return false;
}
const int written = std::snprintf(
output, capacity, "%s/songs/%s/%s_shot.mp3",
menu.rootPath, song->key, names[menu.difficulty]);
return written > 0 && static_cast<std::size_t>(written) < capacity;
}
} // namespace openroller::psp
+444
View File
@@ -0,0 +1,444 @@
#include "StageRuntime.hpp"
#include <algorithm>
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <limits>
namespace openroller::psp {
namespace {
constexpr std::size_t kMaximumStageBytes = 4u * 1024u * 1024u;
constexpr float kPi = 3.14159265358979323846f;
void setError(char* output, std::size_t capacity, const char* message) {
if (!output || capacity == 0) return;
std::snprintf(output, capacity, "%s", message);
}
template <typename T>
bool sectionValid(const StagePackageHeader& header, PackageSection section) {
if ((section.offset & 15u) != 0 || section.offset < header.headerSize) return false;
if (section.count > std::numeric_limits<std::uint32_t>::max() / sizeof(T)) return false;
const std::uint32_t bytes = section.count * static_cast<std::uint32_t>(sizeof(T));
return section.offset <= header.fileSize && bytes <= header.fileSize - section.offset;
}
template <typename T>
const T* sectionPointer(const void* storage, PackageSection section) {
const auto* bytes = static_cast<const std::uint8_t*>(storage);
return reinterpret_cast<const T*>(bytes + section.offset);
}
bool rangeValid(PackageRange range, std::uint32_t count) {
return range.first <= count && range.count <= count - range.first;
}
Vec3 add(Vec3 a, Vec3 b) { return {a.x + b.x, a.y + b.y, a.z + b.z}; }
Vec3 subtract(Vec3 a, Vec3 b) { return {a.x - b.x, a.y - b.y, a.z - b.z}; }
Vec3 multiply(Vec3 a, float value) { return {a.x * value, a.y * value, a.z * value}; }
float dot(Vec3 a, Vec3 b) { return a.x * b.x + a.y * b.y + a.z * b.z; }
Vec3 cross(Vec3 a, Vec3 b) {
return {
a.y * b.z - a.z * b.y,
a.z * b.x - a.x * b.z,
a.x * b.y - a.y * b.x,
};
}
float length(Vec3 value) { return std::sqrt(dot(value, value)); }
Vec3 normalize(Vec3 value, Vec3 fallback = {0.0f, 0.0f, 0.0f}) {
const float magnitude = length(value);
return magnitude > 1.0e-6f ? multiply(value, 1.0f / magnitude) : fallback;
}
Vec3 mix(Vec3 a, Vec3 b, float u) { return add(a, multiply(subtract(b, a), u)); }
float mix(float a, float b, float u) { return a + (b - a) * u; }
Vec3 fromArray(const float value[3]) { return {value[0], value[1], value[2]}; }
std::uint32_t lowerTrackIndex(const StageView& stage, float timeMs) {
const std::uint32_t count = stage.header->track.count;
std::uint32_t first = 0;
std::uint32_t last = count;
while (first < last) {
const std::uint32_t middle = first + (last - first) / 2;
if (static_cast<float>(stage.track[middle].timeMs) <= timeMs) first = middle + 1;
else last = middle;
}
return first == 0 ? 0 : first - 1;
}
std::uint32_t lowerCameraIndex(const StageView& stage, float timeMs) {
const std::uint32_t count = stage.header->cameras.count;
std::uint32_t first = 0;
std::uint32_t last = count;
while (first < last) {
const std::uint32_t middle = first + (last - first) / 2;
if (static_cast<float>(stage.cameras[middle].timeMs) <= timeMs) first = middle + 1;
else last = middle;
}
return first == 0 ? 0 : first - 1;
}
Vec3 cameraOrbit(const PackageCameraPoint& camera) {
const float a = (-camera.rotationA[1] * kPi / 180.0f) * 0.5f;
const float b = (camera.rotationA[0] * kPi / 180.0f) * 0.5f;
const float c = 0.0f;
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 multiply({
2.0f * (qz * qx + qw * qy),
2.0f * (qy * qz - qw * qx),
1.0f - 2.0f * (qx * qx + qy * qy),
}, camera.distance);
}
Vec3 rotateAround(Vec3 value, Vec3 axis, float degrees) {
const float radians = degrees * kPi / 180.0f;
const float cosine = std::cos(radians);
const float sine = std::sin(radians);
return add(
add(multiply(value, cosine), multiply(cross(axis, value), sine)),
multiply(axis, dot(axis, value) * (1.0f - cosine)));
}
void adjustCameraUp(CameraState* camera, float rollDegrees) {
const Vec3 view = normalize(subtract(camera->target, camera->eye));
if (dot(view, view) < 1.0e-10f) {
camera->up = {0.0f, 1.0f, 0.0f};
return;
}
Vec3 reference{0.0f, 1.0f, 0.0f};
Vec3 projected = subtract(reference, multiply(view, dot(reference, view)));
if (dot(projected, projected) < 1.0e-8f) {
reference = {0.0f, 0.0f, 1.0f};
projected = subtract(reference, multiply(view, dot(reference, view)));
}
camera->up = normalize(projected, {0.0f, 1.0f, 0.0f});
if (rollDegrees != 0.0f) camera->up = rotateAround(camera->up, view, rollDegrees);
}
PackageCameraPoint mixCamera(const PackageCameraPoint& a, const PackageCameraPoint& b, float u) {
PackageCameraPoint output = a;
output.distance = mix(a.distance, b.distance, u);
for (int i = 0; i < 2; ++i) output.rotationA[i] = mix(a.rotationA[i], b.rotationA[i], u);
for (int i = 0; i < 3; ++i) {
output.originOffset[i] = mix(a.originOffset[i], b.originOffset[i], u);
output.fieldFar[i] = mix(a.fieldFar[i], b.fieldFar[i], u);
output.fieldNear[i] = mix(a.fieldNear[i], b.fieldNear[i], u);
}
output.rotationB = mix(a.rotationB, b.rotationB, u);
return output;
}
CameraState evaluateCameraInternal(
const StageView& stage,
float timeMs,
bool interpolate,
int depth) {
CameraState state{};
if (stage.header->cameras.count == 0 || depth > 8) {
state.target = trackPositionAt(stage, timeMs);
state.eye = add(state.target, {0.0f, 0.0f, 10.0f});
state.projectionBlend = 1.0f;
return state;
}
const std::uint32_t index = lowerCameraIndex(stage, timeMs);
const PackageCameraPoint* key = &stage.cameras[index];
PackageCameraPoint mixed{};
const bool between = interpolate && key->fMode != 0 &&
timeMs > static_cast<float>(key->timeMs) && index + 1 < stage.header->cameras.count &&
timeMs < static_cast<float>(stage.cameras[index + 1].timeMs);
if (between) {
const PackageCameraPoint& following = stage.cameras[index + 1];
const float span = static_cast<float>(following.timeMs - key->timeMs);
const float u = span > 0.0f ? (timeMs - static_cast<float>(key->timeMs)) / span : 0.0f;
if (key->fMode == 1) {
CameraState from = evaluateCameraInternal(stage, static_cast<float>(key->timeMs), true, depth + 1);
CameraState to = evaluateCameraInternal(stage, static_cast<float>(following.timeMs), true, depth + 1);
adjustCameraUp(&from, 0.0f);
adjustCameraUp(&to, 0.0f);
state.eye = mix(from.eye, to.eye, u);
state.target = mix(from.target, to.target, u);
state.up = normalize(mix(from.up, to.up, u), {0.0f, 1.0f, 0.0f});
state.projectionBlend = mix(from.projectionBlend, to.projectionBlend, u);
const float roll = mix(key->rotationB, following.rotationB, u);
const Vec3 axis = normalize(subtract(state.target, state.eye));
if (roll != 0.0f && dot(axis, axis) > 0.0f) state.up = rotateAround(state.up, axis, roll);
return state;
}
if (key->fMode == 2) {
mixed = mixCamera(*key, following, u);
key = &mixed;
}
}
const Vec3 orbit = cameraOrbit(*key);
const Vec3 currentTrack = trackPositionAt(stage, timeMs);
const Vec3 origin = fromArray(key->originOffset);
switch (key->aMode) {
case 0:
state.target = add(currentTrack, origin);
state.eye = add(state.target, orbit);
break;
case 1:
state.target = add(trackPositionAt(stage, static_cast<float>(key->timeMs)), origin);
state.eye = add(state.target, orbit);
break;
case 2:
if (index > 1) {
return evaluateCameraInternal(
stage, static_cast<float>(stage.cameras[index].timeMs) - 1.0f, false, depth + 1);
}
state.target = add(currentTrack, origin);
state.eye = add(state.target, orbit);
break;
case 3:
state.target = add(currentTrack, origin);
state.eye = add(add(trackPositionAt(stage, static_cast<float>(key->timeMs)), origin), orbit);
break;
case 4:
state.target = fromArray(key->fieldFar);
state.eye = fromArray(key->fieldNear);
break;
case 5:
state.target = add(currentTrack, origin);
state.eye = fromArray(key->fieldNear);
break;
case 6:
state.target = fromArray(key->fieldFar);
state.eye = add(currentTrack, orbit);
break;
default:
state.target = add(currentTrack, origin);
state.eye = add(state.target, orbit);
break;
}
adjustCameraUp(&state, key->rotationB);
state.projectionBlend = key->projectionType ? 1.0f : 0.0f;
return state;
}
std::uint8_t colorChannel(std::uint32_t color, int shift) {
return static_cast<std::uint8_t>((color >> shift) & 0xffu);
}
std::uint32_t mixColor(std::uint32_t a, std::uint32_t b, float u) {
std::uint32_t output = 0;
for (int shift = 0; shift <= 24; shift += 8) {
const float value = mix(
static_cast<float>(colorChannel(a, shift)),
static_cast<float>(colorChannel(b, shift)), u);
output |= static_cast<std::uint32_t>(std::clamp(value, 0.0f, 255.0f) + 0.5f) << shift;
}
return output;
}
} // namespace
bool loadStagePackage(const char* path, StageView* stage, char* error, std::size_t errorCapacity) {
if (!path || !stage) {
setError(error, errorCapacity, "invalid stage loader arguments");
return false;
}
unloadStagePackage(stage);
std::FILE* file = std::fopen(path, "rb");
if (!file) {
setError(error, errorCapacity, "stage.orps not found");
return false;
}
if (std::fseek(file, 0, SEEK_END) != 0) {
std::fclose(file);
setError(error, errorCapacity, "could not seek stage.orps");
return false;
}
const long length = std::ftell(file);
if (length < static_cast<long>(sizeof(StagePackageHeader)) ||
length > static_cast<long>(kMaximumStageBytes) ||
std::fseek(file, 0, SEEK_SET) != 0) {
std::fclose(file);
setError(error, errorCapacity, "stage.orps has an invalid size");
return false;
}
void* storage = std::malloc(static_cast<std::size_t>(length));
if (!storage) {
std::fclose(file);
setError(error, errorCapacity, "not enough memory for stage.orps");
return false;
}
const std::size_t read = std::fread(storage, 1, static_cast<std::size_t>(length), file);
std::fclose(file);
if (read != static_cast<std::size_t>(length)) {
std::free(storage);
setError(error, errorCapacity, "could not read complete stage.orps");
return false;
}
const auto* header = static_cast<const StagePackageHeader*>(storage);
const bool headerValid =
std::memcmp(header->magic, kStagePackageMagic, sizeof(header->magic)) == 0 &&
header->version == kStagePackageVersion &&
header->headerSize == sizeof(StagePackageHeader) &&
header->fileSize == static_cast<std::uint32_t>(length);
const bool sectionsValid = headerValid &&
sectionValid<PackageTrackPoint>(*header, header->track) &&
sectionValid<PackageNote>(*header, header->notes) &&
sectionValid<PackageCameraPoint>(*header, header->cameras) &&
sectionValid<PackageDrawDistancePoint>(*header, header->drawDistances) &&
sectionValid<PackageBackgroundColorPoint>(*header, header->backgroundColors) &&
sectionValid<PackageBackgroundModel>(*header, header->backgroundModels) &&
sectionValid<PackageBackgroundVertex>(*header, header->backgroundVertices) &&
sectionValid<PackageBackgroundObject>(*header, header->backgroundObjects) &&
sectionValid<PackageVisibilityKey>(*header, header->visibilityKeys) &&
sectionValid<PackageTransformKey>(*header, header->transformKeys) &&
sectionValid<PackageObjectColorKey>(*header, header->objectColorKeys) &&
sectionValid<PackageParticlePoint>(*header, header->particles) &&
sectionValid<PackageVisualizerPoint>(*header, header->visualizer) &&
sectionValid<PackageBpmPoint>(*header, header->bpmChanges);
bool contentsValid = sectionsValid;
if (contentsValid) {
const auto* models = sectionPointer<PackageBackgroundModel>(storage, header->backgroundModels);
for (std::uint32_t i = 0; i < header->backgroundModels.count; ++i) {
contentsValid = contentsValid &&
rangeValid(models[i].triangles, header->backgroundVertices.count) &&
rangeValid(models[i].solidLines, header->backgroundVertices.count) &&
rangeValid(models[i].wireframeLines, header->backgroundVertices.count);
}
const auto* objects = sectionPointer<PackageBackgroundObject>(storage, header->backgroundObjects);
for (std::uint32_t i = 0; i < header->backgroundObjects.count; ++i) {
const bool parentValid = objects[i].parentIndex < 0 ||
static_cast<std::uint32_t>(objects[i].parentIndex) < header->backgroundObjects.count;
contentsValid = contentsValid &&
(objects[i].model == std::numeric_limits<std::uint32_t>::max() ||
objects[i].model < header->backgroundModels.count) && parentValid &&
rangeValid(objects[i].visibility, header->visibilityKeys.count) &&
rangeValid(objects[i].movement, header->transformKeys.count) &&
rangeValid(objects[i].scaling, header->transformKeys.count) &&
rangeValid(objects[i].rotations, header->transformKeys.count) &&
rangeValid(objects[i].colorChanges, header->objectColorKeys.count);
}
}
if (!contentsValid || header->track.count < 2 ||
header->backgroundObjects.count > kMaximumPackageBackgroundObjects) {
std::free(storage);
setError(error, errorCapacity, "stage.orps header or sections are invalid");
return false;
}
stage->storage = storage;
stage->storageSize = static_cast<std::size_t>(length);
stage->header = header;
stage->track = sectionPointer<PackageTrackPoint>(storage, header->track);
stage->notes = sectionPointer<PackageNote>(storage, header->notes);
stage->cameras = sectionPointer<PackageCameraPoint>(storage, header->cameras);
stage->drawDistances = sectionPointer<PackageDrawDistancePoint>(storage, header->drawDistances);
stage->backgroundColors = sectionPointer<PackageBackgroundColorPoint>(storage, header->backgroundColors);
stage->backgroundModels = sectionPointer<PackageBackgroundModel>(storage, header->backgroundModels);
stage->backgroundVertices = sectionPointer<PackageBackgroundVertex>(storage, header->backgroundVertices);
stage->backgroundObjects = sectionPointer<PackageBackgroundObject>(storage, header->backgroundObjects);
stage->visibilityKeys = sectionPointer<PackageVisibilityKey>(storage, header->visibilityKeys);
stage->transformKeys = sectionPointer<PackageTransformKey>(storage, header->transformKeys);
stage->objectColorKeys = sectionPointer<PackageObjectColorKey>(storage, header->objectColorKeys);
stage->particles = sectionPointer<PackageParticlePoint>(storage, header->particles);
stage->visualizer = sectionPointer<PackageVisualizerPoint>(storage, header->visualizer);
stage->bpmChanges = sectionPointer<PackageBpmPoint>(storage, header->bpmChanges);
setError(error, errorCapacity, "ok");
return true;
}
void unloadStagePackage(StageView* stage) {
if (!stage) return;
std::free(stage->storage);
*stage = {};
}
Vec3 trackPositionAt(const StageView& stage, float timeMs) {
if (!stage.header || stage.header->track.count == 0) return {};
if (timeMs <= static_cast<float>(stage.track[0].timeMs)) {
return {stage.track[0].x, stage.track[0].y, stage.track[0].z};
}
const std::uint32_t last = stage.header->track.count - 1;
if (timeMs >= static_cast<float>(stage.track[last].timeMs)) {
return {stage.track[last].x, stage.track[last].y, stage.track[last].z};
}
const std::uint32_t index = lowerTrackIndex(stage, timeMs);
const PackageTrackPoint& a = stage.track[index];
const PackageTrackPoint& b = stage.track[index + 1];
const float span = static_cast<float>(b.timeMs - a.timeMs);
const float u = span > 0.0f ? (timeMs - static_cast<float>(a.timeMs)) / span : 0.0f;
return mix({a.x, a.y, a.z}, {b.x, b.y, b.z}, u);
}
Vec3 trackTangentAt(const StageView& stage, float timeMs) {
const float first = static_cast<float>(stage.track[0].timeMs);
const float last = static_cast<float>(stage.track[stage.header->track.count - 1].timeMs);
const Vec3 before = trackPositionAt(stage, std::max(first, timeMs - 4.0f));
const Vec3 after = trackPositionAt(stage, std::min(last, timeMs + 4.0f));
return normalize(subtract(after, before), {0.0f, 0.0f, 1.0f});
}
CameraState evaluateCamera(const StageView& stage, float timeMs) {
return evaluateCameraInternal(stage, timeMs, true, 0);
}
BackgroundColors evaluateBackground(const StageView& stage, float timeMs) {
if (!stage.header || stage.header->backgroundColors.count == 0) return {};
const std::uint32_t count = stage.header->backgroundColors.count;
std::uint32_t index = 0;
while (index + 1 < count && static_cast<float>(stage.backgroundColors[index + 1].timeMs) <= timeMs) {
++index;
}
const PackageBackgroundColorPoint& a = stage.backgroundColors[index];
if (index + 1 >= count) {
return {a.topRightRgba, a.topLeftRgba, a.bottomRightRgba, a.bottomLeftRgba};
}
const PackageBackgroundColorPoint& b = stage.backgroundColors[index + 1];
const float span = static_cast<float>(b.timeMs - a.timeMs);
const float u = span > 0.0f
? std::clamp((timeMs - static_cast<float>(a.timeMs)) / span, 0.0f, 1.0f)
: 0.0f;
return {
mixColor(a.topRightRgba, b.topRightRgba, u),
mixColor(a.topLeftRgba, b.topLeftRgba, u),
mixColor(a.bottomRightRgba, b.bottomRightRgba, u),
mixColor(a.bottomLeftRgba, b.bottomLeftRgba, u),
};
}
float evaluateDrawAhead(const StageView& stage, float timeMs) {
if (!stage.header || stage.header->drawDistances.count == 0) {
return stage.header ? stage.header->forwardDrawDistance : 7.0f;
}
const std::uint32_t count = stage.header->drawDistances.count;
std::uint32_t index = 0;
while (index + 1 < count && static_cast<float>(stage.drawDistances[index + 1].timeMs) <= timeMs) ++index;
const PackageDrawDistancePoint& a = stage.drawDistances[index];
if (index + 1 >= count) return std::max(0.0f, a.distance);
const PackageDrawDistancePoint& b = stage.drawDistances[index + 1];
const float span = static_cast<float>(b.timeMs - a.timeMs);
const float u = span > 0.0f
? std::clamp((timeMs - static_cast<float>(a.timeMs)) / span, 0.0f, 1.0f)
: 0.0f;
return std::max(0.0f, mix(a.distance, b.distance, u));
}
float evaluateBeatDurationMs(const StageView& stage, float timeMs) {
if (!stage.header || stage.header->bpmChanges.count == 0) return 500.0f;
std::uint32_t active = 0;
for (std::uint32_t i = 1; i < stage.header->bpmChanges.count; ++i) {
if (static_cast<float>(stage.bpmChanges[i].timeMs) > timeMs) break;
active = i;
}
const std::uint32_t bpm = stage.bpmChanges[active].bpm;
return bpm > 0 ? 60000.0f / static_cast<float>(bpm) : 500.0f;
}
} // namespace openroller::psp
+2014
View File
File diff suppressed because it is too large Load Diff