commit 8c4e1bcacb781a9876aaa64b59f1bb5c455d455d Author: Kiyooru Date: Sun Aug 2 17:05:27 2026 +0200 Initial Vectorail GC format library diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8b71ebb --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +/build/ +/cmake-build-*/ +/.cache/ +/.clangd/ +/compile_commands.json +*.o +*.a +*.so +*.dll +*.dylib +*.exe + diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..8c4e0cf --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,93 @@ +cmake_minimum_required(VERSION 3.20) + +project(vectorail-gc VERSION 0.1.0 LANGUAGES CXX) + +include(GNUInstallDirs) +include(CMakePackageConfigHelpers) + +option(VECTORAIL_GC_BUILD_TOOLS "Build format inspection tools" ON) + +find_package(glm CONFIG REQUIRED) + +add_library(vectorail-gc + src/EventStream.cpp + src/MtxArchive.cpp + src/RvbLayout.cpp + src/RvbScene.cpp + src/StageCatalog.cpp + src/StageDat.cpp + src/StagePattern.cpp + src/TumoModel.cpp +) +add_library(Vectorail::GC ALIAS vectorail-gc) +target_compile_features(vectorail-gc PUBLIC cxx_std_17) +target_include_directories(vectorail-gc + PUBLIC + $ + $ +) +set_target_properties(vectorail-gc PROPERTIES + EXPORT_NAME GC + VERSION ${PROJECT_VERSION} + SOVERSION ${PROJECT_VERSION_MAJOR} +) + +add_library(vectorail-gc-effects src/GcTargetEffect.cpp) +add_library(Vectorail::GCEffects ALIAS vectorail-gc-effects) +target_compile_features(vectorail-gc-effects PUBLIC cxx_std_17) +target_include_directories(vectorail-gc-effects + PUBLIC + $ + $ +) +target_link_libraries(vectorail-gc-effects PUBLIC glm::glm) +set_target_properties(vectorail-gc-effects PROPERTIES + EXPORT_NAME GCEffects + VERSION ${PROJECT_VERSION} + SOVERSION ${PROJECT_VERSION_MAJOR} +) + +if(VECTORAIL_GC_BUILD_TOOLS) + add_executable(vectorail-gc-stage-probe tools/stage_probe.cpp) + target_link_libraries(vectorail-gc-stage-probe PRIVATE Vectorail::GC) + + add_executable(vectorail-gc-rvb-probe tools/rvb_probe.cpp) + target_link_libraries(vectorail-gc-rvb-probe PRIVATE Vectorail::GC) + + add_executable(vectorail-gc-mtx-probe tools/mtx_probe.cpp) + target_link_libraries(vectorail-gc-mtx-probe PRIVATE Vectorail::GC) + + add_executable(vectorail-gc-effect-probe tools/effect_probe.cpp) + target_link_libraries(vectorail-gc-effect-probe PRIVATE Vectorail::GCEffects) +endif() + +install(TARGETS vectorail-gc vectorail-gc-effects + EXPORT VectorailGCTargets + ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR} + LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR} + RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} + INCLUDES DESTINATION ${CMAKE_INSTALL_INCLUDEDIR} +) +install(DIRECTORY include/ DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}) +install(EXPORT VectorailGCTargets + FILE VectorailGCTargets.cmake + NAMESPACE Vectorail:: + DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/VectorailGC +) + +configure_package_config_file( + cmake/VectorailGCConfig.cmake.in + ${CMAKE_CURRENT_BINARY_DIR}/VectorailGCConfig.cmake + INSTALL_DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/VectorailGC +) +write_basic_package_version_file( + ${CMAKE_CURRENT_BINARY_DIR}/VectorailGCConfigVersion.cmake + VERSION ${PROJECT_VERSION} + COMPATIBILITY SameMajorVersion +) +install(FILES + ${CMAKE_CURRENT_BINARY_DIR}/VectorailGCConfig.cmake + ${CMAKE_CURRENT_BINARY_DIR}/VectorailGCConfigVersion.cmake + DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/VectorailGC +) + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0827584 --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +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. + diff --git a/README.md b/README.md new file mode 100644 index 0000000..a064004 --- /dev/null +++ b/README.md @@ -0,0 +1,30 @@ +# Vectorail GC + +Headless parsers and evaluators for Groove Coaster interoperability. The +library has no windowing, rendering, audio, input, or gameplay dependencies. + +## Formats + +- arcade stage `.dat` streams and note opcodes; +- stage catalog and pattern data; +- `.tumo` model geometry; +- MTX texture archives; +- RVB scenes and layout snapshots; +- effect and UV animation data. + +No game files are distributed with this repository. Applications must point +the library at data obtained from their own installation or hardware dump. + +## Build + +```sh +cmake -S . -B build +cmake --build build +cmake --install build --prefix /desired/prefix +``` + +Consumers use `Vectorail::GC` for the base parsers and +`Vectorail::GCEffects` for the GLM-based effect evaluator. + +Vectorail GC is distributed under the MIT License. + diff --git a/cmake/VectorailGCConfig.cmake.in b/cmake/VectorailGCConfig.cmake.in new file mode 100644 index 0000000..9441e3d --- /dev/null +++ b/cmake/VectorailGCConfig.cmake.in @@ -0,0 +1,7 @@ +@PACKAGE_INIT@ + +include(CMakeFindDependencyMacro) +find_dependency(glm CONFIG) + +include("${CMAKE_CURRENT_LIST_DIR}/VectorailGCTargets.cmake") + diff --git a/include/gc/EventStream.hpp b/include/gc/EventStream.hpp new file mode 100644 index 0000000..b084e18 --- /dev/null +++ b/include/gc/EventStream.hpp @@ -0,0 +1,46 @@ +#ifndef OPENROLLER_GC_EVENTSTREAM_HPP +#define OPENROLLER_GC_EVENTSTREAM_HPP + +#include +#include +#include +#include + +namespace gc { + +struct GameEvent { + uint16_t id = 0; + float timestamp = 0.0f; + uint16_t a = 0; // unknown (often 0) + uint32_t type = 0; + float value = 0.0f; + uint16_t b = 0; // unknown (often 0) +}; + +struct EventStreamDecodeResult { + size_t recordSize = 16; // 16 or 12 (for now) + int alignment = 0; // best alignment within [0,recordSize-1] + size_t eventCount = 0; // decoded event count at best alignment (bounded) + double padZeroRatio = 0.0; // fraction of padding fields that are zero (0..1) + int score = 0; // heuristic score for ranking +}; + +EventStreamDecodeResult TryDecodeEventStream(const std::vector& bytes, size_t start, size_t end); + +// Like TryDecodeEventStream, but forces recordSize=12 or 16. +EventStreamDecodeResult TryDecodeEventStreamFixed(const std::vector& bytes, size_t start, size_t end, size_t recordSize); + +// Decodes using a fixed record size (big endian). +// recordSize=16: u16 id, f32 timestamp, u16 a, u16 type, f32 value, u16 b +// recordSize=12: f32 timestamp, u32 type, f32 value +bool DecodeEventStream( + const std::vector& bytes, + size_t start, + size_t end, + size_t recordSize, + std::vector* out, + std::string* err); + +} // namespace gc + +#endif diff --git a/include/gc/GcTargetEffect.hpp b/include/gc/GcTargetEffect.hpp new file mode 100644 index 0000000..95cd1d5 --- /dev/null +++ b/include/gc/GcTargetEffect.hpp @@ -0,0 +1,67 @@ +#pragma once + +#include +#include +#include +#include + +#include + +struct GcUvCell { + uint16_t x = 0; + uint16_t y = 0; + uint16_t width = 0; + uint16_t height = 0; +}; + +struct GcEffectSprite { + uint16_t uvRecord = 0; + int frame = 0; + glm::vec3 offsetPixels{0.0f}; + glm::vec4 color{1.0f}; + glm::vec2 scale{1.0f}; + float rotationDegrees = 0.0f; +}; + +// Interpreter for the sprite subset of the original common effect format. +// It follows game471 FUN_005f2030/FUN_005f2250/FUN_005f23f0 and is enough to +// reproduce control-helper effects 61..69, including parent rotation nodes. +class GcTargetEffectBank { +public: + bool load(const std::string& efcPath, const std::string& uvPath, std::string* error = nullptr); + int lifetime(int effectId) const; + std::vector evaluate(int effectId, float tick, int uvRecordBase = 0) const; + const GcUvCell* uvCell(uint16_t record, int frame) const; + int uvTexture(uint16_t record) const; + +private: + struct Key { + uint16_t time = 0; + uint8_t interpolation = 0; + std::vector values; + }; + struct Track { + uint16_t loopStart = 0; + uint16_t loopEnd = 0; + std::vector keys; + }; + struct Child { + uint8_t type = 0; + uint16_t reference = 0xffff; + bool inheritParent = false; + std::array tracks; + }; + struct Effect { + uint16_t lifetime = 0; + std::vector children; + }; + struct UvRecord { + int16_t textureIndex = -1; + std::vector cells; + }; + + static std::vector sampleTrack(const Track& track, float tick, bool* started); + + std::vector effects_; + std::vector uvRecords_; +}; diff --git a/include/gc/MtxArchive.hpp b/include/gc/MtxArchive.hpp new file mode 100644 index 0000000..2f42cf0 --- /dev/null +++ b/include/gc/MtxArchive.hpp @@ -0,0 +1,35 @@ +#pragma once + +#include +#include +#include +#include + +namespace gc { + +struct MtxTexture { + uint32_t offset = 0; + uint32_t size = 0; + uint32_t width = 0; + uint32_t height = 0; + uint32_t bitsPerPixel = 0; + uint32_t fourCC = 0; +}; + +struct MtxArchive { + std::vector textures; +}; + +bool ParseMtxArchive(const std::vector& bytes, + MtxArchive* archive, + std::string* error = nullptr); + +// MTX stores a normal DDS surface with its four-byte magic replaced by an +// internal field. This returns the exact embedded surface with "DDS " put +// back, suitable for the existing DDS loader or external inspection tools. +bool ExtractMtxTextureDds(const std::vector& bytes, + const MtxTexture& texture, + std::vector* dds, + std::string* error = nullptr); + +} // namespace gc diff --git a/include/gc/NoteTypes.hpp b/include/gc/NoteTypes.hpp new file mode 100644 index 0000000..53049ca --- /dev/null +++ b/include/gc/NoteTypes.hpp @@ -0,0 +1,42 @@ +#pragma once + +#include + +namespace gc { + +// NOTE: these IDs are based on static analysis of a limited sample set and may +// need adjustments as we decode more charts. +enum class NoteType : uint32_t { + // --- System / Timing --- + BpmChange = 0x00000000, // Value = BPM? Or a timing/speed scalar. + Camera = 0x00000002, // Camera control (hypothesis) + MeasureLine = 0x00000004, // Measure start / marker (hypothesis) + BeatLine = 0x0000003C, // (60) Visual beat grid line (hypothesis) + + // --- Basic Notes --- + Tap = 0x00000029, // (41) HIT: regular tap (hypothesis) + Critical = 0x0000002A, // (42) CRITICAL: double tap / star (hypothesis) + + // --- Holds --- + HoldStart = 0x0000002B, // (43) HOLD start (hypothesis) + HoldEnd = 0x0000002C, // (44) HOLD end (hypothesis) + DualHold = 0x0000002F, // (47) Dual hold (hypothesis) + + // --- Slides (Value = Angle in Radians) --- + Slide = 0x0000002D, // (45) Slide (hypothesis) + DualSlide = 0x0000002E, // (46) Dual slide (hypothesis) + SlideHold = 0x00000030, // (48) Slide + hold? (hypothesis) + + // --- Special / Legacy (seen in 10pt8tion) --- + Legacy_Tap = 0x00000100, + Legacy_Slide = 0x00000A00, + + // --- Unknown --- + Unknown = 0xFFFFFFFFu, +}; + +inline bool IsNote(uint32_t type) { + return (type >= 0x29u && type <= 0x30u) || type == 0x100u || type == 0xA00u; +} + +} // namespace gc diff --git a/include/gc/RvbLayout.hpp b/include/gc/RvbLayout.hpp new file mode 100644 index 0000000..aa135b2 --- /dev/null +++ b/include/gc/RvbLayout.hpp @@ -0,0 +1,59 @@ +#pragma once + +#include "gc/RvbScene.hpp" + +#include +#include +#include +#include +#include + +namespace gc { + +struct RvbImageDraw { + std::string imageSymbol; + std::string instancePath; + // top-left, top-right, bottom-left, bottom-right in the RVB canvas + std::array, 4> corners{}; + std::array color{1.0f, 1.0f, 1.0f}; + float alpha = 1.0f; + uint32_t depth = 0; +}; + +// A Flash-style MovieClip path mapped to the labeled frame that should be +// visible. Paths are the same absolute paths exported in PREP ("/" is the +// root timeline). Clips not mentioned here remain on their first FRAM. +struct RvbSnapshotState { + std::unordered_map frameByPath; + bool includeRootOther = false; +}; + +// Builds the display list produced by the first frame of the root timeline +// and the first frame of every placed MovieClip. This is the exact static +// base of the original scene; later timeline frames and ActionScript state +// changes can be layered on top as their opcodes are recovered. +bool BuildRvbInitialSnapshot(const std::vector& bytes, + const RvbScene& scene, + std::vector* draws, + std::string* error = nullptr); + +// Builds the display list at selected labeled frames. RVB FRAM records are +// deltas, so this applies PLC3/RMOV commands in order through each requested +// frame instead of treating a frame as a self-contained draw list. +bool BuildRvbSnapshot(const std::vector& bytes, + const RvbScene& scene, + const RvbSnapshotState& state, + std::vector* draws, + std::string* error = nullptr); + +// Builds an exported/linkage MovieClip without requiring it to be placed on +// the root timeline. game471.exe uses this path for the twelve dynamically +// instantiated select-music rows (mc_music_link / mc_index_link). +bool BuildRvbSymbolSnapshot(const std::vector& bytes, + const RvbScene& scene, + const std::string& symbolName, + const RvbSnapshotState& state, + std::vector* draws, + std::string* error = nullptr); + +} // namespace gc diff --git a/include/gc/RvbScene.hpp b/include/gc/RvbScene.hpp new file mode 100644 index 0000000..914b359 --- /dev/null +++ b/include/gc/RvbScene.hpp @@ -0,0 +1,60 @@ +#pragma once + +#include +#include +#include +#include + +namespace gc { + +struct RvbChunk { + std::string tag; + uint32_t offset = 0; + uint32_t size = 0; +}; + +// A PREP entry binds an animation/action name to an instance path in the +// exported menu scene. These names are what game471.exe uses to drive the +// select-music and difficulty state machines. +struct RvbBinding { + std::string action; + std::string instancePath; +}; + +struct RvbImageResource { + std::string fileName; + std::string symbolName; + uint32_t width = 0; + uint32_t height = 0; +}; + +struct RvbExport { + std::string definitionName; + std::string linkageName; +}; + +struct RvbNode { + std::string tag; + uint32_t offset = 0; + uint32_t size = 0; + uint32_t localDataOffset = 0; + uint32_t localDataSize = 0; + std::vector children; +}; + +struct RvbScene { + uint8_t framesPerSecond = 0; + uint16_t sourceWidth = 0; + uint16_t sourceHeight = 0; + std::vector chunks; + std::vector bindings; + std::vector images; + std::vector exports; + std::vector roots; +}; + +bool ParseRvbScene(const std::vector& bytes, + RvbScene* scene, + std::string* error = nullptr); + +} // namespace gc diff --git a/include/gc/StageCatalog.hpp b/include/gc/StageCatalog.hpp new file mode 100644 index 0000000..986833a --- /dev/null +++ b/include/gc/StageCatalog.hpp @@ -0,0 +1,49 @@ +#pragma once + +#include +#include +#include +#include + +namespace gc { + +// One 0xb4-byte runtime entry created by game471.exe's stage_param.dat loader. +// Strings in the file are byte-length-prefixed and retain the game's source +// encoding (mostly CP932 for localized text). +struct StageCatalogEntry { + uint32_t id = 0; + std::string title; + std::string imageKey; + std::string artist; + std::string source; + std::string sortKey; + uint8_t genre = 0; + std::string duration; + std::array difficultyRatings{}; + std::string bpm; + // Per-difficulty percentage levels passed to LoadStageBGM for the two + // continuously synchronized stage stems. + std::array bgmVolumes{}; + std::array shotVolumes{}; + std::array timingValues{}; + std::array unknown60{}; + std::string bgmBase; + std::array chartGroup0{}; + std::array chartSuffixes{}; + std::array chartIds{}; + std::string unknown9c; + uint32_t unknownA0 = 0; + std::array unknownA4{}; + std::string unknownAc; + uint8_t unknownB0 = 0; +}; + +bool ParseStageCatalog(const std::vector& bytes, + std::vector* entries, + std::string* error = nullptr); + +const StageCatalogEntry* FindStageCatalogEntryByChart( + const std::vector& entries, + const std::string& chartId); + +} // namespace gc diff --git a/include/gc/StageDat.hpp b/include/gc/StageDat.hpp new file mode 100644 index 0000000..ccf6540 --- /dev/null +++ b/include/gc/StageDat.hpp @@ -0,0 +1,30 @@ +#ifndef OPENROLLER_GC_STAGEDAT_HPP +#define OPENROLLER_GC_STAGEDAT_HPP + +#include +#include +#include +#include + +namespace gc { + +struct Section { + size_t start = 0; + size_t end = 0; +}; + +struct StageDat { + std::vector bytes; + + uint32_t headerSize = 0; + std::vector headerWords; + std::vector offsets; // sorted unique offsets within file + std::vector
sections; // derived from offsets + + static bool LoadFromFile(const std::string& path, StageDat& out, std::string* err); +}; + +} // namespace gc + +#endif + diff --git a/include/gc/StagePattern.hpp b/include/gc/StagePattern.hpp new file mode 100644 index 0000000..3227716 --- /dev/null +++ b/include/gc/StagePattern.hpp @@ -0,0 +1,234 @@ +#ifndef OPENROLLER_GC_STAGEPATTERN_HPP +#define OPENROLLER_GC_STAGEPATTERN_HPP + +#include "gc/StageDat.hpp" + +#include +#include +#include +#include + +namespace gc { + +struct Color { + uint8_t r = 0; + uint8_t g = 0; + uint8_t b = 0; + uint8_t a = 0; +}; + +struct StageHeader { + uint32_t stageCfg = 0; + uint32_t trackDrawDist = 0; + uint32_t track = 0; + uint32_t notes = 0; + uint32_t camera = 0; + uint32_t particles = 0; + uint32_t visualizer = 0; + uint32_t unk1 = 0; + uint32_t colors = 0; + uint32_t objects = 0; + uint32_t unk2 = 0; + uint32_t colors2 = 0; + uint32_t unk3 = 0; +}; + +struct BpmChange { + uint32_t timeMs = 0; + uint32_t bpm = 0; +}; + +struct NoteSetting { + uint32_t timeMs = 0; + // TuneTimingData::GetTime: 1 is an absolute millisecond value, 3 uses + // the spacing to the next note, and every other mode scales by beat_ms. + uint32_t mode = 0; + float value = 0.0f; +}; + +struct StageConfig { + float endTime1 = 0.0f; + float endTime2 = 0.0f; + float outroTime = 0.0f; + std::vector bpmChanges; + std::array, 4> noteSettings; + std::string chartName; + std::string chartName2; + std::string bgmName; + std::string shotName; + float backwardsDrawDist = 0.0f; + float forwardDrawDist = 0.0f; + Color trackAheadColor; + Color trackBehindColor; + uint8_t audioOffset = 0; + float visualOffset = 0.0f; + Color unkColor; +}; + +struct DrawDistancePoint { + uint32_t timeMs = 0; + float distance = 0.0f; +}; + +struct TrackPiece { + uint32_t timeMs = 0; + float x = 0.0f; + float y = 0.0f; + float z = 0.0f; +}; + +// Type byte used by the original arcade stage-note record. Keep this +// distinct from the legacy editor-side NoteType in include/gc/NoteTypes.hpp, +// whose numeric IDs describe another interchange format. +enum class StageNoteType : uint8_t { + None = 0, + Normal = 1, + Flick = 2, + Hold = 3, + Scratch = 4, + Beat = 5, + MerryGoRound = 6, + Hidden = 7, + Hidden2 = 8, + Critical = 9, + SlideHold = 10, + SlideCounter = 11, + Turn = 12, + Spin = 13, + Finish = 14, + DualHold = 15, +}; + +const char* NoteTypeName(uint8_t rawType); + +// Wire layout recovered from game471.exe: FUN_005ea800 reads exactly 99 bytes +// per note. Field semantics remain neutral until their consumers are mapped. +struct StageNote { + static constexpr size_t kRecordSize = 99; + + uint32_t timeMs = 0; + StageNoteType type = StageNoteType::None; + uint8_t typeOverride = 0; // original runtime forces type=1 when non-zero + std::array params16{}; + uint8_t flag24 = 0; + std::array params25{}; + uint8_t flag37 = 0; + uint8_t flag38 = 0; + std::array params39{}; + std::array params55{}; + float param67 = 0.0f; + uint32_t param71 = 0; + std::array params75{}; + uint32_t param95 = 0; +}; + +struct CameraPoint { + uint32_t timeMs = 0; + uint8_t aMode = 0; + uint8_t fMode = 0; + float dist = 0.0f; + float rotationA[2] = {0.0f, 0.0f}; + float originOff[3] = {0.0f, 0.0f, 0.0f}; + uint8_t projType = 0; + float fieldFar[3] = {0.0f, 0.0f, 0.0f}; + float fieldNear[3] = {0.0f, 0.0f, 0.0f}; + float rotationB = 0.0f; +}; + +struct ParticlePoint { + static constexpr size_t kRecordSize = 44; + + uint32_t timeMs = 0; + uint32_t enabled = 0; + uint32_t shape = 0; + uint32_t texture = 0; + Color color; + float velocity[3] = {0.0f, 0.0f, 0.0f}; + float repeatMeasure = 0.0f; + float lifespanMeasure = 0.0f; + uint32_t groupShapeSize = 0; +}; + +struct VisualizerPoint { + static constexpr size_t kRecordSize = 12; + + uint32_t timeMs = 0; + uint32_t type = 0; + Color color; +}; + +struct BackgroundColorPoint { + static constexpr size_t kRecordSize = 22; + + uint32_t timeMs = 0; + Color topRight; + Color topLeft; + Color bottomRight; + Color bottomLeft; + bool interpolateToNext = false; + bool audioReactive = false; +}; + +struct VisibilityPoint { + uint32_t timeMs = 0; + bool fadeOut = false; + bool fadeIn = false; + bool visible = false; +}; + +struct TransformPoint { + uint32_t timeMs = 0; + bool tweenTowards = false; + bool tweenAway = false; + float value[3] = {0.0f, 0.0f, 0.0f}; +}; + +struct ObjectColorPoint { + uint32_t timeMs = 0; + bool tweenTowards = false; + bool tweenAway = false; + Color color; +}; + +struct StageObject { + uint32_t model = 0; + uint32_t fragmentShader = 0; + bool wireframe = false; + bool flashing = false; + bool unknownFlag = false; + float position[3] = {0.0f, 0.0f, 0.0f}; + float scale[3] = {1.0f, 1.0f, 1.0f}; + float rotation[3] = {0.0f, 0.0f, 0.0f}; + float color[4] = {1.0f, 1.0f, 1.0f, 1.0f}; + float unknownVector[3] = {0.0f, 0.0f, 0.0f}; + // game471 supplemental object table (stage format > 0x29ce). A child is + // rendered with parent * child transform and component-wise parent color. + int32_t parentIndex = -1; + std::vector visibility; + std::vector movement; + std::vector scaling; + std::vector rotations; + std::vector colorChanges; +}; + +struct ParsedStagePattern { + StageHeader header; + StageConfig config; + std::vector drawDistances; + std::vector track; + std::vector noteNames; + std::vector notes; + std::vector cameras; + std::vector particles; + std::vector visualizer; + std::vector backgroundColors; + std::vector modelNames; + std::vector fragmentShaderNames; + std::vector objects; +}; + +bool ParseStagePattern(const StageDat& dat, ParsedStagePattern* out, std::string* err); + +} // namespace gc + +#endif diff --git a/include/gc/TumoModel.hpp b/include/gc/TumoModel.hpp new file mode 100644 index 0000000..43c01ff --- /dev/null +++ b/include/gc/TumoModel.hpp @@ -0,0 +1,29 @@ +#ifndef OPENROLLER_GC_TUMOMODEL_HPP +#define OPENROLLER_GC_TUMOMODEL_HPP + +#include +#include +#include + +namespace gc { + +struct TumoVertex { + float x = 0.0f; + float y = 0.0f; + float z = 0.0f; +}; + +struct TumoGeometry { + std::vector triangles; + std::vector solidLines; + std::vector wireframeLines; +}; + +// Decodes the common one-mesh TUMO layout used by gameplay stage objects. +// Polygon fans are expanded on the host so the PSP only has to submit flat +// GU_TRIANGLES/GU_LINES arrays. +bool LoadTumoGeometry(const std::string& path, TumoGeometry* out, std::string* error); + +} // namespace gc + +#endif diff --git a/src/EventStream.cpp b/src/EventStream.cpp new file mode 100644 index 0000000..6d89eb5 --- /dev/null +++ b/src/EventStream.cpp @@ -0,0 +1,261 @@ +#include "gc/EventStream.hpp" + +#include +#include +#include + +namespace gc { + +static uint16_t u16be(const std::vector& b, size_t off) { + return static_cast((static_cast(b[off + 0]) << 8) | + (static_cast(b[off + 1]) << 0)); +} + +static uint32_t u32be(const std::vector& b, size_t off) { + return (static_cast(b[off + 0]) << 24) | + (static_cast(b[off + 1]) << 16) | + (static_cast(b[off + 2]) << 8) | + (static_cast(b[off + 3]) << 0); +} + +static float f32be(const std::vector& b, size_t off) { + const uint32_t u = u32be(b, off); + float f = 0.0f; + static_assert(sizeof(float) == sizeof(uint32_t), "float must be 32-bit"); + std::memcpy(&f, &u, sizeof(float)); + return f; +} + +static bool isPlausibleTs(float ts) { + // Timestamps appear to be a monotonic progress/beat count float. + return std::isfinite(ts) && ts >= -1.0f && ts <= 1.0e6f; +} + +static bool isPlausibleVal(float v) { + return std::isfinite(v) && std::fabs(v) <= 1.0e7f; +} + +static int scoreStream16(const std::vector& bytes, size_t start, size_t end, size_t* outEventCount, double* outPadZeroRatio) { + if (end <= start) return 0; + size_t len = end - start; + len -= (len % 16); + if (len < 16 * 10) return 0; + + // Score only first N events for speed and to avoid false confidence on huge blocks. + const size_t n = std::min(len / 16, 200); + int score = 0; + size_t padFields = 0; + size_t padZeros = 0; + size_t plausible = 0; + size_t typeNonZero = 0; + size_t tsNonZero = 0; + size_t tsChanges = 0; + size_t tsBackwards = 0; + float prevTs = 0.0f; + bool havePrevTs = false; + + for (size_t i = 0; i < n; i++) { + const size_t off = start + i * 16; + const uint16_t id = u16be(bytes, off + 0); + const float ts = f32be(bytes, off + 2); + const uint16_t a = u16be(bytes, off + 6); + const uint16_t type = u16be(bytes, off + 8); + const float val = f32be(bytes, off + 10); + const uint16_t b = u16be(bytes, off + 14); + + padFields += 2; + if (a == 0) padZeros++; + if (b == 0) padZeros++; + + if (id == 0) score += 1; + if (a == 0) score += 2; + if (b == 0) score += 2; + + if (type != 0 && type != 0xFFFF) score += 1; + if (type > 0 && type < 0x4000) score += 1; + if (type != 0 && type != 0xFFFF) typeNonZero++; + + if (isPlausibleTs(ts)) { + score += 2; + plausible++; + } + if (isPlausibleVal(val)) { + score += 1; + } + + if (ts != 0.0f) tsNonZero++; + if (havePrevTs) { + if (ts < prevTs) tsBackwards++; + if (std::fabs(ts - prevTs) > 1.0e-6f) tsChanges++; + } else { + havePrevTs = true; + } + prevTs = ts; + } + + if (outEventCount) *outEventCount = n; + if (outPadZeroRatio) *outPadZeroRatio = padFields ? (static_cast(padZeros) / static_cast(padFields)) : 0.0; + + // Penalize if almost nothing looks plausible. + if (plausible < (n / 4)) score /= 2; + + // Prefer streams that look like a real timeline (changing, mostly monotonic), + // and avoid giant zero-filled blocks that otherwise look "plausible". + if (typeNonZero < (n / 4)) score /= 2; + if (tsNonZero < (n / 4)) score /= 2; + if (tsChanges < (n / 8)) score /= 2; + if (tsBackwards > (n / 20)) score /= 2; + return score; +} + +static int scoreStream12(const std::vector& bytes, size_t start, size_t end, size_t* outEventCount) { + if (end <= start) return 0; + size_t len = end - start; + len -= (len % 12); + if (len < 12 * 10) return 0; + + const size_t n = std::min(len / 12, 200); + int score = 0; + size_t plausible = 0; + size_t typeNonZero = 0; + size_t tsNonZero = 0; + size_t tsChanges = 0; + size_t tsBackwards = 0; + float prevTs = 0.0f; + bool havePrevTs = false; + + for (size_t i = 0; i < n; i++) { + const size_t off = start + i * 12; + const float ts = f32be(bytes, off + 0); + const uint32_t type = u32be(bytes, off + 4); + const float val = f32be(bytes, off + 8); + + if (type != 0 && type != 0xFFFFFFFFu) score += 3; + if (type != 0 && type != 0xFFFFFFFFu) typeNonZero++; + + if (isPlausibleTs(ts)) { + score += 2; + plausible++; + } + if (isPlausibleVal(val)) score += 1; + + if (ts != 0.0f) tsNonZero++; + if (havePrevTs) { + if (ts < prevTs) tsBackwards++; + if (std::fabs(ts - prevTs) > 1.0e-6f) tsChanges++; + } else { + havePrevTs = true; + } + prevTs = ts; + } + + if (outEventCount) *outEventCount = n; + + if (plausible < (n / 4)) score /= 2; + if (typeNonZero < (n / 4)) score /= 2; + if (tsNonZero < (n / 4)) score /= 2; + if (tsChanges < (n / 8)) score /= 2; + if (tsBackwards > (n / 20)) score /= 2; + return score; +} + +EventStreamDecodeResult TryDecodeEventStreamFixed(const std::vector& bytes, size_t start, size_t end, size_t recordSize) { + EventStreamDecodeResult best; + best.recordSize = recordSize; + best.alignment = 0; + best.score = 0; + best.eventCount = 0; + best.padZeroRatio = 0.0; + + if (end <= start) return best; + if (recordSize != 12 && recordSize != 16) return best; + + const size_t maxAlign = recordSize; + for (size_t align = 0; align < maxAlign; align++) { + if (start + align >= end) break; + if (recordSize == 16) { + size_t eventCount = 0; + double padZeroRatio = 0.0; + const int s = scoreStream16(bytes, start + align, end, &eventCount, &padZeroRatio); + if (s > best.score) { + best.recordSize = 16; + best.score = s; + best.alignment = static_cast(align); + best.eventCount = eventCount; + best.padZeroRatio = padZeroRatio; + } + } else { // 12 + size_t eventCount = 0; + const int s = scoreStream12(bytes, start + align, end, &eventCount); + if (s > best.score) { + best.recordSize = 12; + best.score = s; + best.alignment = static_cast(align); + best.eventCount = eventCount; + best.padZeroRatio = 0.0; + } + } + } + return best; +} + +EventStreamDecodeResult TryDecodeEventStream(const std::vector& bytes, size_t start, size_t end) { + EventStreamDecodeResult best16 = TryDecodeEventStreamFixed(bytes, start, end, 16); + EventStreamDecodeResult best12 = TryDecodeEventStreamFixed(bytes, start, end, 12); + return (best12.score > best16.score) ? best12 : best16; +} + +bool DecodeEventStream( + const std::vector& bytes, + size_t start, + size_t end, + size_t recordSize, + std::vector* out, + std::string* err) { + if (!out) return false; + out->clear(); + + if (end < start) { + if (err) *err = "end < start"; + return false; + } + if (recordSize != 12 && recordSize != 16) { + if (err) *err = "unsupported recordSize"; + return false; + } + + size_t len = end - start; + len -= (len % recordSize); + if (len == 0) return true; + if (start + len > bytes.size()) { + if (err) *err = "range out of bounds"; + return false; + } + + const size_t n = len / recordSize; + out->reserve(n); + for (size_t i = 0; i < n; i++) { + const size_t off = start + i * recordSize; + GameEvent e; + if (recordSize == 16) { + e.id = u16be(bytes, off + 0); + e.timestamp = f32be(bytes, off + 2); + e.a = u16be(bytes, off + 6); + e.type = static_cast(u16be(bytes, off + 8)); + e.value = f32be(bytes, off + 10); + e.b = u16be(bytes, off + 14); + } else { // 12 + e.id = 0; + e.timestamp = f32be(bytes, off + 0); + e.a = 0; + e.type = u32be(bytes, off + 4); + e.value = f32be(bytes, off + 8); + e.b = 0; + } + out->push_back(e); + } + + return true; +} + +} // namespace gc diff --git a/src/GcTargetEffect.cpp b/src/GcTargetEffect.cpp new file mode 100644 index 0000000..97ecaf8 --- /dev/null +++ b/src/GcTargetEffect.cpp @@ -0,0 +1,216 @@ +#include "gc/GcTargetEffect.hpp" + +#include +#include +#include +#include + +namespace { + +bool readFile(const std::string& path, std::vector* out) { + std::ifstream file(path, std::ios::binary); + if (!file) return false; + file.seekg(0, std::ios::end); + const std::streamoff length = file.tellg(); + if (length < 0) return false; + file.seekg(0, std::ios::beg); + out->assign(static_cast(length), 0); + if (!out->empty()) file.read(reinterpret_cast(out->data()), length); + return static_cast(file) || file.eof(); +} + +uint16_t u16be(const std::vector& data, size_t offset) { + return static_cast((static_cast(data[offset]) << 8) | data[offset + 1]); +} + +uint32_t u32be(const std::vector& data, size_t offset) { + return (static_cast(data[offset]) << 24) | + (static_cast(data[offset + 1]) << 16) | + (static_cast(data[offset + 2]) << 8) | + static_cast(data[offset + 3]); +} + +float f32be(const std::vector& data, size_t offset) { + const uint32_t bits = u32be(data, offset); + float value = 0.0f; + std::memcpy(&value, &bits, sizeof(value)); + return value; +} + +} // namespace + +std::vector GcTargetEffectBank::sampleTrack(const Track& track, float tick, bool* started) { + if (started) *started = false; + if (track.keys.empty()) return {}; + + float sample = tick; + const int loopLength = static_cast(track.loopEnd) - static_cast(track.loopStart); + if (loopLength > 0 && sample >= track.loopStart) { + sample = std::fmod(sample - track.loopStart, static_cast(loopLength)) + track.loopStart; + } + if (sample < track.keys.front().time) return track.keys.front().values; + if (started) *started = true; + + size_t index = 0; + while (index + 1 < track.keys.size() && sample >= track.keys[index + 1].time) ++index; + const auto& current = track.keys[index]; + if (index + 1 >= track.keys.size() || current.interpolation == 0) return current.values; + + const auto& next = track.keys[index + 1]; + const float span = static_cast(next.time - current.time); + const float amount = span > 0.0f ? std::clamp((sample - current.time) / span, 0.0f, 1.0f) : 0.0f; + std::vector result = current.values; + for (size_t i = 0; i < result.size() && i < next.values.size(); ++i) { + result[i] = current.values[i] + (next.values[i] - current.values[i]) * amount; + } + return result; +} + +bool GcTargetEffectBank::load(const std::string& efcPath, const std::string& uvPath, std::string* error) { + std::vector uv; + std::vector efc; + if (!readFile(uvPath, &uv) || uv.size() < 6) { + if (error) *error = "could not read uvdata.dat"; + return false; + } + if (!readFile(efcPath, &efc) || efc.size() < 6) { + if (error) *error = "could not read efcdata.dat"; + return false; + } + + const uint16_t uvCount = u16be(uv, 4); + if (6u + static_cast(uvCount) * 4u > uv.size()) { + if (error) *error = "invalid uvdata.dat offset table"; + return false; + } + uvRecords_.assign(uvCount, {}); + for (uint16_t record = 0; record < uvCount; ++record) { + const size_t begin = u32be(uv, 6 + record * 4); + const size_t end = record + 1 < uvCount ? u32be(uv, 6 + (record + 1) * 4) : uv.size(); + if (begin + 4 > end || end > uv.size()) continue; + const uint16_t cellCount = u16be(uv, begin + 2); + if (begin + 4u + static_cast(cellCount) * 8u > end) continue; + UvRecord& decoded = uvRecords_[record]; + decoded.textureIndex = static_cast(u16be(uv, begin)); + auto& cells = decoded.cells; + cells.reserve(cellCount); + for (uint16_t cell = 0; cell < cellCount; ++cell) { + const size_t at = begin + 4 + cell * 8; + cells.push_back({u16be(uv, at), u16be(uv, at + 2), u16be(uv, at + 4), u16be(uv, at + 6)}); + } + } + + static constexpr int firstTrackDimensions[] = {1, 0, 7, 4, 13, 3, 2}; + static constexpr int otherTrackDimensions[] = {0, 3, 4, 2, 1}; + const uint16_t effectCount = u16be(efc, 4); + if (6u + static_cast(effectCount) * 4u > efc.size()) { + if (error) *error = "invalid efcdata.dat offset table"; + return false; + } + effects_.assign(effectCount, {}); + for (uint16_t effectIndex = 0; effectIndex < effectCount; ++effectIndex) { + const size_t begin = u32be(efc, 6 + effectIndex * 4); + const size_t end = effectIndex + 1 < effectCount ? u32be(efc, 6 + (effectIndex + 1) * 4) : efc.size(); + if (begin + 3 > end || end > efc.size()) continue; + Effect& effect = effects_[effectIndex]; + effect.lifetime = u16be(efc, begin); + const uint8_t childCount = efc[begin + 2]; + if (begin + 3u + static_cast(childCount) * 2u > end) continue; + effect.children.reserve(childCount); + for (uint8_t childIndex = 0; childIndex < childCount; ++childIndex) { + const size_t child = begin + u16be(efc, begin + 3 + childIndex * 2); + if (child + 15 > end) continue; + Child decoded; + decoded.type = efc[child]; + decoded.reference = u16be(efc, child + 1); + decoded.inheritParent = efc[child + 4] != 0; + for (int trackIndex = 0; trackIndex < 5; ++trackIndex) { + const size_t trackAt = child + u16be(efc, child + 5 + trackIndex * 2); + if (trackAt + 6 > end) continue; + Track& track = decoded.tracks[trackIndex]; + const uint16_t keyCount = u16be(efc, trackAt); + track.loopStart = u16be(efc, trackAt + 2); + track.loopEnd = u16be(efc, trackAt + 4); + const int dimensions = trackIndex == 0 + ? (decoded.type < sizeof(firstTrackDimensions) / sizeof(firstTrackDimensions[0]) + ? firstTrackDimensions[decoded.type] : 0) + : otherTrackDimensions[trackIndex]; + const size_t stride = 3u + static_cast(dimensions) * 4u; + if (dimensions <= 0 || trackAt + 6u + static_cast(keyCount) * stride > end) continue; + track.keys.reserve(keyCount); + for (uint16_t keyIndex = 0; keyIndex < keyCount; ++keyIndex) { + const size_t keyAt = trackAt + 6 + keyIndex * stride; + Key key; + key.time = u16be(efc, keyAt); + key.interpolation = efc[keyAt + 2]; + key.values.reserve(dimensions); + for (int value = 0; value < dimensions; ++value) { + key.values.push_back(f32be(efc, keyAt + 3 + value * 4)); + } + track.keys.push_back(std::move(key)); + } + } + effect.children.push_back(std::move(decoded)); + } + } + return true; +} + +int GcTargetEffectBank::lifetime(int effectId) const { + return effectId >= 0 && static_cast(effectId) < effects_.size() ? effects_[effectId].lifetime : 0; +} + +std::vector GcTargetEffectBank::evaluate(int effectId, float tick, int uvRecordBase) const { + std::vector result; + if (effectId < 0 || static_cast(effectId) >= effects_.size()) return result; + + float parentRadians = 0.0f; + bool haveParent = false; + for (const Child& child : effects_[effectId].children) { + bool firstTrackStarted = false; + const std::vector first = sampleTrack(child.tracks[0], tick, &firstTrackStarted); + if (child.type == 0) { + parentRadians = !first.empty() ? first[0] : 0.0f; + haveParent = firstTrackStarted; + continue; + } + if (child.type != 2 || child.reference == 0xffff || first.size() < 1 || !firstTrackStarted) continue; + + GcEffectSprite sprite; + const int resolvedRecord = static_cast(child.reference) + uvRecordBase; + if (resolvedRecord < 0 || resolvedRecord > 0xffff) continue; + sprite.uvRecord = static_cast(resolvedRecord); + sprite.frame = static_cast(std::lround(first[0])); + const std::vector position = sampleTrack(child.tracks[1], tick, nullptr); + const std::vector color = sampleTrack(child.tracks[2], tick, nullptr); + const std::vector scale = sampleTrack(child.tracks[3], tick, nullptr); + const std::vector rotation = sampleTrack(child.tracks[4], tick, nullptr); + if (position.size() >= 3) sprite.offsetPixels = {position[0], position[1], position[2]}; + if (color.size() >= 4) sprite.color = {color[1], color[2], color[3], color[0]}; + if (scale.size() >= 2) sprite.scale = {scale[0], scale[1]}; + if (!rotation.empty()) sprite.rotationDegrees = rotation[0]; + + if (child.inheritParent && haveParent) { + const float c = std::cos(parentRadians); + const float s = std::sin(parentRadians); + const glm::vec2 p(sprite.offsetPixels.x, sprite.offsetPixels.y); + sprite.offsetPixels.x = c * p.x - s * p.y; + sprite.offsetPixels.y = s * p.x + c * p.y; + sprite.rotationDegrees += glm::degrees(parentRadians); + } + result.push_back(sprite); + } + return result; +} + +const GcUvCell* GcTargetEffectBank::uvCell(uint16_t record, int frame) const { + if (record >= uvRecords_.size() || frame < 0 || + static_cast(frame) >= uvRecords_[record].cells.size()) { + return nullptr; + } + return &uvRecords_[record].cells[frame]; +} + +int GcTargetEffectBank::uvTexture(uint16_t record) const { + return record < uvRecords_.size() ? uvRecords_[record].textureIndex : -1; +} diff --git a/src/MtxArchive.cpp b/src/MtxArchive.cpp new file mode 100644 index 0000000..a699012 --- /dev/null +++ b/src/MtxArchive.cpp @@ -0,0 +1,100 @@ +#include "gc/MtxArchive.hpp" + +#include +#include + +namespace gc { +namespace { + +uint16_t readU16Le(const std::vector& bytes, size_t offset) { + return static_cast(bytes[offset]) | + static_cast(bytes[offset + 1] << 8); +} + +uint32_t readU32Le(const std::vector& bytes, size_t offset) { + return static_cast(bytes[offset]) | + (static_cast(bytes[offset + 1]) << 8) | + (static_cast(bytes[offset + 2]) << 16) | + (static_cast(bytes[offset + 3]) << 24); +} + +bool fail(std::string* error, const std::string& message) { + if (error) *error = message; + return false; +} + +} // namespace + +bool ParseMtxArchive(const std::vector& bytes, + MtxArchive* archive, + std::string* error) { + if (!archive) return fail(error, "null MTX archive output"); + *archive = {}; + if (bytes.size() < 32 || std::memcmp(bytes.data(), "MTX\0", 4) != 0) { + return fail(error, "missing MTX header"); + } + const uint32_t headerOffset = readU32Le(bytes, 8); + if (headerOffset > bytes.size() - 16) return fail(error, "invalid MTX header offset"); + const uint32_t count = readU32Le(bytes, headerOffset); + const uint32_t tableOffset = readU32Le(bytes, headerOffset + 4); + const uint32_t stride = readU32Le(bytes, headerOffset + 8); + const uint16_t nameBytes = readU16Le(bytes, headerOffset + 12); + if (stride < 16 || count > std::numeric_limits::max() / stride) { + return fail(error, "invalid MTX entry table shape"); + } + const uint64_t tableStart = static_cast(headerOffset) + tableOffset; + const uint64_t tableBytes = static_cast(count) * stride; + const uint64_t dataStart64 = tableStart + tableBytes + nameBytes; + if (tableStart > bytes.size() || dataStart64 > bytes.size()) { + return fail(error, "MTX entry table exceeds the file"); + } + + size_t dataCursor = static_cast(dataStart64); + archive->textures.reserve(count); + for (uint32_t index = 0; index < count; ++index) { + const size_t entry = static_cast(tableStart) + static_cast(index) * stride; + const uint32_t size = readU32Le(bytes, entry); + if (size < 128 || size > bytes.size() - dataCursor) { + return fail(error, "MTX texture payload exceeds the file"); + } + // Apart from the first dword, each embedded payload is a standard + // little-endian DDS_HEADER. game471.exe passes it directly to its DDS + // reader after locating it with this same cumulative-size algorithm. + if (readU32Le(bytes, dataCursor + 4) != 124 || + readU32Le(bytes, dataCursor + 76) != 32) { + return fail(error, "MTX texture does not contain a DDS header"); + } + MtxTexture texture; + texture.offset = static_cast(dataCursor); + texture.size = size; + texture.height = readU32Le(bytes, dataCursor + 12); + texture.width = readU32Le(bytes, dataCursor + 16); + texture.fourCC = readU32Le(bytes, dataCursor + 84); + texture.bitsPerPixel = readU32Le(bytes, dataCursor + 88); + archive->textures.push_back(texture); + dataCursor += size; + } + if (dataCursor != bytes.size()) return fail(error, "MTX has trailing texture data"); + return true; +} + +bool ExtractMtxTextureDds(const std::vector& bytes, + const MtxTexture& texture, + std::vector* dds, + std::string* error) { + if (!dds) return fail(error, "null DDS output"); + dds->clear(); + if (texture.size < 128 || texture.offset > bytes.size() || + texture.size > bytes.size() - texture.offset) { + return fail(error, "MTX texture range is invalid"); + } + dds->assign(bytes.begin() + texture.offset, + bytes.begin() + texture.offset + texture.size); + (*dds)[0] = 'D'; + (*dds)[1] = 'D'; + (*dds)[2] = 'S'; + (*dds)[3] = ' '; + return true; +} + +} // namespace gc diff --git a/src/RvbLayout.cpp b/src/RvbLayout.cpp new file mode 100644 index 0000000..6c30eeb --- /dev/null +++ b/src/RvbLayout.cpp @@ -0,0 +1,413 @@ +#include "gc/RvbLayout.hpp" + +#include +#include +#include +#include + +namespace gc { +namespace { + +uint32_t readU32Le(const std::vector& bytes, size_t offset) { + return static_cast(bytes[offset]) | + (static_cast(bytes[offset + 1]) << 8) | + (static_cast(bytes[offset + 2]) << 16) | + (static_cast(bytes[offset + 3]) << 24); +} + +float readFloatLe(const std::vector& bytes, size_t offset) { + const uint32_t value = readU32Le(bytes, offset); + float result = 0.0f; + std::memcpy(&result, &value, sizeof(result)); + return result; +} + +bool readCString(const std::vector& bytes, size_t* cursor, + size_t end, std::string* value) { + if (!cursor || !value || *cursor >= end || end > bytes.size()) return false; + const auto begin = bytes.begin() + static_cast(*cursor); + const auto last = bytes.begin() + static_cast(end); + const auto zero = std::find(begin, last, uint8_t{0}); + if (zero == last) return false; + value->assign(reinterpret_cast(&*begin), + static_cast(zero - begin)); + *cursor += value->size() + 1; + return true; +} + +struct Matrix { + float xx = 1.0f; + float yy = 1.0f; + float xy = 0.0f; + float yx = 0.0f; + float tx = 0.0f; + float ty = 0.0f; +}; + +Matrix multiply(const Matrix& a, const Matrix& b) { + Matrix out; + out.xx = a.xx * b.xx + a.xy * b.yx; + out.xy = a.xx * b.xy + a.xy * b.yy; + out.tx = a.xx * b.tx + a.xy * b.ty + a.tx; + out.yx = a.yx * b.xx + a.yy * b.yx; + out.yy = a.yx * b.xy + a.yy * b.yy; + out.ty = a.yx * b.tx + a.yy * b.ty + a.ty; + return out; +} + +std::array transform(const Matrix& m, float x, float y) { + return {m.xx * x + m.xy * y + m.tx, m.yx * x + m.yy * y + m.ty}; +} + +Matrix nodeTransform(const std::vector& bytes, const RvbNode& node) { + for (const RvbNode& child : node.children) { + if (child.tag != "TRN2" && child.tag != "TRAN") continue; + if (child.localDataSize < 24) continue; + const size_t p = child.localDataOffset; + Matrix m; + m.xx = readFloatLe(bytes, p + 0); + m.yy = readFloatLe(bytes, p + 4); + m.xy = readFloatLe(bytes, p + 8); + m.yx = readFloatLe(bytes, p + 12); + m.tx = readFloatLe(bytes, p + 16); + m.ty = readFloatLe(bytes, p + 20); + return m; + } + return {}; +} + +bool hasNodeTransform(const RvbNode& node) { + for (const RvbNode& child : node.children) { + if ((child.tag == "TRN2" || child.tag == "TRAN") && child.localDataSize >= 24) { + return true; + } + } + return false; +} + +struct ColorTransform { + float red = 1.0f; + float green = 1.0f; + float blue = 1.0f; + float alpha = 1.0f; +}; + +ColorTransform multiply(const ColorTransform& a, const ColorTransform& b) { + return {a.red * b.red, a.green * b.green, a.blue * b.blue, a.alpha * b.alpha}; +} + +bool nodeColor(const std::vector& bytes, const RvbNode& node, + ColorTransform* color) { + if (!color) return false; + for (const RvbNode& child : node.children) { + if (child.tag != "COLT" || child.localDataSize < 20) continue; + // RGBAColorTransform::read (game471.exe FUN_004d64b0) reads four + // floats followed by four bytes. The animation node adds a leading + // four-byte field, so alpha multiplication is the fourth float at + // local + 16. Alpha zero is how authored clips are made invisible. + color->red = readFloatLe(bytes, child.localDataOffset + 4); + color->green = readFloatLe(bytes, child.localDataOffset + 8); + color->blue = readFloatLe(bytes, child.localDataOffset + 12); + color->alpha = readFloatLe(bytes, child.localDataOffset + 16); + return true; + } + return false; +} + +const RvbNode* firstChild(const RvbNode& node, const char* tag) { + for (const RvbNode& child : node.children) { + if (child.tag == tag) return &child; + } + return nullptr; +} + +std::string definitionName(const std::vector& bytes, const RvbNode& node) { + size_t cursor = node.localDataOffset; + std::string name; + readCString(bytes, &cursor, node.localDataOffset + node.localDataSize, &name); + return name; +} + +std::string frameActionSource(const std::vector& bytes, const RvbNode& frame) { + for (const RvbNode& child : frame.children) { + if (child.tag != "ASRC") continue; + size_t cursor = child.localDataOffset; + std::string source; + if (readCString(bytes, &cursor, child.localDataOffset + child.localDataSize, &source)) { + return source; + } + } + return {}; +} + +std::string findImageSymbol(const std::vector& bytes, const RvbNode& node) { + if (node.tag == "IMGF" && node.localDataSize >= 2) { + size_t cursor = node.localDataOffset + 1; + std::string symbol; + if (readCString(bytes, &cursor, node.localDataOffset + node.localDataSize, &symbol) && + !symbol.empty()) { + return symbol; + } + } + for (const RvbNode& child : node.children) { + std::string symbol = findImageSymbol(bytes, child); + if (!symbol.empty()) return symbol; + } + return {}; +} + +struct Context { + const std::vector& bytes; + std::unordered_map definitions; + std::vector* draws = nullptr; + std::string* error = nullptr; + const RvbSnapshotState* state = nullptr; + + bool renderDefinition(const std::string& name, const Matrix& matrix, + const ColorTransform& color, uint32_t depth, unsigned recursion, + const std::string& path) { + if (recursion > 64) { + if (error) *error = "RVB MovieClip recursion limit exceeded"; + return false; + } + const auto found = definitions.find(name); + if (found == definitions.end()) return true; + const RvbNode& definition = *found->second; + if (definition.tag == "SHAP") { + size_t cursor = definition.localDataOffset; + std::string ignored; + if (!readCString(bytes, &cursor, + definition.localDataOffset + definition.localDataSize, &ignored) || + cursor + 16 > definition.localDataOffset + definition.localDataSize) { + return true; + } + const std::string image = findImageSymbol(bytes, definition); + if (image.empty()) return true; + const float left = readFloatLe(bytes, cursor + 0); + const float top = readFloatLe(bytes, cursor + 4); + const float right = readFloatLe(bytes, cursor + 8); + const float bottom = readFloatLe(bytes, cursor + 12); + RvbImageDraw draw; + draw.imageSymbol = image; + draw.instancePath = path; + draw.corners[0] = transform(matrix, left, top); + draw.corners[1] = transform(matrix, right, top); + draw.corners[2] = transform(matrix, left, bottom); + draw.corners[3] = transform(matrix, right, bottom); + draw.depth = depth; + draw.color = {color.red, color.green, color.blue}; + draw.alpha = color.alpha; + draws->push_back(std::move(draw)); + return true; + } + if (definition.tag == "MOVC") { + const RvbNode* timeline = firstChild(definition, "TIME"); + return !timeline || renderTimeline(*timeline, matrix, color, recursion + 1, path); + } + return true; + } + + bool renderTimeline(const RvbNode& timeline, const Matrix& parent, + const ColorTransform& parentColor, + unsigned recursion, const std::string& path) { + const RvbNode* targetFrame = nullptr; + std::string wantedLabel; + if (state) { + const auto found = state->frameByPath.find(path); + if (found != state->frameByPath.end()) wantedLabel = found->second; + } + for (const RvbNode& child : timeline.children) { + if (child.tag != "FRAM") continue; + if (!targetFrame) targetFrame = &child; + if (wantedLabel.empty()) break; + size_t cursor = child.localDataOffset; + std::string label; + if (readCString(bytes, &cursor, child.localDataOffset + child.localDataSize, + &label) && label == wantedLabel) { + targetFrame = &child; + break; + } + } + if (!targetFrame) return true; + // A label whose script calls play() is an animation entry point, not + // the visible steady state. The original player advances until the + // following stop(); doing the same resolves authored fades such as + // lf_title_selectmusic_start and jf_focusds_start. + if (frameActionSource(bytes, *targetFrame).find("play();") != std::string::npos) { + bool afterTarget = false; + for (const RvbNode& child : timeline.children) { + if (child.tag != "FRAM") continue; + if (&child == targetFrame) { + afterTarget = true; + continue; + } + if (!afterTarget) continue; + targetFrame = &child; + if (frameActionSource(bytes, child).find("stop();") != std::string::npos) break; + } + } + struct Placement { + uint32_t depth = 0; + std::string definition; + std::string instance; + Matrix matrix; + ColorTransform color; + }; + std::unordered_map displayList; + bool reachedTarget = false; + for (const RvbNode& frame : timeline.children) { + if (frame.tag != "FRAM") continue; + for (const RvbNode& command : frame.children) { + if (command.tag == "RMOV") { + if (command.localDataSize >= 5) { + displayList.erase(readU32Le(bytes, command.localDataOffset + 1)); + } + continue; + } + if (command.tag != "PLC3") continue; + size_t cursor = command.localDataOffset; + const size_t end = command.localDataOffset + command.localDataSize; + std::string definition; + std::string instance; + if (!readCString(bytes, &cursor, end, &definition) || + !readCString(bytes, &cursor, end, &instance) || cursor + 8 > end) { + continue; + } + cursor += 4; + const uint32_t depth = readU32Le(bytes, cursor); + const bool hasTransform = hasNodeTransform(command); + ColorTransform color; + const bool hasColor = nodeColor(bytes, command, &color); + if (!definition.empty()) { + Placement placement; + placement.depth = depth; + placement.definition = std::move(definition); + placement.instance = std::move(instance); + if (hasTransform) placement.matrix = nodeTransform(bytes, command); + if (hasColor) placement.color = color; + displayList[depth] = std::move(placement); + } else { + const auto found = displayList.find(depth); + if (found == displayList.end()) continue; + if (!instance.empty()) found->second.instance = std::move(instance); + if (hasTransform) found->second.matrix = nodeTransform(bytes, command); + if (hasColor) found->second.color = color; + } + } + if (&frame == targetFrame) { + reachedTarget = true; + break; + } + } + if (!reachedTarget) return true; + std::vector placements; + placements.reserve(displayList.size()); + for (auto& [_, placement] : displayList) placements.push_back(std::move(placement)); + std::stable_sort(placements.begin(), placements.end(), + [](const Placement& a, const Placement& b) { return a.depth < b.depth; }); + for (const Placement& placement : placements) { + // imc_other is the game's reusable off-focus song template. The + // CSelectMusicTask renderer moves and draws it once per carousel + // entry; leaving its authored root placement visible produces a + // spurious duplicate focus panel in a static snapshot. + if (recursion == 0 && placement.instance == "imc_other" && + (!state || !state->includeRootOther)) { + continue; + } + const Matrix placed = multiply(parent, placement.matrix); + const ColorTransform color = multiply(parentColor, placement.color); + if (color.alpha <= 0.0001f) continue; + const std::string childName = placement.instance.empty() + ? placement.definition + : placement.instance; + const std::string childPath = path == "/" ? "/" + childName + : path + "/" + childName; + if (!renderDefinition(placement.definition, placed, color, placement.depth, + recursion, childPath)) { + return false; + } + } + return true; + } +}; + +void collectDefinitions(const std::vector& bytes, + const RvbScene& scene, + Context* context, + const RvbNode** rootTimeline = nullptr) { + if (rootTimeline) *rootTimeline = nullptr; + for (const RvbNode& root : scene.roots) { + if (root.tag == "DEFN") { + for (const RvbNode& definition : root.children) { + const std::string name = definitionName(bytes, definition); + if (!name.empty()) context->definitions.emplace(name, &definition); + } + } else if (root.tag == "TIME" && rootTimeline) { + *rootTimeline = &root; + } + } +} + +} // namespace + +bool BuildRvbInitialSnapshot(const std::vector& bytes, + const RvbScene& scene, + std::vector* draws, + std::string* error) { + if (!draws) { + if (error) *error = "null RVB snapshot output"; + return false; + } + draws->clear(); + const RvbSnapshotState state; + return BuildRvbSnapshot(bytes, scene, state, draws, error); +} + +bool BuildRvbSnapshot(const std::vector& bytes, + const RvbScene& scene, + const RvbSnapshotState& state, + std::vector* draws, + std::string* error) { + if (!draws) { + if (error) *error = "null RVB snapshot output"; + return false; + } + draws->clear(); + Context context{bytes, {}, draws, error, &state}; + const RvbNode* rootTimeline = nullptr; + collectDefinitions(bytes, scene, &context, &rootTimeline); + if (!rootTimeline) { + if (error) *error = "RVB has no root TIME timeline"; + return false; + } + return context.renderTimeline(*rootTimeline, {}, {}, 0, "/"); +} + +bool BuildRvbSymbolSnapshot(const std::vector& bytes, + const RvbScene& scene, + const std::string& symbolName, + const RvbSnapshotState& state, + std::vector* draws, + std::string* error) { + if (!draws) { + if (error) *error = "null RVB symbol snapshot output"; + return false; + } + draws->clear(); + Context context{bytes, {}, draws, error, &state}; + collectDefinitions(bytes, scene, &context); + std::string definitionName = symbolName; + for (const RvbExport& exported : scene.exports) { + if (exported.linkageName == symbolName) { + definitionName = exported.definitionName; + break; + } + } + if (context.definitions.find(definitionName) == context.definitions.end()) { + if (error) *error = "RVB symbol is not present in DEFN: " + symbolName; + return false; + } + return context.renderDefinition(definitionName, {}, {}, 0, 0, "/"); +} + +} // namespace gc diff --git a/src/RvbScene.cpp b/src/RvbScene.cpp new file mode 100644 index 0000000..562d15b --- /dev/null +++ b/src/RvbScene.cpp @@ -0,0 +1,200 @@ +#include "gc/RvbScene.hpp" + +#include +#include +#include + +namespace gc { +namespace { + +uint16_t readU16Le(const std::vector& bytes, size_t offset) { + return static_cast(bytes[offset]) | + static_cast(bytes[offset + 1] << 8); +} + +uint32_t readU32Le(const std::vector& bytes, size_t offset) { + return static_cast(bytes[offset]) | + (static_cast(bytes[offset + 1]) << 8) | + (static_cast(bytes[offset + 2]) << 16) | + (static_cast(bytes[offset + 3]) << 24); +} + +bool hasTag(const std::vector& bytes, size_t offset, const char tag[5]) { + return offset + 4 <= bytes.size() && + std::memcmp(bytes.data() + offset, tag, 4) == 0; +} + +bool readCString(const std::vector& bytes, size_t* cursor, + size_t end, std::string* value) { + if (!cursor || !value || *cursor >= end || end > bytes.size()) return false; + const auto first = bytes.begin() + static_cast(*cursor); + const auto last = bytes.begin() + static_cast(end); + const auto zero = std::find(first, last, uint8_t{0}); + if (zero == last) return false; + value->assign(reinterpret_cast(&*first), + static_cast(zero - first)); + *cursor += value->size() + 1; + return true; +} + +bool fail(std::string* error, const std::string& message) { + if (error) *error = message; + return false; +} + +bool parseNode(const std::vector& bytes, size_t offset, size_t limit, + RvbNode* node, std::string* error) { + if (!node || offset + 12 > limit || limit > bytes.size()) { + return fail(error, "truncated RVB animation node"); + } + const uint32_t size = readU32Le(bytes, offset + 4); + const uint32_t localSize = readU32Le(bytes, offset + 8); + if (size < 12 || size > limit - offset || localSize > size - 12) { + std::ostringstream message; + message << "invalid RVB animation node at 0x" << std::hex << offset; + return fail(error, message.str()); + } + node->tag.assign(reinterpret_cast(bytes.data() + offset), 4); + node->offset = static_cast(offset); + node->size = size; + node->localDataOffset = static_cast(offset + 12); + node->localDataSize = localSize; + size_t childCursor = offset + 12 + localSize; + const size_t nodeEnd = offset + size; + while (childCursor < nodeEnd) { + RvbNode child; + if (!parseNode(bytes, childCursor, nodeEnd, &child, error)) return false; + childCursor += child.size; + node->children.push_back(std::move(child)); + } + return childCursor == nodeEnd; +} + +} // namespace + +bool ParseRvbScene(const std::vector& bytes, + RvbScene* scene, + std::string* error) { + if (!scene) return fail(error, "null RVB scene output"); + *scene = {}; + if (bytes.size() < 0x43) return fail(error, "RVB file is shorter than its MOVI header"); + if (!hasTag(bytes, 0, "RVB_")) return fail(error, "missing RVB_ root tag"); + if (readU32Le(bytes, 4) != bytes.size()) { + return fail(error, "RVB_ root size does not match the file size"); + } + if (!hasTag(bytes, 0x10, "MOVI")) return fail(error, "missing MOVI chunk at 0x10"); + const uint32_t movieSize = readU32Le(bytes, 0x14); + if (movieSize < 0x23 || static_cast(0x10) + movieSize != bytes.size()) { + return fail(error, "invalid MOVI chunk size"); + } + + // Recovered from the MOVI reader layout used by game471.exe. The file + // stores height before width; root placements such as imc_title=(360,112) + // confirm the authored scene is the cabinet's native portrait canvas. + scene->framesPerSecond = bytes[0x1d]; + scene->sourceHeight = readU16Le(bytes, 0x2a); + scene->sourceWidth = readU16Le(bytes, 0x2e); + + // The first child begins at the unaligned 0x33 offset. Each child size + // includes its four-byte tag and size field; walking the sizes lands + // exactly on the end of MOVI. + size_t cursor = 0x33; + while (cursor < bytes.size()) { + if (cursor + 8 > bytes.size()) return fail(error, "truncated RVB child chunk header"); + const uint32_t chunkSize = readU32Le(bytes, cursor + 4); + if (chunkSize < 8 || chunkSize > bytes.size() - cursor) { + std::ostringstream message; + message << "invalid RVB child chunk size at 0x" << std::hex << cursor; + return fail(error, message.str()); + } + RvbChunk chunk; + chunk.tag.assign(reinterpret_cast(bytes.data() + cursor), 4); + chunk.offset = static_cast(cursor); + chunk.size = chunkSize; + scene->chunks.push_back(chunk); + + if (chunk.tag == "PREP") { + if (chunkSize < 16) return fail(error, "PREP chunk is too short"); + const uint32_t payloadSize = readU32Le(bytes, cursor + 8); + const uint32_t count = readU32Le(bytes, cursor + 12); + if (payloadSize != chunkSize - 12) { + return fail(error, "PREP payload size does not match its chunk size"); + } + size_t stringCursor = cursor + 16; + const size_t chunkEnd = cursor + chunkSize; + scene->bindings.reserve(count); + for (uint32_t index = 0; index < count; ++index) { + RvbBinding binding; + if (!readCString(bytes, &stringCursor, chunkEnd, &binding.action) || + !readCString(bytes, &stringCursor, chunkEnd, &binding.instancePath)) { + std::ostringstream message; + message << "truncated PREP binding " << index; + return fail(error, message.str()); + } + scene->bindings.push_back(std::move(binding)); + } + if (stringCursor != chunkEnd) { + return fail(error, "PREP bindings do not consume the complete chunk"); + } + } else if (chunk.tag == "REPO") { + if (chunkSize < 12) return fail(error, "REPO chunk is too short"); + const size_t chunkEnd = cursor + chunkSize; + const uint32_t headerSize = readU32Le(bytes, cursor + 8); + size_t resourceCursor = cursor + 12 + headerSize; + if (resourceCursor > chunkEnd) return fail(error, "REPO header exceeds its chunk"); + while (resourceCursor < chunkEnd) { + if (resourceCursor + 12 > chunkEnd) { + return fail(error, "truncated REPO resource header"); + } + const std::string resourceTag( + reinterpret_cast(bytes.data() + resourceCursor), 4); + const uint32_t resourceSize = readU32Le(bytes, resourceCursor + 4); + if (resourceSize < 12 || resourceSize > chunkEnd - resourceCursor) { + return fail(error, "invalid REPO resource size"); + } + if (resourceTag == "IMAG") { + const size_t resourceEnd = resourceCursor + resourceSize; + size_t fieldCursor = resourceCursor + 16; + RvbImageResource image; + if (!readCString(bytes, &fieldCursor, resourceEnd, &image.fileName) || + !readCString(bytes, &fieldCursor, resourceEnd, &image.symbolName) || + fieldCursor + 13 != resourceEnd) { + return fail(error, "malformed IMAG repository resource"); + } + fieldCursor += 4; // resource flags, zero in the dumped menu assets + image.height = readU32Le(bytes, fieldCursor); + image.width = readU32Le(bytes, fieldCursor + 4); + scene->images.push_back(std::move(image)); + } + resourceCursor += resourceSize; + } + } else if (chunk.tag == "EXPG") { + RvbNode exports; + if (!parseNode(bytes, cursor, cursor + chunkSize, &exports, error)) return false; + for (const RvbNode& child : exports.children) { + if (child.tag != "EXPS") continue; + size_t fieldCursor = child.localDataOffset; + const size_t fieldEnd = child.localDataOffset + child.localDataSize; + RvbExport exported; + if (!readCString(bytes, &fieldCursor, fieldEnd, &exported.definitionName) || + !readCString(bytes, &fieldCursor, fieldEnd, &exported.linkageName) || + fieldCursor != fieldEnd) { + return fail(error, "malformed EXPS linkage record"); + } + scene->exports.push_back(std::move(exported)); + } + } + if (chunk.tag != "PREP") { + RvbNode root; + if (!parseNode(bytes, cursor, cursor + chunkSize, &root, error)) return false; + scene->roots.push_back(std::move(root)); + } + cursor += chunkSize; + } + if (scene->chunks.empty() || scene->chunks.front().tag != "PREP") { + return fail(error, "MOVI has no leading PREP binding chunk"); + } + return true; +} + +} // namespace gc diff --git a/src/StageCatalog.cpp b/src/StageCatalog.cpp new file mode 100644 index 0000000..0637c21 --- /dev/null +++ b/src/StageCatalog.cpp @@ -0,0 +1,146 @@ +#include "gc/StageCatalog.hpp" + +#include +#include + +namespace gc { +namespace { + +class CatalogReader { +public: + explicit CatalogReader(const std::vector& bytes) : bytes_(bytes) {} + + size_t tell() const { return pos_; } + bool ok() const { return ok_; } + + uint8_t u8() { + if (pos_ >= bytes_.size()) { + ok_ = false; + return 0; + } + return bytes_[pos_++]; + } + + uint16_t u16be() { + const uint16_t a = u8(); + const uint16_t b = u8(); + return static_cast((a << 8) | b); + } + + uint32_t u32be() { + const uint32_t a = u8(); + const uint32_t b = u8(); + const uint32_t c = u8(); + const uint32_t d = u8(); + return (a << 24) | (b << 16) | (c << 8) | d; + } + + std::string string8() { + const size_t size = u8(); + if (!ok_ || size > bytes_.size() - pos_) { + ok_ = false; + return {}; + } + const char* begin = reinterpret_cast(bytes_.data() + pos_); + pos_ += size; + return std::string(begin, size); + } + +private: + const std::vector& bytes_; + size_t pos_ = 0; + bool ok_ = true; +}; + +template +void readArray(std::array& values, Read&& read) { + for (T& value : values) value = read(); +} + +} // namespace + +bool ParseStageCatalog(const std::vector& bytes, + std::vector* entries, + std::string* error) { + if (!entries) { + if (error) *error = "null stage catalog output"; + return false; + } + entries->clear(); + if (bytes.size() < 2) { + if (error) *error = "stage_param.dat is shorter than its u16 count"; + return false; + } + + CatalogReader r(bytes); + const uint16_t count = r.u16be(); + entries->reserve(count); + for (uint16_t index = 0; index < count; ++index) { + const size_t recordOffset = r.tell(); + StageCatalogEntry entry; + entry.id = r.u32be(); + entry.title = r.string8(); + entry.imageKey = r.string8(); + entry.artist = r.string8(); + entry.source = r.string8(); + entry.sortKey = r.string8(); + entry.genre = r.u8(); + entry.duration = r.string8(); + readArray(entry.difficultyRatings, [&] { return r.u8(); }); + entry.bpm = r.string8(); + readArray(entry.bgmVolumes, [&] { return r.u8(); }); + readArray(entry.shotVolumes, [&] { return r.u8(); }); + readArray(entry.timingValues, [&] { return r.u32be(); }); + readArray(entry.unknown60, [&] { return r.u8(); }); + entry.bgmBase = r.string8(); + readArray(entry.chartGroup0, [&] { return r.string8(); }); + readArray(entry.chartSuffixes, [&] { return r.string8(); }); + readArray(entry.chartIds, [&] { return r.string8(); }); + entry.unknown9c = r.string8(); + entry.unknownA0 = r.u32be(); + readArray(entry.unknownA4, [&] { return r.u8(); }); + entry.unknownAc = r.string8(); + entry.unknownB0 = r.u8(); + + if (!r.ok()) { + if (error) { + std::ostringstream message; + message << "truncated stage_param.dat record " << index + << " at file offset 0x" << std::hex << recordOffset; + *error = message.str(); + } + entries->clear(); + return false; + } + entries->push_back(std::move(entry)); + } + + if (r.tell() != bytes.size()) { + if (error) { + std::ostringstream message; + message << "stage_param.dat has " << (bytes.size() - r.tell()) + << " trailing bytes after " << count << " records"; + *error = message.str(); + } + entries->clear(); + return false; + } + return true; +} + +const StageCatalogEntry* FindStageCatalogEntryByChart( + const std::vector& entries, + const std::string& chartId) { + for (const StageCatalogEntry& entry : entries) { + for (const std::string& candidate : entry.chartIds) { + if (candidate == chartId) return &entry; + } + // Older/alternate data can use either of the two preceding chart groups. + for (const std::string& candidate : entry.chartGroup0) { + if (candidate == chartId) return &entry; + } + } + return nullptr; +} + +} // namespace gc diff --git a/src/StageDat.cpp b/src/StageDat.cpp new file mode 100644 index 0000000..db45aca --- /dev/null +++ b/src/StageDat.cpp @@ -0,0 +1,95 @@ +#include "gc/StageDat.hpp" + +#include +#include +#include + +namespace gc { + +static bool readFile(const std::string& path, std::vector* out, std::string* err) { + std::ifstream f(path, std::ios::binary); + if (!f.is_open()) { + if (err) *err = "could not open file"; + return false; + } + f.seekg(0, std::ios::end); + std::streampos end = f.tellg(); + if (end < 0) { + if (err) *err = "tellg failed"; + return false; + } + const size_t size = static_cast(end); + f.seekg(0, std::ios::beg); + + out->assign(size, 0); + if (size > 0) f.read(reinterpret_cast(out->data()), static_cast(size)); + if (!f) { + if (err) *err = "read failed"; + return false; + } + return true; +} + +static uint32_t u32be(const std::vector& b, size_t off) { + return (static_cast(b[off + 0]) << 24) | + (static_cast(b[off + 1]) << 16) | + (static_cast(b[off + 2]) << 8) | + (static_cast(b[off + 3]) << 0); +} + +bool StageDat::LoadFromFile(const std::string& path, StageDat& out, std::string* err) { + StageDat tmp; + if (!readFile(path, &tmp.bytes, err)) return false; + if (tmp.bytes.size() < 4) { + if (err) *err = "file too small"; + return false; + } + + const size_t size = tmp.bytes.size(); + tmp.headerSize = u32be(tmp.bytes, 0); + if (tmp.headerSize < 4 || tmp.headerSize > size) { + if (err) { + std::ostringstream oss; + oss << "invalid headerSize=" << tmp.headerSize << " for file size=" << size; + *err = oss.str(); + } + return false; + } + if ((tmp.headerSize % 4) != 0) { + if (err) *err = "headerSize is not multiple of 4"; + return false; + } + + // The first headerSize bytes are typically a u32 table (big endian) containing section offsets. + tmp.headerWords.reserve(tmp.headerSize / 4); + for (size_t off = 0; off < tmp.headerSize; off += 4) { + tmp.headerWords.push_back(u32be(tmp.bytes, off)); + } + + // Collect plausible offsets from the header table. + // We keep only offsets that point past the header and within the file. + tmp.offsets.clear(); + tmp.offsets.reserve(tmp.headerWords.size()); + for (uint32_t w : tmp.headerWords) { + if (w >= tmp.headerSize && w < size) tmp.offsets.push_back(static_cast(w)); + } + tmp.offsets.push_back(static_cast(tmp.headerSize)); + tmp.offsets.push_back(size); + std::sort(tmp.offsets.begin(), tmp.offsets.end()); + tmp.offsets.erase(std::unique(tmp.offsets.begin(), tmp.offsets.end()), tmp.offsets.end()); + + // Sections are just adjacent offset pairs. + tmp.sections.clear(); + for (size_t i = 0; i + 1 < tmp.offsets.size(); i++) { + const size_t a = tmp.offsets[i]; + const size_t b = tmp.offsets[i + 1]; + if (a >= b) continue; + tmp.sections.push_back(Section{a, b}); + } + + out = std::move(tmp); + return true; +} + +} // namespace gc + diff --git a/src/StagePattern.cpp b/src/StagePattern.cpp new file mode 100644 index 0000000..253a87a --- /dev/null +++ b/src/StagePattern.cpp @@ -0,0 +1,589 @@ +#include "gc/StagePattern.hpp" + +#include +#include +#include + +namespace gc { + +namespace { + +class PatternReader { +public: + PatternReader(const std::vector& bytes, size_t pos, size_t end) + : bytes_(bytes), pos_(pos), end_(end) {} + + size_t tell() const { return pos_; } + + bool u8(uint8_t* out) { + if (!out || !need(1)) return false; + *out = bytes_[pos_++]; + return true; + } + + bool u16(uint16_t* out) { + if (!out || !need(2)) return false; + *out = static_cast((static_cast(bytes_[pos_]) << 8) | + static_cast(bytes_[pos_ + 1])); + pos_ += 2; + return true; + } + + bool u32(uint32_t* out) { + if (!out || !need(4)) return false; + *out = (static_cast(bytes_[pos_ + 0]) << 24) | + (static_cast(bytes_[pos_ + 1]) << 16) | + (static_cast(bytes_[pos_ + 2]) << 8) | + (static_cast(bytes_[pos_ + 3])); + pos_ += 4; + return true; + } + + bool f32(float* out) { + uint32_t u = 0; + if (!u32(&u) || !out) return false; + static_assert(sizeof(float) == sizeof(uint32_t), "float must be 32-bit"); + std::memcpy(out, &u, sizeof(float)); + return true; + } + + bool color(Color* out) { + if (!out || !need(4)) return false; + out->r = bytes_[pos_ + 0]; + out->g = bytes_[pos_ + 1]; + out->b = bytes_[pos_ + 2]; + out->a = bytes_[pos_ + 3]; + pos_ += 4; + return true; + } + + bool string8(std::string* out) { + uint8_t len = 0; + if (!u8(&len)) return false; + return sizedString(len, out); + } + + bool string16(std::string* out) { + uint16_t len = 0; + if (!u16(&len)) return false; + return sizedString(len, out); + } + +private: + bool need(size_t n) const { + return pos_ <= end_ && n <= end_ - pos_ && pos_ + n <= bytes_.size(); + } + + bool sizedString(size_t len, std::string* out) { + if (!out || !need(len)) return false; + out->assign(reinterpret_cast(bytes_.data() + pos_), len); + while (!out->empty() && out->back() == '\0') out->pop_back(); + pos_ += len; + return true; + } + + const std::vector& bytes_; + size_t pos_ = 0; + size_t end_ = 0; +}; + +bool failAt(const char* what, size_t off, std::string* err) { + if (err) { + std::ostringstream oss; + oss << "failed to parse " << what << " at 0x" << std::hex << off; + *err = oss.str(); + } + return false; +} + +bool validOffset(const StageDat& dat, uint32_t off) { + return off < dat.bytes.size(); +} + +bool validEndOffset(const StageDat& dat, uint32_t off) { + return off <= dat.bytes.size(); +} + +bool parseHeader(const StageDat& dat, StageHeader* out, std::string* err) { + if (!out) return false; + if (dat.headerWords.size() < 10) { + if (err) *err = "file too small for stage header"; + return false; + } + out->stageCfg = dat.headerWords[0]; + out->trackDrawDist = dat.headerWords[1]; + out->track = dat.headerWords[2]; + out->notes = dat.headerWords[3]; + out->camera = dat.headerWords[4]; + out->particles = dat.headerWords[5]; + out->visualizer = dat.headerWords[6]; + out->unk1 = dat.headerWords[7]; + out->colors = dat.headerWords[8]; + out->objects = dat.headerWords[9]; + // Older stage revisions have an 11-word header and place ColorTable2 in + // slot 10. The 13-word revision used by game471 inserts a scalar at 10, + // moves ColorTable2 to 11, then appends another scalar. + if (dat.headerWords.size() == 11) { + out->colors2 = dat.headerWords[10]; + } else if (dat.headerWords.size() >= 13) { + out->unk2 = dat.headerWords[10]; + out->colors2 = dat.headerWords[11]; + out->unk3 = dat.headerWords[12]; + } + return true; +} + +bool parseStageConfig(const StageDat& dat, uint32_t start, uint32_t next, StageConfig* out, std::string* err) { + if (!out || !validOffset(dat, start)) return false; + PatternReader r(dat.bytes, start, next); + uint16_t bpmCount = 0; + + if (!r.f32(&out->endTime1) || + !r.f32(&out->endTime2) || + !r.f32(&out->outroTime) || + !r.u16(&bpmCount)) { + return failAt("StageConfig header", r.tell(), err); + } + + out->bpmChanges.clear(); + out->bpmChanges.reserve(bpmCount); + for (uint16_t i = 0; i < bpmCount; i++) { + BpmChange bp; + if (!r.u32(&bp.timeMs) || !r.u32(&bp.bpm)) return failAt("BpmChange", r.tell(), err); + out->bpmChanges.push_back(bp); + } + + for (std::vector& list : out->noteSettings) { + uint16_t count = 0; + if (!r.u16(&count)) return failAt("NoteSetting count", r.tell(), err); + list.clear(); + list.reserve(count); + for (uint16_t i = 0; i < count; ++i) { + NoteSetting ns; + if (!r.u32(&ns.timeMs) || !r.u32(&ns.mode) || !r.f32(&ns.value)) { + return failAt("NoteSetting", r.tell(), err); + } + list.push_back(ns); + } + } + + if (!r.string16(&out->chartName) || + !r.string16(&out->chartName2) || + !r.string16(&out->bgmName) || + !r.string16(&out->shotName) || + !r.f32(&out->backwardsDrawDist) || + !r.f32(&out->forwardDrawDist) || + !r.color(&out->trackAheadColor) || + !r.color(&out->trackBehindColor) || + !r.u8(&out->audioOffset) || + !r.f32(&out->visualOffset) || + !r.color(&out->unkColor)) { + return failAt("StageConfig tail", r.tell(), err); + } + + return true; +} + +bool readCount(uint32_t count, size_t recordSize, size_t start, size_t end, const char* what, std::string* err) { + const size_t maxCount = (end > start) ? ((end - start) / recordSize) : 0; + if (count > maxCount) { + if (err) { + std::ostringstream oss; + oss << what << " count " << count << " exceeds section capacity " << maxCount; + *err = oss.str(); + } + return false; + } + return true; +} + +bool parseDrawDistances(const StageDat& dat, uint32_t start, uint32_t next, std::vector* out, std::string* err) { + if (!out || !validOffset(dat, start)) return false; + out->clear(); + PatternReader r(dat.bytes, start, next); + uint32_t count = 0; + if (!r.u32(&count)) return failAt("TrackDrawDist count", r.tell(), err); + if (!readCount(count, 8, r.tell(), next, "TrackDrawDist", err)) return false; + out->reserve(count); + for (uint32_t i = 0; i < count; i++) { + DrawDistancePoint p; + if (!r.u32(&p.timeMs) || !r.f32(&p.distance)) return failAt("DrawDistance", r.tell(), err); + out->push_back(p); + } + return true; +} + +bool parseTrack(const StageDat& dat, uint32_t start, uint32_t next, std::vector* out, std::string* err) { + if (!out || !validOffset(dat, start)) return false; + out->clear(); + PatternReader r(dat.bytes, start, next); + uint32_t count = 0; + if (!r.u32(&count)) return failAt("TrackPieceArray count", r.tell(), err); + if (!readCount(count, 16, r.tell(), next, "TrackPieceArray", err)) return false; + out->reserve(count); + for (uint32_t i = 0; i < count; i++) { + TrackPiece p; + if (!r.u32(&p.timeMs) || !r.f32(&p.x) || !r.f32(&p.y) || !r.f32(&p.z)) { + return failAt("TrackPiece", r.tell(), err); + } + out->push_back(p); + } + return true; +} + +bool parseNotes( + const StageDat& dat, + uint32_t start, + uint32_t next, + std::vector* names, + std::vector* out, + std::string* err) { + if (!names || !out || !validOffset(dat, start)) return false; + names->clear(); + out->clear(); + PatternReader r(dat.bytes, start, next); + uint32_t nameCount = 0; + uint32_t count = 0; + if (!r.u32(&nameCount)) return failAt("NoteArray name count", r.tell(), err); + names->reserve(nameCount); + for (uint32_t i = 0; i < nameCount; ++i) { + std::string name; + if (!r.string8(&name)) return failAt("NoteArray name", r.tell(), err); + names->push_back(std::move(name)); + } + if (!r.u32(&count)) return failAt("NoteArray note count", r.tell(), err); + if (!readCount(count, StageNote::kRecordSize, r.tell(), next, "NoteArray", err)) return false; + + out->reserve(count); + for (uint32_t i = 0; i < count; ++i) { + StageNote note; + uint8_t rawType = 0; + if (!r.u32(¬e.timeMs) || + !r.u8(&rawType) || + !r.u8(¬e.typeOverride)) { + return failAt("Note prefix", r.tell(), err); + } + note.type = static_cast(rawType); + for (int16_t& value : note.params16) { + uint16_t encoded = 0; + if (!r.u16(&encoded)) return failAt("Note s16 fields", r.tell(), err); + value = static_cast(encoded); + } + if (!r.u8(¬e.flag24)) return failAt("Note flag24", r.tell(), err); + for (float& value : note.params25) if (!r.f32(&value)) return failAt("Note float fields at +25", r.tell(), err); + if (!r.u8(¬e.flag37) || !r.u8(¬e.flag38)) return failAt("Note flags at +37", r.tell(), err); + for (float& value : note.params39) if (!r.f32(&value)) return failAt("Note float fields at +39", r.tell(), err); + for (uint32_t& value : note.params55) if (!r.u32(&value)) return failAt("Note u32 fields at +55", r.tell(), err); + if (!r.f32(¬e.param67) || !r.u32(¬e.param71)) return failAt("Note fields at +67", r.tell(), err); + for (float& value : note.params75) if (!r.f32(&value)) return failAt("Note float fields at +75", r.tell(), err); + if (!r.u32(¬e.param95)) return failAt("Note field at +95", r.tell(), err); + out->push_back(note); + } + + if (r.tell() != next) { + if (err) { + std::ostringstream oss; + oss << "NoteArray leaves " << (next - r.tell()) << " trailing bytes"; + *err = oss.str(); + } + return false; + } + return true; +} + +bool parseCameras(const StageDat& dat, uint32_t start, uint32_t next, std::vector* out, std::string* err) { + if (!out || !validOffset(dat, start)) return false; + out->clear(); + PatternReader r(dat.bytes, start, next); + uint32_t count = 0; + if (!r.u32(&count)) return failAt("CameraArray count", r.tell(), err); + if (!readCount(count, 59, r.tell(), next, "CameraArray", err)) return false; + out->reserve(count); + for (uint32_t i = 0; i < count; i++) { + CameraPoint c; + if (!r.u32(&c.timeMs) || + !r.u8(&c.aMode) || + !r.u8(&c.fMode) || + !r.f32(&c.dist) || + !r.f32(&c.rotationA[0]) || + !r.f32(&c.rotationA[1]) || + !r.f32(&c.originOff[0]) || + !r.f32(&c.originOff[1]) || + !r.f32(&c.originOff[2]) || + !r.u8(&c.projType) || + !r.f32(&c.fieldFar[0]) || + !r.f32(&c.fieldFar[1]) || + !r.f32(&c.fieldFar[2]) || + !r.f32(&c.fieldNear[0]) || + !r.f32(&c.fieldNear[1]) || + !r.f32(&c.fieldNear[2]) || + !r.f32(&c.rotationB)) { + return failAt("Camera", r.tell(), err); + } + out->push_back(c); + } + return true; +} + +bool parseParticles(const StageDat& dat, uint32_t start, uint32_t next, + std::vector* out, std::string* err) { + if (!out || !validOffset(dat, start)) return false; + out->clear(); + PatternReader r(dat.bytes, start, next); + uint32_t count = 0; + if (!r.u32(&count)) return failAt("ParticleArray count", r.tell(), err); + if (!readCount(count, ParticlePoint::kRecordSize, r.tell(), next, "ParticleArray", err)) return false; + out->reserve(count); + for (uint32_t i = 0; i < count; ++i) { + ParticlePoint p; + if (!r.u32(&p.timeMs) || !r.u32(&p.enabled) || !r.u32(&p.shape) || !r.u32(&p.texture) || + !r.color(&p.color) || !r.f32(&p.velocity[0]) || !r.f32(&p.velocity[1]) || + !r.f32(&p.velocity[2]) || !r.f32(&p.repeatMeasure) || !r.f32(&p.lifespanMeasure) || + !r.u32(&p.groupShapeSize)) { + return failAt("Particle", r.tell(), err); + } + out->push_back(p); + } + if (r.tell() != next) return failAt("ParticleArray trailing bytes", r.tell(), err); + return true; +} + +bool parseVisualizer(const StageDat& dat, uint32_t start, uint32_t next, + std::vector* out, std::string* err) { + if (!out || !validOffset(dat, start)) return false; + out->clear(); + PatternReader r(dat.bytes, start, next); + uint32_t count = 0; + if (!r.u32(&count)) return failAt("VisualizerArray count", r.tell(), err); + if (!readCount(count, VisualizerPoint::kRecordSize, r.tell(), next, "VisualizerArray", err)) return false; + out->reserve(count); + for (uint32_t i = 0; i < count; ++i) { + VisualizerPoint v; + if (!r.u32(&v.timeMs) || !r.u32(&v.type) || !r.color(&v.color)) { + return failAt("Visualizer", r.tell(), err); + } + out->push_back(v); + } + if (r.tell() != next) return failAt("VisualizerArray trailing bytes", r.tell(), err); + return true; +} + +bool parseBackgroundColors(const StageDat& dat, uint32_t start, uint32_t next, + std::vector* out, std::string* err) { + if (!out || !validOffset(dat, start)) return false; + out->clear(); + PatternReader r(dat.bytes, start, next); + uint32_t count = 0; + if (!r.u32(&count)) return failAt("ColorTable count", r.tell(), err); + if (!readCount(count, BackgroundColorPoint::kRecordSize, r.tell(), next, "ColorTable", err)) return false; + out->reserve(count); + for (uint32_t i = 0; i < count; ++i) { + BackgroundColorPoint c; + uint8_t interpolateToNext = 0; + uint8_t audioReactive = 0; + if (!r.u32(&c.timeMs) || !r.color(&c.topRight) || !r.color(&c.topLeft) || + !r.color(&c.bottomRight) || !r.color(&c.bottomLeft) || + !r.u8(&interpolateToNext) || !r.u8(&audioReactive)) { + return failAt("ColorTable entry", r.tell(), err); + } + c.interpolateToNext = interpolateToNext != 0; + c.audioReactive = audioReactive != 0; + out->push_back(c); + } + if (r.tell() != next) return failAt("ColorTable trailing bytes", r.tell(), err); + return true; +} + +bool parseTransformArray(PatternReader& r, std::vector* out, + const char* what, std::string* err) { + uint32_t count = 0; + if (!r.u32(&count)) return failAt(what, r.tell(), err); + out->clear(); + out->reserve(count); + for (uint32_t i = 0; i < count; ++i) { + TransformPoint p; + uint8_t towards = 0; + uint8_t away = 0; + if (!r.u32(&p.timeMs) || !r.u8(&towards) || !r.u8(&away) || + !r.f32(&p.value[0]) || !r.f32(&p.value[1]) || !r.f32(&p.value[2])) { + return failAt(what, r.tell(), err); + } + p.tweenTowards = towards != 0; + p.tweenAway = away != 0; + out->push_back(p); + } + return true; +} + +bool parseObjects(const StageDat& dat, uint32_t start, uint32_t next, + std::vector* modelNames, + std::vector* fragmentShaderNames, + std::vector* out, std::string* err) { + if (!modelNames || !fragmentShaderNames || !out || !validOffset(dat, start)) return false; + PatternReader r(dat.bytes, start, next); + modelNames->clear(); + fragmentShaderNames->clear(); + out->clear(); + + uint32_t count = 0; + if (!r.u32(&count)) return failAt("ObjectArray model name count", r.tell(), err); + modelNames->reserve(count); + for (uint32_t i = 0; i < count; ++i) { + std::string name; + if (!r.string8(&name)) return failAt("ObjectArray model name", r.tell(), err); + modelNames->push_back(std::move(name)); + } + if (!r.u32(&count)) return failAt("ObjectArray shader name count", r.tell(), err); + fragmentShaderNames->reserve(count); + for (uint32_t i = 0; i < count; ++i) { + std::string name; + if (!r.string8(&name)) return failAt("ObjectArray shader name", r.tell(), err); + fragmentShaderNames->push_back(std::move(name)); + } + if (!r.u32(&count)) return failAt("ObjectArray count", r.tell(), err); + out->reserve(count); + + for (uint32_t i = 0; i < count; ++i) { + StageObject object; + uint8_t wireframe = 0; + uint8_t flashing = 0; + uint8_t unknown = 0; + if (!r.u32(&object.model) || !r.u32(&object.fragmentShader) || + !r.u8(&wireframe) || !r.u8(&flashing) || !r.u8(&unknown)) { + return failAt("Object prefix", r.tell(), err); + } + object.wireframe = wireframe != 0; + object.flashing = flashing != 0; + object.unknownFlag = unknown != 0; + for (float& value : object.position) if (!r.f32(&value)) return failAt("Object position", r.tell(), err); + for (float& value : object.scale) if (!r.f32(&value)) return failAt("Object scale", r.tell(), err); + for (float& value : object.rotation) if (!r.f32(&value)) return failAt("Object rotation", r.tell(), err); + for (float& value : object.color) if (!r.f32(&value)) return failAt("Object color", r.tell(), err); + for (float& value : object.unknownVector) if (!r.f32(&value)) return failAt("Object vector", r.tell(), err); + + uint32_t keyCount = 0; + if (!r.u32(&keyCount)) return failAt("Object visibility count", r.tell(), err); + object.visibility.reserve(keyCount); + for (uint32_t key = 0; key < keyCount; ++key) { + VisibilityPoint p; + uint8_t fadeOut = 0; + uint8_t fadeIn = 0; + uint8_t visible = 0; + if (!r.u32(&p.timeMs) || !r.u8(&fadeOut) || !r.u8(&fadeIn) || !r.u8(&visible)) { + return failAt("Object visibility", r.tell(), err); + } + p.fadeOut = fadeOut != 0; + p.fadeIn = fadeIn != 0; + p.visible = visible != 0; + object.visibility.push_back(p); + } + if (!parseTransformArray(r, &object.movement, "Object movement", err) || + !parseTransformArray(r, &object.scaling, "Object scaling", err) || + !parseTransformArray(r, &object.rotations, "Object rotation keys", err)) { + return false; + } + + if (!r.u32(&keyCount)) return failAt("Object color count", r.tell(), err); + object.colorChanges.reserve(keyCount); + for (uint32_t key = 0; key < keyCount; ++key) { + ObjectColorPoint p; + uint8_t towards = 0; + uint8_t away = 0; + if (!r.u32(&p.timeMs) || !r.u8(&towards) || !r.u8(&away) || !r.color(&p.color)) { + return failAt("Object color key", r.tell(), err); + } + p.tweenTowards = towards != 0; + p.tweenAway = away != 0; + object.colorChanges.push_back(p); + } + out->push_back(std::move(object)); + } + if (r.tell() != next) return failAt("ObjectArray trailing bytes", r.tell(), err); + return true; +} + +bool parseObjectParents(const StageDat& dat, uint32_t extensionBase, uint32_t version, + std::vector* objects, std::string* err) { + if (!objects || objects->empty() || version <= 0x29ce) return true; + if (extensionBase > dat.bytes.size() || dat.bytes.size() - extensionBase < 12) { + return true; + } + + // The extension begins with relative offsets. game471 seeks base+8 for + // the parent stream, then reads objectCount followed by signed BE int16s. + PatternReader table(dat.bytes, extensionBase + 8, dat.bytes.size()); + uint32_t relative = 0; + if (!table.u32(&relative) || relative > dat.bytes.size() - extensionBase) { + return true; + } + const size_t parentStart = static_cast(extensionBase) + relative; + PatternReader parents(dat.bytes, parentStart, dat.bytes.size()); + uint32_t count = 0; + if (!parents.u32(&count) || count != objects->size()) return true; + for (size_t i = 0; i < objects->size(); ++i) { + uint16_t encoded = 0; + if (!parents.u16(&encoded)) return failAt("ObjectArray parent index", parents.tell(), err); + const int32_t parent = static_cast(encoded); + if (parent >= 0 && static_cast(parent) >= objects->size()) { + continue; + } + (*objects)[i].parentIndex = parent; + } + return true; +} + +} // namespace + +const char* NoteTypeName(uint8_t rawType) { + 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 rawType < (sizeof(names) / sizeof(names[0])) ? names[rawType] : "UNKNOWN"; +} + +bool ParseStagePattern(const StageDat& dat, ParsedStagePattern* out, std::string* err) { + if (!out) return false; + ParsedStagePattern tmp; + if (!parseHeader(dat, &tmp.header, err)) return false; + + if (!validOffset(dat, tmp.header.stageCfg) || + !validOffset(dat, tmp.header.trackDrawDist) || + !validOffset(dat, tmp.header.track) || + !validOffset(dat, tmp.header.camera)) { + if (err) *err = "stage header contains out-of-file offsets"; + return false; + } + + if (!parseStageConfig(dat, tmp.header.stageCfg, tmp.header.trackDrawDist, &tmp.config, err)) return false; + if (!parseDrawDistances(dat, tmp.header.trackDrawDist, tmp.header.track, &tmp.drawDistances, err)) return false; + if (!parseTrack(dat, tmp.header.track, tmp.header.notes, &tmp.track, err)) return false; + if (!parseNotes(dat, tmp.header.notes, tmp.header.camera, &tmp.noteNames, &tmp.notes, err)) return false; + + if (!validOffset(dat, tmp.header.particles) || tmp.header.particles < tmp.header.camera) { + if (err) *err = "invalid camera/particle section order"; + return false; + } + if (!parseCameras(dat, tmp.header.camera, tmp.header.particles, &tmp.cameras, err)) return false; + if (!validOffset(dat, tmp.header.visualizer) || tmp.header.visualizer < tmp.header.particles || + !validOffset(dat, tmp.header.unk1) || tmp.header.unk1 < tmp.header.visualizer || + !validOffset(dat, tmp.header.colors) || tmp.header.colors < tmp.header.unk1 || + !validOffset(dat, tmp.header.objects) || tmp.header.objects < tmp.header.colors || + !validEndOffset(dat, tmp.header.colors2) || tmp.header.colors2 < tmp.header.objects) { + if (err) *err = "invalid background section order"; + return false; + } + if (!parseParticles(dat, tmp.header.particles, tmp.header.visualizer, &tmp.particles, err) || + !parseVisualizer(dat, tmp.header.visualizer, tmp.header.unk1, &tmp.visualizer, err) || + !parseBackgroundColors(dat, tmp.header.colors, tmp.header.objects, &tmp.backgroundColors, err) || + !parseObjects(dat, tmp.header.objects, tmp.header.colors2, + &tmp.modelNames, &tmp.fragmentShaderNames, &tmp.objects, err) || + !parseObjectParents(dat, tmp.header.colors2, tmp.header.unk3, &tmp.objects, err)) { + return false; + } + + *out = std::move(tmp); + return true; +} + +} // namespace gc diff --git a/src/TumoModel.cpp b/src/TumoModel.cpp new file mode 100644 index 0000000..0f12158 --- /dev/null +++ b/src/TumoModel.cpp @@ -0,0 +1,163 @@ +#include "gc/TumoModel.hpp" + +#include +#include +#include +#include +#include + +namespace gc { +namespace { + +class Reader { +public: + explicit Reader(std::vector bytes) : bytes_(std::move(bytes)) {} + + bool u32(std::uint32_t* value) { + if (!value || offset_ + 4 > bytes_.size()) return false; + *value = (static_cast(bytes_[offset_]) << 24) | + (static_cast(bytes_[offset_ + 1]) << 16) | + (static_cast(bytes_[offset_ + 2]) << 8) | + static_cast(bytes_[offset_ + 3]); + offset_ += 4; + return true; + } + + bool f32(float* value) { + std::uint32_t encoded = 0; + if (!u32(&encoded) || !value) return false; + std::memcpy(value, &encoded, sizeof(encoded)); + return true; + } + + bool string8() { + if (offset_ >= bytes_.size()) return false; + const std::size_t length = bytes_[offset_++]; + if (length > bytes_.size() - offset_) return false; + offset_ += length; + return true; + } + +private: + std::vector bytes_; + std::size_t offset_ = 0; +}; + +bool readFile(const std::string& path, std::vector* bytes) { + std::ifstream stream(path, std::ios::binary); + if (!stream || !bytes) return false; + stream.seekg(0, std::ios::end); + const std::streamoff size = stream.tellg(); + if (size < 0) return false; + stream.seekg(0, std::ios::beg); + bytes->resize(static_cast(size)); + if (!bytes->empty()) { + stream.read(reinterpret_cast(bytes->data()), size); + } + return static_cast(stream); +} + +bool malformed(std::string* error, const char* message) { + if (error) *error = message; + return false; +} + +} // namespace + +bool LoadTumoGeometry(const std::string& path, TumoGeometry* out, std::string* error) { + if (!out) return malformed(error, "missing TUMO output"); + *out = {}; + std::vector bytes; + if (!readFile(path, &bytes)) return malformed(error, "could not read TUMO file"); + Reader reader(std::move(bytes)); + + std::uint32_t meshCount = 0; + if (!reader.u32(&meshCount) || meshCount != 1) { + return malformed(error, "unsupported TUMO mesh layout"); + } + std::uint32_t nameCount = 0; + if (!reader.u32(&nameCount) || nameCount > 4096) return malformed(error, "invalid TUMO names"); + for (std::uint32_t i = 0; i < nameCount; ++i) { + if (!reader.string8()) return malformed(error, "truncated TUMO name table"); + } + + std::uint32_t vertexCount = 0; + if (!reader.u32(&vertexCount) || vertexCount > 10000000) { + return malformed(error, "invalid TUMO vertex count"); + } + std::vector vertices(vertexCount); + for (TumoVertex& vertex : vertices) { + if (!reader.f32(&vertex.x) || !reader.f32(&vertex.y) || !reader.f32(&vertex.z)) { + return malformed(error, "truncated TUMO vertices"); + } + } + float ignoredBound = 0.0f; + for (int i = 0; i < 8; ++i) { + if (!reader.f32(&ignoredBound)) return malformed(error, "truncated TUMO bounds"); + } + + std::uint32_t partCount = 0; + if (!reader.u32(&partCount) || partCount > 100000) { + return malformed(error, "invalid TUMO part count"); + } + for (std::uint32_t part = 0; part < partCount; ++part) { + std::uint32_t primitiveType = 0; + std::uint32_t ignoredMaterial = 0; + std::uint32_t ignoredIndexCount = 0; + std::uint32_t polygonCount = 0; + if (!reader.u32(&primitiveType) || !reader.u32(&ignoredMaterial) || + !reader.u32(&ignoredIndexCount) || !reader.u32(&polygonCount) || + polygonCount > 1000000) { + return malformed(error, "invalid TUMO part"); + } + for (std::uint32_t polygon = 0; polygon < polygonCount; ++polygon) { + std::uint32_t cornerCount = 0; + if (!reader.u32(&cornerCount) || cornerCount > 1000000) { + return malformed(error, "invalid TUMO polygon"); + } + std::vector corners; + corners.reserve(cornerCount); + for (std::uint32_t corner = 0; corner < cornerCount; ++corner) { + std::uint32_t index = 0; + float ignoredU = 0.0f; + float ignoredV = 0.0f; + if (!reader.u32(&index) || !reader.f32(&ignoredU) || !reader.f32(&ignoredV) || + index >= vertices.size()) { + return malformed(error, "invalid TUMO polygon corner"); + } + corners.push_back(index); + } + if (primitiveType == 1) { + for (std::size_t corner = 0; corner + 1 < corners.size(); corner += 2) { + out->solidLines.push_back(vertices[corners[corner]]); + out->solidLines.push_back(vertices[corners[corner + 1]]); + } + } else { + for (std::size_t corner = 1; corner + 1 < corners.size(); ++corner) { + out->triangles.push_back(vertices[corners[0]]); + out->triangles.push_back(vertices[corners[corner]]); + out->triangles.push_back(vertices[corners[corner + 1]]); + } + } + } + } + + std::uint32_t lineCount = 0; + if (!reader.u32(&lineCount) || lineCount > 10000000) { + return malformed(error, "invalid TUMO edge count"); + } + out->wireframeLines.reserve(static_cast(lineCount) * 2); + for (std::uint64_t line = 0; line < static_cast(lineCount) * 2; ++line) { + std::uint32_t index = 0; + if (!reader.u32(&index) || index >= vertices.size()) { + return malformed(error, "invalid TUMO edge"); + } + out->wireframeLines.push_back(vertices[index]); + } + if (out->triangles.empty() && out->solidLines.empty() && out->wireframeLines.empty()) { + return malformed(error, "TUMO model contains no geometry"); + } + return true; +} + +} // namespace gc diff --git a/tools/effect_probe.cpp b/tools/effect_probe.cpp new file mode 100644 index 0000000..00e5d0c --- /dev/null +++ b/tools/effect_probe.cpp @@ -0,0 +1,49 @@ +#include "gc/GcTargetEffect.hpp" + +#include +#include +#include +#include + +int main(int argc, char** argv) { + if (argc < 3) { + std::cerr << "usage: opencoaster-effect-probe EFC_DATA UV_DATA [first [last]]\n"; + return 2; + } + + GcTargetEffectBank bank; + std::string error; + if (!bank.load(argv[1], argv[2], &error)) { + std::cerr << error << '\n'; + return 1; + } + + const int first = argc > 3 ? std::atoi(argv[3]) : 0; + const int last = argc > 4 ? std::atoi(argv[4]) : first; + const int uvBase = argc > 5 ? std::atoi(argv[5]) : 0; + for (int effect = first; effect <= last; ++effect) { + std::cout << "effect " << effect << " lifetime=" << bank.lifetime(effect) << '\n'; + const int lifetime = bank.lifetime(effect); + const int sampleTicks[] = {0, 1, lifetime / 2, lifetime > 0 ? lifetime - 1 : 0}; + std::set seenTicks; + for (const int tick : sampleTicks) { + if (!seenTicks.insert(tick).second) continue; + const auto sprites = bank.evaluate(effect, static_cast(tick), uvBase); + std::cout << " tick " << tick << " sprites=" << sprites.size() << '\n'; + for (const auto& sprite : sprites) { + const GcUvCell* cell = bank.uvCell(sprite.uvRecord, sprite.frame); + std::cout << " uv=" << sprite.uvRecord << " tex=" + << bank.uvTexture(sprite.uvRecord) << " frame=" << sprite.frame; + if (cell) { + std::cout << " cell=" << cell->x << ',' << cell->y << ' ' + << cell->width << 'x' << cell->height; + } + std::cout << " pos=" << sprite.offsetPixels.x << ',' << sprite.offsetPixels.y + << " scale=" << sprite.scale.x << ',' << sprite.scale.y + << " rot=" << sprite.rotationDegrees + << " rgba=" << sprite.color.r << ',' << sprite.color.g << ',' + << sprite.color.b << ',' << sprite.color.a << '\n'; + } + } + } +} diff --git a/tools/mtx_probe.cpp b/tools/mtx_probe.cpp new file mode 100644 index 0000000..1d14394 --- /dev/null +++ b/tools/mtx_probe.cpp @@ -0,0 +1,99 @@ +#include "gc/MtxArchive.hpp" +#include "gc/RvbScene.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; + +namespace { + +bool readFile(const fs::path& path, std::vector* bytes) { + std::ifstream file(path, std::ios::binary); + if (!file) return false; + bytes->assign(std::istreambuf_iterator(file), std::istreambuf_iterator()); + return true; +} + +size_t textureIndex(const gc::RvbImageResource& image) { + static constexpr char prefix[] = "Image"; + if (image.symbolName.rfind(prefix, 0) != 0 || image.symbolName.size() <= 5) { + return std::numeric_limits::max(); + } + size_t number = 0; + for (size_t i = 5; i < image.symbolName.size(); ++i) { + const char c = image.symbolName[i]; + if (c < '0' || c > '9') return std::numeric_limits::max(); + number = number * 10 + static_cast(c - '0'); + } + return number == 0 ? std::numeric_limits::max() : number - 1; +} + +} // namespace + +int main(int argc, char** argv) { + if (argc < 3 || argc > 4) { + std::cerr << "Usage: " << argv[0] << " [output-dir]\n"; + return 2; + } + std::vector rvbBytes; + std::vector mtxBytes; + if (!readFile(argv[1], &rvbBytes) || !readFile(argv[2], &mtxBytes)) { + std::cerr << "could not read RVB/MTX input\n"; + return 1; + } + gc::RvbScene scene; + gc::MtxArchive archive; + std::string error; + if (!gc::ParseRvbScene(rvbBytes, &scene, &error) || + !gc::ParseMtxArchive(mtxBytes, &archive, &error)) { + std::cerr << "parse failed: " << error << '\n'; + return 1; + } + if (scene.images.size() != archive.textures.size()) { + std::cerr << "resource mismatch: RVB images=" << scene.images.size() + << " MTX textures=" << archive.textures.size() << '\n'; + return 1; + } + + const bool extract = argc == 4; + const fs::path output = extract ? fs::path(argv[3]) : fs::path(); + if (extract) fs::create_directories(output); + for (const gc::RvbImageResource& image : scene.images) { + const size_t index = textureIndex(image); + if (index >= archive.textures.size()) { + std::cerr << "invalid MTX symbol index: " << image.symbolName << '\n'; + return 1; + } + const gc::MtxTexture& texture = archive.textures[index]; + if (image.width != texture.width || image.height != texture.height) { + std::cerr << "dimension mismatch at " << index << ": " << image.symbolName + << " RVB=" << image.width << 'x' << image.height + << " MTX=" << texture.width << 'x' << texture.height << '\n'; + return 1; + } + std::cout << index << ' ' << image.symbolName << ' ' << texture.width << 'x' + << texture.height << " bytes=" << texture.size << '\n'; + if (extract) { + std::vector dds; + if (!gc::ExtractMtxTextureDds(mtxBytes, texture, &dds, &error)) { + std::cerr << "extract failed: " << error << '\n'; + return 1; + } + std::ofstream file(output / (image.symbolName + ".dds"), std::ios::binary); + file.write(reinterpret_cast(dds.data()), + static_cast(dds.size())); + if (!file) { + std::cerr << "could not write extracted DDS\n"; + return 1; + } + } + } + return 0; +} diff --git a/tools/rvb_probe.cpp b/tools/rvb_probe.cpp new file mode 100644 index 0000000..0e94514 --- /dev/null +++ b/tools/rvb_probe.cpp @@ -0,0 +1,117 @@ +#include "gc/RvbScene.hpp" +#include "gc/RvbLayout.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +void printNode(const gc::RvbNode& node, unsigned depth) { + std::cout << " " << std::string(depth * 2, ' ') << node.tag + << " @0x" << std::hex << node.offset << "/0x" << node.size + << std::dec << " local=" << node.localDataSize << '\n'; + for (const gc::RvbNode& child : node.children) printNode(child, depth + 1); +} + +bool probe(const std::string& path, bool tree, const std::string& symbol, + const gc::RvbSnapshotState& state) { + std::ifstream file(path, std::ios::binary); + if (!file) { + std::cerr << path << ": could not open\n"; + return false; + } + std::vector bytes((std::istreambuf_iterator(file)), + std::istreambuf_iterator()); + gc::RvbScene scene; + std::string error; + if (!gc::ParseRvbScene(bytes, &scene, &error)) { + std::cerr << path << ": parse failed: " << error << '\n'; + return false; + } + std::vector snapshot; + const bool built = symbol.empty() + ? gc::BuildRvbSnapshot(bytes, scene, state, &snapshot, &error) + : gc::BuildRvbSymbolSnapshot(bytes, scene, symbol, state, &snapshot, &error); + if (!built) { + std::cerr << path << ": snapshot failed: " << error << '\n'; + return false; + } + + std::cout << path << "\n movie=" << scene.sourceWidth << 'x' << scene.sourceHeight + << " @ " << static_cast(scene.framesPerSecond) << " fps" + << " bindings=" << scene.bindings.size() + << " images=" << scene.images.size() + << " initialDraws=" << snapshot.size() << "\n chunks:"; + for (const gc::RvbChunk& chunk : scene.chunks) { + std::cout << ' ' << chunk.tag << "@0x" << std::hex << chunk.offset + << "/0x" << chunk.size << std::dec; + } + std::cout << "\n PREP bindings:\n"; + for (const gc::RvbBinding& binding : scene.bindings) { + std::cout << " " << std::left << std::setw(28) << binding.action + << ' ' << binding.instancePath << '\n'; + } + if (tree) { + std::cout << " animation tree:\n"; + for (const gc::RvbNode& root : scene.roots) printNode(root, 0); + std::cout << " initial draws:\n"; + for (const gc::RvbImageDraw& draw : snapshot) { + std::cout << " " << draw.imageSymbol << " depth=" << draw.depth + << " path=" << draw.instancePath + << " rgba=(" << draw.color[0] << ',' << draw.color[1] << ',' + << draw.color[2] << ',' << draw.alpha << ')' + << " tl=(" << draw.corners[0][0] << ',' << draw.corners[0][1] + << ") br=(" << draw.corners[3][0] << ',' << draw.corners[3][1] + << ")\n"; + } + } + return true; +} + +} // namespace + +int main(int argc, char** argv) { + bool tree = false; + std::string symbol; + gc::RvbSnapshotState state; + int firstPath = 1; + while (firstPath < argc) { + const std::string option = argv[firstPath]; + if (option == "--tree") { + tree = true; + ++firstPath; + } else if (option == "--include-other") { + state.includeRootOther = true; + ++firstPath; + } else if (option == "--symbol" && firstPath + 1 < argc) { + symbol = argv[firstPath + 1]; + firstPath += 2; + } else if (option == "--state" && firstPath + 1 < argc) { + const std::string value = argv[firstPath + 1]; + const size_t equals = value.find('='); + if (equals == std::string::npos) { + std::cerr << "--state expects /path=frame_label\n"; + return 2; + } + state.frameByPath[value.substr(0, equals)] = value.substr(equals + 1); + firstPath += 2; + } else { + break; + } + } + if (argc <= firstPath) { + std::cerr << "Usage: " << argv[0] + << " [--tree] [--include-other] [--symbol name]" + " [--state /path=frame] [scene.rvb ...]\n"; + return 2; + } + bool ok = true; + for (int i = firstPath; i < argc; ++i) ok = probe(argv[i], tree, symbol, state) && ok; + return ok ? 0 : 1; +} diff --git a/tools/stage_probe.cpp b/tools/stage_probe.cpp new file mode 100644 index 0000000..75fb7b0 --- /dev/null +++ b/tools/stage_probe.cpp @@ -0,0 +1,163 @@ +#include "gc/StageDat.hpp" +#include "gc/StagePattern.hpp" + +#include +#include +#include +#include +#include +#include + +namespace { + +bool probe(const std::string& path) { + gc::StageDat dat; + std::string err; + if (!gc::StageDat::LoadFromFile(path, dat, &err)) { + std::cerr << path << ": load failed: " << err << '\n'; + return false; + } + + gc::ParsedStagePattern stage; + if (!gc::ParseStagePattern(dat, &stage, &err)) { + std::cerr << path << ": parse failed: " << err << '\n'; + return false; + } + + std::map types; + for (const gc::StageNote& note : stage.notes) ++types[static_cast(note.type)]; + std::map cameraAnchorModes; + std::map cameraFadeModes; + std::map cameraProjectionTypes; + for (const gc::CameraPoint& camera : stage.cameras) { + ++cameraAnchorModes[camera.aMode]; + ++cameraFadeModes[camera.fMode]; + ++cameraProjectionTypes[camera.projType]; + } + size_t parentedObjects = 0; + size_t wireframeObjects = 0; + size_t flashingObjects = 0; + size_t noGlobalFadeObjects = 0; + size_t translucentObjects = 0; + for (const gc::StageObject& object : stage.objects) { + if (object.parentIndex >= 0) ++parentedObjects; + if (object.wireframe) ++wireframeObjects; + if (object.flashing) ++flashingObjects; + if (object.unknownFlag) ++noGlobalFadeObjects; + if (object.color[3] < 0.999f) ++translucentObjects; + } + + std::cout << path + << "\n chart=" << stage.config.chartName + << " bgm=" << stage.config.bgmName + << "\n track=" << stage.track.size() + << " drawDistanceKeys=" << stage.drawDistances.size() + << " drawBehind=" << stage.config.backwardsDrawDist + << " drawAhead=" << stage.config.forwardDrawDist + << " audioOffset=" << static_cast(stage.config.audioOffset) + << " visualOffset=" << stage.config.visualOffset + << " trackColors=(" << static_cast(stage.config.trackAheadColor.r) << ',' + << static_cast(stage.config.trackAheadColor.g) << ',' + << static_cast(stage.config.trackAheadColor.b) << ")->(" + << static_cast(stage.config.trackBehindColor.r) << ',' + << static_cast(stage.config.trackBehindColor.g) << ',' + << static_cast(stage.config.trackBehindColor.b) << ')' + << " notes=" << stage.notes.size() + << " cameras=" << stage.cameras.size() + << " noteNames=" << stage.noteNames.size() + << "\n background: particles=" << stage.particles.size() + << " visualizerKeys=" << stage.visualizer.size() + << " colorKeys=" << stage.backgroundColors.size() + << " models=" << stage.modelNames.size() + << " objects=" << stage.objects.size() + << " parented=" << parentedObjects + << " wire=" << wireframeObjects + << " flashing=" << flashingObjects + << " noGlobalFade=" << noGlobalFadeObjects + << " translucent=" << translucentObjects; + if (!stage.notes.empty()) { + const auto [lo, hi] = std::minmax_element( + stage.notes.begin(), stage.notes.end(), + [](const gc::StageNote& a, const gc::StageNote& b) { return a.timeMs < b.timeMs; }); + std::cout << " noteTimeMs=" << lo->timeMs << ".." << hi->timeMs; + } + if (!stage.particles.empty()) { + std::cout << "\n particleKeys:"; + for (const auto& particle : stage.particles) { + std::cout << ' ' << particle.timeMs << ":on=" << particle.enabled + << ",shape=" << particle.shape << ",tex=" << particle.texture + << ",repeat=" << particle.repeatMeasure + << ",life=" << particle.lifespanMeasure + << ",group=" << particle.groupShapeSize; + } + } + if (!stage.visualizer.empty()) { + std::map visualizerTypes; + for (const auto& key : stage.visualizer) ++visualizerTypes[key.type]; + std::cout << "\n visualizerTypes:"; + for (const auto& [type, count] : visualizerTypes) std::cout << ' ' << type << '=' << count; + } + if (!stage.drawDistances.empty()) { + std::cout << "\n drawDistance=" << stage.drawDistances.front().timeMs << ':' + << stage.drawDistances.front().distance << ".." + << stage.drawDistances.back().timeMs << ':' + << stage.drawDistances.back().distance; + } + if (!stage.track.empty()) { + const auto& p = stage.track.front(); + std::cout << "\n firstTrack=" << p.timeMs << ":(" << p.x << ',' << p.y << ',' << p.z << ')'; + if (stage.track.size() > 1) { + const auto& q = stage.track[1]; + std::cout << " nextTrack=" << q.timeMs << ":(" << q.x << ',' << q.y << ',' << q.z << ')'; + } + } + if (!stage.cameras.empty()) { + const auto& c = stage.cameras.front(); + std::cout << " firstCamera=" << c.timeMs << " dist=" << c.dist + << " rot=(" << c.rotationA[0] << ',' << c.rotationA[1] << ',' << c.rotationB << ')' + << " off=(" << c.originOff[0] << ',' << c.originOff[1] << ',' << c.originOff[2] << ')'; + std::cout << "\n cameraModes: aMode"; + for (const auto& [mode, count] : cameraAnchorModes) { + std::cout << ' ' << static_cast(mode) << '=' << count; + } + std::cout << " fMode"; + for (const auto& [mode, count] : cameraFadeModes) { + std::cout << ' ' << static_cast(mode) << '=' << count; + } + std::cout << " projType"; + for (const auto& [mode, count] : cameraProjectionTypes) { + std::cout << ' ' << static_cast(mode) << '=' << count; + } + } + std::cout << "\n noteTypes:"; + for (const auto& [type, count] : types) { + std::cout << " 0x" << std::hex << std::setw(2) << std::setfill('0') + << static_cast(type) << std::dec << '/' << gc::NoteTypeName(type) << '=' << count; + } + if (!stage.fragmentShaderNames.empty()) { + std::cout << "\n fragmentShaders:"; + for (size_t i = 0; i < stage.fragmentShaderNames.size(); ++i) { + std::cout << ' ' << i << '=' << stage.fragmentShaderNames[i]; + } + } + if (!stage.modelNames.empty()) { + std::cout << "\n modelNames:"; + for (size_t i = 0; i < stage.modelNames.size(); ++i) { + std::cout << ' ' << i << '=' << stage.modelNames[i]; + } + } + std::cout << '\n'; + return true; +} + +} // namespace + +int main(int argc, char** argv) { + if (argc < 2) { + std::cerr << "Usage: " << argv[0] << " [stage.dat ...]\n"; + return 2; + } + bool ok = true; + for (int i = 1; i < argc; ++i) ok = probe(argv[i]) && ok; + return ok ? 0 : 1; +}