Initial public source release
Split reusable rendering and format support into vectorail-core and vectorail-gc.
This commit is contained in:
+61
@@ -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/
|
||||||
|
*~
|
||||||
@@ -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.
|
||||||
@@ -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()
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
add_executable(openroller-desktop
|
||||||
|
src/AudioManager.cpp
|
||||||
|
src/CabinetBackend.cpp
|
||||||
|
src/LevelLoader.cpp
|
||||||
|
src/ServiceMenu.cpp
|
||||||
|
src/SongSelect.cpp
|
||||||
|
src/main.cpp
|
||||||
|
)
|
||||||
|
|
||||||
|
set_target_properties(openroller-desktop PROPERTIES OUTPUT_NAME OpenRoller)
|
||||||
|
|
||||||
|
target_include_directories(openroller-desktop PRIVATE include)
|
||||||
|
target_link_libraries(openroller-desktop
|
||||||
|
PRIVATE
|
||||||
|
Vectorail::Core
|
||||||
|
Vectorail::GC
|
||||||
|
Vectorail::GCEffects
|
||||||
|
)
|
||||||
|
|
||||||
|
if(MSVC)
|
||||||
|
target_compile_options(openroller-desktop PRIVATE /W4)
|
||||||
|
else()
|
||||||
|
target_compile_options(openroller-desktop PRIVATE -Wall -Wextra -Wpedantic)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
configure_file(openroller.cfg openroller.cfg COPYONLY)
|
||||||
|
file(COPY shaders DESTINATION "${CMAKE_CURRENT_BINARY_DIR}")
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <SDL3/SDL.h>
|
||||||
|
#include <array>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
class AudioManager {
|
||||||
|
public:
|
||||||
|
enum class GameplaySound : size_t {
|
||||||
|
Adlib = 0,
|
||||||
|
Tap1 = 1,
|
||||||
|
Tap2 = 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
AudioManager();
|
||||||
|
~AudioManager();
|
||||||
|
|
||||||
|
bool loadMusic(const std::string& path, float gain = 1.0f);
|
||||||
|
bool loadMusicPair(const std::string& bgmPath, const std::string& shotPath,
|
||||||
|
float bgmGain, float shotGain);
|
||||||
|
bool loadGameplaySounds(const std::array<std::string, 3>& paths,
|
||||||
|
const std::array<float, 3>& gains);
|
||||||
|
void playGameplaySound(GameplaySound sound);
|
||||||
|
void play();
|
||||||
|
void pause();
|
||||||
|
void resume();
|
||||||
|
void setShotMuted(bool muted);
|
||||||
|
|
||||||
|
double getTime() const;
|
||||||
|
double getDuration() const; // Длина песни в секундах
|
||||||
|
|
||||||
|
bool isPlaying() const { return playing; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
struct GameplaySoundSlot {
|
||||||
|
std::vector<Uint8> data;
|
||||||
|
std::array<SDL_AudioStream*, 2> voices{nullptr, nullptr};
|
||||||
|
size_t nextVoice = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
void clear();
|
||||||
|
void clearGameplaySounds();
|
||||||
|
bool openStreams(const SDL_AudioSpec& bgmSpec, const Uint8* bgmData, Uint32 bgmLen,
|
||||||
|
float bgmGain, const SDL_AudioSpec* shotSpec = nullptr,
|
||||||
|
const Uint8* shotData = nullptr, Uint32 shotLen = 0,
|
||||||
|
float shotGain = 1.0f);
|
||||||
|
|
||||||
|
SDL_AudioDeviceID device;
|
||||||
|
SDL_AudioStream* bgmStream;
|
||||||
|
SDL_AudioStream* shotStream;
|
||||||
|
std::array<GameplaySoundSlot, 3> gameplaySounds;
|
||||||
|
double duration; // Предрассчитанная длительность
|
||||||
|
float shotBaseGain;
|
||||||
|
bool shotMuted;
|
||||||
|
bool playing;
|
||||||
|
Uint64 startTime;
|
||||||
|
Uint64 accumulatedTicks;
|
||||||
|
};
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <array>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
enum class CabinetInput : std::size_t {
|
||||||
|
Test,
|
||||||
|
Service,
|
||||||
|
Coin,
|
||||||
|
Select,
|
||||||
|
Enter,
|
||||||
|
LeftUp,
|
||||||
|
LeftDown,
|
||||||
|
LeftLeft,
|
||||||
|
LeftRight,
|
||||||
|
LeftButton,
|
||||||
|
RightUp,
|
||||||
|
RightDown,
|
||||||
|
RightLeft,
|
||||||
|
RightRight,
|
||||||
|
RightButton,
|
||||||
|
Count,
|
||||||
|
};
|
||||||
|
|
||||||
|
struct CabinetRgb {
|
||||||
|
std::uint8_t r = 0;
|
||||||
|
std::uint8_t g = 0;
|
||||||
|
std::uint8_t b = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
enum class CardReaderStatus {
|
||||||
|
Ready,
|
||||||
|
Disconnected,
|
||||||
|
Unformatted,
|
||||||
|
ReadError,
|
||||||
|
Timeout,
|
||||||
|
};
|
||||||
|
|
||||||
|
struct CardReaderState {
|
||||||
|
CardReaderStatus status = CardReaderStatus::Disconnected;
|
||||||
|
std::string cardId;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hardware boundary shared by gameplay and test mode. The software backend
|
||||||
|
// is deliberately useful on its own; an ACM implementation can replace it
|
||||||
|
// without teaching the test-mode forms about packets or device paths.
|
||||||
|
class CabinetBackend {
|
||||||
|
public:
|
||||||
|
virtual ~CabinetBackend() = default;
|
||||||
|
|
||||||
|
virtual void poll() = 0;
|
||||||
|
virtual bool input(CabinetInput input) const = 0;
|
||||||
|
|
||||||
|
virtual void setLed(std::size_t logicalIndex, CabinetRgb color) = 0;
|
||||||
|
virtual void clearLeds() = 0;
|
||||||
|
virtual void commitOutputs() = 0;
|
||||||
|
virtual const std::array<CabinetRgb, 118>& leds() const = 0;
|
||||||
|
|
||||||
|
virtual int headphoneVolume() const { return 0; }
|
||||||
|
virtual bool headphoneConnected() const { return false; }
|
||||||
|
virtual bool grooveStageConnected() const { return false; }
|
||||||
|
virtual CardReaderState cardReader() const { return {}; }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Keyboard + in-memory outputs. This keeps service mode fully testable before
|
||||||
|
// the cabinet ACM and RFID transports are attached.
|
||||||
|
CabinetBackend& defaultCabinetBackend();
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <array>
|
||||||
|
#include <vector>
|
||||||
|
#include <string>
|
||||||
|
#include <fstream>
|
||||||
|
#include <sstream>
|
||||||
|
#include <map>
|
||||||
|
#include <optional>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <glm/glm.hpp>
|
||||||
|
#include "gc/StagePattern.hpp"
|
||||||
|
|
||||||
|
struct TrackPoint {
|
||||||
|
glm::vec3 position;
|
||||||
|
int type; // 0=Smooth, 1=Straight
|
||||||
|
bool visible = true; // NEW: Render rail or not
|
||||||
|
float timeMs = 0.0f;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct Keyframe {
|
||||||
|
float t;
|
||||||
|
std::string param;
|
||||||
|
float value;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct LevelNote {
|
||||||
|
float distance = 0.0f;
|
||||||
|
float timeMs = 0.0f;
|
||||||
|
float appearTimeMs = 0.0f;
|
||||||
|
float endTimeMs = 0.0f;
|
||||||
|
float endDistance = 0.0f;
|
||||||
|
unsigned int rawType = 0;
|
||||||
|
unsigned int effectiveType = 0;
|
||||||
|
// Wire +5 becomes runtime marker +8 in FUN_005ea800. Any non-zero value
|
||||||
|
// makes FUN_005e9410 select the ALB slot of the active gameplay SE set.
|
||||||
|
bool adlib = false;
|
||||||
|
// First signed 16-bit field at wire +6. DrawMark passes this value minus
|
||||||
|
// one as the UV-list selector for game effect 3.
|
||||||
|
int markEffectId = -1;
|
||||||
|
// game471 wire +55. The stage authors use this packed RRGGBBAA value for
|
||||||
|
// the path/body colour of duration targets.
|
||||||
|
uint32_t packedColor = 0xffffffffu;
|
||||||
|
uint32_t merryCount = 0;
|
||||||
|
// BuildTimingDataSub converts wire +25 (length) and +29/+33 (HPB angles)
|
||||||
|
// to this world-space vector. GameScene::buildGameData later projects it
|
||||||
|
// through the camera active at timeMs to obtain a fixed screen angle.
|
||||||
|
glm::vec3 directionVector{0.0f};
|
||||||
|
// Runtime +0x20 and +0xc4 in game471's marker object.
|
||||||
|
float beatDurationMs = 500.0f;
|
||||||
|
float earlyTimingMs = 250.0f;
|
||||||
|
float lateTimingMs = 250.0f;
|
||||||
|
float missTimingMs = 250.0f;
|
||||||
|
float muteTimingMs = 0.0f;
|
||||||
|
float markerFadeEndTimeMs = 0.0f;
|
||||||
|
bool gcTiming = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Exact 59-byte stage camera record after decoding from big endian. The
|
||||||
|
// original game expands this to a 0x44-byte runtime key by adding rotationA.z
|
||||||
|
// = 0; keeping the wire fields here lets the player reproduce aMode/fMode
|
||||||
|
// instead of flattening them into the legacy editor timeline.
|
||||||
|
struct GcCameraKey {
|
||||||
|
uint32_t timeMs = 0;
|
||||||
|
uint8_t aMode = 0;
|
||||||
|
uint8_t fMode = 0;
|
||||||
|
float dist = 0.0f;
|
||||||
|
glm::vec3 rotationA{0.0f};
|
||||||
|
glm::vec3 originOff{0.0f};
|
||||||
|
uint8_t projType = 0;
|
||||||
|
glm::vec3 fieldFar{0.0f};
|
||||||
|
glm::vec3 fieldNear{0.0f};
|
||||||
|
float rotationB = 0.0f;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct LevelData {
|
||||||
|
std::string title;
|
||||||
|
std::string author;
|
||||||
|
std::string audioPath;
|
||||||
|
std::string audioShotPath;
|
||||||
|
float audioBgmGain = 1.0f;
|
||||||
|
float audioShotGain = 1.0f;
|
||||||
|
// game471 SE set slots: ALB, TP1 and TP2. The arcade runtime allocates
|
||||||
|
// two playback channels for each slot so repeated taps can overlap.
|
||||||
|
std::array<std::string, 3> gameplaySoundPaths;
|
||||||
|
std::array<float, 3> gameplaySoundGains{1.0f, 1.0f, 1.0f};
|
||||||
|
std::string backgroundPath;
|
||||||
|
std::vector<TrackPoint> trackPoints;
|
||||||
|
std::vector<LevelNote> notes;
|
||||||
|
std::vector<GcCameraKey> gcCameraKeys;
|
||||||
|
// Optional arcade *_clip.dat table. It is object-major and contains one
|
||||||
|
// byte per 60 Hz frame; a zero byte suppresses that stage object for the
|
||||||
|
// frame. game471 consumes this before evaluating authored visibility.
|
||||||
|
uint32_t gcObjectClipFrameCount = 0;
|
||||||
|
std::vector<uint8_t> gcObjectClipVisibility;
|
||||||
|
// Complete decoded stage background: particle/visualizer keys, exact
|
||||||
|
// gradient fade flags and the animated 3D object scene.
|
||||||
|
std::optional<gc::ParsedStagePattern> gcStage;
|
||||||
|
std::vector<Keyframe> timeline;
|
||||||
|
std::map<std::string, float> config;
|
||||||
|
};
|
||||||
|
|
||||||
|
class LevelLoader {
|
||||||
|
public:
|
||||||
|
static LevelData load(const std::string& path);
|
||||||
|
};
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <SDL3/SDL.h>
|
||||||
|
|
||||||
|
#include <filesystem>
|
||||||
|
|
||||||
|
class CabinetBackend;
|
||||||
|
|
||||||
|
// Runs the cabinet operator/test mode until Test/Escape returns to the caller.
|
||||||
|
// The existing OpenGL context remains owned by the caller.
|
||||||
|
void runServiceMenu(SDL_Window* window, CabinetBackend& cabinet,
|
||||||
|
const std::filesystem::path& contentPath = {});
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <SDL3/SDL.h>
|
||||||
|
|
||||||
|
#include <filesystem>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
// Runs the Groove Coaster-style song and difficulty selector. Returns false
|
||||||
|
// when the window is closed/cancelled, otherwise writes the chosen stage .dat.
|
||||||
|
bool runSongSelect(SDL_Window* window,
|
||||||
|
const std::filesystem::path& gcRoot,
|
||||||
|
std::string* selectedStagePath);
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# OpenRoller machine configuration.
|
||||||
|
#
|
||||||
|
# Mirrors HKLM\SOFTWARE\taito\typex\Country from the original Type X system.
|
||||||
|
# 0 = original eight coin/song presets
|
||||||
|
# other = extended 39-entry coin/song table
|
||||||
|
Country=0
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
#version 450 core
|
||||||
|
out vec4 FragColor;
|
||||||
|
in vec2 vUv;
|
||||||
|
uniform float uTime;
|
||||||
|
uniform vec3 uResolution;
|
||||||
|
uniform sampler2D uBackdrop;
|
||||||
|
uniform bool uHasBackdrop;
|
||||||
|
uniform float uBackdropAspect;
|
||||||
|
uniform vec3 uBgTopRight;
|
||||||
|
uniform vec3 uBgTopLeft;
|
||||||
|
uniform vec3 uBgBottomRight;
|
||||||
|
uniform vec3 uBgBottomLeft;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
vec3 top = mix(uBgTopLeft, uBgTopRight, vUv.x);
|
||||||
|
vec3 bottom = mix(uBgBottomLeft, uBgBottomRight, vUv.x);
|
||||||
|
vec3 color = mix(bottom, top, vUv.y);
|
||||||
|
|
||||||
|
if (uHasBackdrop) {
|
||||||
|
// The useful jacket cell occupies the left square of GC's 512x256
|
||||||
|
// *_menu.dds atlas. Crop that cell and cover the portrait viewport.
|
||||||
|
float screenAspect = uResolution.x / uResolution.y;
|
||||||
|
vec2 imageUv = vUv - 0.5;
|
||||||
|
if (screenAspect < uBackdropAspect) {
|
||||||
|
imageUv.x *= screenAspect / uBackdropAspect;
|
||||||
|
} else {
|
||||||
|
imageUv.y *= uBackdropAspect / screenAspect;
|
||||||
|
}
|
||||||
|
imageUv += 0.5;
|
||||||
|
// Jacket cell bounds are 197x197 in the original 512x256 atlas.
|
||||||
|
vec2 atlasUv = vec2(imageUv.x * (197.0 / 512.0),
|
||||||
|
(1.0 - imageUv.y) * (197.0 / 256.0));
|
||||||
|
vec4 backdrop = texture(uBackdrop, atlasUv);
|
||||||
|
float vignette = 1.0 - smoothstep(0.18, 0.82, length(vUv - 0.5));
|
||||||
|
color = mix(color, backdrop.rgb * (0.08 + 0.05 * vignette), backdrop.a * 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
FragColor = vec4(color, 1.0);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
#version 450 core
|
||||||
|
layout (location = 0) in vec2 aPos;
|
||||||
|
out vec2 vUv;
|
||||||
|
uniform vec3 uGameplayFlip;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
vUv = aPos * 0.5 + 0.5;
|
||||||
|
gl_Position = vec4(aPos * uGameplayFlip.xy, 0.0, 1.0);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
#version 450 core
|
||||||
|
in float vSide;
|
||||||
|
in float vDist;
|
||||||
|
in float vTrackTimeMs;
|
||||||
|
out vec4 FragColor;
|
||||||
|
|
||||||
|
uniform vec3 uColor;
|
||||||
|
uniform vec3 uBehindColor;
|
||||||
|
uniform bool uIsAvatar;
|
||||||
|
uniform bool uClipTrack;
|
||||||
|
uniform float uCurrentTimeMs;
|
||||||
|
uniform float uDrawBehindMs;
|
||||||
|
uniform float uDrawAheadMs;
|
||||||
|
uniform float uAlpha;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
float dist = abs(vSide);
|
||||||
|
|
||||||
|
// Мягкий туман: трасса видна очень далеко
|
||||||
|
float fog = exp(-vDist * 0.001);
|
||||||
|
|
||||||
|
if (uClipTrack &&
|
||||||
|
(vTrackTimeMs < uCurrentTimeMs - uDrawBehindMs ||
|
||||||
|
vTrackTimeMs > uCurrentTimeMs + uDrawAheadMs)) {
|
||||||
|
discard;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uIsAvatar) {
|
||||||
|
FragColor = vec4(1.0, 1.0, 1.0, 1.0);
|
||||||
|
} else {
|
||||||
|
float intensity = pow(1.0 - dist, 4.0);
|
||||||
|
float core = pow(1.0 - dist, 20.0);
|
||||||
|
vec3 railColor = (uClipTrack && vTrackTimeMs < uCurrentTimeMs) ? uBehindColor : uColor;
|
||||||
|
vec3 finalColor = mix(railColor, vec3(1.0), core);
|
||||||
|
|
||||||
|
// Цвет затухает в темноту, а не просто обрезается
|
||||||
|
FragColor = vec4(finalColor * fog, intensity * fog * uAlpha);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
#version 450 core
|
||||||
|
layout (location = 0) in vec3 aPos;
|
||||||
|
layout (location = 1) in float aSide;
|
||||||
|
layout (location = 2) in vec3 aRight;
|
||||||
|
layout (location = 3) in float aTimeMs;
|
||||||
|
|
||||||
|
uniform mat4 uProjection;
|
||||||
|
uniform mat4 uView;
|
||||||
|
uniform float uWidth;
|
||||||
|
|
||||||
|
out float vSide;
|
||||||
|
out float vDist;
|
||||||
|
out float vTrackTimeMs;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
vSide = aSide;
|
||||||
|
vTrackTimeMs = aTimeMs;
|
||||||
|
vec3 finalPos = aPos + aRight * aSide * uWidth;
|
||||||
|
|
||||||
|
// Считаем позицию в пространстве камеры
|
||||||
|
vec4 viewPos = uView * vec4(finalPos, 1.0);
|
||||||
|
vDist = -viewPos.z; // Глубина (дистанция от камеры)
|
||||||
|
|
||||||
|
gl_Position = uProjection * viewPos;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
#version 450 core
|
||||||
|
out vec4 FragColor;
|
||||||
|
|
||||||
|
uniform vec4 uColor;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
FragColor = uColor;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
#version 450 core
|
||||||
|
layout (location = 0) in vec3 aPos;
|
||||||
|
|
||||||
|
uniform mat4 uProjection;
|
||||||
|
uniform mat4 uView;
|
||||||
|
uniform mat4 uModel;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
gl_Position = uProjection * uView * uModel * vec4(aPos, 1.0);
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
#version 450 core
|
||||||
|
in vec2 vUv;
|
||||||
|
out vec4 FragColor;
|
||||||
|
|
||||||
|
uniform sampler2D uTexture;
|
||||||
|
uniform vec4 uColor;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
vec4 texel = texture(uTexture, vUv);
|
||||||
|
if (texel.a < 0.01) discard;
|
||||||
|
FragColor = vec4(texel.rgb * uColor.rgb, texel.a * uColor.a);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
#version 450 core
|
||||||
|
layout (location = 0) in vec3 aPos;
|
||||||
|
layout (location = 1) in vec2 aUv;
|
||||||
|
|
||||||
|
uniform mat4 uProjection;
|
||||||
|
uniform mat4 uView;
|
||||||
|
uniform vec4 uUvRect;
|
||||||
|
|
||||||
|
out vec2 vUv;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
vUv = uUvRect.xy + aUv * uUvRect.zw;
|
||||||
|
gl_Position = uProjection * uView * vec4(aPos, 1.0);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
#version 450 core
|
||||||
|
in float vLife;
|
||||||
|
out vec4 FragColor;
|
||||||
|
uniform vec3 uColor;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
// В кваде vLife достаточно для затухания
|
||||||
|
FragColor = vec4(uColor, vLife);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
#version 450 core
|
||||||
|
layout (location = 0) in vec3 aPos;
|
||||||
|
layout (location = 1) in vec2 aOffset; // Смещение углов квадрата
|
||||||
|
layout (location = 2) in float aLife;
|
||||||
|
|
||||||
|
uniform mat4 uProjection;
|
||||||
|
uniform mat4 uView;
|
||||||
|
|
||||||
|
out float vLife;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
vLife = aLife;
|
||||||
|
vec4 viewPos = uView * vec4(aPos, 1.0);
|
||||||
|
// Billboard: квадрат всегда смотрит на камеру
|
||||||
|
viewPos.xy += aOffset * aLife * 0.5;
|
||||||
|
gl_Position = uProjection * viewPos;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
#version 450 core
|
||||||
|
in vec4 vColor;
|
||||||
|
out vec4 FragColor;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
FragColor = vColor;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
#version 450 core
|
||||||
|
layout (location = 0) in vec3 aPos;
|
||||||
|
layout (location = 1) in vec4 aColor;
|
||||||
|
|
||||||
|
uniform mat4 uProjection;
|
||||||
|
uniform mat4 uView;
|
||||||
|
|
||||||
|
out vec4 vColor;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
vColor = aColor;
|
||||||
|
gl_Position = uProjection * uView * vec4(aPos, 1.0);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
#version 450 core
|
||||||
|
in vec2 vUv;
|
||||||
|
out vec4 FragColor;
|
||||||
|
uniform sampler2D uTexture;
|
||||||
|
uniform bool uUseTexture;
|
||||||
|
uniform bool uUseGradient;
|
||||||
|
uniform vec4 uColor;
|
||||||
|
uniform vec4 uGradientTop;
|
||||||
|
uniform vec4 uGradientBottom;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
if (uUseTexture) {
|
||||||
|
FragColor = texture(uTexture, vUv) * uColor;
|
||||||
|
} else if (uUseGradient) {
|
||||||
|
FragColor = mix(uGradientTop, uGradientBottom, vUv.y);
|
||||||
|
} else {
|
||||||
|
FragColor = uColor;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
#version 450 core
|
||||||
|
layout (location = 0) in vec2 aPos;
|
||||||
|
layout (location = 1) in vec2 aUv;
|
||||||
|
out vec2 vUv;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
vUv = aUv;
|
||||||
|
gl_Position = vec4(aPos, 0.0, 1.0);
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
#include "openroller/desktop/AudioManager.hpp"
|
||||||
|
#include <algorithm>
|
||||||
|
#include <iostream>
|
||||||
|
|
||||||
|
AudioManager::AudioManager()
|
||||||
|
: device(0), bgmStream(nullptr), shotStream(nullptr), duration(0),
|
||||||
|
shotBaseGain(1.0f), shotMuted(false), playing(false), startTime(0),
|
||||||
|
accumulatedTicks(0) {}
|
||||||
|
|
||||||
|
AudioManager::~AudioManager() {
|
||||||
|
clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
void AudioManager::clear() {
|
||||||
|
clearGameplaySounds();
|
||||||
|
if (bgmStream) SDL_DestroyAudioStream(bgmStream);
|
||||||
|
if (shotStream) SDL_DestroyAudioStream(shotStream);
|
||||||
|
if (device) SDL_CloseAudioDevice(device);
|
||||||
|
device = 0;
|
||||||
|
bgmStream = nullptr;
|
||||||
|
shotStream = nullptr;
|
||||||
|
duration = 0.0;
|
||||||
|
shotBaseGain = 1.0f;
|
||||||
|
shotMuted = false;
|
||||||
|
playing = false;
|
||||||
|
startTime = 0;
|
||||||
|
accumulatedTicks = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
void AudioManager::clearGameplaySounds() {
|
||||||
|
for (GameplaySoundSlot& slot : gameplaySounds) {
|
||||||
|
for (SDL_AudioStream*& voice : slot.voices) {
|
||||||
|
if (voice) SDL_DestroyAudioStream(voice);
|
||||||
|
voice = nullptr;
|
||||||
|
}
|
||||||
|
slot.data.clear();
|
||||||
|
slot.nextVoice = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool AudioManager::openStreams(const SDL_AudioSpec& bgmSpec, const Uint8* bgmData,
|
||||||
|
Uint32 bgmLen, float bgmGain,
|
||||||
|
const SDL_AudioSpec* shotSpec, const Uint8* shotData,
|
||||||
|
Uint32 shotLen, float requestedShotGain) {
|
||||||
|
const int bytesPerSample = SDL_AUDIO_BITSIZE(bgmSpec.format) / 8;
|
||||||
|
if (bytesPerSample <= 0 || bgmSpec.channels <= 0 || bgmSpec.freq <= 0) return false;
|
||||||
|
duration = static_cast<double>(bgmLen) /
|
||||||
|
(static_cast<double>(bgmSpec.channels) * bytesPerSample * bgmSpec.freq);
|
||||||
|
|
||||||
|
device = SDL_OpenAudioDevice(SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK, nullptr);
|
||||||
|
if (!device) {
|
||||||
|
std::cerr << "Audio device open error: " << SDL_GetError() << std::endl;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!SDL_PauseAudioDevice(device)) {
|
||||||
|
std::cerr << "Audio device pause error: " << SDL_GetError() << std::endl;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bgmStream = SDL_CreateAudioStream(&bgmSpec, nullptr);
|
||||||
|
if (shotSpec) shotStream = SDL_CreateAudioStream(shotSpec, nullptr);
|
||||||
|
if (!bgmStream || (shotSpec && !shotStream)) {
|
||||||
|
std::cerr << "Audio stream create error: " << SDL_GetError() << std::endl;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
SDL_AudioStream* streams[] = {bgmStream, shotStream};
|
||||||
|
const int streamCount = shotStream ? 2 : 1;
|
||||||
|
if (!SDL_BindAudioStreams(device, streams, streamCount)) {
|
||||||
|
std::cerr << "Audio stream bind error: " << SDL_GetError() << std::endl;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bgmGain = std::clamp(bgmGain, 0.0f, 1.0f);
|
||||||
|
shotBaseGain = std::clamp(requestedShotGain, 0.0f, 1.0f);
|
||||||
|
if (!SDL_SetAudioStreamGain(bgmStream, bgmGain) ||
|
||||||
|
(shotStream && !SDL_SetAudioStreamGain(shotStream, shotBaseGain))) {
|
||||||
|
std::cerr << "Audio gain error: " << SDL_GetError() << std::endl;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!SDL_PutAudioStreamData(bgmStream, bgmData, static_cast<int>(bgmLen)) ||
|
||||||
|
(shotStream && !SDL_PutAudioStreamData(
|
||||||
|
shotStream, shotData, static_cast<int>(shotLen)))) {
|
||||||
|
std::cerr << "Audio queue error: " << SDL_GetError() << std::endl;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
shotMuted = false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool AudioManager::loadMusic(const std::string& path, float gain) {
|
||||||
|
clear();
|
||||||
|
SDL_AudioSpec loadedSpec{};
|
||||||
|
Uint8* loadedBuf = nullptr;
|
||||||
|
Uint32 loadedLen = 0;
|
||||||
|
if (!SDL_LoadWAV(path.c_str(), &loadedSpec, &loadedBuf, &loadedLen)) {
|
||||||
|
std::cerr << "WAV Load Error: " << SDL_GetError() << std::endl;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const bool opened = openStreams(loadedSpec, loadedBuf, loadedLen, gain);
|
||||||
|
SDL_free(loadedBuf);
|
||||||
|
if (opened) return true;
|
||||||
|
clear();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool AudioManager::loadMusicPair(const std::string& bgmPath, const std::string& shotPath,
|
||||||
|
float bgmGain, float shotGain) {
|
||||||
|
clear();
|
||||||
|
|
||||||
|
SDL_AudioSpec bgmSpec{};
|
||||||
|
SDL_AudioSpec shotSpec{};
|
||||||
|
Uint8* bgmBuf = nullptr;
|
||||||
|
Uint8* shotBuf = nullptr;
|
||||||
|
Uint32 bgmLen = 0;
|
||||||
|
Uint32 shotLen = 0;
|
||||||
|
if (!SDL_LoadWAV(bgmPath.c_str(), &bgmSpec, &bgmBuf, &bgmLen)) {
|
||||||
|
std::cerr << "BGM WAV load error: " << SDL_GetError() << std::endl;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!SDL_LoadWAV(shotPath.c_str(), &shotSpec, &shotBuf, &shotLen)) {
|
||||||
|
std::cerr << "SHOT WAV load error: " << SDL_GetError()
|
||||||
|
<< "; playing BGM only" << std::endl;
|
||||||
|
SDL_free(bgmBuf);
|
||||||
|
return loadMusic(bgmPath, bgmGain);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bool opened = openStreams(bgmSpec, bgmBuf, bgmLen, bgmGain,
|
||||||
|
&shotSpec, shotBuf, shotLen, shotGain);
|
||||||
|
SDL_free(bgmBuf);
|
||||||
|
SDL_free(shotBuf);
|
||||||
|
if (opened) return true;
|
||||||
|
clear();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool AudioManager::loadGameplaySounds(const std::array<std::string, 3>& paths,
|
||||||
|
const std::array<float, 3>& gains) {
|
||||||
|
clearGameplaySounds();
|
||||||
|
if (!device) return false;
|
||||||
|
|
||||||
|
std::array<SDL_AudioSpec, 3> specs{};
|
||||||
|
for (size_t i = 0; i < gameplaySounds.size(); ++i) {
|
||||||
|
Uint8* wavData = nullptr;
|
||||||
|
Uint32 wavLength = 0;
|
||||||
|
if (paths[i].empty() ||
|
||||||
|
!SDL_LoadWAV(paths[i].c_str(), &specs[i], &wavData, &wavLength)) {
|
||||||
|
std::cerr << "Gameplay SE WAV load error: " << paths[i] << ": "
|
||||||
|
<< SDL_GetError() << std::endl;
|
||||||
|
if (wavData) SDL_free(wavData);
|
||||||
|
clearGameplaySounds();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
gameplaySounds[i].data.assign(wavData, wavData + wavLength);
|
||||||
|
SDL_free(wavData);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::array<SDL_AudioStream*, 6> voices{};
|
||||||
|
size_t voiceIndex = 0;
|
||||||
|
for (size_t i = 0; i < gameplaySounds.size(); ++i) {
|
||||||
|
GameplaySoundSlot& slot = gameplaySounds[i];
|
||||||
|
for (SDL_AudioStream*& voice : slot.voices) {
|
||||||
|
voice = SDL_CreateAudioStream(&specs[i], nullptr);
|
||||||
|
if (!voice || !SDL_SetAudioStreamGain(voice, std::clamp(gains[i], 0.0f, 1.0f))) {
|
||||||
|
std::cerr << "Gameplay SE stream error: " << SDL_GetError() << std::endl;
|
||||||
|
clearGameplaySounds();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
voices[voiceIndex++] = voice;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!SDL_BindAudioStreams(device, voices.data(), static_cast<int>(voices.size()))) {
|
||||||
|
std::cerr << "Gameplay SE bind error: " << SDL_GetError() << std::endl;
|
||||||
|
clearGameplaySounds();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void AudioManager::playGameplaySound(GameplaySound sound) {
|
||||||
|
GameplaySoundSlot& slot = gameplaySounds[static_cast<size_t>(sound)];
|
||||||
|
if (slot.data.empty()) return;
|
||||||
|
|
||||||
|
SDL_AudioStream* voice = slot.voices[slot.nextVoice];
|
||||||
|
slot.nextVoice = (slot.nextVoice + 1) % slot.voices.size();
|
||||||
|
if (!voice) return;
|
||||||
|
if (!SDL_ClearAudioStream(voice) ||
|
||||||
|
!SDL_PutAudioStreamData(voice, slot.data.data(), static_cast<int>(slot.data.size())) ||
|
||||||
|
!SDL_FlushAudioStream(voice)) {
|
||||||
|
std::cerr << "Gameplay SE playback error: " << SDL_GetError() << std::endl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void AudioManager::play() {
|
||||||
|
if (device) {
|
||||||
|
SDL_ResumeAudioDevice(device);
|
||||||
|
playing = true;
|
||||||
|
startTime = SDL_GetTicks();
|
||||||
|
accumulatedTicks = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void AudioManager::pause() {
|
||||||
|
if (!device || !playing) return;
|
||||||
|
const Uint64 now = SDL_GetTicks();
|
||||||
|
accumulatedTicks += now - startTime;
|
||||||
|
SDL_PauseAudioDevice(device);
|
||||||
|
playing = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void AudioManager::resume() {
|
||||||
|
if (!device || playing) return;
|
||||||
|
SDL_ResumeAudioDevice(device);
|
||||||
|
startTime = SDL_GetTicks();
|
||||||
|
playing = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void AudioManager::setShotMuted(bool muted) {
|
||||||
|
if (!shotStream || shotMuted == muted) return;
|
||||||
|
if (SDL_SetAudioStreamGain(shotStream, muted ? 0.0f : shotBaseGain)) {
|
||||||
|
shotMuted = muted;
|
||||||
|
} else {
|
||||||
|
std::cerr << "SHOT gain error: " << SDL_GetError() << std::endl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
double AudioManager::getTime() const {
|
||||||
|
const Uint64 liveTicks = playing ? SDL_GetTicks() - startTime : 0;
|
||||||
|
return static_cast<double>(accumulatedTicks + liveTicks) / 1000.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
double AudioManager::getDuration() const {
|
||||||
|
return duration;
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
#include "openroller/desktop/CabinetBackend.hpp"
|
||||||
|
|
||||||
|
#include <SDL3/SDL.h>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
class SoftwareCabinetBackend final : public CabinetBackend {
|
||||||
|
public:
|
||||||
|
void poll() override {
|
||||||
|
const bool* keys = SDL_GetKeyboardState(nullptr);
|
||||||
|
if (!keys) {
|
||||||
|
inputs_.fill(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const auto down = [&](SDL_Scancode key) { return keys[key]; };
|
||||||
|
set(CabinetInput::Test, down(SDL_SCANCODE_CAPSLOCK));
|
||||||
|
set(CabinetInput::Service, down(SDL_SCANCODE_F1));
|
||||||
|
set(CabinetInput::Coin, down(SDL_SCANCODE_F2));
|
||||||
|
set(CabinetInput::Select, down(SDL_SCANCODE_F3));
|
||||||
|
set(CabinetInput::Enter,
|
||||||
|
down(SDL_SCANCODE_RIGHTBRACKET) || down(SDL_SCANCODE_RETURN));
|
||||||
|
set(CabinetInput::LeftUp, down(SDL_SCANCODE_Q));
|
||||||
|
set(CabinetInput::LeftDown, down(SDL_SCANCODE_A));
|
||||||
|
set(CabinetInput::LeftLeft, down(SDL_SCANCODE_LCTRL));
|
||||||
|
set(CabinetInput::LeftRight, down(SDL_SCANCODE_S));
|
||||||
|
set(CabinetInput::LeftButton, down(SDL_SCANCODE_LALT));
|
||||||
|
set(CabinetInput::RightUp, down(SDL_SCANCODE_UP));
|
||||||
|
set(CabinetInput::RightDown, down(SDL_SCANCODE_DOWN));
|
||||||
|
set(CabinetInput::RightLeft, down(SDL_SCANCODE_LEFT));
|
||||||
|
set(CabinetInput::RightRight, down(SDL_SCANCODE_RIGHT));
|
||||||
|
set(CabinetInput::RightButton, down(SDL_SCANCODE_SPACE));
|
||||||
|
}
|
||||||
|
|
||||||
|
bool input(CabinetInput input) const override {
|
||||||
|
return inputs_[static_cast<std::size_t>(input)];
|
||||||
|
}
|
||||||
|
|
||||||
|
void setLed(std::size_t logicalIndex, CabinetRgb color) override {
|
||||||
|
if (logicalIndex < leds_.size()) leds_[logicalIndex] = color;
|
||||||
|
}
|
||||||
|
|
||||||
|
void clearLeds() override {
|
||||||
|
leds_.fill({});
|
||||||
|
}
|
||||||
|
|
||||||
|
void commitOutputs() override {
|
||||||
|
// The software backend intentionally retains the last committed frame
|
||||||
|
// so the LED test can render exactly what a hardware backend receives.
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::array<CabinetRgb, 118>& leds() const override {
|
||||||
|
return leds_;
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
void set(CabinetInput input, bool value) {
|
||||||
|
inputs_[static_cast<std::size_t>(input)] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::array<bool, static_cast<std::size_t>(CabinetInput::Count)> inputs_{};
|
||||||
|
std::array<CabinetRgb, 118> leds_{};
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
CabinetBackend& defaultCabinetBackend() {
|
||||||
|
static SoftwareCabinetBackend backend;
|
||||||
|
return backend;
|
||||||
|
}
|
||||||
@@ -0,0 +1,827 @@
|
|||||||
|
#include "openroller/desktop/LevelLoader.hpp"
|
||||||
|
#include "gc/StageCatalog.hpp"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <array>
|
||||||
|
#include <cctype>
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstring>
|
||||||
|
#include <filesystem>
|
||||||
|
#include <fstream>
|
||||||
|
#include <iostream>
|
||||||
|
#include <sstream>
|
||||||
|
|
||||||
|
namespace fs = std::filesystem;
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
struct GcTrackPiece {
|
||||||
|
uint32_t timeMs = 0;
|
||||||
|
glm::vec3 pos{0.0f};
|
||||||
|
};
|
||||||
|
|
||||||
|
struct GcNote {
|
||||||
|
uint32_t timeMs = 0;
|
||||||
|
uint8_t rawType = 0;
|
||||||
|
uint8_t effectiveType = 0;
|
||||||
|
bool adlib = false;
|
||||||
|
int16_t markEffectId = -1;
|
||||||
|
float appearanceLeadBeats = 0.0f;
|
||||||
|
float durationBeats = 0.0f;
|
||||||
|
uint32_t packedColor = 0xffffffffu;
|
||||||
|
uint32_t merryCount = 0;
|
||||||
|
float merrySpacingBeats = 0.0f;
|
||||||
|
glm::vec3 directionVector{0.0f};
|
||||||
|
};
|
||||||
|
|
||||||
|
struct GcTimingEntry {
|
||||||
|
uint32_t timeMs = 0;
|
||||||
|
uint32_t mode = 0;
|
||||||
|
float value = 0.0f;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct GcSystemTimingConfig {
|
||||||
|
bool missMarkOverride = false;
|
||||||
|
float greatMinTimeMs = 32.0f;
|
||||||
|
std::array<float, 4> miss{236.0f, 202.0f, 168.0f, 168.0f};
|
||||||
|
std::array<float, 4> unmute{202.0f, 168.0f, 134.0f, 134.0f};
|
||||||
|
std::array<float, 4> limit{202.0f, 168.0f, 134.0f, 134.0f};
|
||||||
|
std::array<float, 4> mute{0.0f, 0.0f, 0.0f, 0.0f};
|
||||||
|
float scratchEnableTimeMs = 250.0f;
|
||||||
|
float beatEnableTimeMs = 200.0f;
|
||||||
|
};
|
||||||
|
|
||||||
|
// RotateHPB::ToVector_Deg converts (heading, pitch, bank) to a quaternion and
|
||||||
|
// transforms (0, 0, distance). Directional notes author bank as zero, but keep
|
||||||
|
// the complete original formula here.
|
||||||
|
glm::vec3 gcDirectionVector(float distance, float heading, float pitch, float bank = 0.0f) {
|
||||||
|
const float a = glm::radians(-pitch) * 0.5f;
|
||||||
|
const float b = glm::radians(heading) * 0.5f;
|
||||||
|
const float c = glm::radians(bank) * 0.5f;
|
||||||
|
const float ca = std::cos(a), cb = std::cos(b), cc = std::cos(c);
|
||||||
|
const float sa = std::sin(a), sb = std::sin(b), sc = std::sin(c);
|
||||||
|
|
||||||
|
const float qw = sc * sa * sb + cc * ca * cb;
|
||||||
|
const float qx = sc * ca * sb + cc * sa * cb;
|
||||||
|
const float qy = cc * ca * sb - sc * sa * cb;
|
||||||
|
const float qz = cc * sa * sb - sc * ca * cb;
|
||||||
|
return distance * glm::vec3(
|
||||||
|
2.0f * (qz * qx + qw * qy),
|
||||||
|
2.0f * (qy * qz - qw * qx),
|
||||||
|
1.0f - 2.0f * (qx * qx + qy * qy));
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string trim(std::string value) {
|
||||||
|
const auto first = std::find_if_not(value.begin(), value.end(),
|
||||||
|
[](unsigned char c) { return std::isspace(c); });
|
||||||
|
const auto last = std::find_if_not(value.rbegin(), value.rend(),
|
||||||
|
[](unsigned char c) { return std::isspace(c); }).base();
|
||||||
|
return first < last ? std::string(first, last) : std::string{};
|
||||||
|
}
|
||||||
|
|
||||||
|
bool parseFloatTuple4(std::string value, std::array<float, 4>& out) {
|
||||||
|
for (char& c : value) {
|
||||||
|
if (c == '(' || c == ')' || c == ',') c = ' ';
|
||||||
|
}
|
||||||
|
std::stringstream values(value);
|
||||||
|
std::array<float, 4> parsed{};
|
||||||
|
if (!(values >> parsed[0] >> parsed[1] >> parsed[2] >> parsed[3])) return false;
|
||||||
|
out = parsed;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
GcSystemTimingConfig loadGcSystemTimingConfig(const fs::path& stageFile) {
|
||||||
|
GcSystemTimingConfig config;
|
||||||
|
const fs::path systemCfg = stageFile.parent_path().parent_path() / "system.cfg";
|
||||||
|
std::ifstream file(systemCfg);
|
||||||
|
if (!file.is_open()) return config;
|
||||||
|
|
||||||
|
std::string line;
|
||||||
|
bool inBlockComment = false;
|
||||||
|
while (std::getline(file, line)) {
|
||||||
|
if (inBlockComment) {
|
||||||
|
const size_t end = line.find("*/");
|
||||||
|
if (end == std::string::npos) continue;
|
||||||
|
line.erase(0, end + 2);
|
||||||
|
inBlockComment = false;
|
||||||
|
}
|
||||||
|
for (;;) {
|
||||||
|
const size_t begin = line.find("/*");
|
||||||
|
if (begin == std::string::npos) break;
|
||||||
|
const size_t end = line.find("*/", begin + 2);
|
||||||
|
if (end == std::string::npos) {
|
||||||
|
line.resize(begin);
|
||||||
|
inBlockComment = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
line.erase(begin, end + 2 - begin);
|
||||||
|
}
|
||||||
|
const size_t comment = line.find("//");
|
||||||
|
if (comment != std::string::npos) line.resize(comment);
|
||||||
|
const size_t equals = line.find('=');
|
||||||
|
if (equals == std::string::npos) continue;
|
||||||
|
const std::string key = trim(line.substr(0, equals));
|
||||||
|
const std::string value = trim(line.substr(equals + 1));
|
||||||
|
try {
|
||||||
|
if (key == "MissMarkOverride") config.missMarkOverride = std::stoi(value) != 0;
|
||||||
|
else if (key == "GreatMinTime") config.greatMinTimeMs = std::stof(value);
|
||||||
|
else if (key == "MissTimingOverride") parseFloatTuple4(value, config.miss);
|
||||||
|
else if (key == "UnmuteTimingOverride") parseFloatTuple4(value, config.unmute);
|
||||||
|
else if (key == "LimitTimingOverride") parseFloatTuple4(value, config.limit);
|
||||||
|
else if (key == "MuteTimingOverride") parseFloatTuple4(value, config.mute);
|
||||||
|
else if (key == "ScratchEnableTime") config.scratchEnableTimeMs = std::stof(value);
|
||||||
|
else if (key == "BeatEnableTime") config.beatEnableTimeMs = std::stof(value);
|
||||||
|
} catch (const std::exception&) {
|
||||||
|
// Preserve the shipped defaults if a local config line is malformed.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t gcDifficultyIndex(const fs::path& stageFile) {
|
||||||
|
std::string stem = stageFile.stem().string();
|
||||||
|
std::transform(stem.begin(), stem.end(), stem.begin(),
|
||||||
|
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
|
||||||
|
if (stem.find("_normal") != std::string::npos) return 1;
|
||||||
|
if (stem.find("_hard") != std::string::npos) return 2;
|
||||||
|
if (stem.find("_extra") != std::string::npos ||
|
||||||
|
(stem.size() >= 3 && stem.compare(stem.size() - 3, 3, "_ex") == 0) ||
|
||||||
|
stem.find("_ex_") != std::string::npos) return 3;
|
||||||
|
return 0; // _easy and old charts without a suffix
|
||||||
|
}
|
||||||
|
|
||||||
|
const char* gcNoteTypeName(uint8_t type) {
|
||||||
|
static constexpr const char* names[] = {
|
||||||
|
"NONE", "NORMAL", "FLICK", "HOLD", "SCRATCH", "BEAT", "MERRY GO ROUND", "HIDDEN",
|
||||||
|
"HIDDEN2", "CRITICAL", "SLIDE HOLD", "SLIDE COUNTER", "TURN", "SPIN", "FINISH", "DUAL HOLD",
|
||||||
|
};
|
||||||
|
return type < (sizeof(names) / sizeof(names[0])) ? names[type] : "UNKNOWN";
|
||||||
|
}
|
||||||
|
|
||||||
|
uint16_t u16be(const std::vector<uint8_t>& b, size_t off) {
|
||||||
|
return static_cast<uint16_t>((static_cast<uint16_t>(b[off]) << 8) | static_cast<uint16_t>(b[off + 1]));
|
||||||
|
}
|
||||||
|
|
||||||
|
uint32_t u32be(const std::vector<uint8_t>& b, size_t off) {
|
||||||
|
return (static_cast<uint32_t>(b[off + 0]) << 24) |
|
||||||
|
(static_cast<uint32_t>(b[off + 1]) << 16) |
|
||||||
|
(static_cast<uint32_t>(b[off + 2]) << 8) |
|
||||||
|
static_cast<uint32_t>(b[off + 3]);
|
||||||
|
}
|
||||||
|
|
||||||
|
float f32be(const std::vector<uint8_t>& b, size_t off) {
|
||||||
|
const uint32_t u = u32be(b, off);
|
||||||
|
float f = 0.0f;
|
||||||
|
std::memcpy(&f, &u, sizeof(float));
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool readFile(const std::string& path, std::vector<uint8_t>& out) {
|
||||||
|
std::ifstream file(path, std::ios::binary);
|
||||||
|
if (!file.is_open()) return false;
|
||||||
|
file.seekg(0, std::ios::end);
|
||||||
|
const std::streamoff size = file.tellg();
|
||||||
|
if (size < 0) return false;
|
||||||
|
file.seekg(0, std::ios::beg);
|
||||||
|
out.assign(static_cast<size_t>(size), 0);
|
||||||
|
if (!out.empty()) file.read(reinterpret_cast<char*>(out.data()), static_cast<std::streamsize>(out.size()));
|
||||||
|
return static_cast<bool>(file) || file.eof();
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadGcObjectClipTable(const fs::path& stagePath, LevelData& data) {
|
||||||
|
if (!data.gcStage) return;
|
||||||
|
|
||||||
|
const fs::path clipPath =
|
||||||
|
stagePath.parent_path() / (stagePath.stem().string() + "_clip.dat");
|
||||||
|
std::vector<uint8_t> bytes;
|
||||||
|
if (!readFile(clipPath.string(), bytes) || bytes.size() < 8) return;
|
||||||
|
|
||||||
|
const uint32_t objectCount = u32be(bytes, 0);
|
||||||
|
const uint32_t frameCount = u32be(bytes, 4);
|
||||||
|
if (objectCount != data.gcStage->objects.size() || frameCount == 0) return;
|
||||||
|
if (objectCount > (bytes.size() - 8) / frameCount) return;
|
||||||
|
|
||||||
|
const size_t payloadSize = static_cast<size_t>(objectCount) * frameCount;
|
||||||
|
if (8 + payloadSize > bytes.size()) return;
|
||||||
|
data.gcObjectClipFrameCount = frameCount;
|
||||||
|
data.gcObjectClipVisibility.assign(bytes.begin() + 8,
|
||||||
|
bytes.begin() + 8 + payloadSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool saneFloat(float v, float limit = 1000000.0f) {
|
||||||
|
return std::isfinite(v) && std::fabs(v) <= limit;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string readSizedString16(const std::vector<uint8_t>& bytes, size_t& off, size_t end) {
|
||||||
|
if (off + 2 > end) return {};
|
||||||
|
const uint16_t len = u16be(bytes, off);
|
||||||
|
off += 2;
|
||||||
|
if (off + len > end) return {};
|
||||||
|
std::string s(reinterpret_cast<const char*>(bytes.data() + off), len);
|
||||||
|
off += len;
|
||||||
|
while (!s.empty() && s.back() == '\0') s.pop_back();
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<GcNote> decodeGcNotes(const std::vector<uint8_t>& bytes, size_t start, size_t end) {
|
||||||
|
constexpr size_t recordSize = 99;
|
||||||
|
std::vector<GcNote> notes;
|
||||||
|
if (start + 8 > end || end > bytes.size()) return notes;
|
||||||
|
|
||||||
|
size_t off = start;
|
||||||
|
const uint32_t nameCount = u32be(bytes, off);
|
||||||
|
off += 4;
|
||||||
|
for (uint32_t i = 0; i < nameCount; ++i) {
|
||||||
|
if (off >= end) return {};
|
||||||
|
const size_t len = bytes[off++];
|
||||||
|
if (len > end - off) return {};
|
||||||
|
off += len;
|
||||||
|
}
|
||||||
|
if (off + 4 > end) return {};
|
||||||
|
const uint32_t count = u32be(bytes, off);
|
||||||
|
off += 4;
|
||||||
|
const size_t payloadBytes = static_cast<size_t>(count) * recordSize;
|
||||||
|
if (payloadBytes != end - off) return notes;
|
||||||
|
|
||||||
|
notes.reserve(count);
|
||||||
|
for (uint32_t i = 0; i < count; ++i, off += recordSize) {
|
||||||
|
const uint8_t rawType = bytes[off + 4];
|
||||||
|
uint8_t effectiveType = bytes[off + 5] ? 1 : rawType;
|
||||||
|
if (!bytes[off + 5]) {
|
||||||
|
if (rawType == 0x0b) effectiveType = 0x0a;
|
||||||
|
else if (rawType == 0x0c || rawType == 0x0e) effectiveType = 0x09;
|
||||||
|
else if (rawType == 0x0d) effectiveType = 0x04;
|
||||||
|
}
|
||||||
|
float directionLength = f32be(bytes, off + 25);
|
||||||
|
// LoadTuneMarkDataOne substitutes 1.0 for directional types whose
|
||||||
|
// authored vector length is zero (notably SLIDE HOLD charts).
|
||||||
|
if ((effectiveType == 2 || effectiveType == 10 || rawType == 0x10) &&
|
||||||
|
directionLength <= 0.0f) {
|
||||||
|
directionLength = 1.0f;
|
||||||
|
}
|
||||||
|
notes.push_back({
|
||||||
|
u32be(bytes, off), rawType, effectiveType, bytes[off + 5] != 0,
|
||||||
|
static_cast<int16_t>(u16be(bytes, off + 6)),
|
||||||
|
f32be(bytes, off + 39),
|
||||||
|
f32be(bytes, off + 51),
|
||||||
|
u32be(bytes, off + 55),
|
||||||
|
u32be(bytes, off + 71),
|
||||||
|
f32be(bytes, off + 75),
|
||||||
|
gcDirectionVector(directionLength,
|
||||||
|
f32be(bytes, off + 29),
|
||||||
|
f32be(bytes, off + 33)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return notes;
|
||||||
|
}
|
||||||
|
|
||||||
|
float gcBeatDurationAt(const gc::StageConfig* config, uint32_t timeMs) {
|
||||||
|
uint32_t bpm = 120;
|
||||||
|
if (config) {
|
||||||
|
for (const gc::BpmChange& change : config->bpmChanges) {
|
||||||
|
if (change.timeMs > timeMs) break;
|
||||||
|
if (change.bpm != 0) bpm = change.bpm;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 60000.0f / static_cast<float>(std::max<uint32_t>(1, bpm));
|
||||||
|
}
|
||||||
|
|
||||||
|
float gcTimingAt(const std::vector<GcTimingEntry>& entries, uint32_t timeMs,
|
||||||
|
float beatMs, float nextSpacingMs) {
|
||||||
|
if (entries.empty()) return beatMs * 0.5f;
|
||||||
|
const GcTimingEntry* active = &entries.front();
|
||||||
|
for (const GcTimingEntry& entry : entries) {
|
||||||
|
if (entry.timeMs > timeMs) break;
|
||||||
|
active = &entry;
|
||||||
|
}
|
||||||
|
if (active->mode == 1) return active->value;
|
||||||
|
if (active->mode == 3) return nextSpacingMs;
|
||||||
|
return active->value * beatMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
float cumulativeDistanceAtTime(const std::vector<GcTrackPiece>& track, const std::vector<float>& dists, float noteT, float minT, float maxT) {
|
||||||
|
if (track.empty() || dists.empty()) return 0.0f;
|
||||||
|
|
||||||
|
const float lastMs = static_cast<float>(std::max<uint32_t>(1, track.back().timeMs));
|
||||||
|
float targetMs = 0.0f;
|
||||||
|
if (maxT - minT > 0.001f && maxT * 1000.0f <= lastMs * 1.25f) {
|
||||||
|
targetMs = noteT * 1000.0f;
|
||||||
|
} else if (maxT - minT > 0.001f && maxT <= lastMs * 1.25f) {
|
||||||
|
targetMs = noteT;
|
||||||
|
} else {
|
||||||
|
const float u = (maxT > minT) ? ((noteT - minT) / (maxT - minT)) : 0.0f;
|
||||||
|
targetMs = u * lastMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetMs <= static_cast<float>(track.front().timeMs)) return dists.front();
|
||||||
|
if (targetMs >= lastMs) return dists.back();
|
||||||
|
|
||||||
|
for (size_t i = 0; i + 1 < track.size(); ++i) {
|
||||||
|
const float a = static_cast<float>(track[i].timeMs);
|
||||||
|
const float b = static_cast<float>(track[i + 1].timeMs);
|
||||||
|
if (targetMs >= a && targetMs <= b) {
|
||||||
|
const float span = b - a;
|
||||||
|
const float u = span > 0.001f ? (targetMs - a) / span : 0.0f;
|
||||||
|
return dists[i] + (dists[i + 1] - dists[i]) * u;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dists.back();
|
||||||
|
}
|
||||||
|
|
||||||
|
float trackParamAtTimeMs(const std::vector<GcTrackPiece>& track, float timeMs) {
|
||||||
|
if (track.empty()) return 0.0f;
|
||||||
|
if (timeMs <= static_cast<float>(track.front().timeMs)) return 0.0f;
|
||||||
|
if (timeMs >= static_cast<float>(track.back().timeMs)) return static_cast<float>(track.size() - 1);
|
||||||
|
for (size_t i = 0; i + 1 < track.size(); ++i) {
|
||||||
|
const float a = static_cast<float>(track[i].timeMs);
|
||||||
|
const float b = static_cast<float>(track[i + 1].timeMs);
|
||||||
|
if (timeMs >= a && timeMs <= b) {
|
||||||
|
const float span = b - a;
|
||||||
|
const float u = span > 0.001f ? (timeMs - a) / span : 0.0f;
|
||||||
|
return static_cast<float>(i) + u;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return static_cast<float>(track.size() - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string lower(std::string s) {
|
||||||
|
std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) { return static_cast<char>(std::tolower(c)); });
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string gcStageToken(const fs::path& stagePath, const std::string& chartName) {
|
||||||
|
std::string token = lower(chartName.empty() ? stagePath.stem().string() : chartName);
|
||||||
|
if (token.rfind("ac_", 0) == 0) token = token.substr(3);
|
||||||
|
for (const std::string suffix : {"_hard", "_normal", "_easy"}) {
|
||||||
|
if (token.size() > suffix.size() && token.compare(token.size() - suffix.size(), suffix.size(), suffix) == 0) {
|
||||||
|
token.resize(token.size() - suffix.size());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string inferGcBgmPath(const fs::path& stagePath, const std::string& chartName, const std::string& bgmName) {
|
||||||
|
const fs::path soundDir = stagePath.parent_path() / "sound";
|
||||||
|
if (!fs::is_directory(soundDir)) return {};
|
||||||
|
|
||||||
|
const std::string token = gcStageToken(stagePath, chartName);
|
||||||
|
|
||||||
|
const std::string bgm = lower(bgmName);
|
||||||
|
for (const auto& entry : fs::directory_iterator(soundDir)) {
|
||||||
|
if (!entry.is_regular_file()) continue;
|
||||||
|
const std::string name = lower(entry.path().filename().string());
|
||||||
|
if (entry.path().extension() != ".wav") continue;
|
||||||
|
if (name.find("_bgm") == std::string::npos) continue;
|
||||||
|
if ((!token.empty() && name.find(token) != std::string::npos) ||
|
||||||
|
(!bgm.empty() && name.find(bgm) != std::string::npos)) {
|
||||||
|
return entry.path().string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
struct GcStageAudio {
|
||||||
|
std::string bgmPath;
|
||||||
|
std::string shotPath;
|
||||||
|
float bgmGain = 1.0f;
|
||||||
|
float shotGain = 1.0f;
|
||||||
|
};
|
||||||
|
|
||||||
|
GcStageAudio resolveGcStageAudio(const fs::path& stagePath,
|
||||||
|
const std::string& chartName,
|
||||||
|
const std::string& bgmName,
|
||||||
|
size_t difficultyIndex) {
|
||||||
|
GcStageAudio result;
|
||||||
|
const fs::path dataDir = stagePath.parent_path().parent_path();
|
||||||
|
const fs::path stageParam = dataDir / "boot" / "stage_param.dat";
|
||||||
|
std::vector<uint8_t> catalogBytes;
|
||||||
|
std::vector<gc::StageCatalogEntry> entries;
|
||||||
|
std::string catalogError;
|
||||||
|
if (readFile(stageParam.string(), catalogBytes) &&
|
||||||
|
gc::ParseStageCatalog(catalogBytes, &entries, &catalogError)) {
|
||||||
|
const std::string stageId = stagePath.stem().string();
|
||||||
|
const gc::StageCatalogEntry* entry = gc::FindStageCatalogEntryByChart(entries, stageId);
|
||||||
|
if (!entry && !chartName.empty()) {
|
||||||
|
entry = gc::FindStageCatalogEntryByChart(entries, chartName);
|
||||||
|
}
|
||||||
|
if (entry) {
|
||||||
|
for (size_t i = 0; i < entry->chartIds.size(); ++i) {
|
||||||
|
if (entry->chartIds[i] == stageId || entry->chartIds[i] == chartName) {
|
||||||
|
difficultyIndex = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
difficultyIndex = std::min(difficultyIndex, entry->chartIds.size() - 1);
|
||||||
|
const fs::path soundDir = dataDir / "stage" / "sound";
|
||||||
|
const fs::path bgm = soundDir /
|
||||||
|
(entry->bgmBase + entry->chartGroup0[difficultyIndex] + "_BGM.wav");
|
||||||
|
const fs::path shot = soundDir /
|
||||||
|
(entry->bgmBase + entry->chartSuffixes[difficultyIndex] + "_SHOT.wav");
|
||||||
|
result.bgmGain = static_cast<float>(entry->bgmVolumes[difficultyIndex]) / 100.0f;
|
||||||
|
result.shotGain = static_cast<float>(entry->shotVolumes[difficultyIndex]) / 100.0f;
|
||||||
|
|
||||||
|
// LoadStageBGM requires the pair. Its compatibility path is the
|
||||||
|
// old one-file layout under data/sound.
|
||||||
|
if (fs::is_regular_file(bgm) && fs::is_regular_file(shot)) {
|
||||||
|
result.bgmPath = bgm.string();
|
||||||
|
result.shotPath = shot.string();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
const fs::path legacy = dataDir / "sound" / (entry->bgmBase + ".wav");
|
||||||
|
if (fs::is_regular_file(legacy)) {
|
||||||
|
result.bgmPath = legacy.string();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
// Keep a damaged/incomplete dump playable when its BGM survived.
|
||||||
|
if (fs::is_regular_file(bgm)) {
|
||||||
|
result.bgmPath = bgm.string();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.bgmPath = inferGcBgmPath(stagePath, chartName, bgmName);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string inferGcBackgroundPath(const fs::path& stagePath, const std::string& chartName) {
|
||||||
|
const fs::path image = stagePath.parent_path() / "2d" / (gcStageToken(stagePath, chartName) + "_menu.dds");
|
||||||
|
return fs::is_regular_file(image) ? image.string() : std::string{};
|
||||||
|
}
|
||||||
|
|
||||||
|
LevelData loadGcStageDat(const std::string& stageFile) {
|
||||||
|
LevelData data;
|
||||||
|
std::vector<uint8_t> bytes;
|
||||||
|
if (!readFile(stageFile, bytes) || bytes.size() < 52) return data;
|
||||||
|
|
||||||
|
// Keep the complete clean-room decode alongside the compact player data.
|
||||||
|
// Rendering can consume sections incrementally without teaching this
|
||||||
|
// loader a second, diverging copy of every variable-length object record.
|
||||||
|
gc::StageDat stageDat;
|
||||||
|
gc::ParsedStagePattern parsedStage;
|
||||||
|
std::string stageError;
|
||||||
|
if (gc::StageDat::LoadFromFile(stageFile, stageDat, &stageError) &&
|
||||||
|
gc::ParseStagePattern(stageDat, &parsedStage, &stageError)) {
|
||||||
|
data.gcStage = std::move(parsedStage);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<uint32_t> header;
|
||||||
|
header.reserve(13);
|
||||||
|
for (size_t off = 0; off < 52; off += 4) header.push_back(u32be(bytes, off));
|
||||||
|
|
||||||
|
const uint32_t cfgOff = header[0];
|
||||||
|
const uint32_t trackOff = header[2];
|
||||||
|
const uint32_t notesOff = header[3];
|
||||||
|
const uint32_t cameraOff = header[4];
|
||||||
|
if (cfgOff >= bytes.size() || trackOff >= bytes.size() || notesOff >= bytes.size() || cameraOff >= bytes.size()) return data;
|
||||||
|
|
||||||
|
std::string chartName;
|
||||||
|
std::string bgmName;
|
||||||
|
float backwardsDrawDist = 10.0f;
|
||||||
|
float forwardDrawDist = 7.0f;
|
||||||
|
std::array<uint8_t, 4> trackAheadColor{0, 204, 255, 255};
|
||||||
|
std::array<uint8_t, 4> trackBehindColor{0, 102, 160, 255};
|
||||||
|
std::array<std::vector<GcTimingEntry>, 4> noteTimings;
|
||||||
|
const GcSystemTimingConfig systemTiming = loadGcSystemTimingConfig(fs::path(stageFile));
|
||||||
|
const size_t difficultyIndex = gcDifficultyIndex(fs::path(stageFile));
|
||||||
|
{
|
||||||
|
const size_t cfgEnd = header[1];
|
||||||
|
size_t off = cfgOff + 12;
|
||||||
|
if (off + 2 <= cfgEnd) {
|
||||||
|
const uint16_t bpmCount = u16be(bytes, off);
|
||||||
|
off += 2 + static_cast<size_t>(bpmCount) * 8;
|
||||||
|
bool timingListsValid = off <= cfgEnd;
|
||||||
|
for (std::vector<GcTimingEntry>& list : noteTimings) {
|
||||||
|
if (!timingListsValid || off + 2 > cfgEnd) {
|
||||||
|
timingListsValid = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const uint16_t count = u16be(bytes, off);
|
||||||
|
off += 2;
|
||||||
|
if (static_cast<size_t>(count) > (cfgEnd - off) / 12) {
|
||||||
|
timingListsValid = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
list.reserve(count);
|
||||||
|
for (uint16_t i = 0; i < count; ++i, off += 12) {
|
||||||
|
list.push_back({u32be(bytes, off), u32be(bytes, off + 4), f32be(bytes, off + 8)});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (timingListsValid) {
|
||||||
|
chartName = readSizedString16(bytes, off, cfgEnd);
|
||||||
|
(void)readSizedString16(bytes, off, cfgEnd);
|
||||||
|
bgmName = readSizedString16(bytes, off, cfgEnd);
|
||||||
|
(void)readSizedString16(bytes, off, cfgEnd);
|
||||||
|
if (off + 16 <= cfgEnd) {
|
||||||
|
backwardsDrawDist = f32be(bytes, off);
|
||||||
|
forwardDrawDist = f32be(bytes, off + 4);
|
||||||
|
for (size_t i = 0; i < 4; ++i) trackAheadColor[i] = bytes[off + 8 + i];
|
||||||
|
for (size_t i = 0; i < 4; ++i) trackBehindColor[i] = bytes[off + 12 + i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data.title = chartName.empty() ? fs::path(stageFile).stem().string() : chartName;
|
||||||
|
data.author = "Groove Coaster";
|
||||||
|
const GcStageAudio stageAudio = resolveGcStageAudio(
|
||||||
|
fs::path(stageFile), chartName, bgmName, difficultyIndex);
|
||||||
|
data.audioPath = stageAudio.bgmPath;
|
||||||
|
data.audioShotPath = stageAudio.shotPath;
|
||||||
|
data.audioBgmGain = stageAudio.bgmGain;
|
||||||
|
data.audioShotGain = stageAudio.shotGain;
|
||||||
|
const fs::path soundDir = fs::path(stageFile).parent_path().parent_path() / "sound";
|
||||||
|
// The selected SE id lives in the arcade profile. OpenRoller currently
|
||||||
|
// starts with the shipped default (se0000, "Ver.3 Set") from se.dat.
|
||||||
|
data.gameplaySoundPaths = {
|
||||||
|
(soundDir / "SE_ARRANGE.wav").string(),
|
||||||
|
(soundDir / "TAP_SE1.wav").string(),
|
||||||
|
(soundDir / "TAP_SE2.wav").string(),
|
||||||
|
};
|
||||||
|
// Exact entries in data/sound/SEList.csv for the three default slots.
|
||||||
|
data.gameplaySoundGains = {0.87f, 0.80f, 0.77f};
|
||||||
|
data.backgroundPath = inferGcBackgroundPath(fs::path(stageFile), chartName);
|
||||||
|
data.config["speed"] = 1.0f;
|
||||||
|
data.config["gc_camera"] = 1.0f;
|
||||||
|
// game471.exe uses this fixed vertical FOV in its gameplay projection.
|
||||||
|
data.config["gc_fov"] = 75.0f;
|
||||||
|
data.config["gc_draw_behind"] = saneFloat(backwardsDrawDist) ? std::max(0.0f, backwardsDrawDist) : 10.0f;
|
||||||
|
data.config["gc_draw_ahead"] = saneFloat(forwardDrawDist) ? std::max(0.0f, forwardDrawDist) : 7.0f;
|
||||||
|
data.config["gc_track_ahead_r"] = trackAheadColor[0] / 255.0f;
|
||||||
|
data.config["gc_track_ahead_g"] = trackAheadColor[1] / 255.0f;
|
||||||
|
data.config["gc_track_ahead_b"] = trackAheadColor[2] / 255.0f;
|
||||||
|
data.config["gc_track_behind_r"] = trackBehindColor[0] / 255.0f;
|
||||||
|
data.config["gc_track_behind_g"] = trackBehindColor[1] / 255.0f;
|
||||||
|
data.config["gc_track_behind_b"] = trackBehindColor[2] / 255.0f;
|
||||||
|
data.config["gc_great_min_ms"] = std::max(0.0f, systemTiming.greatMinTimeMs);
|
||||||
|
data.config["gc_scratch_enable_ms"] = std::max(0.0f, systemTiming.scratchEnableTimeMs);
|
||||||
|
data.config["gc_beat_enable_ms"] = std::max(0.0f, systemTiming.beatEnableTimeMs);
|
||||||
|
|
||||||
|
std::vector<GcTrackPiece> gcTrack;
|
||||||
|
{
|
||||||
|
const size_t trackEnd = notesOff;
|
||||||
|
if (trackOff + 4 > trackEnd) return data;
|
||||||
|
const uint32_t count = u32be(bytes, trackOff);
|
||||||
|
size_t off = trackOff + 4;
|
||||||
|
const size_t capacity = (trackEnd - off) / 16;
|
||||||
|
const size_t n = std::min<size_t>(count, capacity);
|
||||||
|
gcTrack.reserve(n);
|
||||||
|
for (size_t i = 0; i < n; ++i, off += 16) {
|
||||||
|
GcTrackPiece p;
|
||||||
|
p.timeMs = u32be(bytes, off + 0);
|
||||||
|
p.pos.x = f32be(bytes, off + 4);
|
||||||
|
p.pos.y = f32be(bytes, off + 8);
|
||||||
|
p.pos.z = f32be(bytes, off + 12);
|
||||||
|
if (saneFloat(p.pos.x) && saneFloat(p.pos.y) && saneFloat(p.pos.z)) gcTrack.push_back(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// game471's FUN_005e9690 interpolates adjacent track keys linearly by
|
||||||
|
// timestamp. Type 1 selects the matching straight-segment path here.
|
||||||
|
for (const auto& p : gcTrack) data.trackPoints.push_back({p.pos, 1, true, static_cast<float>(p.timeMs)});
|
||||||
|
if (gcTrack.size() < 2) return data;
|
||||||
|
data.config["gc_duration_ms"] = static_cast<float>(std::max<uint32_t>(1, gcTrack.back().timeMs));
|
||||||
|
|
||||||
|
{
|
||||||
|
const size_t drawOff = header[1];
|
||||||
|
if (drawOff + 4 <= trackOff) {
|
||||||
|
const uint32_t count = u32be(bytes, drawOff);
|
||||||
|
const size_t capacity = (trackOff - drawOff - 4) / 8;
|
||||||
|
size_t off = drawOff + 4;
|
||||||
|
for (size_t i = 0; i < std::min<size_t>(count, capacity); ++i, off += 8) {
|
||||||
|
const uint32_t timeMs = u32be(bytes, off);
|
||||||
|
const float distance = f32be(bytes, off + 4);
|
||||||
|
if (saneFloat(distance)) {
|
||||||
|
data.timeline.push_back({trackParamAtTimeMs(gcTrack, static_cast<float>(timeMs)),
|
||||||
|
"gc_draw_ahead", std::max(0.0f, distance)});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<float> cumulative;
|
||||||
|
cumulative.reserve(gcTrack.size());
|
||||||
|
cumulative.push_back(0.0f);
|
||||||
|
for (size_t i = 1; i < gcTrack.size(); ++i) {
|
||||||
|
cumulative.push_back(cumulative.back() + glm::distance(gcTrack[i - 1].pos, gcTrack[i].pos));
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const size_t cameraEnd = header[5];
|
||||||
|
if (cameraOff + 4 <= cameraEnd) {
|
||||||
|
const uint32_t count = u32be(bytes, cameraOff);
|
||||||
|
size_t off = cameraOff + 4;
|
||||||
|
const size_t recordSize = 59;
|
||||||
|
const size_t capacity = (cameraEnd - off) / recordSize;
|
||||||
|
const size_t n = std::min<size_t>(count, capacity);
|
||||||
|
data.gcCameraKeys.reserve(n);
|
||||||
|
for (size_t i = 0; i < n; ++i, off += recordSize) {
|
||||||
|
GcCameraKey key;
|
||||||
|
key.timeMs = u32be(bytes, off + 0);
|
||||||
|
key.aMode = bytes[off + 4];
|
||||||
|
key.fMode = bytes[off + 5];
|
||||||
|
key.dist = f32be(bytes, off + 6);
|
||||||
|
key.rotationA = {f32be(bytes, off + 10), f32be(bytes, off + 14), 0.0f};
|
||||||
|
key.originOff = {f32be(bytes, off + 18), f32be(bytes, off + 22), f32be(bytes, off + 26)};
|
||||||
|
key.projType = bytes[off + 30];
|
||||||
|
key.fieldFar = {f32be(bytes, off + 31), f32be(bytes, off + 35), f32be(bytes, off + 39)};
|
||||||
|
key.fieldNear = {f32be(bytes, off + 43), f32be(bytes, off + 47), f32be(bytes, off + 51)};
|
||||||
|
key.rotationB = f32be(bytes, off + 55);
|
||||||
|
|
||||||
|
// game471.exe FUN_005ed4c0 copies all 59 wire bytes into the
|
||||||
|
// 0x44-byte runtime key without finite-value filtering. The
|
||||||
|
// first camera in the three comet charts deliberately carries
|
||||||
|
// NaN rotations, so dropping the whole key changes their intro.
|
||||||
|
data.gcCameraKeys.push_back(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full-screen stage background: four RGBA corners keyed by chart time.
|
||||||
|
// The two trailing bytes are fade flags; linear interpolation matches the
|
||||||
|
// common fade-enabled records and will be specialized once both modes are
|
||||||
|
// mapped from the renderer.
|
||||||
|
if (header.size() > 9) {
|
||||||
|
const size_t colorsOff = header[8];
|
||||||
|
const size_t objectsOff = header[9];
|
||||||
|
constexpr size_t colorRecordSize = 22;
|
||||||
|
if (colorsOff + 4 <= objectsOff && objectsOff <= bytes.size()) {
|
||||||
|
const uint32_t count = u32be(bytes, colorsOff);
|
||||||
|
const size_t capacity = (objectsOff - colorsOff - 4) / colorRecordSize;
|
||||||
|
size_t off = colorsOff + 4;
|
||||||
|
static constexpr const char* corners[] = {"tr", "tl", "br", "bl"};
|
||||||
|
for (size_t i = 0; i < std::min<size_t>(count, capacity); ++i, off += colorRecordSize) {
|
||||||
|
const uint32_t timeMs = u32be(bytes, off);
|
||||||
|
const float t = trackParamAtTimeMs(gcTrack, static_cast<float>(timeMs));
|
||||||
|
for (size_t corner = 0; corner < 4; ++corner) {
|
||||||
|
const size_t colorOff = off + 4 + corner * 4;
|
||||||
|
data.timeline.push_back({t, std::string("gc_bg_") + corners[corner] + "_r", bytes[colorOff + 0] / 255.0f});
|
||||||
|
data.timeline.push_back({t, std::string("gc_bg_") + corners[corner] + "_g", bytes[colorOff + 1] / 255.0f});
|
||||||
|
data.timeline.push_back({t, std::string("gc_bg_") + corners[corner] + "_b", bytes[colorOff + 2] / 255.0f});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data.config["gc_background_keys"] = static_cast<float>(std::min<size_t>(count, capacity));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<GcNote> gcNotes = decodeGcNotes(bytes, notesOff, cameraOff);
|
||||||
|
if (!gcNotes.empty()) {
|
||||||
|
std::array<size_t, 256> typeCounts{};
|
||||||
|
const float minT = static_cast<float>(gcNotes.front().timeMs);
|
||||||
|
const float maxT = static_cast<float>(gcNotes.back().timeMs);
|
||||||
|
for (size_t noteIndex = 0; noteIndex < gcNotes.size(); ++noteIndex) {
|
||||||
|
const GcNote& n = gcNotes[noteIndex];
|
||||||
|
++typeCounts[n.rawType];
|
||||||
|
const float timeMs = static_cast<float>(n.timeMs);
|
||||||
|
const float beatMs = gcBeatDurationAt(data.gcStage ? &data.gcStage->config : nullptr, n.timeMs);
|
||||||
|
const bool durationType = n.effectiveType == 3 || n.effectiveType == 4 ||
|
||||||
|
n.effectiveType == 5 || n.effectiveType == 10 ||
|
||||||
|
n.effectiveType == 15;
|
||||||
|
const float durationBeats = n.effectiveType == 6
|
||||||
|
? static_cast<float>(n.merryCount) * std::max(0.0f, n.merrySpacingBeats)
|
||||||
|
: std::max(0.0f, n.durationBeats);
|
||||||
|
const float appearTimeMs = std::max(0.0f, timeMs - std::max(0.0f, n.appearanceLeadBeats) * beatMs);
|
||||||
|
const float endTimeMs = (durationType || n.effectiveType == 6)
|
||||||
|
? std::max(timeMs, timeMs + durationBeats * beatMs)
|
||||||
|
: timeMs;
|
||||||
|
const float nextSpacingMs = noteIndex + 1 < gcNotes.size()
|
||||||
|
? std::max(0.0f, static_cast<float>(gcNotes[noteIndex + 1].timeMs) - timeMs)
|
||||||
|
: beatMs * 2.0f;
|
||||||
|
float missWindowMs = std::max(0.0f, gcTimingAt(noteTimings[0], n.timeMs, beatMs, nextSpacingMs));
|
||||||
|
float earlyWindowMs = std::max(0.0f, gcTimingAt(noteTimings[1], n.timeMs, beatMs, nextSpacingMs));
|
||||||
|
float lateWindowMs = std::max(0.0f, gcTimingAt(noteTimings[2], n.timeMs, beatMs, nextSpacingMs));
|
||||||
|
float muteTimingMs = std::max(0.0f, gcTimingAt(noteTimings[3], n.timeMs, beatMs, nextSpacingMs));
|
||||||
|
// FUN_005ed4c0 replaces the authored timing-list results with the
|
||||||
|
// four per-difficulty arrays from data/system.cfg when this flag is
|
||||||
|
// enabled. Runtime +0x98/+0x9c/+0xa0 are miss, early and late.
|
||||||
|
if (systemTiming.missMarkOverride) {
|
||||||
|
missWindowMs = std::max(0.0f, systemTiming.miss[difficultyIndex]);
|
||||||
|
earlyWindowMs = std::max(0.0f, systemTiming.unmute[difficultyIndex]);
|
||||||
|
lateWindowMs = std::max(0.0f, systemTiming.limit[difficultyIndex]);
|
||||||
|
muteTimingMs = std::max(0.0f, systemTiming.mute[difficultyIndex]);
|
||||||
|
}
|
||||||
|
// BuildTimingDataSub adds this literal for FLICK and the otherwise
|
||||||
|
// unclassified type 0x10 before constructing runtime +0xc4.
|
||||||
|
if (n.effectiveType == 2 || n.rawType == 0x10) {
|
||||||
|
earlyWindowMs += beatMs * 0.2f;
|
||||||
|
lateWindowMs += beatMs * 0.2f;
|
||||||
|
}
|
||||||
|
const float fadeAnchorMs = (durationType || n.effectiveType == 6)
|
||||||
|
? endTimeMs
|
||||||
|
: timeMs + lateWindowMs;
|
||||||
|
const float markerFadeEndTimeMs = fadeAnchorMs + beatMs * 4.0f;
|
||||||
|
data.notes.push_back({
|
||||||
|
cumulativeDistanceAtTime(gcTrack, cumulative, timeMs, minT, maxT),
|
||||||
|
timeMs,
|
||||||
|
appearTimeMs,
|
||||||
|
endTimeMs,
|
||||||
|
cumulativeDistanceAtTime(gcTrack, cumulative, endTimeMs, minT, maxT),
|
||||||
|
n.rawType,
|
||||||
|
n.effectiveType,
|
||||||
|
n.adlib,
|
||||||
|
n.markEffectId,
|
||||||
|
n.packedColor,
|
||||||
|
n.merryCount,
|
||||||
|
n.directionVector,
|
||||||
|
beatMs,
|
||||||
|
earlyWindowMs,
|
||||||
|
lateWindowMs,
|
||||||
|
missWindowMs,
|
||||||
|
muteTimingMs,
|
||||||
|
markerFadeEndTimeMs,
|
||||||
|
true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
std::cout << "GC note types:";
|
||||||
|
for (size_t type = 0; type < typeCounts.size(); ++type) {
|
||||||
|
if (typeCounts[type] == 0) continue;
|
||||||
|
std::cout << " 0x" << std::hex << type << std::dec << '/' << gcNoteTypeName(static_cast<uint8_t>(type))
|
||||||
|
<< '=' << typeCounts[type];
|
||||||
|
}
|
||||||
|
std::cout << std::endl;
|
||||||
|
}
|
||||||
|
|
||||||
|
// FUN_005ed4c0 loads <stage>_clip.dat as an objectCount x frameCount byte
|
||||||
|
// matrix. FUN_006445b0 indexes object first and round(timeMs / (1000/60))
|
||||||
|
// second, and skips the object when the stored byte is zero.
|
||||||
|
loadGcObjectClipTable(fs::path(stageFile), data);
|
||||||
|
|
||||||
|
std::cout << "GC stage loaded: " << data.title << " track=" << data.trackPoints.size()
|
||||||
|
<< " notes=" << data.notes.size()
|
||||||
|
<< " cameras=" << data.gcCameraKeys.size()
|
||||||
|
<< " clipFrames=" << data.gcObjectClipFrameCount
|
||||||
|
<< " bgKeys=" << static_cast<size_t>(data.config.count("gc_background_keys")
|
||||||
|
? data.config.at("gc_background_keys") : 0.0f)
|
||||||
|
<< (data.gcStage ? " particles=" + std::to_string(data.gcStage->particles.size()) +
|
||||||
|
" visualizers=" + std::to_string(data.gcStage->visualizer.size()) +
|
||||||
|
" models=" + std::to_string(data.gcStage->modelNames.size()) +
|
||||||
|
" objects=" + std::to_string(data.gcStage->objects.size())
|
||||||
|
: " backgroundScene=<decode-failed>")
|
||||||
|
<< (data.audioPath.empty() ? " audio=<none>" : " bgm=" + data.audioPath)
|
||||||
|
<< (data.audioShotPath.empty() ? " shot=<none>" : " shot=" + data.audioShotPath)
|
||||||
|
<< " audioGain=" << data.audioBgmGain << '/' << data.audioShotGain
|
||||||
|
<< (data.gameplaySoundPaths[1].empty() ? " tapSE=<none>" : " tapSE=Ver.3")
|
||||||
|
<< (data.backgroundPath.empty() ? " background=<none>" : " background=" + data.backgroundPath)
|
||||||
|
<< std::endl;
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
LevelData loadLegacyFolder(const std::string& mapFolder) {
|
||||||
|
LevelData data;
|
||||||
|
std::string metaPath = mapFolder + "/map.txt";
|
||||||
|
std::ifstream metaFile(metaPath);
|
||||||
|
if (!metaFile.is_open()) return data;
|
||||||
|
|
||||||
|
std::string line, trackFile, notesFile, timelineFile;
|
||||||
|
while (std::getline(metaFile, line)) {
|
||||||
|
if (line.empty() || line[0] == '#') continue;
|
||||||
|
std::stringstream ss(line);
|
||||||
|
std::string key, value; ss >> key; std::getline(ss, value);
|
||||||
|
if (!value.empty() && value[0] == ' ') value.erase(0, 1);
|
||||||
|
if (key == "title") data.title = value;
|
||||||
|
else if (key == "author") data.author = value;
|
||||||
|
else if (key == "audio") data.audioPath = mapFolder + "/" + value;
|
||||||
|
else if (key == "track") trackFile = mapFolder + "/" + value;
|
||||||
|
else if (key == "notes") notesFile = mapFolder + "/" + value;
|
||||||
|
else if (key == "timeline") timelineFile = mapFolder + "/" + value;
|
||||||
|
else { try { data.config[key] = std::stof(value); } catch(...) {} }
|
||||||
|
}
|
||||||
|
|
||||||
|
std::ifstream tFile(trackFile);
|
||||||
|
if (tFile.is_open()) {
|
||||||
|
while (std::getline(tFile, line)) {
|
||||||
|
std::stringstream ss(line);
|
||||||
|
float x, y, z; int type = 0; int visible = 1;
|
||||||
|
if (ss >> x >> y >> z) {
|
||||||
|
if (!(ss >> type)) type = 0;
|
||||||
|
if (!(ss >> visible)) visible = 1;
|
||||||
|
data.trackPoints.push_back({{x, y, z}, type, visible != 0});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::ifstream nFile(notesFile);
|
||||||
|
if (nFile.is_open()) {
|
||||||
|
float distance = 0.0f;
|
||||||
|
while (nFile >> distance) data.notes.push_back({distance, 0.0f, 0, 0});
|
||||||
|
}
|
||||||
|
|
||||||
|
std::ifstream tlFile(timelineFile);
|
||||||
|
if (tlFile.is_open()) {
|
||||||
|
while (std::getline(tlFile, line)) {
|
||||||
|
if (line.empty() || line[0] == '#') continue;
|
||||||
|
std::stringstream ss(line);
|
||||||
|
float t, val; std::string param;
|
||||||
|
if (ss >> t >> param >> val) data.timeline.push_back({t, param, val});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
LevelData LevelLoader::load(const std::string& path) {
|
||||||
|
const fs::path p(path);
|
||||||
|
if (fs::is_regular_file(p) && p.extension() == ".dat") return loadGcStageDat(path);
|
||||||
|
return loadLegacyFolder(path);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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]];
|
||||||
@@ -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]];
|
||||||
@@ -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]];
|
||||||
@@ -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]];
|
||||||
@@ -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]];
|
||||||
@@ -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]];
|
||||||
@@ -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]];
|
||||||
@@ -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]];
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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
@@ -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;
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
File diff suppressed because it is too large
Load Diff
+2997
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Executable
+12
@@ -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" "$@"
|
||||||
@@ -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:]))
|
||||||
@@ -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; }
|
||||||
@@ -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
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
LIBRARY kernel32.dll
|
||||||
|
EXPORTS
|
||||||
|
CreateFileA
|
||||||
|
SetFilePointer
|
||||||
|
WriteFile
|
||||||
|
CloseHandle
|
||||||
Executable
+39
@@ -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
Reference in New Issue
Block a user