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
+61
View File
@@ -0,0 +1,61 @@
# User-provided game data and decoded assets
/GC/
/dds_png/
# Build and release output
/build/
/build-*/
/dist/
CMakeFiles/
CMakeCache.txt
cmake_install.cmake
compile_commands.json
Makefile
*.o
*.obj
*.a
*.lib
*.so
*.dylib
*.dll
*.exe
*.pdb
*.ilk
*.dSYM/
# PSPDEV output and optional private XMB media
/psp/EBOOT.PBP
/psp/PARAM.SFO
/psp/*.elf
/psp/*.prx
/psp/assets/*.PNG
/psp/assets/*.AT3
!/psp/assets/README.md
# Local reverse-engineering environments and scratch data
/.agents/
/.codex/
/.cache/
/.ghidra_projects/
/ghidra_projects/
/.proton-gc/
/.wine-gc/
/.venv-re/
/.env
/tools/__pycache__/
*.pyc
# External checkouts are separate repositories
/third_party/
# Local/generated experiments
/a.svg
/Makefile.in
/iDmacDrv32.spec
/iDmacDrv32_main.c
# Editor and OS files
.DS_Store
.idea/
.vscode/
*~
+18
View File
@@ -0,0 +1,18 @@
# Changelog
## 0.1.5-publicTest
- Added 15 tracks to the PSP test library.
- Added a PSP pause menu with Continue, Restart and Back to Menu actions.
- Rotated directional controls in Tate mode to match the screen orientation.
- Swapped PSP confirm/cancel defaults: Circle confirms and Cross cancels.
- Added additional note types, helpers and hit sound effects.
- Reworked real-hardware audio playback to avoid dual-stream crackling.
- Removed L/R gameplay bindings that interfered with normal play.
- Fixed notes being depth-tested behind the route and removed phantom notes.
- End stages from music completion and return to song select after the result
sequence instead of replaying the track.
This release is still a beta. When reporting an issue, include the PSP model,
cIPL/CFW type and version, the selected song and difficulty, and the exact point
where the problem occurred.
+83
View File
@@ -0,0 +1,83 @@
cmake_minimum_required(VERSION 3.20)
project(openroller VERSION 0.1.5 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
option(OPENROLLER_BUILD_DESKTOP "Build the SDL/OpenGL desktop player" ON)
option(OPENROLLER_BUILD_TOOLS "Build host-side research and PSP packaging tools" ON)
option(OPENROLLER_BUILD_PSP_RUNTIME_PROBE "Build the host PSP runtime probe" ON)
set(VECTORAIL_CORE_SOURCE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../vectorail-core"
CACHE PATH "Path to a vectorail-core source checkout")
set(VECTORAIL_GC_SOURCE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../vectorail-gc"
CACHE PATH "Path to a vectorail-gc source checkout")
if(OPENROLLER_BUILD_DESKTOP AND NOT TARGET Vectorail::Core)
find_package(VectorailCore CONFIG QUIET)
if(NOT VectorailCore_FOUND)
if(EXISTS "${VECTORAIL_CORE_SOURCE_DIR}/CMakeLists.txt")
add_subdirectory("${VECTORAIL_CORE_SOURCE_DIR}"
"${CMAKE_CURRENT_BINARY_DIR}/_deps/vectorail-core")
else()
message(FATAL_ERROR
"vectorail-core was not found. Install VectorailCore or set "
"VECTORAIL_CORE_SOURCE_DIR to its source checkout.")
endif()
endif()
endif()
if(NOT TARGET Vectorail::GC)
find_package(VectorailGC CONFIG QUIET)
if(NOT VectorailGC_FOUND)
if(EXISTS "${VECTORAIL_GC_SOURCE_DIR}/CMakeLists.txt")
set(VECTORAIL_GC_BUILD_TOOLS OFF CACHE BOOL "" FORCE)
add_subdirectory("${VECTORAIL_GC_SOURCE_DIR}"
"${CMAKE_CURRENT_BINARY_DIR}/_deps/vectorail-gc")
else()
message(FATAL_ERROR
"vectorail-gc was not found. Install VectorailGC or set "
"VECTORAIL_GC_SOURCE_DIR to its source checkout.")
endif()
endif()
endif()
function(openroller_enable_warnings target)
if(MSVC)
target_compile_options("${target}" PRIVATE /W4)
else()
target_compile_options("${target}" PRIVATE -Wall -Wextra -Wpedantic)
endif()
endfunction()
if(OPENROLLER_BUILD_DESKTOP)
add_subdirectory(apps/desktop)
endif()
if(OPENROLLER_BUILD_TOOLS)
add_executable(openroller-stage-probe src/main.cpp)
target_link_libraries(openroller-stage-probe PRIVATE Vectorail::GC)
openroller_enable_warnings(openroller-stage-probe)
add_executable(openroller-psp-pack src/psp_pack.cpp)
target_include_directories(openroller-psp-pack PRIVATE include)
target_link_libraries(openroller-psp-pack PRIVATE Vectorail::GC)
openroller_enable_warnings(openroller-psp-pack)
add_executable(openroller-psp-catalog src/psp_catalog.cpp)
target_include_directories(openroller-psp-catalog PRIVATE include)
target_link_libraries(openroller-psp-catalog PRIVATE Vectorail::GC)
openroller_enable_warnings(openroller-psp-catalog)
endif()
if(OPENROLLER_BUILD_PSP_RUNTIME_PROBE)
add_executable(openroller-psp-runtime-probe
src/psp_runtime_probe.cpp
psp/src/StageRuntime.cpp
psp/src/Gameplay.cpp
)
target_include_directories(openroller-psp-runtime-probe PRIVATE psp/include include)
openroller_enable_warnings(openroller-psp-runtime-probe)
endif()
+13
View File
@@ -0,0 +1,13 @@
# Contributing
Bug reports and focused patches are welcome. For hardware-specific issues,
include the platform, firmware or CFW version, song and difficulty, and steps
that reproduce the problem.
Do not submit copyrighted game assets, executables, keys, firmware dumps or
data extracted from a copy you are not authorized to inspect. Research notes
should describe observable formats and behavior without embedding original
assets. New code must be compatible with the repository's MIT license.
Keep reusable rendering/platform primitives in `vectorail-core`, format-only
parsing in `vectorail-gc`, and game behavior in OpenRoller.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Kiyooru Takasaki
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+79
View File
@@ -0,0 +1,79 @@
# OpenRoller
OpenRoller is an experimental rhythm-game runtime for desktop Linux and Sony
PSP. It reconstructs compatible playback from independently researched stage
formats and user-provided data.
The repository contains the gameplay, menus, platform code and tools that turn
parsed stage data into a playable game. Reusable code is split into two sibling
projects:
- [vectorail-core](../vectorail-core): SDL, OpenGL, windowing and composition
primitives (BSD-3-Clause).
- [vectorail-gc](../vectorail-gc): parsers for DAT, TUMO, RVB, MTX and related
formats (MIT).
- **openroller**: gameplay, desktop integration, the PSP port and packaging
tools (MIT).
## Data policy
No game executables, decryption keys, music, charts, textures or other
copyrighted game data are distributed by this project. A local `GC/` directory
may be used with data you are legally entitled to access; it is ignored by Git.
Generated PSP libraries and optional XMB artwork/audio are ignored as well.
OpenRoller is an independent interoperability project. It is not affiliated
with or endorsed by TAITO, Square Enix, Sony, Nintendo or Valve. Groove Coaster
and other names and marks belong to their respective owners.
## Desktop build
Place all three repositories next to each other:
```text
workspace/
├── vectorail-core/
├── vectorail-gc/
└── openroller/
```
Install SDL3, OpenGL, libpng and GLM, then build:
```sh
cmake -S openroller -B openroller/build -DCMAKE_BUILD_TYPE=Release
cmake --build openroller/build -j
openroller/tools/run_stage_player.sh
```
CMake first looks for installed `VectorailCore` and `VectorailGC` packages,
then falls back to sibling source checkouts. Their locations can be overridden
with `VECTORAIL_CORE_SOURCE_DIR` and `VECTORAIL_GC_SOURCE_DIR`.
The raw stage inspection utility is built as `openroller-stage-probe`.
## PSP build
The PSP port is built with the `pspdev/pspdev` container:
```sh
tools/build_psp.sh
```
This produces `psp/EBOOT.PBP`. Optional local XMB media can be placed in
`psp/assets`; see [psp/assets/README.md](psp/assets/README.md). Preparing a
playable song library requires user-provided data and FFmpeg:
```sh
tools/prepare_psp_library.sh /path/to/GC /path/to/PSP/GAME/OpenRoller
```
## Status
OpenRoller is reverse-engineering research and an early public test. Format and
runtime behavior are incomplete, and crashes on real PSP hardware are still
possible. See [CHANGELOG.md](CHANGELOG.md) and the notes under `docs/`.
## License
OpenRoller source code is available under the [MIT License](LICENSE). This
license does not apply to user-provided game data or media.
+27
View File
@@ -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);
+6
View File
@@ -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
+39
View File
@@ -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);
}
+9
View File
@@ -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);
}
+39
View File
@@ -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);
}
}
+25
View File
@@ -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;
}
+8
View File
@@ -0,0 +1,8 @@
#version 450 core
out vec4 FragColor;
uniform vec4 uColor;
void main() {
FragColor = uColor;
}
+10
View File
@@ -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);
}
+12
View File
@@ -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);
}
+14
View File
@@ -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);
}
+9
View File
@@ -0,0 +1,9 @@
#version 450 core
in float vLife;
out vec4 FragColor;
uniform vec3 uColor;
void main() {
// В кваде vLife достаточно для затухания
FragColor = vec4(uColor, vLife);
}
+17
View File
@@ -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;
}
+7
View File
@@ -0,0 +1,7 @@
#version 450 core
in vec4 vColor;
out vec4 FragColor;
void main() {
FragColor = vColor;
}
+13
View File
@@ -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);
}
+19
View File
@@ -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;
}
}
+9
View File
@@ -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);
}
+233
View File
@@ -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;
}
+71
View File
@@ -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;
}
+827
View File
@@ -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
+30
View File
@@ -0,0 +1,30 @@
#pragma endian big
import std.io;
import std.string;
using string = std::string::SizedString<u8> [[format("string_formatter")]];
fn string_formatter(ref string s) {
return std::format("\"{:s}\"", s);
};
enum Version : u8 {
GC1 = 1,
GC1EX = 2,
GC2 = 3,
GC3 = 4,
GC4 = 5,
};
Version PARSE_VER = Version::GC4;
struct Item {
u32 id;
string texture;
string unk2; // Name?
u8 unk3;
string descriptionJP;
string descriptionEN;
};
u16 itemCount @ 0x00;
Item items[itemCount] @ 0x02 [[inline]];
+32
View File
@@ -0,0 +1,32 @@
#pragma endian big
import std.io;
import std.string;
using string = std::string::SizedString<u8> [[format("string_formatter")]];
fn string_formatter(ref string s) {
return std::format("\"{:s}\"", s);
};
enum Version : u8 {
GC1 = 1,
GC1EX = 2,
GC2 = 3,
GC3 = 4,
GC4 = 5,
};
Version PARSE_VER = Version::GC4;
struct Message {
u32 id;
string texture;
string unk1;
u8 unk2;
string unk3; // Contents
string unk4; // Contents
bool unk5;
bool unk6;
};
u16 messageCount @ 0x00;
Message messages[messageCount] @ 0x02 [[inline]];
+46
View File
@@ -0,0 +1,46 @@
#pragma endian big
import std.io;
import std.string;
using string = std::string::SizedString<u8> [[format("string_formatter")]];
fn string_formatter(ref string s) {
return std::format("\"{:s}\"", s);
};
enum Version : u8 {
GC1 = 1,
GC1EX = 2,
GC2 = 3,
GC3 = 4,
GC4 = 5,
};
Version PARSE_VER = Version::GC4;
struct Navigator {
u32 id;
string texture; // /data/2d_boost/navigator/<name>.dds
string unk2;
string unk3;
string unk4;
string unk5;
string unk6;
string unk7;
string unk8;
string unk9;
string unk10;
u8 unk11;
u8 unk12;
u8 unk13;
u8 unk14;
u32 unk15;
u32 unk16;
u8 unk17;
u8 unk18;
u8 unk19;
string descriptionJP;
string descriptionEN;
};
u16 navigatorCount @ 0x00;
Navigator navigators[navigatorCount] @ 0x02 [[inline]];
+47
View File
@@ -0,0 +1,47 @@
#pragma endian big
import std.io;
import std.string;
using string = std::string::SizedString<u8> [[format("string_formatter")]];
fn string_formatter(ref string s) {
return std::format("\"{:s}\"", s);
};
enum Version : u8 {
GC1 = 1,
GC1EX = 2,
GC2 = 3,
GC3 = 4,
GC4 = 5,
};
Version PARSE_VER = Version::GC4;
struct Player {
u32 id;
string texture; // /data/2d_boost/avatar/<name>.dds
string unk2;
string unk3;
string unk4;
string unk5;
string unk6;
string unk7;
u8 unk8;
string unk9; // /data/model/<name> - pngs only
string unk10; // /data/model/<name> - uvb/tumo files
string unk11; // /data/model/<name> - tusc/efcb2 files
u32 unk12;
u32 unk13;
u8 unk14;
u32 unk15;
u32 unk16;
u32 unk17;
u32 unk18;
u8 unk19;
string descriptionJP;
string descriptionEN;
u32 unk22;
};
u16 playerCount @ 0x00;
Player players[playerCount] @ 0x02 [[inline]];
+35
View File
@@ -0,0 +1,35 @@
#pragma endian big
import std.io;
import std.string;
using string = std::string::SizedString<u8> [[format("string_formatter")]];
fn string_formatter(ref string s) {
return std::format("\"{:s}\"", s);
};
enum Version : u8 {
GC1 = 1,
GC1EX = 2,
GC2 = 3,
GC3 = 4,
GC4 = 5,
};
Version PARSE_VER = Version::GC4;
struct SE {
u32 id;
string texture;
string unk2; // Name long?
string unk3; // Name short?
string unk4;
string se_name1; // /data/sound/<name>.wav
string se_name2;
string se_name3;
u8 unk8;
string descriptionJP;
string descriptionEN;
};
u16 seCount @ 0x00;
SE ses[seCount] @ 0x02 [[inline]];
+31
View File
@@ -0,0 +1,31 @@
#pragma endian big
import std.io;
import std.string;
using string = std::string::SizedString<u8> [[format("string_formatter")]];
fn string_formatter(ref string s) {
return std::format("\"{:s}\"", s);
};
enum Version : u8 {
GC1 = 1,
GC1EX = 2,
GC2 = 3,
GC3 = 4,
GC4 = 5,
};
Version PARSE_VER = Version::GC4;
struct Skin {
u32 id;
string texture;
string unk2;
string skinFolder;
u8 unk4;
string descriptionJP;
string descriptionEN;
};
u16 skinCount @ 0x00;
Skin skins[skinCount] @ 0x02 [[inline]];
+197
View File
@@ -0,0 +1,197 @@
#pragma endian big
import std.io;
import std.string;
using string = std::string::SizedString<u8> [[format("string_formatter")]];
fn string_formatter(ref string s) {
return std::format("\"{:s}\"", s);
};
enum Version : u8 {
GC1 = 1,
GC1EX = 2,
GC2 = 3,
GC3 = 4,
GC4 = 5,
};
enum Genre_New : u8 {
GENRE_UNKNOWN = 0,
GENRE_ANIME_POP,
GENRE_VOCALOID,
GENRE_OTOGE,
GENRE_GAME,
GENRE_VARIETY,
GENRE_ORIGINAL,
GENRE_TOUHOU
};
enum Genre_GC2 : u8 {
GENRE_GC2_UNKNOWN = 0,
GENRE_GC2_POPS,
GENRE_GC2_VOCALOID,
GENRE_GC2_GAME,
GENRE_GC2_VARIETY,
GENRE_GC2_ORIGINAL,
GENRE_GC2_ANIME,
GENRE_GC2_TOUHOU
};
enum Genre_GC1 : u8 {
GENRE_GC1_UNKNOWN = 0,
GENRE_GC1_ANIME_POP,
GENRE_GC1_VOCALOID,
GENRE_GC1_GAME, // Rhythm games and touhou are merged into this in GC1
GENRE_GC1_VARIETY,
GENRE_GC1_ORIGINAL
};
enum SKIN : u8 {
SKIN_NONE = 0,
SKIN_BASIC,
SKIN_FLOWER,
SKIN_FIREWORKS,
SKIN_MARBLES,
SKIN_MIDCENTURY,
SKIN_INFINITYGENE,
SKIN_MOLECULE,
SKIN_BUTTERFLY,
SKIN_PAISLEY,
SKIN_8BIT,
SKIN_BRIGHTNESS,
SKIN_HOLOGRAM,
SKIN_HEAVEN,
SKIN_SNOW,
SKIN_FEATHER,
SKIN_NOTE,
SKIN_STAR,
SKIN_HORROR,
SKIN_HEART,
SKIN_SYNCHRONICA
};
enum SongFlag : u8 {
NONE = 0,
TUTORIAL = 1,
GC1_CREDITS = 2,
SHOW_CATEGORY = 3,
GC2_CREDITS = 4,
TUTORIAL_JP = 5,
TUTORIAL_EN_RV = 6,
TUTORIAL_EN_GC = 7,
GC3_CREDITS = 8,
GC3EX_CREDITS = 9,
GC4_CREDITS = 10,
};
struct Song {
u32 id;
string name;
string ident;
string artist;
if (PARSE_VER >= Version::GC2)
string extraInfo;
string yomigana;
if (PARSE_VER <= Version::GC1EX)
Genre_GC1 genre;
else if (PARSE_VER == Version::GC2)
Genre_GC2 genre;
else
Genre_New genre;
string duration;
u8 diffSimple;
u8 diffNormal;
u8 diffHard;
if (PARSE_VER > Version::GC1) u8 diffExtra;
string bpm;
if (PARSE_VER == Version::GC1) {
u32 unk1; // TODO: Maybe look what this is?
} else if (PARSE_VER == Version::GC1EX) {
// TODO: for 1EX one of these needs to have [4] otherise shit gets fucked
u8 bgmVolume[4];
u8 shotVolume;
} else {
u8 bgmVolume[4];
u8 shotVolume[4];
}
if (PARSE_VER <= Version::GC1EX) u32 unk2;
if (PARSE_VER >= Version::GC2) {
u16 unk3;
u16 unk4;
}
u32 previewSeekStartMillis;
u32 previewSeekEndMillis;
SKIN defaultSkin;
SongFlag specialFlags; // TODO: Implement the enum
string bgmFilePrefix;
// TODO: Rework this a bit
if (PARSE_VER == Version::GC1) {
string stageFilenameSimple;
string stageFilenameNormal;
string stageFilenameHard;
string bgmFilenameSuffixSimple;
string bgmFilenameSuffixNormal;
string bgmFilenameSuffixHard;
} else if (PARSE_VER == Version::GC1EX) {
if (id != 999) {
string shotFilenameSuffixSimple;
string shotFilenameSuffixNormal;
string shotFilenameSuffixHard;
string shotFilenameSuffixExtra;
}
string stageFilenameSimple;
string stageFilenameNormal;
string stageFilenameHard;
string stageFilenameExtra;
string bgmFilenameSuffixSimple;
string bgmFilenameSuffixNormal;
string bgmFilenameSuffixHard;
string bgmFilenameSuffixExtra;
} else {
string bgmFilenameSuffixSimple;
string bgmFilenameSuffixNormal;
string bgmFilenameSuffixHard;
string bgmFilenameSuffixExtra;
string shotFilenameSuffixSimple;
string shotFilenameSuffixNormal;
string shotFilenameSuffixHard;
string shotFilenameSuffixExtra;
string stageFilenameSimple;
string stageFilenameNormal;
string stageFilenameHard;
string stageFilenameExtra;
}
if (PARSE_VER >= Version::GC2) {
string stageRegionSuffix;
u32 judgeDelay; // TODO: Confirm
}
if (PARSE_VER == Version::GC1EX) u8 musicPanelCost;
u8 unlockable; // definitely a bool, seems to dictate whether server unlocks work
u8 songEnabled;
u8 unk7;
if (PARSE_VER == Version::GC4) u8 isRecommended;
} [[format("song_formatter")]];
fn song_formatter(ref Song s) {
return std::format("{} {}: ({})", s.id, s.ident, s.name);
};
Version PARSE_VER = Version::GC1EX;
u16 songCount @ 0x00;
Song songs[songCount] @ 0x02 [[inline]];
+40
View File
@@ -0,0 +1,40 @@
#pragma endian big
import std.io;
import std.string;
using string = std::string::SizedString<u8> [[format("string_formatter")]];
fn string_formatter(ref string s) {
return std::format("\"{:s}\"", s);
};
enum Version : u8 {
GC1 = 1,
GC1EX = 2,
GC2 = 3,
GC3 = 4,
GC4 = 5,
};
Version PARSE_VER = Version::GC4;
struct Title {
u32 id;
string texture;
string textureSuffixEn;
string textJP;
string textEN;
u8 unk5;
string descriptionJP;
string descriptionEN;
string unk6; // Also descriptions, maybe for next title?
string unk7; // ^
u8 unk8;
u8 unk9;
u8 unk10;
u8 unk11;
u8 unk12;
u8 unk13;
};
u16 titleCount @ 0x00;
Title titles[titleCount] @ 0x02 [[inline]];
+211
View File
@@ -0,0 +1,211 @@
# OpenRoller PSP port
The first hardware target is a PSP-1004 (PSP-1000/Fat). The runtime must fit
the original model's memory limits and must not depend on the extra application
memory available on later PSP models.
## Display contract
The arcade game uses a 720x1280 portrait canvas. OpenRoller keeps those logical
coordinates on PSP and rotates the final scene into the physical 480x272 panel:
- scale: 3/8;
- resulting image: 270x480;
- physical border: one pixel on each long side;
- `SELECT`: swap clockwise/counter-clockwise tate orientation;
- `START`: pause/resume once a stage is loaded.
This transform must remain above both the gameplay renderer and the song-select
renderer. Content code should not contain PSP screen coordinates.
Gameplay geometry is submitted to the native PSP Geometry Engine as
`GU_TRANSFORM_3D` vertices. The 720x1280 portrait projection is preserved in
tate mode by swapping its horizontal/vertical projection extents and rolling
the camera basis by 90 degrees. The GU performs perspective division,
near/far clipping, and depth testing; CPU projection is not part of the runtime
renderer except for the original screen-relative directional-note arrow and
lower-right helper overlays.
## Memory rules for PSP-1000
- Use RGB565 double framebuffers and a 16-bit depth buffer. Together they use
0xcc000 bytes of VRAM, leaving about 1.2 MiB of VRAM for resident textures.
- Never load a whole song into RAM. Audio is decoded from a bounded streaming
ring and the decoded sample count is the gameplay clock.
- Keep only the current jacket resident; replace it when selection moves.
- Convert DDS/MTX textures on the host to RGB565, RGBA4444, or indexed T8 data.
- Parse arcade formats on the host. PSP consumes little-endian, aligned binary
arrays and never instantiates the reverse-engineering object graph.
- Generate/copy only the visible rail window and visible notes each frame.
- Background meshes require culling and a fixed resident-memory budget.
## Build
The local machine does not need a permanent PSPDEV installation. The build
script uses the official PSPDEV Docker image:
```sh
tools/build_psp.sh
```
The result is `psp/EBOOT.PBP`. Copy the runtime directory to:
```text
ms0:/PSP/GAME/OpenRoller/
```
When `catalog.orpc` is present next to `EBOOT.PBP`, the runtime opens the
portrait song-select screen and lazily loads the selected jacket, chart and
audio stream. It renders the real track, camera, notes, draw window, four-corner color
background, and the stage-authored `.tumo` objects. Object translation, scale,
rotation, color and visibility channels are evaluated on PSP; one-level parent
composition, wireframe selection, the fixed 250 ms visibility fade and sorted
alpha rendering follow the recovered `game471.exe` path.
Particle repeat/lifetime, spawn shape, direction, velocity and opacity are now
evaluated against the authored timeline and current BPM. The sprite resource
lookup is not ported yet, so particle instances use a small geometric marker.
Visualizer type, color and timeline are also carried by `.orps`; the current GE
renderer maps those types to rings, rays, bars and scanlines, but still uses a
deterministic pulse rather than the original audio analyser/FFT path. TUMO
geometry is position/color only at this stage; its material textures remain the
next background fidelity layer.
Without a catalog, the legacy `stage.orps`/`audio.mp3` pair still works. Without
either format, the executable falls back to the synthetic smoke scene.
The host preparation script applies both per-difficulty volumes from
`stage_param.dat`, pre-mixes the authored BGM and SHOT WAVs with a
latency-compensated limiter, and emits one CBR 128 kbit/s, 44.1 kHz stereo MP3
per chart. The PSP runtime decodes that stream incrementally through one handle
of the native MP3 module and submits it through one SRC channel. This is
deliberately single-stream: simultaneous MP3 handles stutter on PSP-1000 while
the 3D stage is active. Reactive SHOT muting is therefore temporarily disabled
in the PSP build. The number of samples accepted by audio output is the gameplay
clock; rendering framerate does not advance song time. `SE_ARRANGE`, `TAP_SE1` and
`TAP_SE2` are host-converted to small 44.1 kHz PCM effects mixed by the same
thread.
The host-side chart normalizer is built with the regular desktop CMake build:
```sh
cmake -S . -B build
cmake --build build --target openroller-psp-pack
build/openroller-psp-pack GC/data/stage/ac_example_easy.dat stage.orps
```
The version-4 `.orps` file is a little-endian, 16-byte-aligned snapshot containing the
track, notes, camera, draw distance, background colors, pre-triangulated TUMO
vertices, stage-object records, flattened animation keys, particles,
visualizer keys and BPM changes. Each note also carries the effective runtime
type, appearance/end times, directional vector, per-note early/late/miss/mute
windows, colour and effect selector produced by the recovered
`BuildTimingDataSub` path. It deliberately
retains unknown note fields so discoveries made later in the reverse do not
invalidate imported charts. For `ac_10pt8tion_easy.dat` the full package is
about 493 KiB; the 36 source models contribute about 356 KiB of position-only
vertices and are submitted to GU directly from the loaded package.
For a ready-to-copy Memory Stick directory using the default test chart:
```sh
tools/prepare_psp_demo.sh
```
This creates `dist/PSP/GAME/OpenRoller/`. Copy that `OpenRoller` directory to
`ms0:/PSP/GAME/`. Runtime controls are:
For the 30-song library build used by the PSP-1004 port:
```sh
tools/prepare_psp_library.sh
```
This produces 97 difficulty packages for `10pt8ion`, `Oshama Scramble!`,
`Bonetrousle`, `Shadow`, `Planet connection`, `Departure`, `Journey`,
`SPACE ARCADIAN`, `Altale`, `Analysis Division`, `Thrash Beat`, `Agent Angels`,
`FLOWER`, `ADRENA`, and `7 days a week`, plus `Satisfiction` and fourteen
VOCALOID tracks: `MikuMiku`, `The Disappearance of Hatsune Miku`,
`World's End Dancehall`, `Rolling Girl`, `Uraomote Lovers`,
`Unknown Mother-Goose`, `Redial`, `Tell Your World`, `Umiyuri`,
`Karakuri Pierrot`, `Dappo Rock`, `ECHO`, `Vampire`, and
`PaIII.SENSATION`. Audio is CBR 128 kbit/s MP3 and jackets are 128x128
RGBA4444. Only the selected song's resources are resident.
The song selector has a continuously scrolling public-test notice. Its build
identifier is compiled as `tsuki@kagebaito-yyyymmdd-hhmmss` by
`tools/build_psp.sh`; each release build therefore exposes its exact timestamp
on screen.
Song-select controls are:
- D-pad up/down: previous/next song;
- `L` / `R`: move five songs;
- `CIRCLE`: enter difficulty selection, then start the selected chart;
- D-pad in difficulty selection: choose an available mode;
- `CROSS`: back (or exit from the song list);
- `SELECT`: switch tate side.
The physical D-pad is rotated into the portrait coordinate system. In the
default clockwise orientation physical left/up/right/down becomes logical
down/left/up/right; counter-clockwise tate uses the inverse mapping.
Gameplay controls are:
- D-pad or `CROSS` / `CIRCLE` / `TRIANGLE` / `SQUARE`: tap;
- `SELECT`: switch tate side;
- `START`: open the pause menu;
- `L` / `R`: seek backward/forward five seconds;
- `START` + `SELECT`: exit (the PSP `HOME` menu also remains available).
The pause menu contains `CONTINUE`, `RESTART`, and `BACK TO MENU`; `CIRCLE`
confirms and `CROSS` cancels. At chart end the current song is unloaded and the
runtime returns to song select automatically. The Linux runtime performs the
same transition after the authored track duration and reopens its selector.
Every newly pressed D-pad or face-button bit is retained as an independent
input, so simultaneous buttons can judge CRITICAL and start DUAL HOLD.
Releases terminate HOLD/DUAL HOLD, while alternating/repeated presses keep
SCRATCH and BEAT active. The recovered `game471.exe` judge routine uses
25% / 50% / 100% of the full early or late input-window width for GREAT / COOL /
GOOD, with `GreatMinTime = 32` ms as the lower GREAT bound. Windows come from
the chart timing lists or the per-difficulty `system.cfg` override, including
the recovered FLICK extension. Long-note ranks use the original held-percentage
thresholds and short-tail correction. SLIDE HOLD and MERRY GO ROUND rendering
data is packaged, but their final input state machines remain unresolved in
the desktop reverse as well.
Notes fade in/out on authored beat-relative times and are submitted after the
rail without depth testing. HOLD/SCRATCH/BEAT/SLIDE/DUAL bodies and matching end
markers are generated as PSP GE geometry; directional arrows are fixed in
screen orientation using the camera at the note timestamp, and the control
helper stays in the lower-right HUD slot. The original note/effect atlases are
not yet converted for PSP, so these marks remain geometric rather than using
the arcade textures.
## Runtime package
```text
OpenRoller/
EBOOT.PBP
catalog.orpc
songs/
<song-id>/
easy.orps
normal.orps
hard.orps
extra.orps # when present
easy_bgm.mp3
normal_bgm.mp3
...
jacket.orpj
sounds/
adlib.pcm
tap1.pcm
tap2.pcm
```
`catalog.orpc` is a fixed-record index generated from the original
`stage_param.dat`; it binds display title, artist, BPM, duration, genre,
difficulty ratings and availability to a stable song key. The menu uses that
same key to resolve the chart package, per-difficulty mix and jacket paths.
+537
View File
@@ -0,0 +1,537 @@
# game471.exe: Notes (Static Analysis)
Goal: understand file formats and runtime behavior for a clean-room reimplementation.
This is based on static inspection (strings + disassembly). No patching, no runtime hooking.
## Quick Map
- Stage file path templates referenced in code:
- `data/stage/%s.dat`
- `data/stage/%s_ext.dat`
- `data/stage/%s_clip.dat`
- The game uses a background file reader (a small work queue + thread) that:
1. Opens a file path stored in a request object.
2. Reads the file into a buffer in 64 KiB chunks.
3. Calls a callback function provided when the queue was created.
## Background File Reader
### Worker thread proc
- `0x004ca320` is used as the thread entry function (passed as a function pointer).
- It receives a small argument struct, extracts two pointers from it, then calls `0x004ca070`.
### File read loop
- `0x004ca070` implements:
- open file (`CreateFileA`), then `ReadFile` in a loop
- destination buffer is `request->buf` at offset `+0x204`
- expected size is `request->size` at offset `+0x208`
At several points it calls a callback:
- `queue->callback_fn` stored at offset `queue + 0x120`
- `queue->callback_ctx` stored at offset `queue + 0x124` (passed as last argument)
### Queue initialization
- `0x004ca5f0` looks like the queue constructor/initializer.
- It stores its arguments into `this+0x120` and `this+0x124`.
So, to find the actual parsing logic for a specific file type, you typically follow the callback function pointer passed into the queue creation.
## Stage Loader Entry
There is code that formats the stage file path strings and attempts to open:
- `data/stage/<name>.dat`
- `data/stage/<name>_ext.dat`
- `data/stage/<name>_clip.dat`
This is a good anchor when looking for where the game requests reads for stage containers.
Static addresses in the 4.71 executable:
- `0x0063ea70`: formats all three stage paths and requests each resource.
- `0x00427730`: resource request wrapper.
- `0x004279d0`: creates the 0x4c-byte resource object (Ghidra mislabels it as an MFC `CreateObject`).
- `0x0063ecc0`: completion/cleanup callback which unwraps three resources before passing the result onward.
## Main stage header
The file is big-endian. The initial words are not all section offsets. Confirmed section slots are:
| Index | Meaning |
| ---: | --- |
| 0 | stage config |
| 1 | track draw distances |
| 2 | track points |
| 3 | notes |
| 4 | camera |
| 5 | particles |
| 6 | visualizer |
| 7 | unknown section |
| 8 | first color table |
| 9 | objects |
| 10 | scalar/unknown; often `0x30`, not an offset |
| 11 | second color table |
| 12 | scalar/unknown; can accidentally look like an in-file offset |
This matters because treating every plausible header word as an offset can split the notes or camera section at a false boundary.
Older 11-word stage headers instead store the second color-table offset in
slot 10. The scalar/offset/scalar arrangement in slots 10..12 belongs to the
newer 13-word format used by the 4.71-era charts.
## Note array: confirmed wire layout
The note section starts at header word 3 and ends exactly at header word 4. Its layout is:
```text
u32be name_count // observed: 0 in all 2,968 dumped main charts
repeat name_count times:
string8 name // u8 length followed by bytes
u32be count
repeat count times:
u32be time_ms
u8 raw_type
u8 type_override
s16be params16[9]
u8 flag24
f32be params25[3]
u8 flag37
u8 flag38
f32be params39[4]
u32be params55[3]
f32be param67
u32be param71
f32be params75[5]
u32be param95
```
Thus each note record is exactly 99 bytes and the invariant is:
```text
camera_offset - notes_offset == 8 + encoded_names_size + count * 99
```
The invariant holds on tested charts of very different sizes. For example,
`ac_10pt8tion_hard.dat` has 669 records from 6162 through 125568 ms.
Its raw-type histogram is `01:366, 02:231, 03:1, 04:3, 05:3, 09:63, 0f:2`.
Observed raw types over the parsed corpus are `00, 01, 02, 03, 04, 05, 06, 09, 0a, 0f`.
The payload field meanings remain unnamed until backed by code references or
controlled runtime observations.
The exact read order above comes from the game's stage constructor `0x005ed4c0`
and its note-section parser `0x005ea800`. The latter allocates a 0x11c-byte
runtime object for every compact 99-byte wire record. It also proves one behavior:
if `type_override` is non-zero, the runtime replaces `raw_type` with type 1.
The post-load pass at `0x005ebaa0` additionally proves that runtime types 11,
12, 13 and 14 are compatibility aliases: 11 becomes 10, 12/14 become 9, and
13 becomes 4. Other game modes can deliberately simplify types (for example
2 to 1, and 4/5/10/15 to 3), so a clean player should preserve both the raw
wire type and the effective runtime type.
Full-dump validation with `tools/scan_gc_notes.py GC/data/stage` covers 2,968
main stage files and 1,080,259 notes with zero size mismatches. All of those
files currently have zero note-name strings. The aggregate raw-type counts are:
```text
00:6 01:719892 02:163462 03:67753 04:8651
05:5115 06:193 09:102699 0a:6360 0f:6128
```
`type_override` is non-zero in 52,506 records, so it cannot be discarded.
## Confirmed type names
The executable contains a direct pointer table at `0x0077d480`; its array index
is the numeric note type. This yields the complete original enum:
| Value | Original name |
| ---: | --- |
| 0 | NONE |
| 1 | NORMAL |
| 2 | FLICK |
| 3 | HOLD |
| 4 | SCRATCH |
| 5 | BEAT |
| 6 | MERRY GO ROUND |
| 7 | HIDDEN |
| 8 | HIDDEN2 |
| 9 | CRITICAL |
| 10 | SLIDE HOLD |
| 11 | SLIDE COUNTER |
| 12 | TURN |
| 13 | SPIN |
| 14 | FINISH |
| 15 | DUAL HOLD |
The same 16-entry sequence is repeated in several embedded reflection/debug
tables, which independently confirms the ordering.
## Confirmed runtime classifications
With the exact names assigned, several behavioral groups are also explicit in code:
- `0x005e9480` classifies types 3, 4, 5, 10 and 15 as duration/path notes.
- `0x005ebaa0` generates sampled paths for those types. Type 4 additionally
builds two derived point arrays; types 3/10/15 share one path branch.
- `0x005e94c0` singles out type 6. During gameplay setup it expands that note
into several runtime judge objects using the runtime field originating from
wire offset `+71`.
- Types 1, 2 and 9 follow the non-duration branches in the preview/gameplay
render path at `0x006492f0`.
The enum names are now exact. The remaining reverse target is the meaning of
the compact payload fields and the type-specific hold/slide behavior.
## Appearance, duration and control-helper effects
The post-load pass at `0x005ebaa0` converts several compact fields using the
BPM active at the note timestamp. With `beat_ms = 60000 / bpm`:
| Wire field | Runtime meaning |
| ---: | --- |
| `+6` | signed marker-effect/UV selector; the note-head draw passes `value - 1` to effect 3 |
| `+39` | appearance lead in beats; runtime `+0xbc = max(time - value * beat_ms, 0)` |
| `+51` | duration in beats for types 3/4/5/10/15; runtime `+0x58` and `+0xac = time + value * beat_ms` |
| `+55` | packed authored `RRGGBBAA` colour used by duration-target geometry |
| `+71` | number of generated MERRY GO ROUND targets |
| `+75` | MERRY GO ROUND spacing in beats |
`0x005efe40` confirms that MERRY's span is
`count * spacing * beat_ms`. Gameplay setup allocates three visual handles per
generated target, which explains why treating type 6 as one marker loses most
of the authored pattern.
The four blocks following the BPM table in `StageConfig` are variable-length
timing lists, not four fixed records. Each list is `u16be count`, followed by
`count` entries of `u32be time_ms, u32be mode, f32be value`.
`TuneTimingData::GetTime` selects the last entry at or before the note: mode 1
returns `value` as milliseconds, mode 3 returns the spacing to the next note,
and the other modes return `value * beat_ms`. The third list supplies the late
boundary used as runtime `+0xac` for ordinary targets. FLICK (and raw type
`0x10`) receives an additional literal `0.2 * beat_ms`.
Marker opacity is independent of the fixed judgment-window overrides below.
`0x0063ff90` stores `beat_ms` at runtime `+0x20`; `0x0064ab80` divides it by
the literal `2.0`, while `0x005ebaa0` sets runtime `+0xc4` to
`+0xac + 4 * beat_ms`. With `start = +0xbc`, `finish = +0xc4`, and
`fade = beat_ms / 2`, the exact unclamped shape is:
```text
time < start + fade : alpha = (time - start) / fade
time <= finish - fade : alpha = 1
otherwise : alpha = (finish - time) / fade
```
The renderer clamps this to `[0, 1]`. Duration targets use their authored end
as `+0xac`; MERRY GO ROUND first adds its generated-target span. The Linux
player now carries the per-note BPM and `+0xc4` equivalent instead of using
the former guessed 236 ms marker lifetime.
The lower-right control helpers are not note-head effects. `0x00661680` reads the 16-entry
table at `0x006ea040`, loads the indicated `efcdata.dat` record, and stores it
in runtime slot `0xb1 + type`:
```text
type: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
effect id: -1 61 66 62 63 64 61 -1 67 65 69 -1 -1 -1 -1 68
```
Thus NONE and HIDDEN have no helper resource. FLICK and SLIDE HOLD also draw
runtime slot `0xb9` as a direction overlay in `0x006492f0`. The leading words
of effect records 61--69 give animation lengths of 16, 38, 34, 13, 16, 14,
14, 38 and 38 update ticks respectively.
The common-effect interpreter is now mapped far enough to reproduce those
records exactly. `0x005f2800` dispatches the child nodes, while
`0x005f2030`, `0x005f2250` and `0x005f23f0` implement looping, key lookup and
step/linear interpolation. Every child has five relative track offsets. A
track begins with `u16be key_count, loop_start, loop_end`, followed by keys of
`u16be time, u8 interpolation, f32be values[]`. For a sprite child (type 2),
the tracks are:
| Track | Values |
| ---: | --- |
| 0 | UV frame plus six unused/secondary values |
| 1 | XYZ position |
| 2 | alpha, red, green, blue |
| 3 | XY scale |
| 4 | rotation in degrees |
Type 0 is a parent rotation node in radians. A sprite whose child flag at
`+4` is set inherits that rotation, including the rotated XY offset. Effects
61--69 use UV record 13 from `uvdata.dat`, whose image is `img13.bin`: target
rings, balls, hand prompts, arrows, action bursts and the type-specific help
caption are separate animated layers. `0x006492f0` advances the records with
an integer 60 Hz tick, places the selected composite at the literal virtual-
screen coordinate `(580, 788, 0)` and applies the literal global XY scale
`1.3`. `0x006e9ee4` supplies a 1000 ms look-ahead, so only the current helper
is selected; the effect is not cloned onto every world-space note. The Linux
player implements this format
in `vectorail-gc/src/GcTargetEffect.cpp`; it does not substitute a
single guessed icon for the composite any more.
## Cross-version confirmation of note-head rendering
The Android ARM64 build preserves C++ symbols which are stripped from the
arcade executable. Its `GameScene::DrawGameStageCharacter` establishes the
same ordering seen in game471: it disables depth writes, calls `DrawWay`,
then calls `DrawMark` without disabling the depth test. The route therefore
cannot punch holes through marker sprites drawn afterwards.
`TuneGameData::BuildTimingDataSub` (`0x00196a74`) fixes the note head's world
position before gameplay by calling `GetWayPosition(note_time)` directly and
storing the returned XYZ at runtime `+0xe0`. Long-note endpoints likewise use
`GetWayPosition(end_time)` at `+0xf8`. There is no conversion through physical
track distance and no note-time lead added in this path. `DrawMark` consumes
these stored positions. The separate one-frame `-16.6667 ms` lookup in
`GameScene::SetCommonParam` is used for the moving player segment, not for note
placement.
The Linux player now performs the same direct timestamp lookup for heads,
endpoint heads, generated BEAT/MERRY targets, and approach circles. Its shared
physical-distance LUT is still needed for duration-body sampling; it now
samples each authored segment independently and includes every exact corner.
The previous cumulative `t += 0.02` loop could jump across a segment boundary,
measure a shortcut chord through the corner, and progressively place later
distance-based geometry ahead of the authored route position.
`GameScene::DrawMark` creates each head with effect manager group 1,
effect 3, and the final selector `wire_mark_effect_id - 1`. Group 1 is bound
by `GameScene::LoadSkinData` to the selected skin's `uv.dat` and its
texture list. The first signed word at stage-note wire offset `+6` contains
the selector source. Examples from `ac_10pt8tion_hard.dat` are:
| Note | Wire `+6` | Resolved UV record |
| --- | ---: | ---: |
| NORMAL | 1 | 1 |
| HOLD | 9 | 9 |
| FLICK | 10 | 10 |
| SCRATCH | 11 | 11 |
| BEAT | 12 | 12 |
| CRITICAL | 32 | 32 |
| DUAL HOLD | 37 | 37 |
Effect 3 has a 15-tick loop. Records 1--12 use 32x32 cells from the common
skin atlas, while records 32 and 37 use 64x64 cells from the second common
atlas. The effect instance uses the literal XY scale `0.025`; consequently
the authored 32px and 64px cells occupy 0.8 and 1.6 route-world units.
Directional heads have a second world-space layer. `DrawMark` plays effect
39 over FLICK with UV base 0 and over SLIDE HOLD with UV base 10. The final
root angle is not wire `+29` used directly: wire `+25` is a vector length and
`+29/+33` are HPB angles. `BuildTimingDataSub` converts these through
`RotateHPB::ToVector_Deg`; `GameScene::buildGameData` projects the route point
and the displaced point with the camera evaluated at the note timestamp, then
uses `RotateHPB::SetVector` (`atan2(screen_dx, screen_dy_down)`) and stores the
resulting screen angle. It is computed before gameplay and remains fixed while
the live camera moves. Effect 39 resolves to a 128x32 animated strip whose
arrows extend to the sides of the 32x32 head; it uses the same `0.025` scale,
colour and marker alpha. This is independent of the lower-right control
helper, even though that helper also has a direction overlay.
The marker animation clock is beat-synchronized, not a free-running 60 Hz
counter. Android `GameScene::SetCommonParam` computes
`phase = 1 - (((time - bpm_change_time) / beat_ms * 2000) % 2000) / 2000`
and `DrawMark` writes `phase * EffectData::GetFrameMax()` to effect field
`+8`. Thus effect 3 runs backwards from frame 15 toward frame 0 once per beat
and resets at every beat; BPM changes also reset the phase origin. BEAT adds
`sample_index * 2` to this base frame.
The approach ring is geometry, not another atlas layer.
`GameScene::drawHitTimingCircle` draws a white 16-segment circle with additive
blending, `radius = 0.2 + progress * 0.5`, and alpha `marker_alpha * 128/255`.
For ordinary notes `progress = (late_boundary - time) / (2 * (late_boundary - early_boundary))`,
capped at 1 and only shown before
the target time. The arcade MERRY branch instead evaluates the analogous
progress separately at each generated target's shifted timestamp.
Both that circle and the effect-3 head are camera-facing billboards. The
mobile renderer calls `SetTransposeMatrix(world_position, camera_matrix)` for
the circle and leaves the effect sprite in billboard mode; neither is laid in
the route tangent plane. Duration bodies remain authored 3D route geometry.
The Linux player now decodes this selector, interprets effect 3, loads
`skin/common/common.png`, `skin/common/common2.png`, the selected
`skinN/img.dat` list and `skinN/uv.dat`, and renders the selected animated
UV record. The former guessed `effect/game/img4.bin` glyph mapping and
synthetic pulsing rings are no longer used for decoded GC stages.
Duration bodies are also type-specific in the executable:
- HOLD calls `0x00647cf0` and SLIDE HOLD calls `0x00641fd0`; both emit ribbon
triangles rather than an OpenGL-style line.
- SCRATCH is sampled every `0.15` world units. `0x005ebaa0` derives two
opposing paths with radius `0.2` and rotates the offset by 45 degrees per
sample; `0x00641d50` emits six vertices per segment for each path.
- BEAT is sampled every `0.55` world units and `0x0064ab80` creates a separate
effect at every point. There is no continuous BEAT body. Its case-5 loop
draws only points whose sampled timestamp has not passed, and writes
`base_frame + sample_index * 2` into effect field `+8`. Android's symbolized
`EffectData` access confirms this field is the animation frame; the authored
UV-list selector remains unchanged.
- MERRY GO ROUND computes the `+0xbc…+0xc4` alpha once outside its generated-
target loop and multiplies every copied effect by that same parent alpha.
Individual offsets from `0x005efe40` change target time and position, not
the fade interval.
- DUAL HOLD uses `0x00646d70` to build two ribbon bands. Its literal inner and
outer offsets are `0.2` and `0.5`.
- Android `GameScene::drawLongShotEffect` (matching arcade `0x0064ab80`)
draws an effect-3 marker at the authored endpoint as well as the starting
marker. The fixed endpoint UV bases are 29 for HOLD, 30 for SCRATCH, 41 for
SLIDE HOLD, and 42 for DUAL HOLD. SLIDE HOLD additionally layers effect 39
with UV base 10 at the endpoint and rotates its root by the precomputed
screen direction. Both endpoint layers inherit the long note's colour, fade alpha,
billboard transform, beat-synchronised animation and `0.025` sprite scale.
## Tap judgment timing
The system-config loader at `0x00635c90` lays out the timing overrides in four
per-difficulty arrays. The stage loader applies them in `0x005ed4c0`, and the
post-load pass at `0x005ebaa0` derives these runtime note boundaries:
| Runtime field | Boundary | Easy override |
| ---: | --- | ---: |
| `+0x98` | note time minus `MissTimingOverride` | 236 ms |
| `+0x9c` | note time minus `UnmuteTimingOverride` | 202 ms |
| `+0xa0` | note time plus `LimitTimingOverride` | 202 ms |
| `+0xa4` | note time plus `MuteTimingOverride` | 0 ms |
`0x005d1690` is the tap-grade function. It selects `+0x9c` for an early tap or
`+0xa0` for a late tap and computes the absolute distance `W` from the note
time to that boundary. It then builds thresholds using the literal table
`{100000, 100, 50, 25}` at `0x006e8bc0`, divided by 100. The grade loop checks
indices 3 down to 1, which matches the adjacent original enum/string order
`MISS, GOOD, COOL, GREAT`.
For an error magnitude `e` and the selected outer input distance `W`, the
unclamped comparisons are therefore:
```text
GREAT: e < 0.25 * W
COOL: e < 0.50 * W
GOOD: e < 1.00 * W
```
`GreatMinTime` is copied into the gameplay object at `+0x104`; if the computed
GREAT threshold is below that value, it is clamped and COOL is redistributed
by the same routine to `great + (good - great) / 3`, capped at GOOD. With the
shipped Easy `W = 202`, the absolute-error thresholds are 50.5 ms for GREAT,
101 ms for COOL, and 202 ms for GOOD. HARD/EXTRA use `W = 134`, producing
33.5/67/134 ms. The shipped `GreatMinTime = 32` therefore does not clamp these
four standard difficulty windows, but the branch matters for custom configs.
`MissMarkOverride=1` in the shipped `data/system.cfg` makes the stage loader
replace the timing-list results with these four Simple/Normal/Hard/Extra arrays:
```text
MissTimingOverride = (236,202,168,168)
UnmuteTimingOverride = (202,168,134,134)
LimitTimingOverride = (202,168,134,134)
MuteTimingOverride = (0,0,0,0)
```
## SHOT miss gating
The two stage WAVs remain sample-synchronized, but they are not mixed at a
fixed level. `FUN_00640790` reads each player's `IsMute` state after the
judgment update. For player zero, `FUN_00611920`/`FUN_00611940` pass `1.0` or
`0.0` through `FUN_00611800` to the third volume multiplier of channel 13
(SHOT); channel 12 (BGM) is untouched. The write is immediate rather than a
timed SoundManager fade.
An unresolved target closes the SHOT gate at runtime `+0xa4`, which is note
time plus `MuteTimingOverride` (zero in the shipped configuration). The note
remains judgeable through `+0xa0`; a successful late judgment opens SHOT again
immediately. This is why a miss removes the chart-performance layer without
stopping or seeking either WAV.
## Gameplay tap and AD-LIB sound effects
The hit sounds are a separate three-WAV sound set; they are not baked into
either stage stem. `FUN_00613430` resolves the selected entry from
`data/boot/se.dat` and loads its three names from record fields `+0x14`,
`+0x18`, and `+0x1c`. The default `se0000` ("Ver.3 Set") maps them to:
| Slot | WAV | `SEList.csv` volume | Playback channels |
| ---: | --- | ---: | --- |
| 0 | `SE_ARRANGE.wav` | 87 | 6, 7 |
| 1 | `TAP_SE1.wav` | 80 | 10, 11 |
| 2 | `TAP_SE2.wav` | 77 | 8, 9 |
Every slot is deliberately double-buffered. `FUN_00611fc0` round-robins slot
0 over channels 6/7. `FUN_00611e70` round-robins the two booster sounds over
10/11 and 8/9. Consequently consecutive taps overlap instead of cutting off
the previous sample. Gameplay update `FUN_00640790` consumes the two booster
edge flags at player fields `+0xed/+0xee` and the separate AD-LIB flag at
`+0xaa`; the booster edge is what starts TP1/TP2, before the resulting note
rank is known. A mistimed press still produces its tap sound. The AD-LIB flag
selects slot 0 independently.
The other `se.dat` entries preserve the same three-slot layout, for example
`ALB_06/TP1_06/TP2_06` for the Scratch set. The chosen set id comes from the
player customization state (`Afx` state `+0xce0` in the gameplay loader), not
from the stage chart.
The Linux player currently loads the default Ver.3 set at the exact CSV gains
and reproduces the original two voices per slot. It decodes serialized marker
byte `+5` into the runtime `+8` AD-LIB flag and starts ALB after a successful
rank on such a marker. D-pad inputs are treated as booster 1 and face-button
inputs as booster 2; this also matches the PSP-style control grouping.
## Ranked hit effects
`setRankedEffect` records the rank and gameplay timestamp. The later
`DrawGameStageCharacter` pass selects group-1 effect `rank + 0x1c`, so the
effect IDs are 29 MISS, 30 GOOD, 31 COOL, and 32 GREAT. Their decoded lifetimes
are respectively 20, 12, 12, and 12 frames. The effect advances from the hit
timestamp at 60 Hz, uses additive blending, a global XY scale of `0.03`, a
camera-facing matrix, and a stored random root rotation from `rand() % 360`.
## Hit-type dispatch and long-shot ranks
The arcade dispatcher at `0x005d5fb0` selects the handler from the effective
marker type: 1 TAP, 2 FLICK, 3 HOLD, 4 SCRATCH, 5 BEAT, 6 MERRY, 9 CRITICAL,
10 SLIDE HOLD, and 15 DUAL HOLD. Android's symbolized
`GameScene::checkHitMark` independently confirms the same mapping and names
type 9 `checkHitMarkDualTap`.
Dual tap stores the timestamp of its first independent input, consumes a
second independent input, then grades using the first timestamp. DUAL HOLD
also waits for two independent inputs, but begins its measured hold duration
when the second arrives; releasing either side ends it.
Arcade `FUN_005d0d80` grades HOLD and DUAL HOLD by the percentage of the
authored body actually covered after clamping press/release to its start/end:
```text
GREAT: held > 80%
COOL: held > 60%
GOOD: held > 40%
MISS: otherwise
```
The comparisons are strict. Before computing the percentage it also applies
the original short-tail compensation: if 20% of the body is below
`66.666664 ms`, that difference is added to covered time. `FUN_005d0eb0`
finalizes a still-pressed hold at the authored end; an early release grades it
immediately.
BEAT (`FUN_005d41b0`) uses the same long-shot percentage grader, but records
the first tap and continually replaces the covered endpoint with each later
tap. If no fresh trigger arrives for `BeatEnableTime` (200 ms in the shipped
config), it finalizes at the last trigger. Thus it measures how far through the
body the repeated taps continue, rather than merely counting taps.
SCRATCH (`FUN_005d44f0`) listens to four directional triggers. The first one
starts coverage; each later trigger extends it only when its direction differs
from the previous accepted direction. It finalizes after `ScratchEnableTime`
(250 ms) without a direction change or at the body end, then uses the same
40/60/80-percent long-shot grader.
+374
View File
@@ -0,0 +1,374 @@
# GC Camera Reverse Notes
Status: work in progress. This file records facts extracted from `game471.exe`
and `docs/stage.pat` so the Vectorail camera port does not keep accumulating
guesswork.
## Stage Camera Records
`docs/stage.pat` defines a 59-byte camera key:
```c
struct Camera {
u32 timeMs;
u8 aMode;
u8 fMode;
float dist;
float rotationA[2];
float originOff[3];
u8 projType;
float fieldFar[3];
float fieldNear[3];
float rotationB[1];
};
```
Observed across stage files:
- `fieldFar[]` and `fieldNear[]` are almost always zero.
- Therefore `fieldNear[0]` is not the gameplay FOV. The current Vectorail GC
loader uses it as `fov`; that is almost certainly wrong.
- Useful camera fields are `dist`, `rotationA[0]`, `rotationA[1]`,
`originOff[0..2]`, `rotationB`, and the mode bytes.
- Common defaults seen in real stages:
- `projType` is usually `1`.
- `fMode` is usually `1` or `2`, with some `0`.
- `aMode` is usually `0`, but `1..6` also appear.
Example from `ac_10pt8tion_hard.dat`:
```text
time=0 aMode=0 fMode=0 dist=22 rotA=(0,90) originOff=(0,0,9) projType=1 rotB=0
```
## Runtime Matrix Path
The apparent gameplay matrix slots in the tune object are not where the camera
is calculated:
- `tune + 0x150`: saved D3D `VIEW` matrix.
- `tune + 0x110`: saved D3D `PROJECTION` matrix.
- `tune + 0x0d0`: alternate saved D3D `VIEW` matrix.
- `tune + 0x090`: alternate saved D3D `PROJECTION` matrix.
Functions confirmed:
- `FUN_006449f0` calls `GetTransform(2, tune+0x150)` and
`GetTransform(3, tune+0x110)`.
- `FUN_00645e00` does the same after setting a 2D ortho projection.
- `FUN_00648d40` also snapshots `VIEW/PROJECTION` at render start.
- `FUN_0064da90` snapshots `VIEW/PROJECTION` into `+0xd0/+0x90`.
So these offsets are consumers/snapshots, not the camera mechanics.
The D3D device calls use the normal Direct3D 9 transform ids:
```text
0xb0 on IDirect3DDevice9 vtable = SetTransform
+0xe4 on IDirect3DDevice9 vtable = GetRenderState
+0xb4 on IDirect3DDevice9 vtable = GetTransform
2 = D3DTS_VIEW
3 = D3DTS_PROJECTION
0x100 = D3DTS_WORLD
```
`FUN_004e72c0` / `FUN_004e5af0` are a central D3D state cache, not camera
math. The cache fields are:
```text
+0x3a8 cached WORLD
+0x3e8 cached VIEW
+0x428 cached PROJECTION
+0x390 cached viewport
```
Wrapper setters identified:
```text
FUN_004e3c10 = set view matrix, copies 16 floats to +0x310 and calls SetTransform(2)
FUN_004e3bc0 = set projection matrix, copies 16 floats to +0x350 and calls SetTransform(3)
FUN_004e3c60 = copy 16-float matrix to +0x180, no direct D3D SetTransform
```
These are useful for tracing the render path, but current xrefs show most
gameplay code uses the global D3D device helpers and snapshots rather than
directly calling these wrappers.
## Common 3D Camera
`CCommon3DCamera` is identified around `0063d820..0063d9d0`.
Struct layout:
```text
+0x04 eye vec3
+0x10 target vec3
+0x1c up vec3
+0x28 base fov, radians
+0x2c camera mode
```
Projection:
```c
D3DXMatrixPerspectiveFovLH(
out,
camera->fovRad * fovMul,
viewportWidth / viewportHeight,
nearZ,
farZ);
```
Important difference from Vectorail: the original uses the runtime viewport
aspect, not a hardcoded `1280.0 / 720.0`.
View:
```c
D3DXMatrixLookAtLH(out, eye, target, up);
```
when camera mode is `0`.
Known refs:
```text
FUN_0063d820 projection, caller FUN_00577840
FUN_0063d9d0 view/look-at, caller FUN_00577840
FUN_0063d8a0 constructor, caller FUN_00578570
```
Only one `D3DXMatrixPerspectiveFovLH` call is present in the binary, and it is
in `FUN_0063d820`. Only two `D3DXMatrixLookAtLH` calls are present:
`FUN_0063d9d0` and `FUN_0065b1f0`. This makes `CCommon3DCamera` the strongest
candidate for the gameplay camera path, but its stage-game caller has not been
found yet.
## Stage Object Transform Detour (Corrected)
`FUN_00643570` is not a camera or stage-object evaluator. It belongs to the
temporary rail-strip construction path. The actual scene-object loop is
`FUN_006445b0`; it iterates `0xdc`-byte runtime objects from
`stageResource + 0x140` and calls `FUN_00643b20` for their transforms and
`FUN_00643fd0` for their colors.
Inside `FUN_00643b20`:
```text
+0xa4/+0xa8/+0xac count/times/vec3 track-like stream
+0xb0/+0xb4/+0xb8 count/times/vec3 stream
+0xbc/+0xc0/+0xc4 count/times/vec3 stream
```
It samples every keyframe array through `FUN_005e9100`. Movement, scale, and
rotation are linearly interpolated vec3 values. The final Euler rotation is
then converted to a quaternion using the engine's `(-Y, X, Z)` convention and
converted to a matrix; there is no quaternion slerp. Base and
animated translations, rotations and scales are separate matrices which are
multiplied in this exact sequence:
```text
T(base) * T(move) * R(base) * R(animated) * S(base) * S(animated)
```
The color evaluator selects a keyed RGBA value in
place of the base color and linearly interpolates it when enabled.
The recovered helper operations include:
```text
FUN_005df6a0 = vec3 subtract
FUN_005df660 = vec3 scale
FUN_005df6f0 = vec3 add
```
and builds matrices through the local matrix stack:
```text
FUN_005e0650 = translation matrix
FUN_005e0610 = scale matrix
FUN_005e0330 = Euler-degrees-to-rotation-matrix entry
FUN_005e0220 = quaternion from the (-Y, X, Z) half angles
FUN_005df790 = quaternion-to-column-major-matrix
FUN_005e0680 = in-place right matrix multiply
```
The first boolean in transform/color key records becomes runtime metadata byte
`+5` and identifies repeating key ranges. The second becomes byte `+4` and
enables interpolation from the active key to the next. Visibility uses a
separate fixed fade window: `_DAT_006fcae8` is `250.0f` milliseconds.
The renderer also checks runtime object `+0xd4`, populated from the newer stage
supplemental parent-index stream. When present, it evaluates the parent and
child independently, multiplies their matrices, and multiplies their RGBA via
`FUN_0043a540`. It does not recursively walk more than one parent level.
## Stage Camera Evaluator (Confirmed)
`FUN_005e9e20(stage, result, timeMs, interpolate)` is the stage-camera
evaluator. The output is 44 bytes:
```text
+0x00 eye vec3
+0x0c target vec3
+0x18 up vec3
+0x24 projType byte
+0x28 projBlend float (0 or 1 outside an fMode=1 transition)
```
`FUN_005e0ca0` passes those first three vectors to `FUN_005e0800`, the game's
look-at matrix builder. This proves the vector order and bypasses the earlier
`CCommon3DCamera` false lead.
The 59-byte wire key becomes this 0x44-byte runtime key:
```text
+0x00 aMode (int)
+0x04 fMode (int)
+0x08 dist
+0x0c rotationA.x
+0x10 rotationA.y
+0x14 rotationA.z = 0
+0x18 originOff vec3
+0x24 projType byte
+0x28 fieldFar vec3
+0x34 fieldNear vec3
+0x40 rotationB
```
`FUN_005ed4c0` reads every serialized field directly and does not reject
non-finite floats. This matters for `ac_comet_{easy,normal,hard}.dat`: their
first key at 0 ms intentionally has NaN in both `rotationA` components, and the
next key starts at 1316 ms. The Linux loader must retain that first key; dropping
it activates the second camera too early and changes the intro.
Let `T(t)` be the linearly interpolated track position and `R` be the orbit
vector calculated from `rotationA` and `dist`. The seven `aMode` branches are:
```text
aMode 0: target = T(t) + originOff; eye = target + R
aMode 1: target = T(keyTime) + originOff; eye = target + R
aMode 2: for camera index > 1, return camera(keyTime - 1ms, no interpolation);
otherwise the same as mode 0
aMode 3: target = T(t) + originOff;
eye = T(keyTime) + originOff + R
aMode 4: target = fieldFar; eye = fieldNear
aMode 5: target = T(t) + originOff; eye = fieldNear
aMode 6: target = fieldFar; eye = T(t) + R
```
This confirms that `originOff` is a world-space addition, not a rail-local
offset.
### Orbit axis convention
`FUN_005dfaa0` creates a quaternion from the Euler tuple
`(-rotationA.y, rotationA.x, rotationA.z)` in degrees. `FUN_005e01d0` then
transforms `(0, 0, dist)` by its matrix. In particular, the first 10pt8tion
key `rotationA=(0,90), dist=22` produces `R=(0,22,0)`: the camera is directly
above the rail, not behind it.
### Interpolation modes
Interpolation only happens strictly between the active key and the following
key:
```text
fMode 0: evaluate the active key directly
fMode 1: evaluate camera states at both key timestamps, remove endpoint roll,
linearly interpolate eye/target/up, normalize up, then apply the
linearly interpolated rotationB
fMode 2: linearly interpolate all raw float/vector fields, retain the active
key's aMode/fMode/projType, then evaluate that mixed key
```
There is no shortest-angle interpolation in `fMode=2`; the rotation floats are
mixed as ordinary scalars.
### Up vector and roll
`FUN_005e0ad0` rebuilds `up` by projecting world-up `(0,1,0)` onto the plane
normal to `target-eye`. If the two directions are collinear it falls back to
`(0,0,1)`. `rotationB` then rotates that up vector around the normalized view
axis.
### Gameplay projection
`FUN_0063fd60(cameraState, fovMul)` consumes the complete 44-byte result of
the stage-camera evaluator. It selects or blends two projections:
```text
projBlend <= 0: orthographic
projBlend >= 1: perspective
0 < projBlend < 1:
ortho + (perspective - ortho) * clamp(projBlend^3, 0, 1)
```
The perspective matrix uses vertical FOV `75 * fovMul`, the live viewport
aspect, near `1` and far `1000`. The orthographic half-height is
`distance(eye,target) * tan(FOV/2)` and its half-width is that value times the
viewport aspect. Consequently objects on the target plane keep the same
screen size while the projection changes, but depth shrinking disappears in
orthographic sections.
The distance is not clamped to the near plane. A zero-length camera therefore
also produces zero orthographic extents in the original. Likewise, its vector
normalizer returns `(0,0,0)` for a zero-length input instead of propagating a
NaN as `glm::normalize` does. The Linux port mirrors both edge cases and uses
explicit left-handed view/projection builders; the `NO` depth variant is the
OpenGL backend adaptation of the original D3D left-handed matrices.
The evaluator sets `projBlend` to `0` for `projType=0` and `1` for
`projType=1`. An `fMode=1` transition linearly interpolates the endpoint
blend values before the projection builder applies the cubic curve.
The gameplay owner initializes its `fovMul` field at `+0x238` to `1.0` and the
normal update path passes it unchanged to `FUN_0063fd60`; therefore the normal
stage camera uses a 75-degree vertical FOV. This scalar is runtime state rather
than part of the 59-byte camera record. The same is true of viewport aspect and
the fixed near/far planes.
### Gameplay item projection modifiers
The value checked at global gameplay-state offset `+0xcd8` is the selected item
ID from `data/boot/item.dat`, not a camera or screen-mode enum:
- `4`, `MIRROR`: flip the stage horizontally;
- `6`, `REVERSE`: flip the stage horizontally and vertically.
At the end of `FUN_0063ff90`, both items negate the first column of the 3D
projection at gameplay-camera offset `+0x90` and the 2D orthographic projection
at `+0x110`. `REVERSE` also negates their second columns. The view matrix at
`+0xd0` is not changed. Course geometry switches from `D3DCULL_CCW` to
`D3DCULL_CW` only for `MIRROR`; the two-axis `REVERSE` transform preserves
triangle winding.
## Vectorail Port Status
The player now imports the raw camera keys (including non-finite sentinel
values) and ports the confirmed `aMode`,
`fMode`, orbit, up/roll, projection type/blend, FOV and clipping-plane
behavior. GC cameras are evaluated directly without the legacy follow-camera
smoothing. Remaining camera work is validation against captured original
frames.
## Switch and Android cross-version validation
The base Switch executable retains `CTuneGameData` RTTI and the original GC
source filenames. Its stripped `CTuneGameData::GetWayPosition` and
`CTuneGameData::GetCameraData` implementations were matched to the named
Android ARM64 functions by class layout, control flow, and camera-mode
behavior.
Switch stores camera timestamps separately from a 0x44-byte runtime payload,
where Android uses an interleaved 0x48-byte record. The evaluator itself still
implements the same `aMode` branches `0..6`, `fMode` 1/2 interpolation, up and
roll construction, projection type/blend, and the mode-2 one-millisecond
hold. This provides an independent confirmation of the arcade reconstruction
above.
FOV is platform policy rather than a stage field. Android chooses between
`60.0`, `68.5`, and `75.0` degrees for its 3:2, 16:9, and iPhone-X layout
modes. That mobile-only aspect switch does not override the arcade
executable's confirmed 75-degree gameplay FOV used by the Linux arcade target.
See `re_gc_switch.md` for the Switch addresses and asset inventory.
+107
View File
@@ -0,0 +1,107 @@
# GC 4.71 song catalog reverse notes
The Windows build loads its master song catalog from
`data/boot/stage_param.dat`. `LevelList.dat` is a different fixed-record table:
its 9002 bytes are a big-endian `u16` count of 1000 followed by 1000 records of
9 bytes, and it is not the song/asset relation table.
## Confirmed loader
`FUN_005e66a0` opens `data/boot/stage_param.dat`. It reads a big-endian `u16`
record count, allocates `count * 0xb4` bytes, and deserializes every variable-size
disk record into one `0xb4`-byte runtime entry. The dumped file contains 924
records. IDs are explicit and can have gaps; they are not array indices.
The game's primitives used by this loader are:
- `FUN_005d8bc0`: big-endian `u32`;
- `FUN_005d8cc0`: `u8`;
- `FUN_005d97d0`: `u8 byteLength` followed by that many string bytes;
- `FUN_005d96d0`: CP932-to-current-Windows-codepage conversion, used for display
strings but not asset identifiers.
The recovered runtime entry is:
```text
offset type confirmed/current meaning
0x00 u32 numeric song ID
0x04 string* display title
0x08 string* image/asset key
0x0c string* artist
0x10 string* source/subtitle
0x14 string* normalized sort key
0x18 u8 genre ID (1 anime, 2 Vocaloid, 3 rhythm game, 4 game,
5 variety, 6 original, 7 Touhou)
0x1c string* duration, e.g. "2:03"
0x20 u8[4] EASY/NORMAL/HARD/EXTRA ratings
0x30 string* BPM text
0x34 u8[4] per-difficulty BGM volume percentage
0x44 u8[4] per-difficulty SHOT volume percentage
0x54 u32[3] timing values (exact roles not yet named)
0x60 u8[2] not yet named
0x68 string* BGM base name
0x6c string*[4] alternate chart group (mostly empty in current songs)
0x7c string*[4] auxiliary chart suffixes
0x8c string*[4] EASY/NORMAL/HARD/EXTRA chart IDs
0x9c string* not yet named
0xa0 u32 not yet named
0xa4 u8[2] not yet named
0xac string* not yet named
0xb0 u8 not yet named
```
The clean-room implementation is in `src/gc/StageCatalog.cpp`; the old
nearby-string search used by `--track-info` has been replaced by this exact
record parser.
## Asset relation
For `Oshama Scramble!`, the catalog record contains:
```text
title Oshama Scramble!
image key oshama
artist t+pazolite
ratings 1, 7, 13, 0
BGM base bgm_b-879_oshama
charts ac_oshama_easy
ac_oshama_normal
ac_oshama_hard
```
The executable derives paths rather than storing full paths in the catalog:
```text
chart ID -> data/stage/<chart ID>.dat
data/stage/<chart ID>_ext.dat
data/stage/<chart ID>_clip.dat
BGM base -> data/stage/sound/<BGM base><BGM difficulty suffix>_BGM.wav
data/stage/sound/<BGM base><SHOT difficulty suffix>_SHOT.wav
data/stage/sound/<BGM base>_VIB.csv
image key -> data/stage/2d/<image key>_menu.dds
data/stage/2d/<image key>_start.dds
```
`FUN_0063ea70` confirms the three chart formats. `FUN_005b3980` and
`FUN_005b3850` build the `_menu.dds` path from runtime offset `+0x08`.
For English UI they first try `data/stage/2d/eng/<key>_menu.dds` and fall back to
the non-language directory. `_start.dds` follows the same rule.
`FUN_00613710` prefixes stage audio with `data/stage/sound/` and appends the
suffixes. If the stage-specific BGM cannot be found, it also has a legacy
fallback under `data/sound/`.
The stage `.dat` then supplies the playable geometry, notes, camera, authored
background color table, particles, visualizer data and object/model scene. The
`*_menu.dds` image is selection/game UI artwork, not the gameplay background.
## Inspecting a record
```sh
./build/openroller GC/data/boot/stage_param.dat --track-info ac_oshama_hard
```
This prints metadata, all difficulty chart IDs, and every derived stage, sound,
menu and start-image path with a missing-file marker where applicable.
+224
View File
@@ -0,0 +1,224 @@
# Menu reverse notes: game471.exe as source of truth
This work deliberately does not derive menu layout or behavior from web
screenshots. The authoritative inputs are `GC/game_patched.exe` and the
matching files in `GC/data/2d_boost` and `GC/data/task_cfg`.
## Task flow
The global state machine at `FUN_00651440` constructs these tasks in order:
| state | task | constructor | scheduler id |
|---|---|---:|---:|
| `0x10` | `CSelectMusicTask` | `FUN_005aa900` | `0x260` |
| `0x11` | `CDifficultyTask` | `FUN_005bc750` | `0x261` |
| `0x12` | `CGameMainTask` | gameplay constructor | — |
This proves that selecting a song and selecting its difficulty are separate
screens. OpenRoller's earlier combined selector was structurally wrong.
The main vtables recovered from RTTI are:
- `CSelectMusicTask::vftable` at `0x006fbcdc`;
- `CDifficultyTask::vftable` at `0x006fb898`.
Their render callbacks are `FUN_005aca40` and `FUN_005be2d0` respectively.
## Scene files chosen by the executable
`FUN_00446a10` chooses the language-specific select-music task config. The
English configs resolve to:
```text
data/task_cfg/selectmusic_eng.cfg
data/2d_boost/selectmusic2_eng.rvb
data/2d_boost/selectmusic2_eng.mtx
data/task_cfg/difficulty_eng.cfg
data/2d_boost/selectmode2_eng.rvb
data/2d_boost/selectmode2_eng.mtx
```
The `.rvb` MOVI header identifies both scenes as `720x1280 @ 60 fps`. Its two
dimension fields are stored height-first. This ordering is confirmed by the
root placements recovered from `TIME`: `imc_title=(360,112)`,
`imc_focus=(341,695)`, and `imc_navi=(238,1136)`. The UI is authored directly
for the cabinet's portrait display.
The RVB top-level child layout is now parsed by `gc::ParseRvbScene`. For the
English music scene it is:
```text
PREP @ 0x000033 size 0x0056cf (455 action/path bindings)
REPO @ 0x005702 size 0x0017b0
DEFN @ 0x006eb2 size 0x03046e
EXPG @ 0x037320 size 0x000054
TIME @ 0x037374 size 0x00177a
```
`PREP` exposes the real animation hierarchy, including
`/imc_focus/imc_fd_jacket_anim`, the four nodes below `/imc_focus/imc_diff`,
the eight `/imc_sort/imc_sort*` nodes, `tg_decision`, and the focus in/out
actions. The companion difficulty scene exposes its own `/imc_slmode`
hierarchy.
Use the local probe to inspect all bindings:
```sh
./build/openroller-rvb-probe GC/data/2d_boost/selectmusic2_eng.rvb
./build/openroller-rvb-probe GC/data/2d_boost/selectmode2_eng.rvb
```
The probe also accepts exact MovieClip states, for example:
```sh
./build/openroller-rvb-probe --state /=jf_slmusic_start \
--state /imc_focus=jf_focus_start \
GC/data/2d_boost/selectmusic2_eng.rvb
```
## Recovered RVB/MTX runtime
Every animation object is a recursive node with a four-byte tag, total size,
local-data size, local data, and child nodes. `DEFN` supplies named `MOVC` and
`SHAP` definitions; `TIME` contains labeled `FRAM` records. Frames are display
list deltas rather than complete scenes:
- `PLC3` creates/replaces a definition at a depth or updates its transform;
- `RMOV` removes a depth;
- `TRN2` carries the 2D affine transform;
- `COLT` carries RGBA multiplication, including authored visibility fades;
- `ASRC` contains the exported `play();`, `stop();`, and target actions.
`gc::BuildRvbSnapshot` now accumulates those deltas through a selected label
and advances `play()` entry frames to their following `stop()` frame. This is
why hidden templates and transition masks no longer appear together.
MTX starts with `MTX\0`; each payload is a DDS whose first dword was replaced
by the container. Restoring `DDS ` yields a standard DDS. RVB `ImageN` maps to
MTX texture `N-1`; all 115 music resources and all 210 difficulty resources
match their declared dimensions.
## Coordinates recovered from render code
The select-music render callback walks exactly twelve neighboring song slots.
The associated catalog offsets stored at `0x006e0708` are:
```text
-5 -4 -3 -2 -1 0 0 1 2 3 4 5
```
`FUN_00447170` supplies the stable slot geometry. Its twelve vertical pairs
are:
```text
175/189 228/242 281/295 334/348 387/401 440/454
701/715 754/768 807/821 860/874 913/927 966/980
```
The fixed components in `FUN_005aca40` include positions `(39,497)`,
`(251,467)`, `(262,500)`, `(262,522)`, and `(330,594)`. These are floats read
directly from the executable's `.rdata`, not measurements from a screenshot.
`CDifficultyTask` passes sprite centres to `FUN_005b33a0`, which subtracts half
of the scaled source rectangle. The selected song fragments therefore resolve
to these destination rectangles:
```text
jacket source (1,1,196,196) -> (105,167,98,98)
title source (0,197,374,34) -> (209,178,374,34)
source source (0,232,374,24) -> (220,214,374,24)
artist source (198,180,314,16) -> (220,239,314,16)
```
The difficulty callback centers variable-length groups using the executable's
actual formulas and spacings:
- `(7 - count) * 0.5 * 68`, with the row anchored at `426 + 16`;
- `(9 - count) * 0.5 * 52`, with the row anchored at `433 + 16`.
The remaining visual work is outside this recovered static scene snapshot:
the executable-owned player/status HUD in the blank upper band, continuous
timeline interpolation, and the exact transition timing between task states.
## Common and navigator layers
The select task is not visually self-contained. Two additional original
movies are composed with it:
```text
data/2d_boost/common_eng.rvb/.mtx
data/2d_boost/navigator/navi_001_yume.rvb/.mtx
```
`common_eng` supplies the network/player icons and the three bottom controller
prompts. The select-music controller uses the `jf_ctrl_3` layout and the
`jf_ctrl_tx02`, `jf_ctrl_tx05`, and `jf_ctrl_tx08` label states (`Select`,
`Change song order`, `Confirm`).
The navigator is itself a 720x1280 MovieClip scene, not a single positioned
DDS. Its recovered opening state draws the bottom backing plate at
`(0,1000)..(720,1232)`, Yume at `(416,830)..(720,1280)`, and a separate mouth
layer. Rendering only `navigator/001_yume/base.dds` was therefore structurally
incorrect.
## Dynamically linked carousel rows
The twelve list rows do not live in the root select-music timeline. The task
creates twelve instances of each exported linkage symbol below and attaches
them to `imc_scroll_dds`:
```text
EXPG UNIQUE_71 -> mc_music_link
EXPG UNIQUE_74 -> mc_index_link
```
`gc::BuildRvbSymbolSnapshot` resolves those EXPG symbols back to their DEFN
MovieClips. `mc_music_link` contains the 520x34 row plate and its three score
cells. `FUN_00447170` places it at x=8 and the twelve y positions listed above;
`FUN_00447620` supplies row opacity (`0,.7,.8,.9,1,0,0,1,.9,.8,.7,0`), not a
geometric scale. The title atlas fragments do use the same values as scale.
`mc_index_link` is the corresponding 356x36 category plate. Entries returned
by `FUN_005aa7c0` below 50000 select `mc_music_link`; pseudo entries at or above
50000 select `mc_index_link` and therefore consume a normal carousel slot.
For the Genre sort, `FUN_005b40f0` loads
`data/2d_boost/menu/s_j[_eng].dds`. `FUN_005b3fc0` selects one of its 256x32
rows and `FUN_005aca40` draws it at the index clip position plus `(53,2)`:
with the static x=88 row position this gives label x=141. The English rows are
beginner, Anime & Pops, VOCALOID, Touhou, Rhythm Game, Game, Variety, Original.
## Sort-tab indirection
The integer stored in `DAT_007f3134` is not the left-to-right tab number. The
eight internal sort kinds map through the executable byte table `34621857`;
internal kind 0 (Genre and `s_j_eng.dds`) consequently drives visual frame
`jf_sort3_ini`. The visual tab order remains New, Monthly Theme Music, Genre,
Difficulty, Score Average, Title, Favorite, At random. Each `imc_sortN` child
also receives its independent `jf_sortN_on/off` availability frame. The small
40x18 NEW marker at `(44,181)` is a separate root child present in both
`jf_sort3_ini` and the stable `jf_sort3` frame; it is not part of `imc_sort1`.
## Executable-owned menu background
The background is a separate 3D task, not part of any RVB and not a guessed
flat colour. `FUN_00577fb0` first emits a full-screen four-vertex strip with
the exact D3D colours:
```text
top: ARGB FF30309B
bottom: ARGB FFE57386
```
It then draws two locally loaded TUMO resources. The loader table begins at
the literal `data/model/menu_obj_05.tumo`; its second 0x40-byte entry is
`data/model/obj_sphere06.tumo`. The latter is the 288-segment wire sphere seen
behind the list. Its recovered camera is LH, FOV 60 degrees, aspect 720/1280,
near/far 0.1/1000, looking along +Z. The sphere is translated to z=100, scaled
by 5, rotated equally about XYZ at `time*0.125`, and given the small authored
two-frequency vertical drift.
Finally `FUN_005b73a0` draws `data/2d_boost/menu/balloon.dds` as a 12x4 grid
of 64px cells at y=1000. This reconstructs source rectangle `(0,0,768,256)`;
the last 48 pixels are clipped by the 720px cabinet viewport. This is the
dark controller backing visible behind the common HUD and navigator.
+61
View File
@@ -0,0 +1,61 @@
# GC-style song selection
Running the stage player without a stage path opens the graphical song
selector backed by `data/boot/stage_param.dat`:
```sh
./tools/run_stage_player.sh
```
Passing a stage remains the direct reverse/debug path:
```sh
./tools/run_stage_player.sh GC/data/stage/ac_oshama_hard.dat
```
The selector renders the original English `selectmusic2` and `selectmode2`
RVB/MTX scenes. Exact task flow, coordinates, animation bindings, and the
container reverse are recorded in `docs/re_gc_menu_exe.md` from
`game471.exe` itself.
What is already wired:
- the original portrait 720x1280 `SELECT MUSIC` and `SELECT MODE` scenes;
- original MovieClip frame labels, display-list depth updates, transforms,
alpha fades, and RGB color transforms;
- the original `common_eng` controller HUD and animated Yume navigator scenes;
- the executable's purple-to-pink background strip, rotating wire sphere,
translucent menu geometry, and `balloon.dds` lower backing layer;
- genre filtering from the catalog genre byte;
- the twelve dynamically linked `mc_music_link`/`mc_index_link` carousel
slots with their executable positions and opacity table, including original
`s_j_eng.dds` genre headings as real scrolling entries;
- title rows, jacket, source and artist cut directly from each original
`data/stage/2d/<imageKey>_menu.dds` atlas;
- SIMPLE/NORMAL/HARD/EXTRA availability and selected-state MovieClips from the
same catalog;
- separate select-music and difficulty states, matching the task transition
proven in the executable;
- launch through the selected difficulty's exact `ac_*` stage ID.
Entries are included only when both their menu atlas and at least one local
stage `.dat` exist. This dump currently yields 887 playable songs from the 924
master catalog records. Only the visible carousel window is uploaded to the
GPU, rather than all jackets at once.
Controls:
```text
Up / Down, W / S previous / next song
PageUp / PageDown jump by eight songs
Q / E or Tab genre
Enter or Space enter difficulty screen
Escape close selector
Difficulty state:
```text
Arrows, W / S, A / D difficulty
Enter or Space play
Escape back to music selection
```
+154
View File
@@ -0,0 +1,154 @@
# Groove Coaster Wai Wai Party Switch reverse notes
Status: work in progress. These notes describe the base title
`0100EB500D92E000`; update and DLC content have not yet been merged into the
analysis corpus.
## Executable and engine lineage
The program NCA contains an AArch64 NSO (`main`). Converting it to ELF and
importing it into Ghidra exposes a stripped executable with relocations, RTTI,
and exception unwind data.
This is the same custom GC engine lineage as the arcade and Android builds,
not a Unity rewrite. Direct evidence in `main` includes:
- RTTI name `13CTuneGameData`;
- assertion/source paths under
`D:/project/GC/svn/latest/Program/Main/Src/GCMain/Tune/Functions/`;
- `TuneGameData.cpp`, `TuneGameManager.cpp`, and
`TuneGameManager_Draw.cpp` source names;
- the assertion label `LoadStageData`;
- stage paths `stage/data_gz/%s.dat.gz`, `%s_ext.dat.gz`, and
`%s_clip.dat.gz`.
Initial function mapping in the base Switch executable (Ghidra image
addresses):
```text
001456a0 CTuneGameData constructor
001457f0 CTuneGameData destructor
00145f60 CTuneGameData deleting destructor
0014a3d0 CTuneGameData::GetWayPosition
0014a560 CTuneGameData::GetCameraData
00134af0 asynchronous stage .dat/.ext/.clip loader
00155b50 TuneGameManager::LoadStageData owner/assert site
```
The names after the destructors are cross-matched against the symbol-bearing
Android ARM64 build and then checked from their decompiled behavior.
## RomFS inventory
The base RomFS contains 8,976 files (about 1.6 GiB). Important families are:
```text
3421 .tumo scene models
2136 .gz compressed stage data
1790 .opus audio, normally named *.wav.opus
1052 .bntx Switch textures
104 .tusc
88 .bnvib
85 .dat boot/catalog tables
69 .efcb2
65 .uvb
57 .rvb
57 .mtx
19 .bnsh shaders
```
Gameplay audio names explicitly distinguish tap, slide-hold, scratch,
critical, beat, and adlib hit effects. This makes the Switch assets useful for
validating the note-type-to-sound mapping even where the arcade catalog uses
numeric IDs.
## Stage data compatibility
Each chart still consists of the familiar three files, now gzip-compressed:
```text
stage/data_gz/<chart>.dat.gz
stage/data_gz/<chart>_ext.dat.gz
stage/data_gz/<chart>_clip.dat.gz
```
After gzip decompression the normal `.dat` is accepted directly by
OpenRoller's arcade `StageDat` and `StagePattern` parsers. For example,
`sw_adr_hard_1.dat` decodes as:
```text
track points: 240
notes: 529
camera keys: 67
draw-distance keys: 21
background models: 8
background objects: 199
```
The parser also recovers all expected note types, authored camera modes,
particle/visualizer keys, colors, and object animation. This is strong
structural confirmation that the arcade field interpretations are not merely
heuristics.
The Switch `_clip.dat` can be much larger than the main chart because it
contains baked per-frame object visibility/animation data, just like the
arcade clip stream.
All 712 main chart files in the extracted base-game RomFS parse successfully
with the current OpenRoller stage parser. A chart can be staged and launched
directly by ID:
```sh
tools/run_switch_stage.sh /path/to/decoded/romfs sw_adr_hard_1
```
List the available chart IDs with:
```sh
tools/run_switch_stage.sh /path/to/decoded/romfs --list
```
The launcher decompresses the chart, extension, and clip streams into
`build/nsw_runtime`, links the common model directory, converts the matching
Nintendo Switch OPUS stream with `vgmstream-cli`, and starts the Linux player.
Set `VGMSTREAM_CLI=/path/to/vgmstream-cli` if it is not on `PATH` or in the
repository build directory.
This currently provides chart, camera, note, clip, model geometry, and BGM
loading. The `.bntx` texture/material pipeline and the newer Switch song
catalog are not implemented yet, so a successfully running stage is not yet a
pixel-identical Switch presentation and there is no Switch-native song menu.
The BGM resolver finds a shipped audio stream for 711 of the 712 chart files,
including charts whose internal authoring-time BGM name differs from the
release filename. The sole exception is `sw_shoukon_hard_1`: it is absent from
the base `stage_param.dat` catalog and the base RomFS contains no matching BGM,
so this orphan chart can only be launched silently from that data set.
`boot/stage_param.dat` is a newer catalog revision. The current arcade catalog
parser does not consume it completely and must not be treated as compatible
until its extra fields are mapped from the Switch loader.
## Camera cross-check
The Switch `CTuneGameData::GetCameraData` stores key timestamps in a separate
array and uses a 0x44-byte runtime camera payload rather than Android's
interleaved 0x48-byte record. Despite that storage change, its behavior is the
same:
- the active camera key is selected by timestamp;
- `fMode` 1 evaluates both endpoint cameras, rebuilds endpoint up vectors,
then interpolates the complete camera states;
- `fMode` 2 interpolates raw key fields before evaluation;
- `aMode` has the same seven branches `0..6`;
- projection type and projection blend are separate results;
- the mode-2 hold branch samples the state one millisecond before the key.
This independently validates the camera evaluator currently documented in
`re_gc_camera.md` and implemented in the Linux player.
The projection/FOV policy remains platform-specific. The Android build calls
`SwitchAspectValue(60.0, 68.5, 75.0)` and selects the value for its 3:2,
16:9, or iPhone-X layout. The arcade executable instead establishes a normal
75-degree gameplay FOV. Therefore Android's aspect presets must not replace
the arcade target behavior in the Linux player.
+310
View File
@@ -0,0 +1,310 @@
# game471 test mode
This note describes the operator/test mode in the arcade `game471.exe`
(4.74.00ENG). The structural and layout observations below come from the
executable and its shipped resources, not from screenshots.
## Live entry
The game exposes twenty logical cabinet inputs through `FUN_00634060`
(`0x00634060`). Logical input 0 is the TEST switch.
During normal operation, the main task at `FUN_006396f0` samples input 0 on
every update. Holding it for at least three updates starts the test-mode
transition. The game then:
1. stops the normal game/audio/render tasks;
2. waits 45 updates;
3. constructs the test-mode text renderer, sprite renderer, input adapter,
SE adapter, and BGM adapter;
4. initializes the test-mode form system and opens the main form.
The already generated `game471_bootskip.exe` can reach the original menu under
Wine. Once the `GameWare` window is active, press and briefly hold Caps Lock.
Caps Lock is the executable's built-in DirectInput fallback for the TEST
switch; no test-mode-specific patch is needed.
The original executable's keyboard fallback table starts at `0x007840f0`:
| Logical input | Cabinet control | FAST I/O mask | DirectInput fallback |
| --- | --- | ---: | --- |
| 0 | Test switch | `0x00000040` | `DIK_CAPITAL` (Caps Lock) |
| 1 | Service switch | `0x00000001` | `DIK_F1` |
| 2 | Coin switch | `0x00000004` | `DIK_F2` |
| 3 | Select switch | `0x00000010` | `DIK_F3` |
| 4 | Enter switch | `0x00000020` | `DIK_RBRACKET` (`]`) |
| 5 | Left booster up | `0x00000100` | `DIK_Q` |
| 6 | Left booster down | `0x00000200` | `DIK_A` |
| 7 | Left booster left | `0x00000400` | `DIK_LCONTROL` |
| 8 | Left booster right | `0x00000800` | `DIK_S` |
| 9 | Left booster button | `0x00100000` | `DIK_LMENU` (left Alt) |
| 10..14 | Right booster controls | `0x00010000` through `0x00200000` | raw scan codes `78,7d,7a,7b,6a` |
`FUN_00633d00` obtains the FAST I/O bitfield and masks it with the first table
above. `FUN_00633de0` applies the per-input active-high/active-low table.
`FUN_00634060` ORs that result with the DirectInput fallback.
## Test-mode input adapter
`GWTestModeInput_GW` is constructed by `FUN_00569d00`. Its update method,
`FUN_00569d20`, translates cabinet inputs to the generic test-mode flags:
- Test switch -> `0x20` (back/exit)
- Select switch -> `0x02`
- Enter switch -> `0x58`
- left booster up/down/left/right/button -> `0x01`, `0x02`, `0x84`, `0x48`,
`0x10`
The language table describes the intended behavior:
- booster up/down or Select moves the cursor;
- booster buttons or Enter confirms;
- booster left/right or Enter changes a setting;
- Test returns/backtracks.
Short synthetic X11 key taps may be sampled for several game updates because
the original runs uncapped. For automated navigation, inject an explicit
cabinet state for one update instead of relying on `xdotool key`.
## Resources
Test mode does not use the normal RVB/MTX menu assets. Its strings come from:
- `data/TestModeLaungage/Laungage_eng_sjis.csv`
- `data/TestModeLaungage/Laungage_jpn_sjis.csv`
Both are CP932/Shift-JIS. `Laungage` is the spelling used by the shipped game.
The glyphs themselves come from `data/font/Font.mtf` and the
`data/font/FontXXXXXXXX.mfi` atlas pages. `GWTestModeRenderText_GW`
constructs a pool of 256 regular GameWare text objects backed by that font.
It does not use a test-mode-specific bitmap font or GDI text.
`Font.mtf` starts with a 16-way Unicode radix table. A resolved entry stores
the MFI page in its low 24 bits and the high nibble of the atlas cell in bits
28..31; the low cell nibble comes from the Unicode codepoint. Each MFI contains
a 512x512 DXT5 atlas split into 16x16 cells. This is confirmed by
`FUN_00485f90`, which creates GameWare image format 3, and by
`GWPCImage2D::Create`, whose format table maps index 3 to `D3DFMT_DXT5`.
The font shader uses the DXT5 alpha channel as glyph coverage. The English
ASCII glyphs use page 0 and occupy the left half of each cell, producing 8x16
glyphs. The selection marker is U+2192 (`→`), resolved by the MTF to page 27,
cell `0xb2`, and is 16x16.
The renderer is a separate, mostly text-based framework represented by the
following RTTI classes:
- `GWTestModeWindowText`, `GWTestModeWindow`, `GWTestModeWindowList`
- `GWTestModeForm`, `GWTestModeSelectForm`
- `GWTestModeForm_YesNo`, `GWTestModeForm_ProcYesNo`
- `GWTestModeInput_GW`
- `GWTestModeRenderText_GW`, `GWTestModeRenderSprite_GW`
- `GWTestModeBGM_GW`, `GWTestModeSE_GW`
The framework screen is 720x1280 on black. `FUN_00577940` installs a logical
text size of 16x16, pure red `(1,0,0,1)` for the selected/help color, and pure
cyan `(0,1,1,1)` for normal selectable entries. The three centered common
title windows use normalized y coordinates `-0.95`, `-0.925`, and `-0.9`,
which map to pixel y positions 32, 48, and 64.
The main list is anchored at normalized y `-0.725` (176 px). Its default row
gap is `0.01` of the 640-pixel half-height, so rows advance by
`16 + 6.4 = 22.4` pixels. The selected row is red and has a separately drawn
`→`; unselected rows are cyan. The help form is centered at normalized
y `0.2` (768 px), while the main five-line build/machine/time information list
is centered at y `0.4` (896 px).
## Main menu in 4.74.00ENG
The live build displays:
1. Monitor Test
2. Input/Output Test
3. LED Test
4. Card Test
5. Audio Settings
6. Game Settings
7. Network Info
8. Bookkeeping
9. System Info
10. Restore factory settings
11. Exit Test Mode
The CSV also contains `Check Input Count`, `System Settings`, `Delete High
Scores`, and `Machine Connection Test`, but prefixes those entries with `#`.
It contains `Update Online` without `#`, although that item is still filtered
out by this build's runtime conditions.
## Forms and data exposed
- Monitor Test: color bars, white, red, green, blue, and cross-hatch patterns.
- Input/Output Test: all cabinet switches, both five-input boosters, headphone
volume/jack state, and Groove Stage connection. Booster buttons drive their
lamps; the coin switch changes lockout.
- LED Test: all lights and individual title, side, and booster light groups.
- Card Test: card-reader status and card ID.
- Audio Settings: test BGM, master/headphone/demo levels, five speaker channels,
Groove Stage status, and normal/demo vibration intensity.
- Game Settings: coin/song price, per-day operating hours, and score-attack
mode.
- Network Info: location identity/address/IP, cabinet IP/MAC, NESYS versions,
and relay server.
- Bookkeeping: uptime/play totals, free plays, service-switch count, player
statistics, 30-week and hourly histograms, play logs, the last twenty errors,
and service-switch history.
- System Info: program/system configuration values.
- Factory Settings: confirmation form followed by persistent-data reset.
Persistent test-mode data has separate RTTI classes for system settings, high
scores, play/weekly/daily/error/service-switch logs, and game-unique data. Log
paths embedded in the executable live under `TestModeFile\...\Log`.
### Coin/song presets
`CTestModeForm_GameSetting` chooses its coin/song table from the Type X
`HKLM\SOFTWARE\taito\typex\Country` registry value. `Country == 0` uses eight
entries, in this exact order:
1. 1 coin, 2 songs
2. 1 coin, 3 songs
3. 2 coins, 2 songs
4. 2 coins, 3 songs
5. Free play, 1 song
6. Free play, 2 songs
7. Free play, 3 songs
8. 1 coin, 1 song
For a non-zero country value it instead exposes 39 entries: every combination
of 1 through 12 coins with 1 through 3 songs, followed by the three free-play
variants. `FUN_00570a70` and `FUN_00570810` wrap the selected index forward and
backward respectively.
OpenRoller mirrors the registry value with `Country` in `openroller.cfg` beside
the executable. CMake creates the file on the first build but leaves an
existing copy untouched. The value is reread whenever test mode is entered, so
the application does not need to be rebuilt after changing it.
## Useful code addresses
| Address | Role |
| ---: | --- |
| `0x006396f0` | top-level boot/game/test-mode state machine |
| `0x00634060` | logical cabinet input + DirectInput fallback |
| `0x00633d00` | mask current FAST I/O input bitfield |
| `0x00633de0` | apply active-level table |
| `0x00569d00` | construct `GWTestModeInput_GW` |
| `0x00569d20` | translate game inputs to generic test-mode input flags |
| `0x00569610` | construct `GWTestModeRenderText_GW` |
| `0x00569ad0` | construct `GWTestModeRenderSprite_GW` |
| `0x00569440` | construct `GWTestModeSE_GW` |
| `0x005693f0` | construct `GWTestModeBGM_GW` |
| `0x00577940` | initialize test-mode form/render state |
| `0x00634fd0` | credit/service-credit handling and `SERVICE-SW LOCKED` |
`SERVICE-SW LOCKED` is unrelated to entering test mode. It is emitted by the
credit controller after repeated Service-switch credit pulses and holds the
service-credit lock for 180 updates.
## LED output pipeline
The LED test form's main methods are:
| Address | Role |
| ---: | --- |
| `0x0056e160` | construct `CTestModeForm_LEDTest` |
| `0x0056e390` | update the selected LED test pattern |
| `0x0056e6c0` | handle the seven LED-test menu items |
| `0x0062b0e0` | set logical LED `index` to `R,G,B,alpha` |
| `0x0062d4d0` | construct all logical LED objects and their hardware mapping |
| `0x0062a4d0` | update one `CLedDevice` and place its value in the cabinet output buffers |
| `0x004b3b40` | write one RGB pixel into the shared board buffer |
| `0x004b4c50` | pack shared LED buffers into the FIO transfer blocks |
| `0x004b55f0` | exchange registers and transfer blocks with `iDmacDrv32` |
| `0x004b6700` | wrapper for `iDmacDrvRegisterBufferWrite` |
### Logical groups
There are 118 logical LED objects:
| Logical indices | Test-mode group | Hardware coordinates |
| ---: | --- | --- |
| `0` | left booster button | simple output 4 |
| `1..8` | left booster RGB group 8 | board 0, group 8, positions 0..7 |
| `9..40` | left booster RGB groups 0..7 | board 0, groups 0..7, positions 0..3 |
| `41` | right booster button | simple output 5 |
| `42..49` | right booster RGB group 8 | board 1, group 8, positions 0..7 |
| `50..81` | right booster RGB groups 0..7 | board 1, groups 0..7, positions 0..3 |
| `82..93` | title | board 2, strips 0..1, pixels 0..5 |
| `94..105` | left side | board 2, strips 2..3, pixels 0..5 |
| `106..117` | right side | board 2, strips 4..5, pixels 0..5 |
The menu implements the groups directly:
- item 0 cycles all 118 LEDs through off, white, red, green, and blue;
- items 1, 2, and 3 fill the twelve title/left/right-side LEDs white one
address at a time, then clear them in the same order;
- items 4 and 5 perform the same fill/clear sequence across 41 LEDs on the
corresponding booster;
- item 6 starts the form-exit sequence.
The LED screen itself is only the generic seven-row `GWTestModeWindowList`,
anchored at y `-0.725` with an explicit row gap of `0.05` (48 px row
advance). It does not draw a schematic of the cabinet or booster rings.
The all-light loop special-cases logical LEDs 0 and 41 because the booster
button lamps are single-channel. For those two, it ORs `R|G|B` and writes the
same intensity as a simple lamp.
The executable establishes 40 independently controlled RGB positions plus one
simple button lamp per booster. The `group/position` coordinates above describe
the software/FIO mapping only; they do not establish the physical arrangement
of those RGB positions inside the arcade booster's plastic assembly.
### Color representation
`FUN_004b3b40(board, strip, pixel, R, G, B)` stores each full-color LED as
three bytes in this order:
```text
B, R, G
```
Each board reserves `9 * 8 * 3 = 0xd8` bytes even where fewer than eight
pixels are connected. Before placing a value in the hardware buffer,
`CLedDevice` applies the executable's integer gamma curve independently to
each channel:
```text
wire_channel = min(channel * channel / 255, 255)
```
The alpha/intensity field is applied by the LED object before this final
conversion. Test mode always passes alpha `0xff`.
### iDmac/FIO transfers for board type 0x825c
The current shim reports FIO type `0x825c`; the game selects its `0x25c`
packing branch. Each hardware update eventually makes these calls:
| Register-buffer address | Bytes | LED content |
| ---: | ---: | --- |
| `0x5000` | `0x1b0` | general FIO block; title bytes at offset `0x168`, button lamps at offsets 4 and 5 |
| `0x5200` | `0x1e0` | general FIO block; left/right-side bytes at offset `0x168` |
| `0x5400` | `0x0d8` | raw board-0 buffer: left booster |
| `0x5600` | `0x0d8` | raw board-1 buffer: right booster |
The board-2 source buffer is split as follows:
- its first `0x30` bytes (two 8-pixel strip slots) go to
`0x5000 + 0x168`;
- its following `0x60` bytes (four 8-pixel strip slots) go to
`0x5200 + 0x168`.
The unused bytes in those strip slots remain zero. This was confirmed both in
the decompiled `GWInputDeviceXioFio_BOOST` update path and in the live
`idmac_shim.log`: normal steady-state writes have sizes `1b0`, `1e0`, `d8`,
and `d8` respectively. Initial device negotiation first sends four `0x200`
byte blocks, then switches to the exact steady-state lengths above.
The game also contains a `0x23c` packing branch and generic transfer banks
through `0x5e00`, but those are not selected by the current emulated hardware
ID.
+183
View File
@@ -0,0 +1,183 @@
# GC Track and Background Reverse Notes
Status: linear track evaluation and the base background color table are
implemented in the Vectorail player. Camera mode bytes, fade modes and stage
objects still need deeper mapping.
## Track wire data
The main stage file stores track keys as big-endian records:
```text
u32 count
repeat count times:
u32 time_ms
f32 x
f32 y
f32 z
```
The stage constructor `FUN_005ed4c0` loads these into 16-byte runtime entries.
The fourth float is not read from disk: the constructor computes the physical
length to the next point and stores it there. It also accumulates the complete
track length at stage-object offset `+0xb0`.
## Confirmed evaluation
`FUN_005e9690` evaluates a track position at an integer timestamp. It finds the
adjacent `time_ms` keys and computes:
```text
u = (time_ms - key[i].time_ms) / (key[i+1].time_ms - key[i].time_ms)
position = key[i] + (key[i+1] - key[i]) * u
```
There is no Catmull-Rom or other spline interpolation in this path.
The Android symbol `GameScene::DrawWay` (`0x001b3b68`) establishes how the
visible route itself is rendered. It clips the authored point list to the
requested first and last timestamps, inserts linearly interpolated points at
both exact boundaries, interpolates an RGBA color over that time interval, and
submits the result as primitive mode 3: a line strip. It does not generate a
camera-facing ribbon, resample by physical distance, or cull triangles.
`FUN_005e9990` is a separate physical-distance sampler used while constructing
duration-note paths. It carries leftover distance between segments so those
generated samples remain uniform over corners; it is not the normal route
renderer.
The player now mirrors `DrawWay`: it uploads the original authored points plus
the two exact timestamp intersections and draws separate behind/current and
current/ahead line strips with endpoint color gradients. The stage values
`backwardsDrawDist` and `forwardDrawDist` behave as seconds and are converted
to milliseconds before comparison with track and note timestamps.
Dynamic `TrackDrawDist` records are step changes, not interpolation keys. The
StageConfig forward distance remains active until the timestamp of the first
matching record, then each record replaces it. This matters for Knight Rider:
its only record is `128400 ms: 0`, which hides the route at the end of the song;
using the first record before its timestamp incorrectly hid the entire future
route from the beginning.
For `ac_10pt8tion_hard.dat`:
```text
first track key: 0 ms, (0, 0, 0)
second track key: 6486 ms, (0, 0, 199.985)
draw behind: 10 s
draw ahead: 7 s
```
Treating 7/10 as world units collapses the visible rail to a tiny fraction of
the first segment; timestamp clipping produces the expected visible range.
## Track colors
The stage config stores two RGBA colors directly after the draw-range values.
They are used for the forward and already-travelled portions of the rail. For
`10pt8tion_hard` they are `(255,0,128)` ahead and `(255,255,255)` behind.
## Base background
The main `.dat` contains a `ColorTable` section. Each 22-byte record is:
```text
u32 time_ms
rgba top_right
rgba top_left
rgba bottom_right
rgba bottom_left
u8 interpolate_to_next
u8 audio_reactive_color
```
`FUN_00642390` holds the active colors unless `interpolate_to_next` is set; in
that case it linearly interpolates all four RGBA values to the following key.
`audio_reactive_color` applies `FUN_005d9650` to each active color using the
runtime analyser value. The Linux player now implements the exact hold versus
interpolate selection and keeps the second mode at its neutral color factor
until the analyser feeding `stage renderer +0x24` is ported.
`data/stage/2d/<song>_menu.dds` is not the gameplay background. It is a
512x256 UI atlas whose top-left 197x197 cell is the song jacket. The player has
a small uncompressed DDS loader, but the jacket is disabled by default and is
only an explicit debug comparison layer (`B`). The inherited procedural
blue/black square grid has also been removed: the base layer is now only the
stage-authored color table before particles, visualizers and objects are drawn.
The remaining original scene is produced by the stage `particles`,
`visualizer`, and `objects` sections (plus `.tumo` models), not by a single
background bitmap. These sections are now decoded completely by
`StagePattern`: particle records are 44 bytes, visualizer records are 12 bytes,
and every variable-length object record is consumed through its visibility,
movement, scaling, rotation and color-key arrays. Oshama contains 5 particle
keys, 19 visualizer keys, 46 model names and 316 object instances; 10pt8tion
contains 2, 28, 37 and 352 respectively. The Vectorail level data retains this
decoded scene for the model-rendering pass.
The PSP package now retains all three timelines. The particle constructor at
`FUN_005f0940` creates a 64-instance pool for every configured particle key.
`FUN_005f0130` scales both `repeatMeasure` and `lifespanMeasure` by the active
beat duration. Recovered spawn layouts are: type 1, a deterministic/random
point; type 2, a screen/grid group using `groupShapeSize`; type 3, six points
at 60-degree intervals around a circle. The PSP implementation follows these
timing and layout rules, but substitutes geometry for the original particle
texture resource until that resource binding is mapped.
The common `.tumo` container is also big-endian. Its outer count is followed,
for each mesh, by resource names, an XYZ vertex table, eight bound floats,
render parts, and polygon lists. A polygon corner is `u32 vertexIndex, f32 u,
f32 v`; a separate block contains explicit line-index pairs. The player now
triangulates polygon fans, retains line primitives, and renders stage objects
with the original five animation channels: visibility, movement, scaling,
linearly interpolated Euler rotation, and RGBA color. The final rotation uses
the recovered `(-Y, X, Z)` quaternion-builder convention. Base and animated transform
components are composed as separate matrices, matching `FUN_00643b20`;
visibility fades use the original fixed 250 ms window. All 46 unique Oshama
models and all 37 unique 10pt8tion models decode. `O` toggles this object layer
for comparison.
For stage format versions newer than `0x29ce`, the supplemental table beginning
at the header's `ColorTable2` offset has a relative pointer at `base + 8`. Its
stream is `u32 objectCount` followed by one signed big-endian `int16`
`parentIndex` per object (`-1` means no parent). `FUN_006445b0` evaluates one
parent level and renders `parentTransform * childTransform`; parent and child
RGBA are multiplied component-wise. Oshama uses this on 80 of 316 objects and
10pt8tion on 87 of 352, so omitting it visibly loses grouped scale, placement,
and opacity.
The object's `wireframe` byte also selects mutually exclusive model paths:
`FUN_005dd8e0` draws polygon parts, while `FUN_005dd7f0` draws the explicit TUMO
edge buffer. The player previously drew both. Polygon/edge selection now
matches the flag. `FUN_00648c30` enables `D3DRS_ZENABLE` and
`D3DRS_ZWRITEENABLE` for the complete authored object loop, then disables both
before the rail and markers are submitted. Objects therefore depth-test one
another in file order, but never hide gameplay geometry drawn afterwards.
### Per-frame object clipping
The stage loader also opens `data/stage/<chart>_clip.dat`. `FUN_005ed4c0`
decodes it as:
```text
u32be object_count
u32be frame_count
u8 visible[object_count][frame_count]
```
The payload is object-major. `FUN_006445b0` selects frame
`round(time_ms / (1000 / 60))` and skips the object when the byte is zero;
when the table is absent or the requested frame is past its end, it falls
back to the normal authored-visibility path. Knight Rider has 178 objects and
7909 clip frames. Ignoring this table submits many objects which the original
never draws in that camera frame and turns its background into overlapping
full-screen geometry. The Linux player now applies the same test before object
animation and submission.
`FUN_005dd8e0` itself supports two TUMO part types. Type `0` is a triangle
list; type `1` is a line list. Type-1 source polygons contain exactly two
corners. Oshama's models contain ten such parts, including one with 1498 line
vertices; treating them as polygon fans silently discarded all of them. The
player now keeps solid line parts separate from both triangle parts and the
wireframe edge buffer. All Oshama and 10pt8tion models end with a zero node
count, so an internal model hierarchy is not responsible for their transforms.
+332
View File
@@ -0,0 +1,332 @@
// Common
using string8 = std::string::SizedString<u8> [[format("string_formatter8")]];
using string16 = std::string::SizedString<u16> [[format("string_formatter16")]];
fn string_formatter8(ref string8 s) {
return std::format("\"{:s}\"", s);
};
fn string_formatter16(ref string16 s) {
return std::format("\"{:s}\"", s);
};
struct color {
u8 r;
u8 g;
u8 b;
u8 a;
} [[format("color_formatter")]];
fn color_formatter(ref color c) {
return std::format("#{:02x}{:02x}{:02x}{:02x}", c.r, c.g, c.b, c.a);
};
struct fcolor {
float r;
float g;
float b;
float a;
};
// Header
struct Header {
u32 stageCfg;
u32 trackDrawDist;
u32 track;
u32 notes;
u32 camera;
u32 particles;
u32 visualizer;
u32 unk1;
u32 colors;
u32 objects;
u32 unk2;
u32 colors2;
u32 unk3;
};
// Stage Config
struct BpmChange {
u32 timeMs;
u32 bpm;
} [[single_color]];
struct NoteTimingEntry {
u32 timeMs;
u32 mode;
float value;
} [[single_color]];
struct NoteTimingList {
u16 size;
NoteTimingEntry entries[size];
} [[single_color]];
struct StageConfig {
float endTime1;
float endTime2;
float outroTime;
u16 bpmChangeSz [[hidden]];
BpmChange bpmChanges[bpmChangeSz];
NoteTimingList noteTimings[4];
string16 chartName;
string16 chartName2;
string16 bgmName;
string16 shotName;
float backwardsDrawDist;
float forwardDrawDist;
color trackAheadColor;
color trackBehindColor;
u8 audioOffset;
float visualOffset;
color unk;
};
// Track Draw Distance
struct DrawDistance {
u32 timeMs;
float distance;
} [[single_color]];
struct TrackDrawDist {
u32 sz;
DrawDistance points[sz];
};
// Track
struct TrackPiece {
u32 timeMs;
float x;
float y;
float z;
} [[single_color]];
struct TrackPieceArray {
u32 sz;
TrackPiece pieces[sz];
};
// Notes
enum NoteType : u8 {
NONE = 0,
NORMAL = 1,
FLICK = 2,
HOLD = 3,
SCRATCH = 4,
BEAT = 5,
MERRY_GO_ROUND = 6,
HIDDEN = 7,
HIDDEN2 = 8,
CRITICAL = 9,
SLIDE_HOLD = 10,
SLIDE_COUNTER = 11,
TURN = 12,
SPIN = 13,
FINISH = 14,
DUAL_HOLD = 15,
};
struct Note {
u32 timeMs;
NoteType type;
u8 typeOverride;
s16 params16[9];
u8 flag24;
float params25[3];
u8 flag37;
u8 flag38;
float params39[4];
u32 params55[3];
float param67;
u32 param71;
float params75[5];
u32 param95;
} [[single_color]];
struct NoteArray {
u32 namesSz;
string8 names[namesSz];
u32 sz;
Note entries[sz];
};
// Camera
struct Camera {
u32 timeMs;
u8 aMode;
u8 fMode;
float dist;
float rotationA[2];
float originOff[3];
u8 projType;
float fieldFar[3];
float fieldNear[3];
float rotationB[1];
} [[single_color]];
struct CameraArray {
u32 sz;
Camera points[sz];
};
// Particles
struct Particle {
u32 timeMs;
u32 unk;
u32 shape;
u32 texture;
color color;
float velocity[3];
float repeatMeasure;
float lifespanMeasure;
u32 groupShapeSize;
} [[single_color]];
struct ParticleArray {
u32 sz;
Particle particles[sz];
};
// Visualizer
struct Visualizer {
u32 timeMs;
u32 type;
color color;
} [[same_color]];
struct VisualizerArray {
u32 sz;
Visualizer entries[sz];
};
// Color Table 1
struct ColorTable {
u32 timeMs;
color topRight;
color topLeft;
color bottomRight;
color bottomLeft;
bool fadeOut;
bool fadeIn;
} [[same_color]];
struct ColorTableArray {
u32 sz;
ColorTable entries[sz];
};
// Objects
struct Visibility {
u32 timeMs;
bool fadeOut;
bool fadeIn;
bool visible;
};
struct VisibilityArray {
u32 sz;
Visibility entries[sz];
};
struct Movement {
u32 timeMs;
bool tweenTowards;
bool tweenAway;
float pos[3];
};
struct MovementArray {
u32 sz;
Movement entries[sz];
};
struct Scaling {
u32 timeMs;
bool tweenTowards;
bool tweenAway;
float scale[3];
};
struct ScalingArray {
u32 sz;
Scaling entries[sz];
};
struct Rotation {
u32 timeMs;
bool tweenTowards;
bool tweenAway;
float rotation[3];
};
struct RotationArray {
u32 sz;
Rotation entries[sz];
};
struct ColorChange {
u32 timeMs;
bool tweenTowards;
bool tweenAway;
color new;
};
struct ColorChangeArray {
u32 sz;
ColorChange entries[sz];
};
struct Object {
u32 model;
u32 fs;
bool wireframe;
bool flashing;
bool unk;
float position[3];
float scale[3];
float rotation[3];
fcolor color;
float unk2[3];
VisibilityArray vis;
MovementArray mov;
ScalingArray scaling;
RotationArray rotations;
ColorChangeArray colorChanges;
} [[same_color]];
struct ObjectArray {
u32 namesSz;
string8 names[namesSz];
u32 names2Sz;
string8 names2[names2Sz];
u32 sz;
Object entries[sz];
};
// Color Table 2
struct ColorTable2Entry1 {
color color;
u8 unk;
};
struct ColorTable2Entry2 {
bool visible;
color center;
color top;
color bottom;
color left;
color right;
};
struct ColorTable2Entry4 {
u8 unk;
color color;
float unk2;
u8 unk3;
u8 unk4;
};
struct ColorTable2Array {
u32 off1; // after offsets
u32 off2; // after arr1
u32 off3; // after arr2
u32 off4; // after arr3
u32 off5; // after arr4
u32 sz1;
ColorTable2Entry1 arr1[sz1];
u32 sz2;
ColorTable2Entry2 arr2[sz2];
u32 sz3;
u16 arr3[sz3];
u32 sz4;
ColorTable2Entry4 arr4[sz4];
};
Header hdr @ 0x00;
StageConfig cfg @ hdr.stageCfg;
TrackDrawDist trackDrawDist @ hdr.trackDrawDist;
TrackPieceArray track @ hdr.track;
NoteArray notes @ hdr.notes;
CameraArray camera @ hdr.camera;
ParticleArray particles @ hdr.particles;
VisualizerArray visualizer @ hdr.visualizer;
ColorTableArray colorTable @ hdr.colors;
ObjectArray objects @ hdr.objects;
ColorTable2Array colorTable2 @ hdr.colors2;
+49
View File
@@ -0,0 +1,49 @@
#pragma once
#include <cstdint>
namespace openroller::psp {
constexpr char kSongCatalogMagic[4] = {'O', 'R', 'P', 'C'};
constexpr std::uint16_t kSongCatalogVersion = 1;
constexpr std::uint32_t kMaximumCatalogSongs = 64;
struct SongCatalogHeader {
char magic[4]{};
std::uint16_t version = 0;
std::uint16_t headerSize = 0;
std::uint32_t fileSize = 0;
std::uint32_t songCount = 0;
std::uint32_t recordSize = 0;
std::uint32_t reserved[3]{};
};
struct SongCatalogRecord {
char key[32]{};
char title[64]{};
char artist[48]{};
char duration[8]{};
char bpm[16]{};
std::uint8_t genre = 0;
std::uint8_t availableMask = 0;
std::uint8_t ratings[4]{};
std::uint8_t reserved[2]{};
};
constexpr char kJacketMagic[4] = {'O', 'R', 'P', 'J'};
constexpr std::uint16_t kJacketVersion = 1;
struct JacketHeader {
char magic[4]{};
std::uint16_t version = 0;
std::uint16_t width = 0;
std::uint16_t height = 0;
std::uint16_t pixelFormat = 0; // 0 = PSP GU_PSM_4444
std::uint32_t dataSize = 0;
};
static_assert(sizeof(SongCatalogHeader) == 32);
static_assert(sizeof(SongCatalogRecord) == 176);
static_assert(sizeof(JacketHeader) == 16);
} // namespace openroller::psp
+242
View File
@@ -0,0 +1,242 @@
#pragma once
#include <cstdint>
namespace openroller::psp {
constexpr char kStagePackageMagic[4] = {'O', 'R', 'P', 'S'};
constexpr std::uint16_t kStagePackageVersion = 4;
enum class PackageDifficulty : std::uint32_t {
Easy = 0,
Normal = 1,
Hard = 2,
Extra = 3,
};
constexpr std::uint32_t kStageDifficultyMask = 0x3u;
constexpr std::uint32_t kMaximumPackageBackgroundObjects = 1024u;
struct PackageSection {
std::uint32_t offset = 0;
std::uint32_t count = 0;
};
struct PackageRange {
std::uint32_t first = 0;
std::uint32_t count = 0;
};
// All package data is little-endian. Every section begins at a 16-byte
// boundary so it can be read directly into PSP-friendly arrays.
struct StagePackageHeader {
char magic[4]{};
std::uint16_t version = 0;
std::uint16_t headerSize = 0;
std::uint32_t fileSize = 0;
std::uint32_t flags = 0;
std::uint32_t durationMs = 0;
std::uint32_t audioOffsetRaw = 0;
float visualOffset = 0.0f;
float greatMinimumTimeMs = 32.0f;
float scratchEnableTimeMs = 250.0f;
float beatEnableTimeMs = 200.0f;
float backwardsDrawDistance = 0.0f;
float forwardDrawDistance = 0.0f;
std::uint32_t trackAheadRgba = 0;
std::uint32_t trackBehindRgba = 0;
PackageSection track;
PackageSection notes;
PackageSection cameras;
PackageSection drawDistances;
PackageSection backgroundColors;
PackageSection backgroundModels;
PackageSection backgroundVertices;
PackageSection backgroundObjects;
PackageSection visibilityKeys;
PackageSection transformKeys;
PackageSection objectColorKeys;
PackageSection particles;
PackageSection visualizer;
PackageSection bpmChanges;
};
struct PackageTrackPoint {
std::uint32_t timeMs = 0;
float x = 0.0f;
float y = 0.0f;
float z = 0.0f;
};
enum PackageNoteFlags : std::uint8_t {
kNoteTypeOverride = 1u << 0,
kNoteFlag24 = 1u << 1,
kNoteFlag37 = 1u << 2,
kNoteFlag38 = 1u << 3,
};
// This is the original note payload normalized to little-endian and with its
// four single-byte flags packed into one byte. Unknown values are retained so
// later reverse-engineering does not require rebuilding the source parser.
struct PackageNote {
std::uint32_t timeMs = 0;
std::uint8_t type = 0;
std::uint8_t flags = 0;
std::int16_t params16[9]{};
float params25[3]{};
float params39[4]{};
std::uint32_t params55[3]{};
float param67 = 0.0f;
std::uint32_t param71 = 0;
float params75[5]{};
std::uint32_t param95 = 0;
// Values produced by the original BuildTimingDataSub path. Keeping these
// in the host-built package avoids duplicating chart/config resolution on
// the PSP and makes its runtime consume the same semantics as desktop.
std::uint8_t effectiveType = 0;
std::uint8_t reserved = 0;
std::int16_t markEffectId = -1;
float appearTimeMs = 0.0f;
float endTimeMs = 0.0f;
float directionVector[3]{};
float beatDurationMs = 500.0f;
float earlyTimingMs = 250.0f;
float lateTimingMs = 250.0f;
float missTimingMs = 250.0f;
float muteTimingMs = 0.0f;
float markerFadeEndTimeMs = 0.0f;
std::uint32_t packedColor = 0xffffffffu;
std::uint32_t merryCount = 0;
};
struct PackageCameraPoint {
std::uint32_t timeMs = 0;
std::uint8_t aMode = 0;
std::uint8_t fMode = 0;
std::uint8_t projectionType = 0;
std::uint8_t reserved = 0;
float distance = 0.0f;
float rotationA[2]{};
float originOffset[3]{};
float fieldFar[3]{};
float fieldNear[3]{};
float rotationB = 0.0f;
};
struct PackageDrawDistancePoint {
std::uint32_t timeMs = 0;
float distance = 0.0f;
};
struct PackageBackgroundColorPoint {
std::uint32_t timeMs = 0;
std::uint32_t topRightRgba = 0;
std::uint32_t topLeftRgba = 0;
std::uint32_t bottomRightRgba = 0;
std::uint32_t bottomLeftRgba = 0;
std::uint32_t flags = 0;
};
struct PackageBackgroundModel {
PackageRange triangles;
PackageRange solidLines;
PackageRange wireframeLines;
};
struct PackageBackgroundVertex {
float x = 0.0f;
float y = 0.0f;
float z = 0.0f;
};
enum PackageBackgroundObjectFlags : std::uint32_t {
kBackgroundObjectWireframe = 1u << 0,
kBackgroundObjectFlashing = 1u << 1,
kBackgroundObjectUnknown = 1u << 2,
};
struct PackageBackgroundObject {
std::uint32_t model = 0;
std::int32_t parentIndex = -1;
std::uint32_t flags = 0;
std::uint32_t fragmentShader = 0;
float position[3]{};
float scale[3]{1.0f, 1.0f, 1.0f};
float rotation[3]{};
float color[4]{1.0f, 1.0f, 1.0f, 1.0f};
PackageRange visibility;
PackageRange movement;
PackageRange scaling;
PackageRange rotations;
PackageRange colorChanges;
};
enum PackageObjectKeyFlags : std::uint8_t {
kObjectKeyLoop = 1u << 0,
kObjectKeyInterpolate = 1u << 1,
};
struct PackageVisibilityKey {
std::uint32_t timeMs = 0;
std::uint8_t flags = 0;
std::uint8_t visible = 0;
std::uint16_t reserved = 0;
};
struct PackageTransformKey {
std::uint32_t timeMs = 0;
std::uint8_t flags = 0;
std::uint8_t reserved[3]{};
float value[3]{};
};
struct PackageObjectColorKey {
std::uint32_t timeMs = 0;
std::uint8_t flags = 0;
std::uint8_t reserved[3]{};
std::uint32_t rgba = 0;
};
struct PackageParticlePoint {
std::uint32_t timeMs = 0;
std::uint32_t enabled = 0;
std::uint32_t shape = 0;
std::uint32_t texture = 0;
std::uint32_t rgba = 0;
float velocity[3]{};
float repeatMeasure = 0.0f;
float lifespanMeasure = 0.0f;
std::uint32_t groupShapeSize = 0;
};
struct PackageVisualizerPoint {
std::uint32_t timeMs = 0;
std::uint32_t type = 0;
std::uint32_t rgba = 0;
};
struct PackageBpmPoint {
std::uint32_t timeMs = 0;
std::uint32_t bpm = 120;
};
static_assert(sizeof(PackageSection) == 8);
static_assert(sizeof(PackageRange) == 8);
static_assert(sizeof(StagePackageHeader) == 168);
static_assert(sizeof(PackageTrackPoint) == 16);
static_assert(sizeof(PackageNote) == 152);
static_assert(sizeof(PackageCameraPoint) == 60);
static_assert(sizeof(PackageDrawDistancePoint) == 8);
static_assert(sizeof(PackageBackgroundColorPoint) == 24);
static_assert(sizeof(PackageBackgroundModel) == 24);
static_assert(sizeof(PackageBackgroundVertex) == 12);
static_assert(sizeof(PackageBackgroundObject) == 108);
static_assert(sizeof(PackageVisibilityKey) == 8);
static_assert(sizeof(PackageTransformKey) == 20);
static_assert(sizeof(PackageObjectColorKey) == 12);
static_assert(sizeof(PackageParticlePoint) == 44);
static_assert(sizeof(PackageVisualizerPoint) == 12);
static_assert(sizeof(PackageBpmPoint) == 8);
} // namespace openroller::psp
+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
+2997
View File
File diff suppressed because it is too large Load Diff
+218
View File
@@ -0,0 +1,218 @@
#include "gc/StageCatalog.hpp"
#include "openroller/psp/SongCatalog.hpp"
#include <algorithm>
#include <cctype>
#include <cstdint>
#include <cstring>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <string>
#include <vector>
namespace {
bool readFile(const std::filesystem::path& path, std::vector<std::uint8_t>* bytes) {
if (!bytes) return false;
std::ifstream input(path, std::ios::binary);
if (!input) return false;
input.seekg(0, std::ios::end);
const std::streamoff size = input.tellg();
if (size < 0) return false;
input.seekg(0, std::ios::beg);
bytes->resize(static_cast<std::size_t>(size));
if (!bytes->empty()) input.read(reinterpret_cast<char*>(bytes->data()), size);
return static_cast<bool>(input);
}
template <std::size_t N>
void copyDisplay(char (&output)[N], const std::string& input) {
std::size_t written = 0;
for (std::size_t index = 0; index < input.size();) {
const unsigned char value = static_cast<unsigned char>(input[index++]);
if (written + 1 >= N) break;
if (value >= 0x20 && value < 0x7f) {
output[written++] = static_cast<char>(value);
continue;
}
output[written++] = '?';
while (index < input.size() &&
(static_cast<unsigned char>(input[index]) & 0xc0u) == 0x80u) ++index;
}
output[written] = '\0';
}
const gc::StageCatalogEntry* findSong(
const std::vector<gc::StageCatalogEntry>& entries,
const std::string& token) {
const std::string easy = "ac_" + token + "_easy";
if (const auto* found = gc::FindStageCatalogEntryByChart(entries, easy)) return found;
for (const auto& entry : entries) {
if (entry.imageKey == token) return &entry;
}
return nullptr;
}
std::string chartAt(const gc::StageCatalogEntry& entry, std::size_t difficulty) {
std::string chart = entry.chartIds[difficulty];
if (chart.empty()) chart = entry.chartGroup0[difficulty];
return chart;
}
struct AsciiMetadata {
const char* token;
const char* title;
const char* artist;
};
const AsciiMetadata* asciiMetadata(const std::string& token) {
// The PSP port currently uses a compact built-in ASCII font. Keep the
// original catalog as the source of gameplay data, but provide readable
// metadata instead of locale-dependent mojibake/question marks.
static constexpr AsciiMetadata overrides[] = {
{"altale", "Altale", "Sakuzyo"},
{"7days", "7 days a week", "Silver Forest feat. Aki"},
{"mikumiku", "Miku Miku ni Shite Ageru", "ika-mo"},
{"syositu2", "The Disappearance of Hatsune Miku", "cosMo@BousouP feat. Hatsune Miku"},
{"world2", "World's End Dancehall", "wowaka feat. Hatsune Miku & Megurine Luka"},
{"rollingirl2", "Rolling Girl", "wowaka feat. Hatsune Miku"},
{"uraomote", "Two-Faced Lovers", "wowaka"},
{"unknown", "Unknown Mother-Goose", "wowaka feat. Hatsune Miku"},
{"redial", "Redial", "livetune feat. Hatsune Miku"},
{"tellyour", "Tell Your World", "livetune feat. Hatsune Miku"},
{"umiyuri", "Tale of the Deep-sea Lily", "n-buna feat. Hatsune Miku"},
{"karakuri", "Karakuri Pierrot", "40mP feat. Hatsune Miku"},
{"dappo", "Law-evading Rock", "Neru feat. Kagamine Len"},
{"vampire", "The Vampire", "DECO*27 feat. Hatsune Miku"},
{"pa3", "PaIII.SENSATION", "Yunosuke feat. Miku, GUMI & Rin"},
};
for (const AsciiMetadata& metadata : overrides) {
if (token == metadata.token) return &metadata;
}
return nullptr;
}
std::string resolveAudioFilename(
const std::filesystem::path& soundDirectory,
const std::string& authoredName) {
const std::filesystem::path exact = soundDirectory / authoredName;
if (std::filesystem::is_regular_file(exact)) return exact.filename().string();
std::string lower = authoredName;
std::transform(lower.begin(), lower.end(), lower.begin(), [](unsigned char value) {
return static_cast<char>(std::tolower(value));
});
for (const auto& entry : std::filesystem::directory_iterator(soundDirectory)) {
if (!entry.is_regular_file()) continue;
std::string candidate = entry.path().filename().string();
std::transform(candidate.begin(), candidate.end(), candidate.begin(), [](unsigned char value) {
return static_cast<char>(std::tolower(value));
});
if (candidate == lower) return entry.path().filename().string();
}
return authoredName;
}
} // namespace
int main(int argc, char** argv) {
const bool listMode = argc == 3 && std::string(argv[2]) == "--list";
if ((!listMode && argc < 4) || argc < 3) {
std::cerr << "usage: openroller-psp-catalog <stage_param.dat> <catalog.orpc> <song-token>...\n"
<< " openroller-psp-catalog <stage_param.dat> --list\n";
return 2;
}
std::vector<std::uint8_t> bytes;
std::vector<gc::StageCatalogEntry> entries;
std::string error;
if (!readFile(argv[1], &bytes) || !gc::ParseStageCatalog(bytes, &entries, &error)) {
std::cerr << argv[1] << ": " << (error.empty() ? "could not read catalog" : error) << '\n';
return 1;
}
if (listMode) {
for (const gc::StageCatalogEntry& entry : entries) {
std::cout << entry.id << '\t' << entry.title << '\t'
<< entry.artist << '\t' << entry.imageKey << '\t'
<< entry.bgmBase;
for (const std::string& chart : entry.chartIds) {
std::cout << '\t' << chart;
}
std::cout << '\n';
}
return 0;
}
const std::filesystem::path stageDirectory =
std::filesystem::path(argv[1]).parent_path().parent_path() / "stage";
const std::filesystem::path soundDirectory = stageDirectory / "sound";
std::vector<openroller::psp::SongCatalogRecord> records;
for (int argument = 3; argument < argc; ++argument) {
const std::string token = argv[argument];
if (token.size() >= 32u) {
std::cerr << token << ": song token is too long\n";
return 1;
}
const gc::StageCatalogEntry* entry = findSong(entries, token);
if (!entry) {
std::cerr << token << ": no stage_param.dat record\n";
return 1;
}
openroller::psp::SongCatalogRecord record{};
const AsciiMetadata* metadata = asciiMetadata(token);
copyDisplay(record.key, token);
copyDisplay(record.title, metadata ? metadata->title : entry->title);
copyDisplay(record.artist, metadata ? metadata->artist : entry->artist);
copyDisplay(record.duration, entry->duration);
copyDisplay(record.bpm, entry->bpm);
record.genre = entry->genre;
std::copy(entry->difficultyRatings.begin(), entry->difficultyRatings.end(), record.ratings);
for (std::size_t difficulty = 0; difficulty < 4; ++difficulty) {
const std::string chart = chartAt(*entry, difficulty);
const bool exists = !chart.empty() &&
std::filesystem::is_regular_file(stageDirectory / (chart + ".dat"));
if (exists) record.availableMask |= static_cast<std::uint8_t>(1u << difficulty);
if (exists) {
static constexpr const char* names[4] = {
"easy", "normal", "hard", "extra",
};
const std::string bgmName =
entry->bgmBase + entry->chartGroup0[difficulty] + "_BGM.wav";
const std::string shotName =
entry->bgmBase + entry->chartSuffixes[difficulty] + "_SHOT.wav";
std::cout << token << '\t' << entry->imageKey << '\t'
<< names[difficulty] << '\t' << chart << '\t'
<< resolveAudioFilename(soundDirectory, bgmName) << '\t'
<< resolveAudioFilename(soundDirectory, shotName) << '\t'
<< static_cast<unsigned>(entry->bgmVolumes[difficulty]) << '\t'
<< static_cast<unsigned>(entry->shotVolumes[difficulty]) << '\n';
}
}
if (record.availableMask == 0) {
std::cerr << token << ": catalog record has no local charts\n";
return 1;
}
records.push_back(record);
}
if (records.size() > openroller::psp::kMaximumCatalogSongs) {
std::cerr << "PSP catalog exceeds its song budget\n";
return 1;
}
openroller::psp::SongCatalogHeader header{};
std::copy(std::begin(openroller::psp::kSongCatalogMagic),
std::end(openroller::psp::kSongCatalogMagic), header.magic);
header.version = openroller::psp::kSongCatalogVersion;
header.headerSize = sizeof(header);
header.songCount = static_cast<std::uint32_t>(records.size());
header.recordSize = sizeof(openroller::psp::SongCatalogRecord);
header.fileSize = static_cast<std::uint32_t>(
sizeof(header) + records.size() * sizeof(openroller::psp::SongCatalogRecord));
std::ofstream output(argv[2], std::ios::binary | std::ios::trunc);
output.write(reinterpret_cast<const char*>(&header), sizeof(header));
output.write(reinterpret_cast<const char*>(records.data()),
static_cast<std::streamsize>(records.size() * sizeof(records.front())));
if (!output) {
std::cerr << argv[2] << ": could not write catalog\n";
return 1;
}
return 0;
}
+600
View File
@@ -0,0 +1,600 @@
#include "gc/StageDat.hpp"
#include "gc/StagePattern.hpp"
#include "gc/TumoModel.hpp"
#include "openroller/psp/StagePackage.hpp"
#include <algorithm>
#include <array>
#include <cctype>
#include <cmath>
#include <cstdint>
#include <cstring>
#include <fstream>
#include <filesystem>
#include <iostream>
#include <limits>
#include <sstream>
#include <string>
#include <type_traits>
#include <vector>
#if defined(__BYTE_ORDER__) && __BYTE_ORDER__ != __ORDER_LITTLE_ENDIAN__
#error "openroller-psp-pack currently requires a little-endian host"
#endif
namespace {
using namespace openroller::psp;
struct SystemTimingConfig {
bool missMarkOverride = false;
float greatMinimumTimeMs = 32.0f;
float scratchEnableTimeMs = 250.0f;
float beatEnableTimeMs = 200.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};
};
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>* output) {
if (!output) return false;
for (char& c : value) {
if (c == '(' || c == ')' || c == ',') c = ' ';
}
std::stringstream stream(value);
std::array<float, 4> parsed{};
if (!(stream >> parsed[0] >> parsed[1] >> parsed[2] >> parsed[3])) return false;
*output = parsed;
return true;
}
SystemTimingConfig loadSystemTimingConfig(const std::string& sourcePath) {
SystemTimingConfig config;
const std::filesystem::path path =
std::filesystem::path(sourcePath).parent_path().parent_path() / "system.cfg";
std::ifstream file(path);
if (!file) return config;
std::string line;
bool blockComment = false;
while (std::getline(file, line)) {
if (blockComment) {
const std::size_t end = line.find("*/");
if (end == std::string::npos) continue;
line.erase(0, end + 2);
blockComment = false;
}
for (;;) {
const std::size_t begin = line.find("/*");
if (begin == std::string::npos) break;
const std::size_t end = line.find("*/", begin + 2);
if (end == std::string::npos) {
line.resize(begin);
blockComment = true;
break;
}
line.erase(begin, end + 2 - begin);
}
const std::size_t comment = line.find("//");
if (comment != std::string::npos) line.resize(comment);
const std::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.greatMinimumTimeMs = std::stof(value);
else if (key == "ScratchEnableTime") config.scratchEnableTimeMs = std::stof(value);
else if (key == "BeatEnableTime") config.beatEnableTimeMs = 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);
} catch (const std::exception&) {
}
}
return config;
}
PackageDifficulty inferDifficulty(const std::string& sourcePath) {
std::string lower = sourcePath;
std::transform(lower.begin(), lower.end(), lower.begin(), [](unsigned char value) {
return static_cast<char>(std::tolower(value));
});
if (lower.find("_extra.dat") != std::string::npos ||
lower.find("_ex.dat") != std::string::npos) return PackageDifficulty::Extra;
if (lower.find("_hard.dat") != std::string::npos) return PackageDifficulty::Hard;
if (lower.find("_normal.dat") != std::string::npos) return PackageDifficulty::Normal;
return PackageDifficulty::Easy;
}
float beatDurationAt(const gc::StageConfig& config, std::uint32_t timeMs) {
std::uint32_t bpm = 120;
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<std::uint32_t>(1, bpm));
}
float timingAt(
const std::vector<gc::NoteSetting>& settings,
std::uint32_t timeMs,
float beatMs,
float nextSpacingMs) {
if (settings.empty()) return beatMs * 0.5f;
const gc::NoteSetting* active = &settings.front();
for (const gc::NoteSetting& setting : settings) {
if (setting.timeMs > timeMs) break;
active = &setting;
}
if (active->mode == 1) return active->value;
if (active->mode == 3) return nextSpacingMs;
return active->value * beatMs;
}
std::uint8_t effectiveNoteType(const gc::StageNote& note) {
if (note.typeOverride) return 1;
const std::uint8_t raw = static_cast<std::uint8_t>(note.type);
if (raw == 0x0b) return 0x0a;
if (raw == 0x0c || raw == 0x0e) return 0x09;
if (raw == 0x0d) return 0x04;
return raw;
}
void directionVector(const gc::StageNote& note, std::uint8_t effectiveType, float output[3]) {
float distance = note.params25[0];
const std::uint8_t rawType = static_cast<std::uint8_t>(note.type);
if ((effectiveType == 2 || effectiveType == 10 || rawType == 0x10) && distance <= 0.0f) {
distance = 1.0f;
}
const float pi = 3.14159265358979323846f;
const float a = (-note.params25[2] * pi / 180.0f) * 0.5f;
const float b = (note.params25[1] * pi / 180.0f) * 0.5f;
const float ca = std::cos(a);
const float cb = std::cos(b);
const float sa = std::sin(a);
const float sb = std::sin(b);
const float qw = ca * cb;
const float qx = sa * cb;
const float qy = ca * sb;
const float qz = sa * sb;
output[0] = distance * 2.0f * (qz * qx + qw * qy);
output[1] = distance * 2.0f * (qy * qz - qw * qx);
output[2] = distance * (1.0f - 2.0f * (qx * qx + qy * qy));
}
std::uint32_t rgba(const gc::Color& color) {
return static_cast<std::uint32_t>(color.r) |
(static_cast<std::uint32_t>(color.g) << 8) |
(static_cast<std::uint32_t>(color.b) << 16) |
(static_cast<std::uint32_t>(color.a) << 24);
}
template <typename T>
void appendPod(std::vector<std::uint8_t>* bytes, const T& value) {
static_assert(std::is_trivially_copyable<T>::value, "package records must be POD");
const auto* first = reinterpret_cast<const std::uint8_t*>(&value);
bytes->insert(bytes->end(), first, first + sizeof(T));
}
void align16(std::vector<std::uint8_t>* bytes) {
bytes->resize((bytes->size() + 15u) & ~std::size_t(15u), 0);
}
template <typename Source, typename Packed, typename Convert>
PackageSection appendSection(
std::vector<std::uint8_t>* bytes,
const std::vector<Source>& source,
Convert&& convert) {
if (source.size() > std::numeric_limits<std::uint32_t>::max()) {
throw std::length_error("PSP package section exceeds 32-bit count");
}
align16(bytes);
PackageSection section{
static_cast<std::uint32_t>(bytes->size()),
static_cast<std::uint32_t>(source.size()),
};
for (const Source& item : source) {
const Packed packed = convert(item);
appendPod(bytes, packed);
}
return section;
}
std::uint32_t stageDuration(const gc::ParsedStagePattern& stage) {
std::uint32_t duration = 0;
const auto includeTimes = [&duration](const auto& values) {
for (const auto& value : values) duration = std::max(duration, value.timeMs);
};
includeTimes(stage.track);
includeTimes(stage.notes);
includeTimes(stage.cameras);
includeTimes(stage.drawDistances);
includeTimes(stage.backgroundColors);
return duration;
}
PackageTrackPoint packTrack(const gc::TrackPiece& source) {
return {source.timeMs, source.x, source.y, source.z};
}
PackageNote packNote(const gc::StageNote& source) {
PackageNote output{};
output.timeMs = source.timeMs;
output.type = static_cast<std::uint8_t>(source.type);
if (source.typeOverride) output.flags |= kNoteTypeOverride;
if (source.flag24) output.flags |= kNoteFlag24;
if (source.flag37) output.flags |= kNoteFlag37;
if (source.flag38) output.flags |= kNoteFlag38;
std::copy(source.params16.begin(), source.params16.end(), output.params16);
std::copy(source.params25.begin(), source.params25.end(), output.params25);
std::copy(source.params39.begin(), source.params39.end(), output.params39);
std::copy(source.params55.begin(), source.params55.end(), output.params55);
output.param67 = source.param67;
output.param71 = source.param71;
std::copy(source.params75.begin(), source.params75.end(), output.params75);
output.param95 = source.param95;
return output;
}
std::vector<PackageNote> buildNotes(
const gc::ParsedStagePattern& stage,
const std::string& sourcePath) {
std::vector<PackageNote> output;
output.reserve(stage.notes.size());
const SystemTimingConfig systemTiming = loadSystemTimingConfig(sourcePath);
const std::size_t difficulty =
static_cast<std::size_t>(inferDifficulty(sourcePath));
for (std::size_t i = 0; i < stage.notes.size(); ++i) {
const gc::StageNote& source = stage.notes[i];
PackageNote note = packNote(source);
note.effectiveType = effectiveNoteType(source);
note.markEffectId = source.params16[0];
note.beatDurationMs = beatDurationAt(stage.config, source.timeMs);
note.appearTimeMs = std::max(
0.0f,
static_cast<float>(source.timeMs) -
std::max(0.0f, source.params39[0]) * note.beatDurationMs);
const bool durationType =
note.effectiveType == 3 || note.effectiveType == 4 ||
note.effectiveType == 5 || note.effectiveType == 10 ||
note.effectiveType == 15;
const float durationBeats = note.effectiveType == 6
? static_cast<float>(source.param71) * std::max(0.0f, source.params75[0])
: std::max(0.0f, source.params39[3]);
note.endTimeMs = (durationType || note.effectiveType == 6)
? std::max(
static_cast<float>(source.timeMs),
static_cast<float>(source.timeMs) + durationBeats * note.beatDurationMs)
: static_cast<float>(source.timeMs);
const float nextSpacingMs = i + 1 < stage.notes.size()
? std::max(
0.0f,
static_cast<float>(stage.notes[i + 1].timeMs) -
static_cast<float>(source.timeMs))
: note.beatDurationMs * 2.0f;
note.missTimingMs = std::max(
0.0f,
timingAt(stage.config.noteSettings[0], source.timeMs,
note.beatDurationMs, nextSpacingMs));
note.earlyTimingMs = std::max(
0.0f,
timingAt(stage.config.noteSettings[1], source.timeMs,
note.beatDurationMs, nextSpacingMs));
note.lateTimingMs = std::max(
0.0f,
timingAt(stage.config.noteSettings[2], source.timeMs,
note.beatDurationMs, nextSpacingMs));
note.muteTimingMs = std::max(
0.0f,
timingAt(stage.config.noteSettings[3], source.timeMs,
note.beatDurationMs, nextSpacingMs));
if (systemTiming.missMarkOverride) {
note.missTimingMs = std::max(0.0f, systemTiming.miss[difficulty]);
note.earlyTimingMs = std::max(0.0f, systemTiming.unmute[difficulty]);
note.lateTimingMs = std::max(0.0f, systemTiming.limit[difficulty]);
note.muteTimingMs = std::max(0.0f, systemTiming.mute[difficulty]);
}
if (note.effectiveType == 2 || note.type == 0x10) {
note.earlyTimingMs += note.beatDurationMs * 0.2f;
note.lateTimingMs += note.beatDurationMs * 0.2f;
}
const float fadeAnchor = (durationType || note.effectiveType == 6)
? note.endTimeMs
: static_cast<float>(source.timeMs) + note.lateTimingMs;
note.markerFadeEndTimeMs = fadeAnchor + note.beatDurationMs * 4.0f;
note.packedColor = source.params55[0];
note.merryCount = source.param71;
directionVector(source, note.effectiveType, note.directionVector);
output.push_back(note);
}
return output;
}
PackageCameraPoint packCamera(const gc::CameraPoint& source) {
PackageCameraPoint output{};
output.timeMs = source.timeMs;
output.aMode = source.aMode;
output.fMode = source.fMode;
output.projectionType = source.projType;
output.distance = source.dist;
std::copy(std::begin(source.rotationA), std::end(source.rotationA), output.rotationA);
std::copy(std::begin(source.originOff), std::end(source.originOff), output.originOffset);
std::copy(std::begin(source.fieldFar), std::end(source.fieldFar), output.fieldFar);
std::copy(std::begin(source.fieldNear), std::end(source.fieldNear), output.fieldNear);
output.rotationB = source.rotationB;
return output;
}
PackageDrawDistancePoint packDrawDistance(const gc::DrawDistancePoint& source) {
return {source.timeMs, source.distance};
}
PackageBackgroundColorPoint packBackgroundColor(const gc::BackgroundColorPoint& source) {
PackageBackgroundColorPoint output{};
output.timeMs = source.timeMs;
output.topRightRgba = rgba(source.topRight);
output.topLeftRgba = rgba(source.topLeft);
output.bottomRightRgba = rgba(source.bottomRight);
output.bottomLeftRgba = rgba(source.bottomLeft);
output.flags = (source.interpolateToNext ? 1u : 0u) |
(source.audioReactive ? 2u : 0u);
return output;
}
template <typename Source, typename Packed, typename Convert>
PackageRange appendRange(
const std::vector<Source>& source,
std::vector<Packed>* destination,
Convert&& convert) {
if (!destination || destination->size() > std::numeric_limits<std::uint32_t>::max() ||
source.size() > std::numeric_limits<std::uint32_t>::max() - destination->size()) {
throw std::length_error("PSP background key array exceeds 32-bit range");
}
PackageRange range{
static_cast<std::uint32_t>(destination->size()),
static_cast<std::uint32_t>(source.size()),
};
destination->reserve(destination->size() + source.size());
for (const Source& item : source) destination->push_back(convert(item));
return range;
}
PackageVisibilityKey packVisibility(const gc::VisibilityPoint& source) {
PackageVisibilityKey output{};
output.timeMs = source.timeMs;
output.flags = static_cast<std::uint8_t>(
(source.fadeOut ? static_cast<std::uint8_t>(kObjectKeyLoop) : 0u) |
(source.fadeIn ? static_cast<std::uint8_t>(kObjectKeyInterpolate) : 0u));
output.visible = source.visible ? 1u : 0u;
return output;
}
PackageTransformKey packTransform(const gc::TransformPoint& source) {
PackageTransformKey output{};
output.timeMs = source.timeMs;
output.flags = static_cast<std::uint8_t>(
(source.tweenTowards ? static_cast<std::uint8_t>(kObjectKeyLoop) : 0u) |
(source.tweenAway ? static_cast<std::uint8_t>(kObjectKeyInterpolate) : 0u));
std::copy(std::begin(source.value), std::end(source.value), output.value);
return output;
}
PackageObjectColorKey packObjectColor(const gc::ObjectColorPoint& source) {
PackageObjectColorKey output{};
output.timeMs = source.timeMs;
output.flags = static_cast<std::uint8_t>(
(source.tweenTowards ? static_cast<std::uint8_t>(kObjectKeyLoop) : 0u) |
(source.tweenAway ? static_cast<std::uint8_t>(kObjectKeyInterpolate) : 0u));
output.rgba = rgba(source.color);
return output;
}
PackageParticlePoint packParticle(const gc::ParticlePoint& source) {
PackageParticlePoint output{};
output.timeMs = source.timeMs;
output.enabled = source.enabled;
output.shape = source.shape;
output.texture = source.texture;
output.rgba = rgba(source.color);
std::copy(std::begin(source.velocity), std::end(source.velocity), output.velocity);
output.repeatMeasure = source.repeatMeasure;
output.lifespanMeasure = source.lifespanMeasure;
output.groupShapeSize = source.groupShapeSize;
return output;
}
PackageVisualizerPoint packVisualizer(const gc::VisualizerPoint& source) {
return {source.timeMs, source.type, rgba(source.color)};
}
void appendGeometry(
const std::vector<gc::TumoVertex>& source,
std::vector<PackageBackgroundVertex>* vertices,
PackageRange* range) {
if (!vertices || !range || vertices->size() > std::numeric_limits<std::uint32_t>::max() ||
source.size() > std::numeric_limits<std::uint32_t>::max() - vertices->size()) {
throw std::length_error("PSP background vertex array exceeds 32-bit range");
}
range->first = static_cast<std::uint32_t>(vertices->size());
range->count = static_cast<std::uint32_t>(source.size());
vertices->reserve(vertices->size() + source.size());
for (const gc::TumoVertex& vertex : source) {
vertices->push_back({vertex.x, vertex.y, vertex.z});
}
}
bool writePackage(
const std::string& sourcePath,
const std::string& outputPath,
std::string* error) {
gc::StageDat dat;
if (!gc::StageDat::LoadFromFile(sourcePath, dat, error)) return false;
gc::ParsedStagePattern stage;
if (!gc::ParseStagePattern(dat, &stage, error)) return false;
if (stage.objects.size() > kMaximumPackageBackgroundObjects) {
if (error) *error = "stage exceeds the PSP background object budget";
return false;
}
StagePackageHeader header{};
const SystemTimingConfig systemTiming = loadSystemTimingConfig(sourcePath);
std::copy(std::begin(kStagePackageMagic), std::end(kStagePackageMagic), header.magic);
header.version = kStagePackageVersion;
header.headerSize = sizeof(StagePackageHeader);
header.flags = static_cast<std::uint32_t>(inferDifficulty(sourcePath));
header.durationMs = stageDuration(stage);
header.audioOffsetRaw = stage.config.audioOffset;
header.visualOffset = stage.config.visualOffset;
header.greatMinimumTimeMs = std::max(0.0f, systemTiming.greatMinimumTimeMs);
header.scratchEnableTimeMs = std::max(0.0f, systemTiming.scratchEnableTimeMs);
header.beatEnableTimeMs = std::max(0.0f, systemTiming.beatEnableTimeMs);
header.backwardsDrawDistance = stage.config.backwardsDrawDist;
header.forwardDrawDistance = stage.config.forwardDrawDist;
header.trackAheadRgba = rgba(stage.config.trackAheadColor);
header.trackBehindRgba = rgba(stage.config.trackBehindColor);
std::vector<PackageBackgroundModel> backgroundModels;
std::vector<PackageBackgroundVertex> backgroundVertices;
backgroundModels.reserve(stage.modelNames.size());
const std::filesystem::path modelDirectory =
std::filesystem::path(sourcePath).parent_path().parent_path() / "model";
for (const std::string& modelName : stage.modelNames) {
gc::TumoGeometry geometry;
std::string modelError;
const std::filesystem::path modelPath = modelDirectory / (modelName + ".tumo");
if (!gc::LoadTumoGeometry(modelPath.string(), &geometry, &modelError)) {
if (error) *error = modelPath.string() + ": " + modelError;
return false;
}
PackageBackgroundModel model{};
appendGeometry(geometry.triangles, &backgroundVertices, &model.triangles);
appendGeometry(geometry.solidLines, &backgroundVertices, &model.solidLines);
appendGeometry(geometry.wireframeLines, &backgroundVertices, &model.wireframeLines);
backgroundModels.push_back(model);
}
std::vector<PackageBackgroundObject> backgroundObjects;
std::vector<PackageVisibilityKey> visibilityKeys;
std::vector<PackageTransformKey> transformKeys;
std::vector<PackageObjectColorKey> objectColorKeys;
backgroundObjects.reserve(stage.objects.size());
for (const gc::StageObject& source : stage.objects) {
PackageBackgroundObject object{};
object.model = source.model;
object.parentIndex = source.parentIndex;
object.flags = (source.wireframe ? kBackgroundObjectWireframe : 0u) |
(source.flashing ? kBackgroundObjectFlashing : 0u) |
(source.unknownFlag ? kBackgroundObjectUnknown : 0u);
object.fragmentShader = source.fragmentShader;
std::copy(std::begin(source.position), std::end(source.position), object.position);
std::copy(std::begin(source.scale), std::end(source.scale), object.scale);
std::copy(std::begin(source.rotation), std::end(source.rotation), object.rotation);
std::copy(std::begin(source.color), std::end(source.color), object.color);
object.visibility = appendRange<gc::VisibilityPoint, PackageVisibilityKey>(
source.visibility, &visibilityKeys, packVisibility);
object.movement = appendRange<gc::TransformPoint, PackageTransformKey>(
source.movement, &transformKeys, packTransform);
object.scaling = appendRange<gc::TransformPoint, PackageTransformKey>(
source.scaling, &transformKeys, packTransform);
object.rotations = appendRange<gc::TransformPoint, PackageTransformKey>(
source.rotations, &transformKeys, packTransform);
object.colorChanges = appendRange<gc::ObjectColorPoint, PackageObjectColorKey>(
source.colorChanges, &objectColorKeys, packObjectColor);
backgroundObjects.push_back(object);
}
std::vector<std::uint8_t> bytes(sizeof(StagePackageHeader), 0);
const std::vector<PackageNote> notes = buildNotes(stage, sourcePath);
header.track = appendSection<gc::TrackPiece, PackageTrackPoint>(&bytes, stage.track, packTrack);
header.notes = appendSection<PackageNote, PackageNote>(
&bytes, notes, [](const PackageNote& item) { return item; });
header.cameras = appendSection<gc::CameraPoint, PackageCameraPoint>(&bytes, stage.cameras, packCamera);
header.drawDistances = appendSection<gc::DrawDistancePoint, PackageDrawDistancePoint>(
&bytes, stage.drawDistances, packDrawDistance);
header.backgroundColors = appendSection<gc::BackgroundColorPoint, PackageBackgroundColorPoint>(
&bytes, stage.backgroundColors, packBackgroundColor);
header.backgroundModels = appendSection<PackageBackgroundModel, PackageBackgroundModel>(
&bytes, backgroundModels, [](const PackageBackgroundModel& item) { return item; });
header.backgroundVertices = appendSection<PackageBackgroundVertex, PackageBackgroundVertex>(
&bytes, backgroundVertices, [](const PackageBackgroundVertex& item) { return item; });
header.backgroundObjects = appendSection<PackageBackgroundObject, PackageBackgroundObject>(
&bytes, backgroundObjects, [](const PackageBackgroundObject& item) { return item; });
header.visibilityKeys = appendSection<PackageVisibilityKey, PackageVisibilityKey>(
&bytes, visibilityKeys, [](const PackageVisibilityKey& item) { return item; });
header.transformKeys = appendSection<PackageTransformKey, PackageTransformKey>(
&bytes, transformKeys, [](const PackageTransformKey& item) { return item; });
header.objectColorKeys = appendSection<PackageObjectColorKey, PackageObjectColorKey>(
&bytes, objectColorKeys, [](const PackageObjectColorKey& item) { return item; });
header.particles = appendSection<gc::ParticlePoint, PackageParticlePoint>(
&bytes, stage.particles, packParticle);
header.visualizer = appendSection<gc::VisualizerPoint, PackageVisualizerPoint>(
&bytes, stage.visualizer, packVisualizer);
header.bpmChanges = appendSection<gc::BpmChange, PackageBpmPoint>(
&bytes, stage.config.bpmChanges,
[](const gc::BpmChange& item) { return PackageBpmPoint{item.timeMs, item.bpm}; });
align16(&bytes);
if (bytes.size() > std::numeric_limits<std::uint32_t>::max()) {
if (error) *error = "PSP stage package exceeds 4 GiB";
return false;
}
header.fileSize = static_cast<std::uint32_t>(bytes.size());
std::memcpy(bytes.data(), &header, sizeof(header));
std::ofstream output(outputPath, std::ios::binary | std::ios::trunc);
if (!output) {
if (error) *error = "could not create output file";
return false;
}
output.write(reinterpret_cast<const char*>(bytes.data()),
static_cast<std::streamsize>(bytes.size()));
if (!output) {
if (error) *error = "failed while writing output file";
return false;
}
std::cout << outputPath << ": " << bytes.size() << " bytes"
<< ", track=" << header.track.count
<< ", notes=" << header.notes.count
<< ", cameras=" << header.cameras.count
<< ", colors=" << header.backgroundColors.count
<< ", bgModels=" << header.backgroundModels.count
<< ", bgObjects=" << header.backgroundObjects.count
<< ", bgVertices=" << header.backgroundVertices.count
<< ", particles=" << header.particles.count
<< ", visualizer=" << header.visualizer.count
<< ", difficulty=" << (header.flags & kStageDifficultyMask) << '\n';
return true;
}
} // namespace
int main(int argc, char** argv) {
if (argc != 3) {
std::cerr << "usage: openroller-psp-pack <stage.dat> <stage.orps>\n";
return 2;
}
try {
std::string error;
if (!writePackage(argv[1], argv[2], &error)) {
std::cerr << argv[1] << ": " << error << '\n';
return 1;
}
} catch (const std::exception& exception) {
std::cerr << "openroller-psp-pack: " << exception.what() << '\n';
return 1;
}
return 0;
}
+89
View File
@@ -0,0 +1,89 @@
#include "StageRuntime.hpp"
#include "Gameplay.hpp"
#include <cmath>
#include <cstddef>
#include <iostream>
namespace {
bool finite(openroller::psp::Vec3 value) {
return std::isfinite(value.x) && std::isfinite(value.y) && std::isfinite(value.z);
}
} // namespace
int main(int argc, char** argv) {
if (argc != 2) {
std::cerr << "usage: openroller-psp-runtime-probe <stage.orps>\n";
return 2;
}
openroller::psp::StageView stage{};
char error[128]{};
if (!openroller::psp::loadStagePackage(argv[1], &stage, error, sizeof(error))) {
std::cerr << argv[1] << ": " << error << '\n';
return 1;
}
bool valid = true;
const float samples[] = {
0.0f,
static_cast<float>(stage.header->durationMs) * 0.5f,
static_cast<float>(stage.header->durationMs),
};
for (float timeMs : samples) {
const auto position = openroller::psp::trackPositionAt(stage, timeMs);
const auto camera = openroller::psp::evaluateCamera(stage, timeMs);
valid = valid && finite(position) && finite(camera.eye) && finite(camera.target) && finite(camera.up) &&
std::isfinite(camera.projectionBlend);
std::cout << "t=" << timeMs
<< " track=(" << position.x << ',' << position.y << ',' << position.z << ')'
<< " eye=(" << camera.eye.x << ',' << camera.eye.y << ',' << camera.eye.z << ')'
<< " target=(" << camera.target.x << ',' << camera.target.y << ',' << camera.target.z << ')'
<< " projection=" << camera.projectionBlend << '\n';
}
std::cout << "bytes=" << stage.storageSize
<< " track=" << stage.header->track.count
<< " notes=" << stage.header->notes.count
<< " cameras=" << stage.header->cameras.count
<< " bg_models=" << stage.header->backgroundModels.count
<< " bg_objects=" << stage.header->backgroundObjects.count
<< " bg_vertices=" << stage.header->backgroundVertices.count
<< " duration_ms=" << stage.header->durationMs << '\n';
openroller::psp::GameplayState gameplay{};
if (!openroller::psp::initializeGameplay(stage, &gameplay)) {
std::cerr << "could not initialize PSP gameplay state\n";
valid = false;
} else {
std::uint32_t tapIndex = 0;
while (tapIndex < stage.header->notes.count &&
stage.notes[tapIndex].effectiveType != 1 &&
stage.notes[tapIndex].effectiveType != 2) {
++tapIndex;
}
if (tapIndex == stage.header->notes.count) {
std::cerr << "stage has no single-tap note for gameplay probe\n";
valid = false;
tapIndex = 0;
}
const float firstNoteMs = static_cast<float>(stage.notes[tapIndex].timeMs);
const auto judgment = openroller::psp::pressGameplay(
stage, &gameplay, firstNoteMs, 1u);
valid = valid && judgment == openroller::psp::Judgment::Great && gameplay.combo == 1;
std::cout << "tap_at=" << firstNoteMs
<< " judgment=" << static_cast<int>(judgment)
<< " combo=" << gameplay.combo << '\n';
openroller::psp::seekGameplay(stage, &gameplay, 0.0f);
openroller::psp::updateGameplay(
stage, &gameplay,
firstNoteMs + stage.notes[tapIndex].lateTimingMs + 1.0f);
valid = valid && gameplay.missCount > 0;
std::cout << "misses_after_window=" << gameplay.missCount << '\n';
openroller::psp::destroyGameplay(&gameplay);
}
openroller::psp::unloadStagePackage(&stage);
return valid ? 0 : 1;
}
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/env sh
set -eu
repo_dir=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
build_timestamp=${OPENROLLER_BUILD_TIMESTAMP:-$(date +%Y%m%d-%H%M%S)}
docker run --rm \
--user "$(id -u):$(id -g)" \
--volume "$repo_dir:/src" \
--workdir /src/psp \
pspdev/pspdev:latest \
make -B -f GNUmakefile BUILD_TIMESTAMP="$build_timestamp" "$@"
+176
View File
@@ -0,0 +1,176 @@
#!/usr/bin/env python3
"""
Convert all .dds files under a directory to .png into a single output folder.
We intentionally use ffmpeg for decoding DDS, since ImageMagick often lacks a DDS delegate.
Example:
python tools/dds_to_png.py GC/data /tmp/gc_dds_png --jobs 8
"""
from __future__ import annotations
import argparse
import hashlib
import os
import shutil
import subprocess
import sys
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable, List, Tuple
@dataclass(frozen=True)
class Task:
src: Path
dst: Path
def _iter_dds(root: Path) -> Iterable[Path]:
for dirpath, _, filenames in os.walk(root):
for fn in filenames:
if fn.lower().endswith(".dds"):
yield Path(dirpath) / fn
def _safe_flat_name(root: Path, p: Path) -> str:
rel = p.relative_to(root).as_posix()
# Flatten path to a filename. Keep ASCII-ish and avoid huge names.
flat = rel.replace("/", "__").replace("\\", "__")
if len(flat) > 180:
h = hashlib.sha1(rel.encode("utf-8")).hexdigest()[:10]
base = Path(flat).stem[:120]
flat = f"{base}__{h}.dds"
return Path(flat).with_suffix(".png").name
def _run_ffmpeg(src: Path, dst: Path) -> Tuple[bool, str]:
# -frames:v 1 ensures we only keep the first image if ffmpeg treats it as a sequence.
cmd = [
"ffmpeg",
"-y",
"-hide_banner",
"-loglevel",
"error",
"-i",
str(src),
"-frames:v",
"1",
str(dst),
]
try:
p = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
except FileNotFoundError:
return False, "ffmpeg not found"
if p.returncode != 0:
msg = (p.stderr or p.stdout or "").strip()
if not msg:
msg = f"ffmpeg failed with code {p.returncode}"
return False, msg
return True, ""
def _convert_one(t: Task, overwrite: bool) -> Tuple[bool, str]:
if t.dst.exists():
if not overwrite:
return True, "skip"
try:
t.dst.unlink()
except Exception as e:
return False, f"unlink failed: {e}"
t.dst.parent.mkdir(parents=True, exist_ok=True)
ok, err = _run_ffmpeg(t.src, t.dst)
if not ok:
return False, err
return True, ""
def main(argv: List[str]) -> int:
ap = argparse.ArgumentParser()
ap.add_argument("input_dir", help="Root directory to scan for .dds (e.g. GC/data)")
ap.add_argument("output_dir", help="Output directory for flat .png files")
ap.add_argument("--jobs", type=int, default=8, help="Parallel jobs (default: 8)")
ap.add_argument("--overwrite", action="store_true", help="Overwrite existing .png")
ap.add_argument("--max", type=int, default=0, help="Only convert first N files (0 = all)")
ap.add_argument("--list", action="store_true", help="List discovered files and exit")
args = ap.parse_args(argv)
root = Path(args.input_dir).resolve()
out = Path(args.output_dir).resolve()
if not root.exists():
print(f"input_dir does not exist: {root}", file=sys.stderr)
return 2
if shutil.which("ffmpeg") is None:
print("ffmpeg not found in PATH", file=sys.stderr)
return 2
sources = sorted(_iter_dds(root))
if args.max and args.max > 0:
sources = sources[: args.max]
if args.list:
for p in sources:
print(p)
return 0
tasks: List[Task] = []
used = set()
collisions = 0
for p in sources:
name = _safe_flat_name(root, p)
if name in used:
collisions += 1
h = hashlib.sha1(str(p).encode("utf-8")).hexdigest()[:10]
name = Path(name).with_suffix("").name + f"__{h}.png"
used.add(name)
tasks.append(Task(src=p, dst=out / name))
print(f"Found {len(tasks)} DDS files under {root}")
if collisions:
print(f"Name collisions: {collisions} (resolved with hashes)")
print(f"Output dir: {out}")
ok = 0
fail = 0
skipped = 0
errors: List[Tuple[Path, str]] = []
jobs = max(1, int(args.jobs))
with ThreadPoolExecutor(max_workers=jobs) as ex:
futs = {ex.submit(_convert_one, t, args.overwrite): t for t in tasks}
done = 0
for f in as_completed(futs):
t = futs[f]
done += 1
try:
success, msg = f.result()
except Exception as e:
success, msg = False, f"exception: {e}"
if success:
if msg == "skip":
skipped += 1
else:
ok += 1
else:
fail += 1
errors.append((t.src, msg))
if done % 200 == 0 or done == len(tasks):
print(f"Progress: {done}/{len(tasks)} ok={ok} skip={skipped} fail={fail}")
if errors:
log = out / "_errors.txt"
out.mkdir(parents=True, exist_ok=True)
with log.open("w", encoding="utf-8") as fp:
for src, msg in errors[:2000]:
fp.write(f"{src}\t{msg}\n")
print(f"Failures: {fail} (see {log})")
print(f"Done: ok={ok} skip={skipped} fail={fail}")
return 0 if fail == 0 else 1
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
+216
View File
@@ -0,0 +1,216 @@
typedef unsigned int u32;
typedef unsigned char u8;
typedef int i32;
__declspec(dllimport) void *__stdcall CreateFileA(const char *name, u32 access, u32 share,
void *security, u32 creation,
u32 flags, void *template_file);
__declspec(dllimport) u32 __stdcall SetFilePointer(void *file, i32 distance,
i32 *distance_high, u32 method);
__declspec(dllimport) int __stdcall WriteFile(void *file, const void *buffer, u32 bytes,
u32 *written, void *overlapped);
__declspec(dllimport) int __stdcall CloseHandle(void *object);
#define GENERIC_WRITE 0x40000000u
#define FILE_SHARE_READ_WRITE 0x00000003u
#define OPEN_ALWAYS 4u
#define FILE_ATTRIBUTE_NORMAL 0x00000080u
#define FILE_END 2u
#define INVALID_HANDLE_VALUE ((void *)-1)
#define FT_OK 0u
#define FT_INVALID_HANDLE 1u
#define FT_LIST_NUMBER_ONLY 0x80000000u
#define FT_LIST_BY_INDEX 0x40000000u
static void *g_handle = (void *)0x46544449u; /* "FTDI" */
static u32 g_log_count;
static char *append_char(char *p, char c)
{
*p++ = c;
return p;
}
static char *append_text(char *p, const char *s)
{
while (*s) {
*p++ = *s++;
}
return p;
}
static char *append_hex(char *p, u32 value)
{
static const char digits[] = "0123456789abcdef";
int i;
p = append_text(p, "0x");
for (i = 7; i >= 0; --i) {
*p++ = digits[(value >> (i * 4)) & 0xf];
}
return p;
}
static void log4(const char *name, u32 a, u32 b, u32 c, u32 d)
{
char line[192];
char *p;
u32 written;
void *file;
if (g_log_count++ > 20000) {
return;
}
p = line;
p = append_text(p, name);
p = append_char(p, '(');
p = append_hex(p, a);
p = append_text(p, ", ");
p = append_hex(p, b);
p = append_text(p, ", ");
p = append_hex(p, c);
p = append_text(p, ", ");
p = append_hex(p, d);
p = append_text(p, ")\r\n");
file = CreateFileA("Z:\\tmp\\ftd2xx_shim.log", GENERIC_WRITE, FILE_SHARE_READ_WRITE,
0, OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, 0);
if (file == INVALID_HANDLE_VALUE) {
return;
}
SetFilePointer(file, 0, 0, FILE_END);
WriteFile(file, line, (u32)(p - line), &written, 0);
CloseHandle(file);
}
static void copy_text(char *dst, const char *src, u32 max)
{
u32 i;
if (!dst || !max) {
return;
}
for (i = 0; i + 1 < max && src[i]; ++i) {
dst[i] = src[i];
}
dst[i] = 0;
}
int __attribute__((stdcall)) DllMain(void *module, unsigned long reason, void *reserved)
{
(void)module;
(void)reason;
(void)reserved;
return 1;
}
u32 __attribute__((stdcall)) FT_Open(i32 device_number, void **handle_out)
{
log4("FT_Open", (u32)device_number, (u32)handle_out, 0, 0);
if (handle_out) {
*handle_out = g_handle;
}
return FT_OK;
}
u32 __attribute__((stdcall)) FT_OpenEx(void *arg, u32 flags, void **handle_out)
{
log4("FT_OpenEx", (u32)arg, flags, (u32)handle_out, 0);
if (handle_out) {
*handle_out = g_handle;
}
return FT_OK;
}
u32 __attribute__((stdcall)) FT_Close(void *handle)
{
log4("FT_Close", (u32)handle, 0, 0, 0);
return handle ? FT_OK : FT_INVALID_HANDLE;
}
u32 __attribute__((stdcall)) FT_Read(void *handle, void *buffer, u32 bytes, u32 *read_out)
{
u32 i;
log4("FT_Read", (u32)handle, (u32)buffer, bytes, (u32)read_out);
if (buffer) {
for (i = 0; i < bytes; ++i) {
((u8 *)buffer)[i] = 0;
}
}
if (read_out) {
*read_out = 0;
}
return FT_OK;
}
u32 __attribute__((stdcall)) FT_Write(void *handle, const void *buffer, u32 bytes, u32 *written_out)
{
log4("FT_Write", (u32)handle, (u32)buffer, bytes, (u32)written_out);
if (written_out) {
*written_out = bytes;
}
return FT_OK;
}
u32 __attribute__((stdcall)) FT_ListDevices(void *arg1, void *arg2, u32 flags)
{
log4("FT_ListDevices", (u32)arg1, (u32)arg2, flags, 0);
if (flags & FT_LIST_NUMBER_ONLY) {
if (arg1) {
*(u32 *)arg1 = 1;
}
} else if ((flags & FT_LIST_BY_INDEX) && arg2) {
copy_text((char *)arg2, "FTD2XX-GC-RFID", 32);
} else if (arg1) {
*(u32 *)arg1 = 1;
}
return FT_OK;
}
u32 __attribute__((stdcall)) FT_GetStatus(void *handle, u32 *rx_bytes, u32 *tx_bytes, u32 *event_status)
{
log4("FT_GetStatus", (u32)handle, (u32)rx_bytes, (u32)tx_bytes, (u32)event_status);
if (rx_bytes) {
*rx_bytes = 0;
}
if (tx_bytes) {
*tx_bytes = 0;
}
if (event_status) {
*event_status = 0;
}
return FT_OK;
}
u32 __attribute__((stdcall)) FT_W32_CreateFile(const char *name, u32 access, u32 share,
void *security, u32 creation,
u32 flags, void *template_file)
{
log4("FT_W32_CreateFile", (u32)name, access, share, creation);
(void)security;
(void)flags;
(void)template_file;
return (u32)g_handle;
}
u32 __attribute__((stdcall)) FT_EE_Read(void *handle, void *data)
{
log4("FT_EE_Read", (u32)handle, (u32)data, 0, 0);
return FT_OK;
}
u32 __attribute__((stdcall)) FT_EE_Program(void *handle, void *data)
{
log4("FT_EE_Program", (u32)handle, (u32)data, 0, 0);
return FT_OK;
}
u32 __attribute__((stdcall)) FT_ResetDevice(void *handle) { log4("FT_ResetDevice", (u32)handle, 0, 0, 0); return FT_OK; }
u32 __attribute__((stdcall)) FT_SetBaudRate(void *handle, u32 baud) { log4("FT_SetBaudRate", (u32)handle, baud, 0, 0); return FT_OK; }
u32 __attribute__((stdcall)) FT_SetDataCharacteristics(void *handle, u8 word_length, u8 stop_bits, u8 parity) { log4("FT_SetDataCharacteristics", (u32)handle, word_length, stop_bits, parity); return FT_OK; }
u32 __attribute__((stdcall)) FT_Purge(void *handle, u32 mask) { log4("FT_Purge", (u32)handle, mask, 0, 0); return FT_OK; }
u32 __attribute__((stdcall)) FT_SetTimeouts(void *handle, u32 read_ms, u32 write_ms) { log4("FT_SetTimeouts", (u32)handle, read_ms, write_ms, 0); return FT_OK; }
u32 __attribute__((stdcall)) FT_SetEventNotification(void *handle, u32 mask, void *event) { log4("FT_SetEventNotification", (u32)handle, mask, (u32)event, 0); return FT_OK; }
u32 __attribute__((stdcall)) FT_SetLatencyTimer(void *handle, u8 timer) { log4("FT_SetLatencyTimer", (u32)handle, timer, 0, 0); return FT_OK; }
u32 __attribute__((stdcall)) FT_SetUSBParameters(void *handle, u32 in_size, u32 out_size) { log4("FT_SetUSBParameters", (u32)handle, in_size, out_size, 0); return FT_OK; }
u32 __attribute__((stdcall)) FT_CyclePort(void *handle) { log4("FT_CyclePort", (u32)handle, 0, 0, 0); return FT_OK; }
+21
View File
@@ -0,0 +1,21 @@
LIBRARY FTD2XX.dll
EXPORTS
FT_Open @1
FT_Close @2
FT_Read @3
FT_Write @4
FT_ResetDevice @6
FT_SetBaudRate @7
FT_SetDataCharacteristics @8
FT_Purge @16
FT_SetTimeouts @17
FT_SetEventNotification @19
FT_GetStatus @21
FT_OpenEx @27
FT_ListDevices @28
FT_SetLatencyTimer @31
FT_SetUSBParameters @33
FT_EE_Program @37
FT_EE_Read @38
FT_W32_CreateFile @43
FT_CyclePort @69
+7
View File
@@ -0,0 +1,7 @@
Windows Registry Editor Version 5.00
[HKEY_LOCAL_MACHINE\SOFTWARE\taito\typex]
"Country"=dword:00000002
[HKEY_LOCAL_MACHINE\SOFTWARE\Wow6432Node\taito\typex]
"Country"=dword:00000002
@@ -0,0 +1,37 @@
// GhidraScript: find decompiled functions containing text, optionally limiting
// qualified function names to a second substring.
// Usage: DecompileAllSearch.java 0x6458 GameScene
import ghidra.app.decompiler.DecompInterface;
import ghidra.app.decompiler.DecompileResults;
import ghidra.app.script.GhidraScript;
import ghidra.program.model.listing.Function;
import ghidra.program.model.listing.FunctionIterator;
public class DecompileAllSearch extends GhidraScript {
@Override
public void run() throws Exception {
String[] args = getScriptArgs();
if (args == null || args.length == 0) {
printerr("DecompileAllSearch: needs text and optional function-name substring");
return;
}
String needle = args[0];
String nameNeedle = args.length > 1 ? args[1] : "";
DecompInterface decompiler = new DecompInterface();
decompiler.openProgram(currentProgram);
FunctionIterator functions = currentProgram.getFunctionManager().getFunctions(true);
while (functions.hasNext() && !monitor.isCancelled()) {
Function function = functions.next();
if (!nameNeedle.isEmpty() && !function.getName(true).contains(nameNeedle)) continue;
DecompileResults result = decompiler.decompileFunction(function, 20, monitor);
if (!result.decompileCompleted() || result.getDecompiledFunction() == null) continue;
String code = result.getDecompiledFunction().getC();
if (code == null || !code.contains(needle)) continue;
println(function.getEntryPoint() + " " + function.getName(true));
for (String line : code.split("\\R")) {
if (line.contains(needle)) println(" " + line.trim());
}
}
}
}
+65
View File
@@ -0,0 +1,65 @@
// GhidraScript: DecompileByAddr.java
// Usage (headless):
// analyzeHeadless <projDir> <projName> -process <progName> -noanalysis -readOnly \
// -scriptPath <path> -postScript DecompileByAddr.java 0x401000 0x...
//
// Prints decompiled C for the function at (or containing) each address.
import ghidra.app.decompiler.DecompInterface;
import ghidra.app.decompiler.DecompileResults;
import ghidra.app.script.GhidraScript;
import ghidra.program.model.address.Address;
import ghidra.program.model.listing.Function;
public class DecompileByAddr extends GhidraScript {
@Override
public void run() throws Exception {
String[] args = getScriptArgs();
if (args == null || args.length == 0) {
printerr("DecompileByAddr: needs one or more addresses, e.g. 0x63ea70");
return;
}
DecompInterface decomp = new DecompInterface();
decomp.openProgram(currentProgram);
for (String a : args) {
long va;
try {
va = Long.decode(a);
} catch (Exception e) {
printerr("bad address: " + a);
continue;
}
Address addr = toAddr(va);
if (addr == null) {
printerr("addr not in program: " + a);
continue;
}
Function f = getFunctionContaining(addr);
if (f == null) f = getFunctionAt(addr);
println("================================================================================");
println("addr: " + addr);
if (f == null) {
println("(no function found)");
continue;
}
println("function: " + f.getName() + " @ " + f.getEntryPoint());
DecompileResults res = decomp.decompileFunction(f, 60, monitor);
if (!res.decompileCompleted()) {
println("(decompile failed)");
continue;
}
String c = res.getDecompiledFunction().getC();
// Keep output reasonable in headless logs; truncate if huge.
if (c != null && c.length() > 120000) {
c = c.substring(0, 120000) + "\n/* ... truncated ... */\n";
}
println(c);
}
}
}
@@ -0,0 +1,69 @@
// GhidraScript: decompile every caller of a function and print call-site context.
// Usage: DecompileCallContexts.java 0x0063c0f0 [context-lines]
import ghidra.app.decompiler.DecompInterface;
import ghidra.app.decompiler.DecompileResults;
import ghidra.app.script.GhidraScript;
import ghidra.program.model.address.Address;
import ghidra.program.model.listing.Function;
import ghidra.program.model.symbol.Reference;
import ghidra.program.model.symbol.ReferenceIterator;
import java.util.LinkedHashMap;
import java.util.Map;
public class DecompileCallContexts extends GhidraScript {
@Override
public void run() throws Exception {
String[] args = getScriptArgs();
if (args == null || args.length == 0) {
printerr("DecompileCallContexts: needs a function address");
return;
}
Address target = toAddr(Long.decode(args[0]));
Function targetFunction = getFunctionAt(target);
if (targetFunction == null) {
printerr("target is not a function");
return;
}
int context = args.length > 1 ? Integer.decode(args[1]) : 5;
Map<Address, Function> callers = new LinkedHashMap<>();
ReferenceIterator references =
currentProgram.getReferenceManager().getReferencesTo(target);
while (references.hasNext()) {
Reference reference = references.next();
Function caller = getFunctionContaining(reference.getFromAddress());
if (caller != null) callers.put(caller.getEntryPoint(), caller);
}
DecompInterface decompiler = new DecompInterface();
decompiler.openProgram(currentProgram);
String needle = targetFunction.getName();
for (Function caller : callers.values()) {
if (monitor.isCancelled()) break;
DecompileResults result = decompiler.decompileFunction(caller, 90, monitor);
if (!result.decompileCompleted() || result.getDecompiledFunction() == null) continue;
String[] lines = result.getDecompiledFunction().getC().split("\\R");
boolean[] selected = new boolean[lines.length];
for (int i = 0; i < lines.length; ++i) {
if (!lines[i].contains(needle)) continue;
for (int j = Math.max(0, i - context);
j <= Math.min(lines.length - 1, i + context); ++j) {
selected[j] = true;
}
}
println("================================================================================");
println(caller.getEntryPoint() + " " + caller.getName(true));
boolean gap = false;
for (int i = 0; i < lines.length; ++i) {
if (selected[i]) {
if (gap) println("...");
println(String.format("%5d %s", i + 1, lines[i]));
gap = false;
} else if (i > 0 && selected[i - 1]) {
gap = true;
}
}
}
}
}
+50
View File
@@ -0,0 +1,50 @@
// GhidraScript: decompile one function and print matching lines with context.
// Usage: DecompileSearch.java 0x005ed4c0 0x104 8
import ghidra.app.decompiler.DecompInterface;
import ghidra.app.decompiler.DecompileResults;
import ghidra.app.script.GhidraScript;
import ghidra.program.model.listing.Function;
public class DecompileSearch extends GhidraScript {
@Override
public void run() throws Exception {
String[] args = getScriptArgs();
if (args == null || args.length < 2) {
printerr("DecompileSearch: needs address, text, and optional context-line count");
return;
}
Function function = getFunctionContaining(toAddr(Long.decode(args[0])));
if (function == null) {
printerr("function not found");
return;
}
int context = args.length > 2 ? Integer.decode(args[2]) : 5;
DecompInterface decomp = new DecompInterface();
decomp.openProgram(currentProgram);
DecompileResults result = decomp.decompileFunction(function, 120, monitor);
if (!result.decompileCompleted()) {
printerr("decompile failed");
return;
}
String[] lines = result.getDecompiledFunction().getC().split("\\R");
boolean[] selected = new boolean[lines.length];
for (int i = 0; i < lines.length; ++i) {
if (!lines[i].contains(args[1])) continue;
for (int j = Math.max(0, i - context); j <= Math.min(lines.length - 1, i + context); ++j) {
selected[j] = true;
}
}
println("function: " + function.getName() + " @ " + function.getEntryPoint());
boolean gap = false;
for (int i = 0; i < lines.length; ++i) {
if (selected[i]) {
if (gap) println("...");
println(String.format("%5d %s", i + 1, lines[i]));
gap = false;
} else if (i > 0 && selected[i - 1]) {
gap = true;
}
}
}
}
+24
View File
@@ -0,0 +1,24 @@
// GhidraScript: print little-endian dwords as hex, signed integers, and floats.
// Usage: DumpData.java 0x006fcbf0 32
import ghidra.app.script.GhidraScript;
import ghidra.program.model.address.Address;
public class DumpData extends GhidraScript {
@Override
public void run() throws Exception {
String[] args = getScriptArgs();
if (args == null || args.length != 2) {
printerr("DumpData: needs address and dword count");
return;
}
Address base = toAddr(Long.decode(args[0]));
int count = Integer.decode(args[1]);
for (int i = 0; i < count; ++i) {
Address address = base.add(i * 4L);
int value = getInt(address);
println(address + " 0x" + String.format("%08x", value) +
" int=" + value + " float=" + Float.intBitsToFloat(value));
}
}
}
@@ -0,0 +1,38 @@
// GhidraScript: print instructions around one or more addresses.
// Usage: DumpInstructions.java 0x001b184c 0x001b1870
import ghidra.app.script.GhidraScript;
import ghidra.program.model.listing.Function;
import ghidra.program.model.listing.Instruction;
public class DumpInstructions extends GhidraScript {
@Override
public void run() throws Exception {
String[] args = getScriptArgs();
if (args == null || args.length == 0) {
printerr("DumpInstructions: needs one or more addresses");
return;
}
for (String arg : args) {
Instruction center = getInstructionContaining(toAddr(Long.decode(arg)));
println("=== " + arg + " ===");
if (center == null) {
println("(no instruction)");
continue;
}
Instruction cursor = center;
for (int i = 0; i < 18; ++i) {
Instruction previous = cursor.getPrevious();
if (previous == null) break;
cursor = previous;
}
for (int i = 0; i < 40 && cursor != null; ++i) {
Function function = getFunctionContaining(cursor.getAddress());
String marker = cursor.getAddress().equals(center.getAddress()) ? "=>" : " ";
println(marker + " " + cursor.getAddress() + " " +
(function == null ? "" : function.getName() + " ") + cursor);
cursor = cursor.getNext();
}
}
}
}
@@ -0,0 +1,42 @@
// GhidraScript: dump an array of 32-bit pointers as ASCII strings.
// Usage: DumpPointerStrings.java <address> [count]
import ghidra.app.script.GhidraScript;
import ghidra.program.model.address.Address;
import ghidra.program.model.mem.MemoryAccessException;
public class DumpPointerStrings extends GhidraScript {
private String readAscii(Address at) throws MemoryAccessException {
StringBuilder out = new StringBuilder();
for (int i = 0; i < 256; ++i) {
int value = getByte(at.add(i)) & 0xff;
if (value == 0) break;
if (value < 0x20 || value > 0x7e) return "<non-ascii>";
out.append((char)value);
}
return out.toString();
}
@Override
public void run() throws Exception {
String[] args = getScriptArgs();
if (args == null || args.length == 0) {
printerr("DumpPointerStrings: needs address and optional count");
return;
}
Address start = toAddr(Long.decode(args[0]));
int count = args.length > 1 ? Integer.decode(args[1]) : 32;
for (int i = 0; i < count; ++i) {
Address slot = start.add(i * 4L);
long raw = getInt(slot) & 0xffffffffL;
Address target = toAddr(raw);
String value;
try {
value = currentProgram.getMemory().contains(target) ? readAscii(target) : "<outside>";
} catch (Exception exc) {
value = "<invalid>";
}
println(String.format("%4d %s -> %08x %s", i, slot, raw, value));
}
}
}
+30
View File
@@ -0,0 +1,30 @@
// GhidraScript: DumpPointers.java
// Usage:
// analyzeHeadless <projDir> <projName> -process <progName> -noanalysis -readOnly \
// -scriptPath tools/ghidra_scripts -postScript DumpPointers.java 0x006f8990 16
import ghidra.app.script.GhidraScript;
import ghidra.program.model.address.Address;
import ghidra.program.model.listing.Function;
public class DumpPointers extends GhidraScript {
@Override
public void run() throws Exception {
String[] args = getScriptArgs();
if (args == null || args.length < 1) {
printerr("DumpPointers: needs address and optional count");
return;
}
Address at = toAddr(Long.decode(args[0]));
int count = args.length >= 2 ? Integer.decode(args[1]) : 32;
for (int i = 0; i < count; i++) {
Address slot = at.add(i * 4L);
long ptr = getInt(slot) & 0xffffffffL;
Address dst = toAddr(ptr);
Function f = getFunctionAt(dst);
if (f == null) f = getFunctionContaining(dst);
String name = f == null ? "" : f.getName() + " @ " + f.getEntryPoint();
println(String.format("%s +0x%02x -> %08x %s", slot, i * 4, ptr, name));
}
}
}
+26
View File
@@ -0,0 +1,26 @@
// GhidraScript: dump 32-bit words as hex, signed integer, and IEEE-754 float.
// Usage: DumpScalars.java <address> [count]
import ghidra.app.script.GhidraScript;
import ghidra.program.model.address.Address;
public class DumpScalars extends GhidraScript {
@Override
public void run() throws Exception {
String[] args = getScriptArgs();
if (args == null || args.length == 0) {
printerr("DumpScalars: needs address and optional count");
return;
}
Address start = toAddr(Long.decode(args[0]));
int count = args.length > 1 ? Integer.decode(args[1]) : 1;
for (int i = 0; i < count; ++i) {
Address slot = start.add(i * 4L);
int raw = getInt(slot);
println(String.format(
"%s hex=%08x int=%d float=%.9g",
slot, raw, raw, Float.intBitsToFloat(raw)));
}
}
}
+28
View File
@@ -0,0 +1,28 @@
// GhidraScript: FindAddressRefs.java
// Usage: ... -postScript FindAddressRefs.java 0x401000 [...]
import ghidra.app.script.GhidraScript;
import ghidra.program.model.address.Address;
import ghidra.program.model.listing.Function;
import ghidra.program.model.symbol.Reference;
public class FindAddressRefs extends GhidraScript {
@Override
public void run() throws Exception {
for (String arg : getScriptArgs()) {
Address target = toAddr(Long.decode(arg));
println("================================================================================");
println("target: " + target);
Reference[] refs = getReferencesTo(target);
println("refs: " + refs.length);
for (Reference ref : refs) {
Address from = ref.getFromAddress();
Function function = getFunctionContaining(from);
println(" " + from + " -> " +
(function == null ? "(no function)" :
function.getName() + " @ " + function.getEntryPoint()) +
" type=" + ref.getReferenceType());
}
}
}
}
@@ -0,0 +1,87 @@
// GhidraScript: FindD3DSetTransformSites.java
// Usage:
// analyzeHeadless <projDir> <projName> -process <progName> -noanalysis -readOnly \
// -scriptPath tools/ghidra_scripts -postScript FindD3DSetTransformSites.java [minAddr] [maxAddr]
import ghidra.app.script.GhidraScript;
import ghidra.program.model.address.Address;
import ghidra.program.model.listing.Function;
import ghidra.program.model.listing.Instruction;
import ghidra.program.model.scalar.Scalar;
public class FindD3DSetTransformSites extends GhidraScript {
private boolean hasScalar(Instruction ins, long want) {
for (int op = 0; op < ins.getNumOperands(); op++) {
for (Object obj : ins.getOpObjects(op)) {
if (obj instanceof Scalar) {
Scalar s = (Scalar)obj;
if (s.getUnsignedValue() == want || s.getSignedValue() == want) return true;
}
}
}
return false;
}
private boolean inRange(Address addr, Address min, Address max) {
if (min != null && addr.compareTo(min) < 0) return false;
if (max != null && addr.compareTo(max) > 0) return false;
return true;
}
private boolean followedByCall(Instruction ins, int maxSteps) {
Instruction cur = ins;
for (int i = 0; i < maxSteps; i++) {
cur = cur.getNext();
if (cur == null) return false;
if ("CALL".equals(cur.getMnemonicString())) return true;
}
return false;
}
private boolean nearbyPushState(Instruction ins) {
Instruction cur = ins;
for (int i = 0; i < 10; i++) {
cur = cur.getPrevious();
if (cur == null) break;
if (!"PUSH".equals(cur.getMnemonicString())) continue;
if (hasScalar(cur, 2) || hasScalar(cur, 3)) return true;
}
return false;
}
@Override
public void run() throws Exception {
String[] args = getScriptArgs();
Address min = args.length > 0 ? toAddr(Long.decode(args[0])) : null;
Address max = args.length > 1 ? toAddr(Long.decode(args[1])) : null;
println("=== D3D SetTransform VIEW/PROJ candidates ===");
int hits = 0;
for (Instruction ins : currentProgram.getListing().getInstructions(true)) {
if (!inRange(ins.getAddress(), min, max)) continue;
if (!hasScalar(ins, 0xb0)) continue;
if (!followedByCall(ins, 3)) continue;
if (!nearbyPushState(ins)) continue;
hits++;
Function f = getFunctionContaining(ins.getAddress());
String fs = f == null ? "(no func)" : f.getName() + " @ " + f.getEntryPoint();
println("");
println("HIT " + hits + " " + ins.getAddress() + " " + fs);
Instruction cur = ins;
for (int i = 0; i < 10; i++) {
Instruction prev = cur.getPrevious();
if (prev == null) break;
cur = prev;
}
for (int i = 0; i < 18 && cur != null; i++) {
String mark = cur.getAddress().equals(ins.getAddress()) ? "=>" : " ";
println(mark + " " + cur.getAddress() + " " + cur);
cur = cur.getNext();
}
if (monitor.isCancelled()) break;
}
println("hits: " + hits);
}
}
@@ -0,0 +1,63 @@
// GhidraScript: FindFunctionCallers.java
// Usage (headless):
// analyzeHeadless <projDir> <projName> -process <progName> -noanalysis -readOnly \
// -scriptPath <path> -postScript FindFunctionCallers.java 0x0063ea70
//
// Prints the functions which contain callsites (or any references) to the given function entry.
import ghidra.app.script.GhidraScript;
import ghidra.program.model.address.Address;
import ghidra.program.model.listing.Function;
import ghidra.program.model.symbol.Reference;
import ghidra.program.model.symbol.ReferenceIterator;
import java.util.LinkedHashSet;
public class FindFunctionCallers extends GhidraScript {
@Override
public void run() throws Exception {
String[] args = getScriptArgs();
if (args == null || args.length == 0) {
printerr("FindFunctionCallers: needs one or more function entry addresses, e.g. 0x613710");
return;
}
for (String a : args) {
long va;
try {
va = Long.decode(a);
} catch (Exception e) {
printerr("bad address: " + a);
continue;
}
Address entry = toAddr(va);
if (entry == null) {
printerr("addr not in program: " + a);
continue;
}
println("=== callers for " + entry + " ===");
LinkedHashSet<String> callers = new LinkedHashSet<>();
ReferenceIterator it = currentProgram.getReferenceManager().getReferencesTo(entry);
int n = 0;
while (it.hasNext()) {
Reference r = it.next();
n++;
Function f = getFunctionContaining(r.getFromAddress());
if (f != null) {
callers.add(f.getName() + " @ " + f.getEntryPoint() + " (from " + r.getFromAddress() + ")");
} else {
callers.add("(no func) from " + r.getFromAddress());
}
if (n > 5000) break;
}
println("refs: " + n);
if (callers.isEmpty()) {
println("(none)");
} else {
for (String s : callers) println(" " + s);
}
println("");
}
}
}
+25
View File
@@ -0,0 +1,25 @@
// GhidraScript: list functions whose symbol name contains a substring.
// Usage: FindFunctions.java DrawMark
import ghidra.app.script.GhidraScript;
import ghidra.program.model.listing.Function;
import ghidra.program.model.listing.FunctionIterator;
public class FindFunctions extends GhidraScript {
@Override
public void run() throws Exception {
String[] args = getScriptArgs();
if (args == null || args.length == 0) {
printerr("FindFunctions: needs a case-sensitive name substring");
return;
}
String needle = args[0];
FunctionIterator functions = currentProgram.getFunctionManager().getFunctions(true);
while (functions.hasNext() && !monitor.isCancelled()) {
Function function = functions.next();
if (function.getName(true).contains(needle)) {
println(function.getEntryPoint() + " " + function.getName(true));
}
}
}
}
+64
View File
@@ -0,0 +1,64 @@
// GhidraScript: FindScalarOps.java
// Usage:
// analyzeHeadless <projDir> <projName> -process <progName> -noanalysis -readOnly \
// -scriptPath tools/ghidra_scripts -postScript FindScalarOps.java 0x3b 0x3f800000
import ghidra.app.script.GhidraScript;
import ghidra.program.model.listing.Function;
import ghidra.program.model.listing.Instruction;
import ghidra.program.model.scalar.Scalar;
import java.util.LinkedHashSet;
public class FindScalarOps extends GhidraScript {
@Override
public void run() throws Exception {
String[] args = getScriptArgs();
if (args == null || args.length == 0) {
printerr("FindScalarOps: needs one or more scalar values");
return;
}
for (String arg : args) {
long want;
try {
want = Long.decode(arg);
} catch (Exception e) {
printerr("bad scalar: " + arg);
continue;
}
println("=== scalar " + arg + " ===");
int hits = 0;
LinkedHashSet<String> funcs = new LinkedHashSet<>();
for (Instruction ins : currentProgram.getListing().getInstructions(true)) {
int n = ins.getNumOperands();
boolean matched = false;
for (int op = 0; op < n; op++) {
for (Object obj : ins.getOpObjects(op)) {
if (obj instanceof Scalar) {
Scalar s = (Scalar)obj;
long v = s.getUnsignedValue();
long sv = s.getSignedValue();
if (v == want || sv == want) {
matched = true;
break;
}
}
}
if (matched) break;
}
if (!matched) continue;
hits++;
Function f = getFunctionContaining(ins.getAddress());
String fs = f == null ? "(no func)" : f.getName() + " @ " + f.getEntryPoint();
funcs.add(fs);
if (hits <= 250) println(ins.getAddress() + " " + fs + " " + ins);
if (monitor.isCancelled()) break;
}
println("hits: " + hits);
for (String fs : funcs) println(" " + fs);
println("");
}
}
}
@@ -0,0 +1,76 @@
// GhidraScript: FindScalarWindow.java
// Usage:
// analyzeHeadless <projDir> <projName> -process <progName> -noanalysis -readOnly \
// -scriptPath tools/ghidra_scripts -postScript FindScalarWindow.java 0xb0 [minAddr] [maxAddr] [maxHits]
import ghidra.app.script.GhidraScript;
import ghidra.program.model.address.Address;
import ghidra.program.model.listing.Function;
import ghidra.program.model.listing.Instruction;
import ghidra.program.model.scalar.Scalar;
public class FindScalarWindow extends GhidraScript {
private boolean hasScalar(Instruction ins, long want) {
for (int op = 0; op < ins.getNumOperands(); op++) {
for (Object obj : ins.getOpObjects(op)) {
if (obj instanceof Scalar) {
Scalar s = (Scalar)obj;
if (s.getUnsignedValue() == want || s.getSignedValue() == want) return true;
}
}
}
return false;
}
private boolean inRange(Address addr, Address min, Address max) {
if (min != null && addr.compareTo(min) < 0) return false;
if (max != null && addr.compareTo(max) > 0) return false;
return true;
}
@Override
public void run() throws Exception {
String[] args = getScriptArgs();
if (args == null || args.length == 0) {
printerr("FindScalarWindow: needs scalar, optional minAddr maxAddr maxHits");
return;
}
long want = Long.decode(args[0]);
Address min = args.length > 1 ? toAddr(Long.decode(args[1])) : null;
Address max = args.length > 2 ? toAddr(Long.decode(args[2])) : null;
int maxHits = args.length > 3 ? Integer.decode(args[3]) : 120;
println("=== scalar window " + args[0] + " ===");
int hits = 0;
for (Instruction ins : currentProgram.getListing().getInstructions(true)) {
if (!inRange(ins.getAddress(), min, max)) continue;
if (!hasScalar(ins, want)) continue;
hits++;
Function f = getFunctionContaining(ins.getAddress());
String fs = f == null ? "(no func)" : f.getName() + " @ " + f.getEntryPoint();
println("");
println("HIT " + hits + " " + ins.getAddress() + " " + fs);
Instruction cur = ins;
for (int i = 0; i < 8; i++) {
Instruction prev = cur.getPrevious();
if (prev == null) break;
cur = prev;
}
for (int i = 0; i < 17 && cur != null; i++) {
String mark = cur.getAddress().equals(ins.getAddress()) ? "=>" : " ";
println(mark + " " + cur.getAddress() + " " + cur);
cur = cur.getNext();
}
if (hits >= maxHits) {
println("(truncated at " + maxHits + " hits)");
break;
}
if (monitor.isCancelled()) break;
}
println("hits: " + hits);
}
}
+96
View File
@@ -0,0 +1,96 @@
// GhidraScript: FindStringXrefs.java
// Usage (headless):
// analyzeHeadless <projDir> <projName> -process <progName> \
// -scriptPath <path> -postScript FindStringXrefs.java "<needle1>" ["<needle2>" ...]
//
// Prints the addresses where the ASCII needle is found (optionally with NUL terminator)
// and lists the functions that reference the string address.
import ghidra.app.script.GhidraScript;
import ghidra.program.model.address.Address;
import ghidra.program.model.listing.Function;
import ghidra.program.model.mem.Memory;
import ghidra.program.model.symbol.Reference;
import ghidra.program.model.symbol.ReferenceIterator;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashSet;
public class FindStringXrefs extends GhidraScript {
@Override
public void run() throws Exception {
String[] args = getScriptArgs();
if (args == null || args.length == 0) {
printerr("FindStringXrefs: needs at least one ASCII needle arg");
return;
}
Memory mem = currentProgram.getMemory();
Address min = mem.getMinAddress();
Address max = mem.getMaxAddress();
for (String needle : args) {
if (needle == null) continue;
if (needle.isEmpty()) continue;
println("=== needle: " + needle + " ===");
byte[] pat0 = needle.getBytes(StandardCharsets.US_ASCII);
byte[] pat1 = new byte[pat0.length + 1];
System.arraycopy(pat0, 0, pat1, 0, pat0.length);
pat1[pat1.length - 1] = 0;
// First try NUL-terminated.
int hits = 0;
Address at = min;
while (true) {
Address found = mem.findBytes(at, max, pat1, null, true, monitor);
if (found == null) break;
hits++;
dumpHit(found);
at = found.add(1);
}
// If no NUL-terminated matches, fall back to raw bytes search.
if (hits == 0) {
at = min;
while (true) {
Address found = mem.findBytes(at, max, pat0, null, true, monitor);
if (found == null) break;
hits++;
dumpHit(found);
at = found.add(1);
}
}
if (hits == 0) {
println("(no hits)");
}
println("");
}
}
private void dumpHit(Address strAddr) {
println("hit @ " + strAddr);
LinkedHashSet<String> funcs = new LinkedHashSet<>();
ReferenceIterator it = currentProgram.getReferenceManager().getReferencesTo(strAddr);
int nref = 0;
while (it.hasNext()) {
Reference r = it.next();
nref++;
Function f = getFunctionContaining(r.getFromAddress());
if (f != null) {
funcs.add(f.getName() + " @ " + f.getEntryPoint());
} else {
funcs.add("(no func) from " + r.getFromAddress());
}
if (nref > 2000) break; // avoid pathological spam
}
println("refs: " + nref);
for (String s : funcs) {
println(" " + s);
if (funcs.size() > 200) break;
}
}
}
+45
View File
@@ -0,0 +1,45 @@
// GhidraScript: FindSymbolRefs.java
// Usage:
// analyzeHeadless <projDir> <projName> -process <progName> -noanalysis -readOnly \
// -scriptPath <path> -postScript FindSymbolRefs.java D3DXMatrixLookAtLH ...
import ghidra.app.script.GhidraScript;
import ghidra.program.model.address.Address;
import ghidra.program.model.listing.Function;
import ghidra.program.model.symbol.Reference;
import ghidra.program.model.symbol.Symbol;
import ghidra.program.model.symbol.SymbolIterator;
public class FindSymbolRefs extends GhidraScript {
@Override
public void run() throws Exception {
String[] args = getScriptArgs();
if (args == null || args.length == 0) {
printerr("FindSymbolRefs: needs symbol names");
return;
}
for (String needle : args) {
println("================================================================================");
println("symbol needle: " + needle);
boolean any = false;
SymbolIterator it = currentProgram.getSymbolTable().getAllSymbols(true);
while (it.hasNext() && !monitor.isCancelled()) {
Symbol s = it.next();
if (!s.getName(true).contains(needle)) continue;
any = true;
Address addr = s.getAddress();
println("symbol: " + s.getName(true) + " @ " + addr);
Reference[] refs = getReferencesTo(addr);
println("refs: " + refs.length);
for (Reference r : refs) {
Address from = r.getFromAddress();
Function f = getFunctionContaining(from);
String fn = f == null ? "(no function)" : f.getName() + " @ " + f.getEntryPoint();
println(" " + from + " -> " + fn + " type=" + r.getReferenceType());
}
}
if (!any) println("(no symbol hits)");
}
}
}
@@ -0,0 +1,78 @@
// GhidraScript: FindVirtualCallOffset.java
// Usage:
// analyzeHeadless <projDir> <projName> -process <progName> -noanalysis -readOnly \
// -scriptPath tools/ghidra_scripts -postScript FindVirtualCallOffset.java 0xc4 [minAddr] [maxAddr]
import ghidra.app.script.GhidraScript;
import ghidra.program.model.address.Address;
import ghidra.program.model.listing.Function;
import ghidra.program.model.listing.Instruction;
import ghidra.program.model.scalar.Scalar;
public class FindVirtualCallOffset extends GhidraScript {
private boolean hasScalar(Instruction ins, long want) {
for (int op = 0; op < ins.getNumOperands(); op++) {
for (Object obj : ins.getOpObjects(op)) {
if (obj instanceof Scalar) {
Scalar s = (Scalar)obj;
if (s.getUnsignedValue() == want || s.getSignedValue() == want) return true;
}
}
}
return false;
}
private boolean inRange(Address addr, Address min, Address max) {
if (min != null && addr.compareTo(min) < 0) return false;
if (max != null && addr.compareTo(max) > 0) return false;
return true;
}
private boolean followedByCall(Instruction ins) {
Instruction n = ins.getNext();
if (n != null && "CALL".equals(n.getMnemonicString())) return true;
n = n == null ? null : n.getNext();
return n != null && "CALL".equals(n.getMnemonicString());
}
@Override
public void run() throws Exception {
String[] args = getScriptArgs();
if (args == null || args.length == 0) {
printerr("FindVirtualCallOffset: needs vtable offset");
return;
}
long want = Long.decode(args[0]);
Address min = args.length > 1 ? toAddr(Long.decode(args[1])) : null;
Address max = args.length > 2 ? toAddr(Long.decode(args[2])) : null;
println("=== virtual call offset " + args[0] + " ===");
int hits = 0;
for (Instruction ins : currentProgram.getListing().getInstructions(true)) {
if (!inRange(ins.getAddress(), min, max)) continue;
if (!"MOV".equals(ins.getMnemonicString())) continue;
if (!hasScalar(ins, want)) continue;
if (!followedByCall(ins)) continue;
hits++;
Function f = getFunctionContaining(ins.getAddress());
String fs = f == null ? "(no func)" : f.getName() + " @ " + f.getEntryPoint();
println("");
println("HIT " + hits + " " + ins.getAddress() + " " + fs);
Instruction cur = ins;
for (int i = 0; i < 8; i++) {
Instruction prev = cur.getPrevious();
if (prev == null) break;
cur = prev;
}
for (int i = 0; i < 14 && cur != null; i++) {
String mark = cur.getAddress().equals(ins.getAddress()) ? "=>" : " ";
println(mark + " " + cur.getAddress() + " " + cur);
cur = cur.getNext();
}
if (monitor.isCancelled()) break;
}
println("hits: " + hits);
}
}
@@ -0,0 +1,62 @@
// GhidraScript: FindVtableOffsetCalls.java
// Usage:
// analyzeHeadless <projDir> <projName> -process <progName> -noanalysis -readOnly \
// -scriptPath tools/ghidra_scripts -postScript FindVtableOffsetCalls.java 0xb0
import ghidra.app.script.GhidraScript;
import ghidra.program.model.address.Address;
import ghidra.program.model.listing.Function;
import ghidra.program.model.listing.Instruction;
import ghidra.program.model.scalar.Scalar;
public class FindVtableOffsetCalls extends GhidraScript {
private boolean hasScalar(Instruction ins, long want) {
for (int op = 0; op < ins.getNumOperands(); op++) {
for (Object obj : ins.getOpObjects(op)) {
if (obj instanceof Scalar) {
Scalar s = (Scalar)obj;
if (s.getUnsignedValue() == want || s.getSignedValue() == want) return true;
}
}
}
return false;
}
@Override
public void run() throws Exception {
String[] args = getScriptArgs();
if (args == null || args.length == 0) {
printerr("FindVtableOffsetCalls: needs one or more vtable offsets, e.g. 0xb0");
return;
}
for (String arg : args) {
long want = Long.decode(arg);
println("=== vtable call offset " + arg + " ===");
int hits = 0;
for (Instruction ins : currentProgram.getListing().getInstructions(true)) {
if (!"CALL".equals(ins.getMnemonicString())) continue;
if (!hasScalar(ins, want)) continue;
hits++;
Function f = getFunctionContaining(ins.getAddress());
String fs = f == null ? "(no func)" : f.getName() + " @ " + f.getEntryPoint();
println("");
println(ins.getAddress() + " " + fs + " " + ins);
Instruction prev = ins;
for (int i = 0; i < 10; i++) {
prev = prev.getPrevious();
if (prev == null) break;
println(" " + prev.getAddress() + " " + prev);
}
if (hits >= 300) {
println("(truncated at 300 hits)");
break;
}
if (monitor.isCancelled()) break;
}
println("hits: " + hits);
}
}
}
@@ -0,0 +1,28 @@
// GhidraScript: list functions whose entry points fall inside an address range.
// Usage: ListFunctionsRange.java 0x005b6700 0x005b8600
import ghidra.app.script.GhidraScript;
import ghidra.program.model.address.Address;
import ghidra.program.model.listing.Function;
import ghidra.program.model.listing.FunctionIterator;
public class ListFunctionsRange extends GhidraScript {
@Override
public void run() throws Exception {
String[] args = getScriptArgs();
if (args == null || args.length != 2) {
printerr("ListFunctionsRange: needs minAddr maxAddr");
return;
}
Address min = toAddr(Long.decode(args[0]));
Address max = toAddr(Long.decode(args[1]));
FunctionIterator it = currentProgram.getFunctionManager().getFunctions(min, true);
while (it.hasNext() && !monitor.isCancelled()) {
Function function = it.next();
Address entry = function.getEntryPoint();
if (entry.compareTo(max) > 0) break;
println(entry + " " + function.getName() + " size=0x" +
Long.toHexString(function.getBody().getNumAddresses()));
}
}
}
+17
View File
@@ -0,0 +1,17 @@
// GhidraScript: print little-endian 32-bit words and their float view.
// Usage: ReadScalars.java 0x2703f8 0x2703fc
import ghidra.app.script.GhidraScript;
import ghidra.program.model.address.Address;
public class ReadScalars extends GhidraScript {
@Override
public void run() throws Exception {
for (String arg : getScriptArgs()) {
Address address = toAddr(Long.decode(arg));
int word = getInt(address);
println(address + " u32=0x" + Integer.toHexString(word) +
" f32=" + Float.intBitsToFloat(word));
}
}
}
+28
View File
@@ -0,0 +1,28 @@
// GhidraScript: rename functions from address/name pairs.
// Usage: RenameFunctions.java 0x00100000 FunctionName 0x00100100 OtherName
import ghidra.app.script.GhidraScript;
import ghidra.program.model.address.Address;
import ghidra.program.model.listing.Function;
import ghidra.program.model.symbol.SourceType;
public class RenameFunctions extends GhidraScript {
@Override
public void run() throws Exception {
String[] args = getScriptArgs();
if (args == null || args.length == 0 || (args.length & 1) != 0) {
printerr("RenameFunctions: needs address/name pairs");
return;
}
for (int i = 0; i < args.length; i += 2) {
Address address = toAddr(Long.decode(args[i]));
Function function = getFunctionAt(address);
if (function == null) {
printerr("no function at " + address);
continue;
}
function.setName(args[i + 1], SourceType.USER_DEFINED);
println(address + " " + function.getName());
}
}
}
+234
View File
@@ -0,0 +1,234 @@
typedef unsigned int u32;
typedef unsigned char u8;
typedef int i32;
__declspec(dllimport) void *__stdcall CreateFileA(const char *name, u32 access, u32 share,
void *security, u32 creation,
u32 flags, void *template_file);
__declspec(dllimport) u32 __stdcall SetFilePointer(void *file, i32 distance,
i32 *distance_high, u32 method);
__declspec(dllimport) int __stdcall WriteFile(void *file, const void *buffer, u32 bytes,
u32 *written, void *overlapped);
__declspec(dllimport) int __stdcall CloseHandle(void *object);
#define GENERIC_WRITE 0x40000000u
#define FILE_SHARE_READ_WRITE 0x00000003u
#define OPEN_ALWAYS 4u
#define FILE_ATTRIBUTE_NORMAL 0x00000080u
#define FILE_END 2u
#define INVALID_HANDLE_VALUE ((void *)-1)
static u32 g_handle = 0x49444d43u; /* "IDMC" */
static u32 g_status = 0;
static u32 g_regs[0x2000] = {
[0x4150 >> 2] = 0x0000825cu,
[0x4140 >> 2] = 0x80000004u,
[0x41a4 >> 2] = 0x80000005u,
};
static u8 g_buffer[0x8000];
static u32 g_log_count;
static char *append_char(char *p, char c)
{
*p++ = c;
return p;
}
static char *append_text(char *p, const char *s)
{
while (*s) {
*p++ = *s++;
}
return p;
}
static char *append_hex(char *p, u32 value)
{
static const char digits[] = "0123456789abcdef";
int i;
p = append_text(p, "0x");
for (i = 7; i >= 0; --i) {
*p++ = digits[(value >> (i * 4)) & 0xf];
}
return p;
}
static void log3(const char *name, u32 a, u32 b, u32 c)
{
char line[160];
char *p;
u32 written;
void *file;
if (g_log_count++ > 20000) {
return;
}
p = line;
p = append_text(p, name);
p = append_char(p, '(');
p = append_hex(p, a);
p = append_text(p, ", ");
p = append_hex(p, b);
p = append_text(p, ", ");
p = append_hex(p, c);
p = append_text(p, ")\r\n");
file = CreateFileA("Z:\\tmp\\idmac_shim.log", GENERIC_WRITE, FILE_SHARE_READ_WRITE,
0, OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, 0);
if (file == INVALID_HANDLE_VALUE) {
return;
}
SetFilePointer(file, 0, 0, FILE_END);
WriteFile(file, line, (u32)(p - line), &written, 0);
CloseHandle(file);
}
static u32 load_le32(const void *ptr)
{
const u8 *p = (const u8 *)ptr;
return ((u32)p[0]) | ((u32)p[1] << 8) | ((u32)p[2] << 16) | ((u32)p[3] << 24);
}
int __attribute__((stdcall)) DllMain(void *module, unsigned long reason, void *reserved)
{
(void)module;
(void)reason;
(void)reserved;
return 1;
}
int iDmacDrvOpen(u32 device, u32 *handle_out, u32 *status_out)
{
log3("Open", device, (u32)handle_out, (u32)status_out);
(void)device;
if (handle_out) {
*handle_out = g_handle;
}
if (status_out) {
*status_out = g_status;
}
return 0;
}
int iDmacDrvClose(u32 handle, u32 *status_out)
{
log3("Close", handle, (u32)status_out, 0);
(void)handle;
if (status_out) {
*status_out = g_status;
}
return 0;
}
int iDmacDrvRegisterRead(u32 handle, u32 address, u32 *value_out, u32 *status_out)
{
log3("RegisterRead", handle, address, (u32)value_out);
(void)handle;
if (status_out) {
*status_out = g_status;
}
if (!value_out) {
return 0x57;
}
switch (address) {
case 0x400:
*value_out = 0x01010313u;
break;
case 0x4000:
*value_out = 0x00ff00ffu;
break;
case 0x4004:
*value_out = 0x00ff0000u;
break;
default:
if ((address >> 2) < (sizeof(g_regs) / sizeof(g_regs[0]))) {
*value_out = g_regs[address >> 2];
} else {
*value_out = 0;
}
break;
}
log3("RegisterReadValue", address, *value_out, status_out ? *status_out : 0);
return 0;
}
int iDmacDrvRegisterWrite(u32 handle, u32 address, u32 value, u32 *status_out)
{
log3("RegisterWrite", handle, address, value);
(void)handle;
if (status_out) {
*status_out = g_status;
}
if ((address >> 2) < (sizeof(g_regs) / sizeof(g_regs[0]))) {
g_regs[address >> 2] = value;
}
return 0;
}
int iDmacDrvRegisterBufferRead(u32 handle, u32 address, void *buffer, u32 bytes, u32 *status_out)
{
u32 i;
log3("BufferRead", handle, address, bytes);
(void)handle;
if (status_out) {
*status_out = g_status;
}
if (!buffer) {
return 0x57;
}
if (bytes >= 4) {
log3("BufferReadBefore", address, bytes, load_le32(buffer));
}
for (i = 0; i < bytes; i++) {
((u8 *)buffer)[i] = (address + i < sizeof(g_buffer)) ? g_buffer[address + i] : 0;
}
if (bytes >= 4) {
log3("BufferReadAfter", address, bytes, load_le32(buffer));
}
return 0;
}
int iDmacDrvRegisterBufferWrite(u32 handle, u32 address, const void *buffer, u32 bytes, u32 *status_out)
{
u32 i;
log3("BufferWrite", handle, address, bytes);
(void)handle;
if (status_out) {
*status_out = g_status;
}
if (!buffer) {
return 0x57;
}
if (bytes >= 4) {
log3("BufferWriteData", address, bytes, load_le32(buffer));
}
for (i = 0; i < bytes; i++) {
if (address + i < sizeof(g_buffer)) {
g_buffer[address + i] = ((const u8 *)buffer)[i];
}
}
return 0;
}
int iDmacDrvDmaRead(u32 handle, u32 address, void *buffer, u32 bytes, u32 *status_out)
{
return iDmacDrvRegisterBufferRead(handle, address, buffer, bytes, status_out);
}
int iDmacDrvDmaWrite(u32 handle, u32 address, const void *buffer, u32 bytes, u32 *status_out)
{
return iDmacDrvRegisterBufferWrite(handle, address, buffer, bytes, status_out);
}
int iDmacDrvProgramDownload(u32 handle, u32 command, u32 *status_out)
{
log3("ProgramDownload", handle, command, (u32)status_out);
(void)handle;
(void)command;
if (status_out) {
*status_out = g_status;
}
return 0;
}
+11
View File
@@ -0,0 +1,11 @@
LIBRARY iDmacDrv32.dll
EXPORTS
iDmacDrvOpen @1
iDmacDrvClose @2
iDmacDrvDmaRead @3
iDmacDrvDmaWrite @4
iDmacDrvRegisterRead @5
iDmacDrvRegisterWrite @6
iDmacDrvRegisterBufferRead @7
iDmacDrvRegisterBufferWrite @8
iDmacDrvProgramDownload @13
+6
View File
@@ -0,0 +1,6 @@
LIBRARY kernel32.dll
EXPORTS
CreateFileA
SetFilePointer
WriteFile
CloseHandle
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env python3
import argparse
import struct
import subprocess
from pathlib import Path
def main() -> int:
parser = argparse.ArgumentParser(description="Convert a GC menu DDS jacket to PSP RGBA4444")
parser.add_argument("input_dds")
parser.add_argument("output_orpj")
args = parser.parse_args()
command = [
"ffmpeg", "-hide_banner", "-loglevel", "error", "-i", args.input_dds,
"-vf", "crop=197:197:0:0,scale=128:128:flags=lanczos",
"-frames:v", "1", "-f", "rawvideo", "-pix_fmt", "rgba", "-",
]
result = subprocess.run(command, stdout=subprocess.PIPE, check=False)
expected = 128 * 128 * 4
if result.returncode != 0 or len(result.stdout) != expected:
raise SystemExit(f"ffmpeg produced {len(result.stdout)} bytes, expected {expected}")
pixels = bytearray(128 * 128 * 2)
for index in range(128 * 128):
r, g, b, a = result.stdout[index * 4:index * 4 + 4]
value = (r >> 4) | ((g >> 4) << 4) | ((b >> 4) << 8) | ((a >> 4) << 12)
struct.pack_into("<H", pixels, index * 2, value)
output = Path(args.output_orpj)
output.parent.mkdir(parents=True, exist_ok=True)
header = struct.pack("<4sHHHHI", b"ORPJ", 1, 128, 128, 0, len(pixels))
output.write_bytes(header + pixels)
return 0
if __name__ == "__main__":
raise SystemExit(main())

Some files were not shown because too many files have changed in this diff Show More