diff --git a/README.md b/README.md index bfab8d8..aab7dcb 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,9 @@ workspace/ └── openroller/ ``` -Install SDL3, OpenGL, libpng and GLM, then build: +Install CMake, a C++ compiler, and OpenGL development files, then build. SDL3, +GLM, and the PNG decoder are fetched automatically when no installed package +is available: ```sh cmake -S openroller -B openroller/build -DCMAKE_BUILD_TYPE=Release @@ -51,6 +53,33 @@ with `VECTORAIL_CORE_SOURCE_DIR` and `VECTORAIL_GC_SOURCE_DIR`. The raw stage inspection utility is built as `openroller-stage-probe`. +### Windows + +To cross-build a self-contained x86-64 package from Linux with MinGW-w64: + +```sh +cmake -S openroller -B openroller/build-windows \ + -DCMAKE_TOOLCHAIN_FILE=openroller/cmake/toolchains/mingw-x86_64.cmake \ + -DCMAKE_BUILD_TYPE=Release +cmake --build openroller/build-windows --parallel +cmake --install openroller/build-windows \ + --prefix openroller/build-windows/package +``` + +For a native MSYS2 CLANG64 build, install CMake, Ninja, Clang, SDL3, GLM, and +OpenGL packages in the CLANG64 shell, then run: + +```sh +cmake -S openroller -B openroller/build-windows -G Ninja \ + -DCMAKE_BUILD_TYPE=Release +cmake --build openroller/build-windows --parallel +cmake --install openroller/build-windows \ + --prefix openroller/build-windows/package +``` + +The package directory contains `OpenRoller.exe`, `SDL3.dll`, +`openroller.cfg`, and the shaders needed at runtime. + ## PSP build The PSP port is built with the `pspdev/pspdev` container: diff --git a/apps/desktop/CMakeLists.txt b/apps/desktop/CMakeLists.txt index 957d686..ca7831b 100644 --- a/apps/desktop/CMakeLists.txt +++ b/apps/desktop/CMakeLists.txt @@ -17,6 +17,11 @@ target_link_libraries(openroller-desktop Vectorail::GCEffects ) +if(MINGW AND CMAKE_CXX_COMPILER_ID STREQUAL "GNU") + target_link_options(openroller-desktop PRIVATE + -static -static-libgcc -static-libstdc++) +endif() + if(MSVC) target_compile_options(openroller-desktop PRIVATE /W4) else() @@ -25,3 +30,27 @@ endif() configure_file(openroller.cfg openroller.cfg COPYONLY) file(COPY shaders DESTINATION "${CMAKE_CURRENT_BINARY_DIR}") + +add_custom_command(TARGET openroller-desktop POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_directory + "${CMAKE_CURRENT_SOURCE_DIR}/shaders" + "$/shaders" + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${CMAKE_CURRENT_SOURCE_DIR}/openroller.cfg" + "$/openroller.cfg" + VERBATIM +) + +if(WIN32 AND TARGET SDL3::SDL3-shared) + add_custom_command(TARGET openroller-desktop POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "$" + "$" + VERBATIM + ) + install(FILES "$" DESTINATION .) +endif() + +install(TARGETS openroller-desktop RUNTIME DESTINATION .) +install(DIRECTORY shaders DESTINATION .) +install(FILES openroller.cfg DESTINATION .) diff --git a/apps/desktop/src/main.cpp b/apps/desktop/src/main.cpp index 401cbd3..479f0ca 100644 --- a/apps/desktop/src/main.cpp +++ b/apps/desktop/src/main.cpp @@ -686,12 +686,12 @@ struct GcKeySample { float sampledTimeMs = 0.0f; }; -bool gcLoopFlag(const gc::TransformPoint& key) { return key.tweenTowards; } -bool gcLoopFlag(const gc::ObjectColorPoint& key) { return key.tweenTowards; } -bool gcLoopFlag(const gc::VisibilityPoint& key) { return key.fadeOut; } -bool gcInterpolateFlag(const gc::TransformPoint& key) { return key.tweenAway; } -bool gcInterpolateFlag(const gc::ObjectColorPoint& key) { return key.tweenAway; } -bool gcInterpolateFlag(const gc::VisibilityPoint& key) { return key.fadeIn; } +bool gcLoopFlag(const gc::TransformPoint& key) { return key.repeat; } +bool gcLoopFlag(const gc::ObjectColorPoint& key) { return key.repeat; } +bool gcLoopFlag(const gc::VisibilityPoint& key) { return key.repeat; } +bool gcInterpolateFlag(const gc::TransformPoint& key) { return key.interpolate; } +bool gcInterpolateFlag(const gc::ObjectColorPoint& key) { return key.interpolate; } +bool gcInterpolateFlag(const gc::VisibilityPoint& key) { return key.interpolate; } // FUN_005e9100 is shared by all five object-animation channels. The first // flag marks a repeat block; the second enables interpolation to the next key. @@ -807,7 +807,7 @@ GcVisibilityState gcObjectVisibility(const gc::StageObject& object, float timeMs if (active + 1 < static_cast(keys.size())) { const auto& next = keys[active + 1]; const float untilNext = static_cast(next.timeMs) - sample.sampledTimeMs; - if (next.visible != visible && next.fadeIn && + if (next.visible != visible && next.interpolate && untilNext >= 0.0f && untilNext < kGcVisibilityFadeMs) { const float remaining = std::clamp(untilNext / kGcVisibilityFadeMs, 0.0f, 1.0f); alpha = next.visible ? 1.0f - remaining : remaining; diff --git a/cmake/toolchains/mingw-x86_64.cmake b/cmake/toolchains/mingw-x86_64.cmake new file mode 100644 index 0000000..1adb9b7 --- /dev/null +++ b/cmake/toolchains/mingw-x86_64.cmake @@ -0,0 +1,12 @@ +set(CMAKE_SYSTEM_NAME Windows) +set(CMAKE_SYSTEM_PROCESSOR x86_64) + +set(CMAKE_C_COMPILER x86_64-w64-mingw32-gcc) +set(CMAKE_CXX_COMPILER x86_64-w64-mingw32-g++) +set(CMAKE_RC_COMPILER x86_64-w64-mingw32-windres) + +set(CMAKE_FIND_ROOT_PATH /usr/x86_64-w64-mingw32) +set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) +set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) +set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) diff --git a/docs/re_game471_notes.md b/docs/re_game471_notes.md index 139acf3..8a3947a 100644 --- a/docs/re_game471_notes.md +++ b/docs/re_game471_notes.md @@ -70,20 +70,21 @@ The file is big-endian. The initial words are not all section offsets. Confirmed | 2 | track points | | 3 | notes | | 4 | camera | -| 5 | particles | +| 5 | `TuneBGEffectData` / FlowItem keys | | 6 | visualizer | -| 7 | unknown section | +| 7 | background texture names and image keys | | 8 | first color table | | 9 | objects | | 10 | scalar/unknown; often `0x30`, not an offset | -| 11 | second color table | +| 11 | points into the post-object extension offset table | | 12 | scalar/unknown; can accidentally look like an in-file offset | This matters because treating every plausible header word as an offset can split the notes or camera section at a false boundary. -Older 11-word stage headers instead store the second color-table offset in -slot 10. The scalar/offset/scalar arrangement in slots 10..12 belongs to the -newer 13-word format used by the 4.71-era charts. +The post-object extension base is the reader position after the variable +object stream. Relative offset slot 1 extends background colors and slot 2 +stores object parents. Deriving that base from consumed bytes, as Android +`LoadBGData` does, also handles the small mobile chart revision correctly. ## Note array: confirmed wire layout @@ -199,8 +200,15 @@ BPM active at the note timestamp. With `beat_ms = 60000 / bpm`: | ---: | --- | | `+6` | signed marker-effect/UV selector; the note-head draw passes `value - 1` to effect 3 | | `+39` | appearance lead in beats; runtime `+0xbc = max(time - value * beat_ms, 0)` | +| `+37` | enable positional fly-in | +| `+38` | draw the additive fly-in trail | +| `+43` | fly-in interpolation start, in beats after appearance | +| `+47` | fly-in interpolation end, in beats before the hit time | | `+51` | duration in beats for types 3/4/5/10/15; runtime `+0x58` and `+0xac = time + value * beat_ms` | -| `+55` | packed authored `RRGGBBAA` colour used by duration-target geometry | +| `+55` | primary packed authored `RRGGBBAA` target colour; also used by duration geometry | +| `+59` | secondary packed `RRGGBBAA` colour | +| `+63` | integer fly-in oscillation count | +| `+67` | fly-in oscillation endpoint/phase scalar | | `+71` | number of generated MERRY GO ROUND targets | | `+75` | MERRY GO ROUND spacing in beats | @@ -333,7 +341,7 @@ and the displaced point with the camera evaluated at the note timestamp, then uses `RotateHPB::SetVector` (`atan2(screen_dx, screen_dy_down)`) and stores the resulting screen angle. It is computed before gameplay and remains fixed while the live camera moves. Effect 39 resolves to a 128x32 animated strip whose -arrows extend to the sides of the 32x32 head; it uses the same `0.025` scale, +arrows extend to the sides of the 32x32 head; it uses the same `0.025` scale colour and marker alpha. This is independent of the lower-right control helper, even though that helper also has a direction overlay. @@ -366,8 +374,13 @@ synthetic pulsing rings are no longer used for decoded GC stages. Duration bodies are also type-specific in the executable: -- HOLD calls `0x00647cf0` and SLIDE HOLD calls `0x00641fd0`; both emit ribbon - triangles rather than an OpenGL-style line. +- Android HOLD does not submit the prebuilt ribbon array. Its normal gameplay + branch calls `DrawWay(max(current_ms, appear_ms), end_ms, color, color)` with + additive blending and a beat-reactive width from 3 to 5 pixels. This exact + moving timestamp boundary is why dropping whole prebuilt samples produces a + visibly stepped disappearance. Arcade still constructs HOLD helper geometry, + but it is not evidence for the mobile draw path. +- SLIDE HOLD calls its prebuilt triangle-ribbon path. - SCRATCH is sampled every `0.15` world units. `0x005ebaa0` derives two opposing paths with radius `0.2` and rotates the offset by 45 degrees per sample; `0x00641d50` emits six vertices per segment for each path. @@ -391,6 +404,32 @@ Duration bodies are also type-specific in the executable: screen direction. Both endpoint layers inherit the long note's colour, fade alpha, billboard transform, beat-synchronised animation and `0.025` sprite scale. +### Positional fly-in + +`GameScene::DrawMark` converts wire `+25/+29/+33` into direction vector `D` and +starts from the authored route position `P`. With: + +```text +start = max(0, appear_ms + field_43 * beat_ms) +end = max(0, note_ms - field_47 * beat_ms) +u = 0 before start, (now-start)/(end-start) in between, 1 at/after end +``` + +`0x005ebaa0` first stores runtime `+0xc0 = ROUND(appear_ms / frame_ms)`. +`0x0064ab80` then computes the start from that stored integer, but computes the +end from the still-fractional `note_ms / frame_ms`; each final boundary passes +through another nearest-integer conversion at `0x0050a4a0`. The live clock is +quantized the same way. +Flag `+37` selects positional interpolation. With no oscillations the displayed +position is `P + D*(1-u)`. When integer `cycles = +63` is positive, let +`c = +67` for values up to 1 and `c = 2-(+67)` otherwise; then position is +`P + D*(c - abs(sin(u*(asin(c) + cycles*2*pi))))`. Integer cycles guarantee +that the marker lands back on `P` at `u=1`. The approach circle follows the +same moved position. Flag `+38` draws an additive line from the initial external +anchor to the moving marker before the hit time. The player preserves that +nearest-frame 60 Hz quantization rather than smoothing the movement on the +audio clock. + ## Tap judgment timing The system-config loader at `0x00635c90` lays out the timing overrides in four diff --git a/docs/re_gc_android_runtime.md b/docs/re_gc_android_runtime.md new file mode 100644 index 0000000..31e0c2c --- /dev/null +++ b/docs/re_gc_android_runtime.md @@ -0,0 +1,200 @@ +# Groove Coaster Android Runtime Reverse + +This is a clean-room interoperability record for the offline Android package +`gc2offlinev4.xapk`. It documents facts used to validate the GC stage loader +and Vectorail runtime. Addresses below are ELF virtual addresses in +`lib/arm64-v8a/libtune.so`; the Ghidra project uses an additional `0x100000` +image-base offset. + +## Provenance + +```text +XAPK c6c394f7a1cc65331edc98aac94014668f9d5277ce17262fecf9aeeb1a2a3003 +APK 62c9e739dc9b8c1bcbcb4b5234d78f154b20d1a930329b9c21a3c9236b92a0dc +libtune 689b2e4c0bc4479a3f309944b5070796878c66c0dfdbd283c11ec0bbeeea9efa +``` + +Stage and audio ZIPs use the password returned by +`mtxc::ObbFile::getZipPassword`: `eiprblFFv69R83J5`. + +The Android `ac_10pt8tion_{easy,normal,hard}{,_ext}.dat` payloads are binary +identical to the corresponding files in `GC/data/stage`. For example both +copies of `ac_10pt8tion_easy.dat` have SHA-256 +`86a75c86b91bbbe25cae78bcb51d4f7842b3fc4946e4b091f2ec5ba084497765`. +The Android executable is therefore a valid independent specification for the +arcade files consumed by Vectorail. + +## Stage Selection And Containers + +`TuneAppMain::LoadStageData` (`0x9da24`) loads six chart ids per song. The +first three are serialized mobile ids. The other three are synthesized by +prefixing those ids with `ac_`. `GameScene::makeFilenameStageDat` (`0xc4bac`) +then appends `.dat` or `_ext.dat`. + +The sampled offline package contains one-note `placeholder_bgm` mobile charts, +while its `ac_` charts contain the complete arcade route, camera, notes and +background scene. `GameScene::LoadStageData` (`0xc607c`) performs this order: + +1. Load the selected main DAT from `.zip`. +2. Call `TuneGameData::LoadGameData` (`0x933a0`). +3. Call `TuneGameData::LoadBGData` (`0x93aa4`) on the same bytes. +4. Load `_ext.dat` only for an extra/arcade difficulty when arrange mode is off. +5. Build runtime data and load stage resources. + +For every selected `ac_` chart, `LoadExtData` starts at byte 6 of the matching +`_ext.dat`, replaces the four timing lists, reads another array of 99-byte +notes, and links each ext note to a same-time main note. If both effective +types are FLICK, the main runtime type becomes `0x10` (dual flick). + +The corpus contains 2927 valid sidecars with 18965 ext notes. Every sidecar +parses with the layout described by `docs/stage_ext.pat`, and all ext note +records are FLICK entries. One unusual main chart is itself named +`SW_marianne_hard_ext.dat`, so its sidecar is +`SW_marianne_hard_ext_ext.dat`. File discovery must check whether removing the +suffix names an existing main chart instead of excluding every `_ext.dat`. + +## Parsed Stage Sections + +The parser now follows every section consumed by Android `LoadGameData` and +`LoadBGData`: + +| Header | Runtime data | +| ---: | --- | +| 0 | stage config, BPM and four timing tables | +| 1 | route draw-distance keys | +| 2 | route points | +| 3 | 99-byte note records | +| 4 | 59-byte camera records | +| 5 | 44-byte `TuneBGEffectData` / FlowItem keys | +| 6 | 12-byte visualizer keys | +| 7 | background texture names and 20-byte image keys | +| 8 | background color keys | +| 9 | model/shader names and animated stage objects | + +After the variable-length object stream, Android records the current read +position and treats it as the base of four relative extension offsets. The +third extension contains signed object parent indices. Header word 11 points +inside this table on current charts; it is not the object-stream end. The +parser now derives the base from the consumed object stream exactly as Android +does, which also fixes the small mobile DAT revision. + +## Timing, Route And Long Elements + +`TuneTimingData::GetTime` selects the latest timing key at or before the note. +Mode 1 is absolute milliseconds, mode 3 is next-note spacing, and other modes +multiply the authored value by `60000 / BPM`. + +`TuneGameData::GetWayPosition` (`0x953bc`) linearly interpolates route points +by timestamp and clamps before/after the route. `WaySplitCheck` (`0x95c4c`) +clips the polyline to the requested time range, emits samples at a fixed world +distance, carries the unused distance across authored segments, and stores the +true interpolated timestamp for each sample. + +Android spacing is `0.20` for HOLD/SLIDE/DUAL, `0.15` for SCRATCH and `0.40` +for BEAT. Arcade `game471.exe` uses `0.55` for BEAT; Vectorail intentionally +keeps the arcade value when playing arcade DATs. + +`LoadTuneMarkDataOne` (`0x967d8`) confirms the wire-field translation and +compatibility remaps. Runtime types 7/8 become 1, 11 becomes 10, 12/14 become +9, and 13 becomes 4. Marker effects are forced to 35 for type 10, 32 for type +9, 37 for type 15 and 11 for raw type 13. Duration types are 3, 4, 5, 10 and +15. MERRY type 6 expands to `count` targets separated by the authored beat +spacing. + +## Camera + +`TuneGameData::GetCameraData` (`0x95588`) independently confirms the seven +anchor modes, the two interpolation paths and the orthographic/perspective +blend documented in `re_gc_camera.md`. Android uses right-handed OpenGL +look-at/projection matrices. The arcade executable uses the corresponding D3D +left-handed path; Vectorail converts the evaluated eye/target/up state to its +OpenGL renderer and uses the arcade fixed 75 degree FOV. + +No camera wire fields are discarded, including non-finite values in the three +`ac_comet_*` intros. + +## Background Runtime + +`DrawBGColor` (`0xb3fc8`) selects the active color key, optionally cross-fades +to the next key, and applies BPM-derived HSV brightness modulation when its +rhythm-reactive flag is set. `DrawBGImage` (`0xb49b0`) supports tiled atlas +images, a built-in centered image and external texture entries. +`DrawBGVisualizer` (`0xb6118`) selects timed visualizer keys and calls the +8572-byte procedural `DrawVisualizer` path. The conventional FFT routine at +`0x104ec0` is not used to drive this stage visualizer. + +The 44-byte table previously called particles is `TuneBGEffectData`. +`GameScene::ExecFlowItem` (`0xc0610`) uses it to spawn and update authored +flowing background elements. Shape 1 spawns a random screen point, shape 2 a +screen-space grid and shape 3 an eight-point ring. Repeat and lifetime are +scaled by the active beat before points are unprojected to route depth. It is +separate from hit-effect particles. + +## Audio + +Stage audio is stored as encrypted `*_bgm.ogg.zip` and `*_shot.ogg.zip` files. +`MtxSoundBuffer::LoadData` (`0x10a614`) rewrites `.m4a` resource names to +`.ogg`. The sampled Android 10pt8tion pair is Vorbis, 44.1 kHz stereo, and both +streams are 129.621859 seconds. The corresponding arcade BGM WAV is PCM s16le, +44.1 kHz stereo, with the same duration. + +`GameScene::ExecGameStage` (`0xaf8ac`) maintains a 60 Hz logical count, reads +`MtxSoundSource::GetPlaySecTime` (`0x10af3c`) from BGM, and replaces the logical +count when they differ by more than two frames. BGM and SHOT are prepared and +started together. If either source is no longer playing, both are stopped, +repositioned and restarted; SHOT is also periodically aligned to the BGM time. + +Vectorail now derives gameplay time from consumed BGM source bytes instead of +wall time. Its two SDL streams start together, retain their source PCM and are +cleared/requeued at the BGM position when their source clocks differ by more +than two frames. Corpus verification found 114 of 1674 unique arcade pairs +with different PCM frame counts; exhausted SHOT tails remain silent when the +BGM position is already beyond the shorter stream. + +## Verification Status + +`opencoaster-stage-verify` currently validates all 2970 main DATs recursively, +including the nested `stage/sound/ac_dontfight_ex.dat` and the chart whose +actual name ends in `_ext`. It +compares the retained wire model to the runtime route, all camera fields, note +type/effect/color/timing/distance translation and clip dimensions. It also +validates resolved BGM/SHOT containers and PCM formats. + +The final aggregate result is `2970/2970` main charts, 2927 loaded sidecars, +18965 ext notes and 18948 linked dual-flicks. Section coverage is 16869 FlowItem +keys, 54152 visualizer keys, 79 background image keys, 417451 background color +keys and 1171999 stage objects. Audio resolution succeeds for 2918 charts; +2768 have paired BGM/SHOT and 150 intentionally resolve only BGM. Gameplay +coverage includes 193 MERRY records expanded to 676 timed targets and 6364 +SLIDE HOLD records. + +Implemented in the player: + +- route timing and draw windows; +- camera modes, interpolation and projection; +- note heads, directional overlays, long paths, BEAT samples and MERRY layout; +- ext timing tables and dual-flick pairing, including both direction vectors; +- stage model transforms, visibility, colors and one-level parents; +- exact background color cross-fade and BPM brightness; +- timed background image selection and external DDS loading; +- timed visualizer execution for types 1 through 7; +- FlowItem spawning, screen layouts, route-depth unprojection and motion; +- MERRY target timing and desktop SLIDE HOLD duration judging; +- BGM/SHOT playback, hit SE, source-position game clock and pair resync. + +Remaining asset/input fidelity gaps: + +- the seven visualizer types execute as GLSL procedural equivalents; the + original Android vertex generators have not been copied constant-for-constant; +- FlowItem timing/layout/motion executes, but original texture selectors + 34/35 are represented by colored billboards; +- mode-3 background loading is implemented, but `stage_back10` is referenced + by the corpus and absent from the supplied game dump; built-in modes 1/2 + still need their original atlas resources; +- dual flick and directional SLIDE HOLD use desktop multi-input/hold semantics + rather than Android touch lines; +- Android touch-line gesture semantics (the desktop player maps controls to + keyboard/gamepad inputs). + +This distinction is intentional: a green parser/runtime translation result +does not claim pixel-identical procedural meshes or unavailable textures. diff --git a/docs/re_gc_camera.md b/docs/re_gc_camera.md index 3e609b2..8ef52a1 100644 --- a/docs/re_gc_camera.md +++ b/docs/re_gc_camera.md @@ -288,9 +288,11 @@ mixed as ordinary scalars. ### Up vector and roll `FUN_005e0ad0` rebuilds `up` by projecting world-up `(0,1,0)` onto the plane -normal to `target-eye`. If the two directions are collinear it falls back to -`(0,0,1)`. `rotationB` then rotates that up vector around the normalized view -axis. +normal to `target-eye`. The Android `camera3D::BuildUpVector` uses `(0,0,1)` +when the normalized view direction's L1 distance from either Y pole is below +`0.001`; otherwise it uses `(0,1,0)`. `rotationB` then rotates that up vector +around the normalized view axis. This is not equivalent to a generic +dot-product parallelism threshold on near-vertical authored cameras. ### Gameplay projection @@ -314,9 +316,13 @@ orthographic sections. The distance is not clamped to the near plane. A zero-length camera therefore also produces zero orthographic extents in the original. Likewise, its vector normalizer returns `(0,0,0)` for a zero-length input instead of propagating a -NaN as `glm::normalize` does. The Linux port mirrors both edge cases and uses -explicit left-handed view/projection builders; the `NO` depth variant is the -OpenGL backend adaptation of the original D3D left-handed matrices. +NaN as `glm::normalize` does. The Linux port mirrors both edge cases. + +The matrix convention is backend-specific. Arcade emits D3D matrices, while +Android's `matrix44::LookAt` uses `eye-target` and its perspective matrix has +`-1` at `m[2][3]`, the OpenGL right-handed/no-depth-remap convention. Because +Vectorail also renders through OpenGL, its GC path follows the Android RH/NO +builders rather than feeding D3D handedness directly to GLM. The evaluator sets `projBlend` to `0` for `projType=0` and `1` for `projType=1`. An `fMode=1` transition linearly interpolates the endpoint @@ -349,11 +355,66 @@ The player now imports the raw camera keys (including non-finite sentinel values) and ports the confirmed `aMode`, `fMode`, orbit, up/roll, projection type/blend, FOV and clipping-plane behavior. GC cameras are evaluated directly without the legacy follow-camera -smoothing. Remaining camera work is validation against captured original -frames. +smoothing. The runtime uses Android's `a + (b-a)*u` float operation order and +builds the GC view matrix without an extra host-side up-vector fallback. + +An independent verifier reimplements the Android evaluator from decoded wire +records instead of calling the player's camera helpers. Across the current +2970-chart corpus it checked 171333 keys and 9113837 sampled times, including +every track vertex and the interior of every camera-key interval. All seven +`aMode` branches and `fMode` 0/1/2 were present: + +```text +eye/target max error: 0 +up max error: 0 +view max error: 0 +projection max error: 4.76837e-7 +``` + +This pass found and corrected a sign error in the expanded quaternion's `qz` +term and replaced the previous approximate vertical-up threshold with the +exact Android `0.001` L1 test. ## Switch and Android cross-version validation +The Android reference used here comes from +`/home/au/Downloads/gc2offlinev4.xapk`. The outer package is an offline +installer; the original game is its nested `assets/groovecoaster.apk`: + +```text +package: jp.co.taito.groovecoasterzero +version: 1.0.18 (versionCode 76) +native ABI: arm64-v8a +native code: lib/arm64-v8a/libtune.so + +XAPK SHA-256: c6c394f7a1cc65331edc98aac94014668f9d5277ce17262fecf9aeeb1a2a3003 +nested APK SHA-256: 62c9e739dc9b8c1bcbcb4b5234d78f154b20d1a930329b9c21a3c9236b92a0dc +libtune.so SHA-256: 689b2e4c0bc4479a3f309944b5070796878c66c0dfdbd283c11ec0bbeeea9efa +``` + +Unlike the arcade executable, this `libtune.so` retains C++ symbols. Relevant +ELF virtual addresses (before Ghidra's `+0x100000` image base) are: + +```text +0x0933a0 TuneGameData::LoadGameData(bytearray*, bool) +0x0953bc TuneGameData::GetWayPosition(int) +0x095588 TuneGameData::GetCameraData(int, bool) +0x0ad1a4 GameScene::SetCommonParam() +0x0ad524 GameScene::CalcGameProjectionMatrix(camera3D&) +0x0b8410 GameScene::CalcGamePerspectiveMatrix(camera3D&) +0x0b847c GameScene::CalcGameOrthoMatrix(camera3D&) +0x102c78 matrix44::Perspective(float, float, float, float) +0x102d34 matrix44::LookAt(vector3 const&, vector3 const&, vector3 const&) +0x1031f0 camera3D::BuildUpVector(float) +0x103e48 RotateHPB::ToVector_Deg(float) +``` + +`TuneGameData::LoadGameData` independently confirms every wire read and the +59-byte camera record order. It expands each record to an aligned 0x48-byte +runtime entry. `GameScene::SetCommonParam` then calls `GetCameraData` for the +current chart time, builds the projection, and passes `eye`, `target`, and +`up` to `matrix44::LookAt`. + The base Switch executable retains `CTuneGameData` RTTI and the original GC source filenames. Its stripped `CTuneGameData::GetWayPosition` and `CTuneGameData::GetCameraData` implementations were matched to the named diff --git a/docs/re_gc_track.md b/docs/re_gc_track.md index a3ed40d..8fde02d 100644 --- a/docs/re_gc_track.md +++ b/docs/re_gc_track.md @@ -47,10 +47,22 @@ generated samples remain uniform over corners; it is not the normal route renderer. The player now mirrors `DrawWay`: it uploads the original authored points plus -the two exact timestamp intersections and draws separate behind/current and -current/ahead line strips with endpoint color gradients. The stage values -`backwardsDrawDist` and `forwardDrawDist` behave as seconds and are converted -to milliseconds before comparison with track and note timestamps. +the two exact timestamp intersections and draws separate past/current and +current/future line strips with endpoint color gradients. Android +`DrawGameStageCharacter` proves that the active `forwardDrawDist` value is a +count of beats, not seconds. If `beat_ms = 60000 / bpm`, it computes: + +```text +beat_start = floor(current_ms / beat_ms) * beat_ms +future_ms = forwardDrawDist * beat_ms +first_ms = max(0, beat_start - max(beat_ms, future_ms)) +last_ms = min(song_end, beat_start + future_ms) +``` + +The same active value drives both sides, but only the past side has a minimum +one-beat span. Thus a dynamic value of zero removes the future route while +retaining one past beat. The first `backwardsDrawDist` config float is retained +while parsing but is not consumed by this normal Android rendering path. Dynamic `TrackDrawDist` records are step changes, not interpolation keys. The StageConfig forward distance remains active until the timestamp of the first @@ -64,18 +76,22 @@ For `ac_10pt8tion_hard.dat`: ```text first track key: 0 ms, (0, 0, 0) second track key: 6486 ms, (0, 0, 199.985) -draw behind: 10 s -draw ahead: 7 s +first range field: 10 +active range: 7 beats ``` -Treating 7/10 as world units collapses the visible rail to a tiny fraction of -the first segment; timestamp clipping produces the expected visible range. +The rail brightness is also chart-clock driven. Over a two-beat triangle wave, +`pulse = triangle * 0.6 + 0.4`; the past segment grades from `0.7*pulse` to +`pulse` alpha and the future segment from `pulse` to `0.5*pulse`. Both are +submitted with additive blending. ## Track colors The stage config stores two RGBA colors directly after the draw-range values. -They are used for the forward and already-travelled portions of the rail. For -`10pt8tion_hard` they are `(255,0,128)` ahead and `(255,255,255)` behind. +Android uses the first for the past/current call and the second for the +current/future call. For `10pt8tion_hard` they are `(255,0,128)` and +`(255,255,255)` respectively; the older ahead/behind field names were inferred +backwards. ## Base background @@ -88,15 +104,14 @@ rgba top_left rgba bottom_right rgba bottom_left u8 interpolate_to_next -u8 audio_reactive_color +u8 rhythm_reactive_color ``` -`FUN_00642390` holds the active colors unless `interpolate_to_next` is set; in -that case it linearly interpolates all four RGBA values to the following key. -`audio_reactive_color` applies `FUN_005d9650` to each active color using the -runtime analyser value. The Linux player now implements the exact hold versus -interpolate selection and keeps the second mode at its neutral color factor -until the analyser feeding `stage renderer +0x24` is ported. +`FUN_00642390` holds the active colors unless `interpolate_to_next` is set. +The transition uses `CalcCrossFadeColor`'s 25% overlap rather than a plain +linear mix. `rhythm_reactive_color` multiplies HSV brightness by a 0.5..0.75 +triangle wave derived from chart time and the active BPM in `SetCommonParam`; +it is not sourced from an audio analyser. The Linux player implements both. `data/stage/2d/_menu.dds` is not the gameplay background. It is a 512x256 UI atlas whose top-left 197x197 cell is the song jacket. The player has @@ -105,7 +120,7 @@ only an explicit debug comparison layer (`B`). The inherited procedural blue/black square grid has also been removed: the base layer is now only the stage-authored color table before particles, visualizers and objects are drawn. -The remaining original scene is produced by the stage `particles`, +The remaining original scene is produced by the stage FlowItem, `visualizer`, and `objects` sections (plus `.tumo` models), not by a single background bitmap. These sections are now decoded completely by `StagePattern`: particle records are 44 bytes, visualizer records are 12 bytes, @@ -115,14 +130,13 @@ keys, 19 visualizer keys, 46 model names and 316 object instances; 10pt8tion contains 2, 28, 37 and 352 respectively. The Vectorail level data retains this decoded scene for the model-rendering pass. -The PSP package now retains all three timelines. The particle constructor at -`FUN_005f0940` creates a 64-instance pool for every configured particle key. -`FUN_005f0130` scales both `repeatMeasure` and `lifespanMeasure` by the active -beat duration. Recovered spawn layouts are: type 1, a deterministic/random -point; type 2, a screen/grid group using `groupShapeSize`; type 3, six points -at 60-degree intervals around a circle. The PSP implementation follows these -timing and layout rules, but substitutes geometry for the original particle -texture resource until that resource binding is mapped. +Android `GameScene::ExecFlowItem` scales both `repeatMeasure` and +`lifespanMeasure` by the active beat duration. Recovered spawn layouts are: +type 1, a random screen point; type 2, a screen/grid group using +`groupShapeSize`; type 3, eight points at 45-degree intervals around a circle. +The player follows those timing/layout rules, unprojects the points to route +depth and applies the authored velocity, but substitutes colored billboards +for original texture selectors 34/35. The common `.tumo` container is also big-endian. Its outer count is followed, for each mesh, by resource names, an XYZ vertex table, eight bound floats, diff --git a/docs/stage.pat b/docs/stage.pat index 9a5da6f..2bd0a90 100644 --- a/docs/stage.pat +++ b/docs/stage.pat @@ -186,6 +186,21 @@ struct VisualizerArray { Visualizer entries[sz]; }; +// Background Images +struct BackgroundImage { + u32 timeMs; + u32 mode; + u32 atlasIndex; + s32 textureIndex; + color color; +} [[same_color]]; +struct BackgroundImageArray { + u32 textureCount; + string8 textureNames[textureCount]; + u32 sz; + BackgroundImage entries[sz]; +}; + // Color Table 1 struct ColorTable { u32 timeMs; @@ -327,6 +342,8 @@ NoteArray notes @ hdr.notes; CameraArray camera @ hdr.camera; ParticleArray particles @ hdr.particles; VisualizerArray visualizer @ hdr.visualizer; +BackgroundImageArray backgroundImages @ hdr.unk1; ColorTableArray colorTable @ hdr.colors; ObjectArray objects @ hdr.objects; -ColorTable2Array colorTable2 @ hdr.colors2; +// Header word 11 points to offset slot 2, one word into this table. +ColorTable2Array colorTable2 @ hdr.colors2 - 4; diff --git a/docs/stage_ext.pat b/docs/stage_ext.pat new file mode 100644 index 0000000..73dcfcb --- /dev/null +++ b/docs/stage_ext.pat @@ -0,0 +1,58 @@ +// Groove Coaster Android/arcade *_ext.dat +// LoadExtData starts reading at byte 6. Bytes after notes are not consumed by +// the recovered Android routine and are intentionally left untyped. + +struct NoteTimingEntry { + u32 timeMs; + u32 mode; + float value; +} [[single_color]]; +struct NoteTimingList { + u16 size; + NoteTimingEntry entries[size]; +} [[single_color]]; + +enum NoteType : u8 { + NONE = 0, + NORMAL = 1, + FLICK = 2, + HOLD = 3, + SCRATCH = 4, + BEAT = 5, + MERRY_GO_ROUND = 6, + HIDDEN = 7, + HIDDEN2 = 8, + CRITICAL = 9, + SLIDE_HOLD = 10, + SLIDE_COUNTER = 11, + TURN = 12, + SPIN = 13, + FINISH = 14, + DUAL_HOLD = 15, +}; +struct Note { + u32 timeMs; + NoteType type; + u8 typeOverride; + s16 params16[9]; + u8 flag24; + float params25[3]; + u8 flag37; + u8 flag38; + float params39[4]; + u32 params55[3]; + float param67; + u32 param71; + float params75[5]; + u32 param95; +} [[single_color]]; + +struct StageExtension { + NoteTimingList noteTimings[4]; + u32 unknown; + u32 noteCount; + Note notes[noteCount]; +}; + +u8 prefix[6] @ 0x00; +StageExtension extension @ 0x06; diff --git a/src/psp_pack.cpp b/src/psp_pack.cpp index 849a3c2..3583099 100644 --- a/src/psp_pack.cpp +++ b/src/psp_pack.cpp @@ -347,7 +347,7 @@ PackageBackgroundColorPoint packBackgroundColor(const gc::BackgroundColorPoint& output.bottomRightRgba = rgba(source.bottomRight); output.bottomLeftRgba = rgba(source.bottomLeft); output.flags = (source.interpolateToNext ? 1u : 0u) | - (source.audioReactive ? 2u : 0u); + (source.rhythmReactive ? 2u : 0u); return output; } @@ -373,8 +373,8 @@ PackageVisibilityKey packVisibility(const gc::VisibilityPoint& source) { PackageVisibilityKey output{}; output.timeMs = source.timeMs; output.flags = static_cast( - (source.fadeOut ? static_cast(kObjectKeyLoop) : 0u) | - (source.fadeIn ? static_cast(kObjectKeyInterpolate) : 0u)); + (source.repeat ? static_cast(kObjectKeyLoop) : 0u) | + (source.interpolate ? static_cast(kObjectKeyInterpolate) : 0u)); output.visible = source.visible ? 1u : 0u; return output; } @@ -383,8 +383,8 @@ PackageTransformKey packTransform(const gc::TransformPoint& source) { PackageTransformKey output{}; output.timeMs = source.timeMs; output.flags = static_cast( - (source.tweenTowards ? static_cast(kObjectKeyLoop) : 0u) | - (source.tweenAway ? static_cast(kObjectKeyInterpolate) : 0u)); + (source.repeat ? static_cast(kObjectKeyLoop) : 0u) | + (source.interpolate ? static_cast(kObjectKeyInterpolate) : 0u)); std::copy(std::begin(source.value), std::end(source.value), output.value); return output; } @@ -393,8 +393,8 @@ PackageObjectColorKey packObjectColor(const gc::ObjectColorPoint& source) { PackageObjectColorKey output{}; output.timeMs = source.timeMs; output.flags = static_cast( - (source.tweenTowards ? static_cast(kObjectKeyLoop) : 0u) | - (source.tweenAway ? static_cast(kObjectKeyInterpolate) : 0u)); + (source.repeat ? static_cast(kObjectKeyLoop) : 0u) | + (source.interpolate ? static_cast(kObjectKeyInterpolate) : 0u)); output.rgba = rgba(source.color); return output; } diff --git a/tools/ghidra_scripts/DecompileBySymbol.java b/tools/ghidra_scripts/DecompileBySymbol.java new file mode 100644 index 0000000..895e503 --- /dev/null +++ b/tools/ghidra_scripts/DecompileBySymbol.java @@ -0,0 +1,51 @@ +// GhidraScript: DecompileBySymbol.java +// Usage (headless): +// analyzeHeadless -process -noanalysis -readOnly \ +// -scriptPath -postScript DecompileBySymbol.java ... + +import ghidra.app.decompiler.DecompInterface; +import ghidra.app.decompiler.DecompileResults; +import ghidra.app.script.GhidraScript; +import ghidra.program.model.listing.Function; +import ghidra.program.model.listing.FunctionIterator; + +public class DecompileBySymbol extends GhidraScript { + @Override + public void run() throws Exception { + String[] args = getScriptArgs(); + if (args == null || args.length == 0) { + printerr("DecompileBySymbol: needs one or more symbol-name fragments"); + return; + } + + DecompInterface decomp = new DecompInterface(); + decomp.openProgram(currentProgram); + + for (String query : args) { + boolean found = false; + FunctionIterator functions = currentProgram.getFunctionManager().getFunctions(true); + while (functions.hasNext()) { + Function function = functions.next(); + String name = function.getName(true); + if (!name.contains(query)) { + continue; + } + + found = true; + println("================================================================================"); + println("query: " + query); + println("function: " + name + " @ " + function.getEntryPoint()); + + DecompileResults result = decomp.decompileFunction(function, 60, monitor); + if (!result.decompileCompleted()) { + println("(decompile failed)"); + continue; + } + println(result.getDecompiledFunction().getC()); + } + if (!found) { + println("No function matched: " + query); + } + } + } +} diff --git a/tools/scan_gc_notes.py b/tools/scan_gc_notes.py index 7c1762c..389c105 100755 --- a/tools/scan_gc_notes.py +++ b/tools/scan_gc_notes.py @@ -72,6 +72,9 @@ def main() -> int: override_count = 0 overrides = collections.Counter() note_count = 0 + fly_in_count = 0 + fly_trail_count = 0 + fly_bounce_count = 0 samples = {} failures = [] for path in paths: @@ -89,6 +92,12 @@ def main() -> int: if note[2] != 0: overrides[(raw_type, note[2])] += 1 samples.setdefault(raw_type, (path, note)) + flag37 = note[16] + flag38 = note[17] + cycles = note[24] + fly_in_count += flag37 != 0 + fly_trail_count += flag38 != 0 + fly_bounce_count += flag37 != 0 and cycles != 0 print(f"record_size={NOTE.size}") print(f"files={len(paths)} valid={len(paths) - len(failures)} invalid={len(failures)} notes={note_count}") @@ -98,6 +107,7 @@ def main() -> int: for key, value in sorted(types.items()) )) print(f"type_override_nonzero={override_count}") + print(f"fly_in={fly_in_count} fly_trail={fly_trail_count} fly_bounce={fly_bounce_count}") print("overrides=" + " ".join( f"0x{raw:02x}/0x{override:02x}:{count}" for (raw, override), count in sorted(overrides.items())