Add portable Windows build and update GC runtime

This commit is contained in:
2026-08-15 13:01:11 +02:00
parent cd300cd554
commit 623ef9a733
13 changed files with 580 additions and 60 deletions
+30 -1
View File
@@ -37,7 +37,9 @@ workspace/
└── openroller/ └── 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 ```sh
cmake -S openroller -B openroller/build -DCMAKE_BUILD_TYPE=Release 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`. 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 ## PSP build
The PSP port is built with the `pspdev/pspdev` container: The PSP port is built with the `pspdev/pspdev` container:
+29
View File
@@ -17,6 +17,11 @@ target_link_libraries(openroller-desktop
Vectorail::GCEffects 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) if(MSVC)
target_compile_options(openroller-desktop PRIVATE /W4) target_compile_options(openroller-desktop PRIVATE /W4)
else() else()
@@ -25,3 +30,27 @@ endif()
configure_file(openroller.cfg openroller.cfg COPYONLY) configure_file(openroller.cfg openroller.cfg COPYONLY)
file(COPY shaders DESTINATION "${CMAKE_CURRENT_BINARY_DIR}") 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"
"$<TARGET_FILE_DIR:openroller-desktop>/shaders"
COMMAND ${CMAKE_COMMAND} -E copy_if_different
"${CMAKE_CURRENT_SOURCE_DIR}/openroller.cfg"
"$<TARGET_FILE_DIR:openroller-desktop>/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
"$<TARGET_FILE:SDL3::SDL3-shared>"
"$<TARGET_FILE_DIR:openroller-desktop>"
VERBATIM
)
install(FILES "$<TARGET_FILE:SDL3::SDL3-shared>" DESTINATION .)
endif()
install(TARGETS openroller-desktop RUNTIME DESTINATION .)
install(DIRECTORY shaders DESTINATION .)
install(FILES openroller.cfg DESTINATION .)
+7 -7
View File
@@ -686,12 +686,12 @@ struct GcKeySample {
float sampledTimeMs = 0.0f; float sampledTimeMs = 0.0f;
}; };
bool gcLoopFlag(const gc::TransformPoint& key) { return key.tweenTowards; } bool gcLoopFlag(const gc::TransformPoint& key) { return key.repeat; }
bool gcLoopFlag(const gc::ObjectColorPoint& key) { return key.tweenTowards; } bool gcLoopFlag(const gc::ObjectColorPoint& key) { return key.repeat; }
bool gcLoopFlag(const gc::VisibilityPoint& key) { return key.fadeOut; } bool gcLoopFlag(const gc::VisibilityPoint& key) { return key.repeat; }
bool gcInterpolateFlag(const gc::TransformPoint& key) { return key.tweenAway; } bool gcInterpolateFlag(const gc::TransformPoint& key) { return key.interpolate; }
bool gcInterpolateFlag(const gc::ObjectColorPoint& key) { return key.tweenAway; } bool gcInterpolateFlag(const gc::ObjectColorPoint& key) { return key.interpolate; }
bool gcInterpolateFlag(const gc::VisibilityPoint& key) { return key.fadeIn; } bool gcInterpolateFlag(const gc::VisibilityPoint& key) { return key.interpolate; }
// FUN_005e9100 is shared by all five object-animation channels. The first // 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. // 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<int>(keys.size())) { if (active + 1 < static_cast<int>(keys.size())) {
const auto& next = keys[active + 1]; const auto& next = keys[active + 1];
const float untilNext = static_cast<float>(next.timeMs) - sample.sampledTimeMs; const float untilNext = static_cast<float>(next.timeMs) - sample.sampledTimeMs;
if (next.visible != visible && next.fadeIn && if (next.visible != visible && next.interpolate &&
untilNext >= 0.0f && untilNext < kGcVisibilityFadeMs) { untilNext >= 0.0f && untilNext < kGcVisibilityFadeMs) {
const float remaining = std::clamp(untilNext / kGcVisibilityFadeMs, 0.0f, 1.0f); const float remaining = std::clamp(untilNext / kGcVisibilityFadeMs, 0.0f, 1.0f);
alpha = next.visible ? 1.0f - remaining : remaining; alpha = next.visible ? 1.0f - remaining : remaining;
+12
View File
@@ -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)
+49 -10
View File
@@ -70,20 +70,21 @@ The file is big-endian. The initial words are not all section offsets. Confirmed
| 2 | track points | | 2 | track points |
| 3 | notes | | 3 | notes |
| 4 | camera | | 4 | camera |
| 5 | particles | | 5 | `TuneBGEffectData` / FlowItem keys |
| 6 | visualizer | | 6 | visualizer |
| 7 | unknown section | | 7 | background texture names and image keys |
| 8 | first color table | | 8 | first color table |
| 9 | objects | | 9 | objects |
| 10 | scalar/unknown; often `0x30`, not an offset | | 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 | | 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. 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 The post-object extension base is the reader position after the variable
slot 10. The scalar/offset/scalar arrangement in slots 10..12 belongs to the object stream. Relative offset slot 1 extends background colors and slot 2
newer 13-word format used by the 4.71-era charts. 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 ## 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 | | `+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)` | | `+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` | | `+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 | | `+71` | number of generated MERRY GO ROUND targets |
| `+75` | MERRY GO ROUND spacing in beats | | `+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 uses `RotateHPB::SetVector` (`atan2(screen_dx, screen_dy_down)`) and stores the
resulting screen angle. It is computed before gameplay and remains fixed while 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 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 colour and marker alpha. This is independent of the lower-right control
helper, even though that helper also has a direction overlay. 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: Duration bodies are also type-specific in the executable:
- HOLD calls `0x00647cf0` and SLIDE HOLD calls `0x00641fd0`; both emit ribbon - Android HOLD does not submit the prebuilt ribbon array. Its normal gameplay
triangles rather than an OpenGL-style line. 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 - 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 opposing paths with radius `0.2` and rotates the offset by 45 degrees per
sample; `0x00641d50` emits six vertices per segment for each path. 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, screen direction. Both endpoint layers inherit the long note's colour, fade alpha,
billboard transform, beat-synchronised animation and `0.025` sprite scale. 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 ## Tap judgment timing
The system-config loader at `0x00635c90` lays out the timing overrides in four The system-config loader at `0x00635c90` lays out the timing overrides in four
+200
View File
@@ -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 `<stage-pack>.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.
+69 -8
View File
@@ -288,9 +288,11 @@ mixed as ordinary scalars.
### Up vector and roll ### Up vector and roll
`FUN_005e0ad0` rebuilds `up` by projecting world-up `(0,1,0)` onto the plane `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 normal to `target-eye`. The Android `camera3D::BuildUpVector` uses `(0,0,1)`
`(0,0,1)`. `rotationB` then rotates that up vector around the normalized view when the normalized view direction's L1 distance from either Y pole is below
axis. `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 ### Gameplay projection
@@ -314,9 +316,13 @@ orthographic sections.
The distance is not clamped to the near plane. A zero-length camera therefore 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 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 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 NaN as `glm::normalize` does. The Linux port mirrors both edge cases.
explicit left-handed view/projection builders; the `NO` depth variant is the
OpenGL backend adaptation of the original D3D left-handed matrices. 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 The evaluator sets `projBlend` to `0` for `projType=0` and `1` for
`projType=1`. An `fMode=1` transition linearly interpolates the endpoint `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`, values) and ports the confirmed `aMode`,
`fMode`, orbit, up/roll, projection type/blend, FOV and clipping-plane `fMode`, orbit, up/roll, projection type/blend, FOV and clipping-plane
behavior. GC cameras are evaluated directly without the legacy follow-camera behavior. GC cameras are evaluated directly without the legacy follow-camera
smoothing. Remaining camera work is validation against captured original smoothing. The runtime uses Android's `a + (b-a)*u` float operation order and
frames. 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 ## 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 The base Switch executable retains `CTuneGameData` RTTI and the original GC
source filenames. Its stripped `CTuneGameData::GetWayPosition` and source filenames. Its stripped `CTuneGameData::GetWayPosition` and
`CTuneGameData::GetCameraData` implementations were matched to the named `CTuneGameData::GetCameraData` implementations were matched to the named
+40 -26
View File
@@ -47,10 +47,22 @@ generated samples remain uniform over corners; it is not the normal route
renderer. renderer.
The player now mirrors `DrawWay`: it uploads the original authored points plus The player now mirrors `DrawWay`: it uploads the original authored points plus
the two exact timestamp intersections and draws separate behind/current and the two exact timestamp intersections and draws separate past/current and
current/ahead line strips with endpoint color gradients. The stage values current/future line strips with endpoint color gradients. Android
`backwardsDrawDist` and `forwardDrawDist` behave as seconds and are converted `DrawGameStageCharacter` proves that the active `forwardDrawDist` value is a
to milliseconds before comparison with track and note timestamps. 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 Dynamic `TrackDrawDist` records are step changes, not interpolation keys. The
StageConfig forward distance remains active until the timestamp of the first StageConfig forward distance remains active until the timestamp of the first
@@ -64,18 +76,22 @@ For `ac_10pt8tion_hard.dat`:
```text ```text
first track key: 0 ms, (0, 0, 0) first track key: 0 ms, (0, 0, 0)
second track key: 6486 ms, (0, 0, 199.985) second track key: 6486 ms, (0, 0, 199.985)
draw behind: 10 s first range field: 10
draw ahead: 7 s active range: 7 beats
``` ```
Treating 7/10 as world units collapses the visible rail to a tiny fraction of The rail brightness is also chart-clock driven. Over a two-beat triangle wave,
the first segment; timestamp clipping produces the expected visible range. `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 ## Track colors
The stage config stores two RGBA colors directly after the draw-range values. 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 Android uses the first for the past/current call and the second for the
`10pt8tion_hard` they are `(255,0,128)` ahead and `(255,255,255)` behind. 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 ## Base background
@@ -88,15 +104,14 @@ rgba top_left
rgba bottom_right rgba bottom_right
rgba bottom_left rgba bottom_left
u8 interpolate_to_next 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 `FUN_00642390` holds the active colors unless `interpolate_to_next` is set.
that case it linearly interpolates all four RGBA values to the following key. The transition uses `CalcCrossFadeColor`'s 25% overlap rather than a plain
`audio_reactive_color` applies `FUN_005d9650` to each active color using the linear mix. `rhythm_reactive_color` multiplies HSV brightness by a 0.5..0.75
runtime analyser value. The Linux player now implements the exact hold versus triangle wave derived from chart time and the active BPM in `SetCommonParam`;
interpolate selection and keeps the second mode at its neutral color factor it is not sourced from an audio analyser. The Linux player implements both.
until the analyser feeding `stage renderer +0x24` is ported.
`data/stage/2d/<song>_menu.dds` is not the gameplay background. It is a `data/stage/2d/<song>_menu.dds` is not the gameplay background. It is a
512x256 UI atlas whose top-left 197x197 cell is the song jacket. The player has 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 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. 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 `visualizer`, and `objects` sections (plus `.tumo` models), not by a single
background bitmap. These sections are now decoded completely by background bitmap. These sections are now decoded completely by
`StagePattern`: particle records are 44 bytes, visualizer records are 12 bytes, `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 contains 2, 28, 37 and 352 respectively. The Vectorail level data retains this
decoded scene for the model-rendering pass. decoded scene for the model-rendering pass.
The PSP package now retains all three timelines. The particle constructor at Android `GameScene::ExecFlowItem` scales both `repeatMeasure` and
`FUN_005f0940` creates a 64-instance pool for every configured particle key. `lifespanMeasure` by the active beat duration. Recovered spawn layouts are:
`FUN_005f0130` scales both `repeatMeasure` and `lifespanMeasure` by the active type 1, a random screen point; type 2, a screen/grid group using
beat duration. Recovered spawn layouts are: type 1, a deterministic/random `groupShapeSize`; type 3, eight points at 45-degree intervals around a circle.
point; type 2, a screen/grid group using `groupShapeSize`; type 3, six points The player follows those timing/layout rules, unprojects the points to route
at 60-degree intervals around a circle. The PSP implementation follows these depth and applies the authored velocity, but substitutes colored billboards
timing and layout rules, but substitutes geometry for the original particle for original texture selectors 34/35.
texture resource until that resource binding is mapped.
The common `.tumo` container is also big-endian. Its outer count is followed, 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, for each mesh, by resource names, an XYZ vertex table, eight bound floats,
+18 -1
View File
@@ -186,6 +186,21 @@ struct VisualizerArray {
Visualizer entries[sz]; 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 // Color Table 1
struct ColorTable { struct ColorTable {
u32 timeMs; u32 timeMs;
@@ -327,6 +342,8 @@ NoteArray notes @ hdr.notes;
CameraArray camera @ hdr.camera; CameraArray camera @ hdr.camera;
ParticleArray particles @ hdr.particles; ParticleArray particles @ hdr.particles;
VisualizerArray visualizer @ hdr.visualizer; VisualizerArray visualizer @ hdr.visualizer;
BackgroundImageArray backgroundImages @ hdr.unk1;
ColorTableArray colorTable @ hdr.colors; ColorTableArray colorTable @ hdr.colors;
ObjectArray objects @ hdr.objects; 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;
+58
View File
@@ -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;
+7 -7
View File
@@ -347,7 +347,7 @@ PackageBackgroundColorPoint packBackgroundColor(const gc::BackgroundColorPoint&
output.bottomRightRgba = rgba(source.bottomRight); output.bottomRightRgba = rgba(source.bottomRight);
output.bottomLeftRgba = rgba(source.bottomLeft); output.bottomLeftRgba = rgba(source.bottomLeft);
output.flags = (source.interpolateToNext ? 1u : 0u) | output.flags = (source.interpolateToNext ? 1u : 0u) |
(source.audioReactive ? 2u : 0u); (source.rhythmReactive ? 2u : 0u);
return output; return output;
} }
@@ -373,8 +373,8 @@ PackageVisibilityKey packVisibility(const gc::VisibilityPoint& source) {
PackageVisibilityKey output{}; PackageVisibilityKey output{};
output.timeMs = source.timeMs; output.timeMs = source.timeMs;
output.flags = static_cast<std::uint8_t>( output.flags = static_cast<std::uint8_t>(
(source.fadeOut ? static_cast<std::uint8_t>(kObjectKeyLoop) : 0u) | (source.repeat ? static_cast<std::uint8_t>(kObjectKeyLoop) : 0u) |
(source.fadeIn ? static_cast<std::uint8_t>(kObjectKeyInterpolate) : 0u)); (source.interpolate ? static_cast<std::uint8_t>(kObjectKeyInterpolate) : 0u));
output.visible = source.visible ? 1u : 0u; output.visible = source.visible ? 1u : 0u;
return output; return output;
} }
@@ -383,8 +383,8 @@ PackageTransformKey packTransform(const gc::TransformPoint& source) {
PackageTransformKey output{}; PackageTransformKey output{};
output.timeMs = source.timeMs; output.timeMs = source.timeMs;
output.flags = static_cast<std::uint8_t>( output.flags = static_cast<std::uint8_t>(
(source.tweenTowards ? static_cast<std::uint8_t>(kObjectKeyLoop) : 0u) | (source.repeat ? static_cast<std::uint8_t>(kObjectKeyLoop) : 0u) |
(source.tweenAway ? static_cast<std::uint8_t>(kObjectKeyInterpolate) : 0u)); (source.interpolate ? static_cast<std::uint8_t>(kObjectKeyInterpolate) : 0u));
std::copy(std::begin(source.value), std::end(source.value), output.value); std::copy(std::begin(source.value), std::end(source.value), output.value);
return output; return output;
} }
@@ -393,8 +393,8 @@ PackageObjectColorKey packObjectColor(const gc::ObjectColorPoint& source) {
PackageObjectColorKey output{}; PackageObjectColorKey output{};
output.timeMs = source.timeMs; output.timeMs = source.timeMs;
output.flags = static_cast<std::uint8_t>( output.flags = static_cast<std::uint8_t>(
(source.tweenTowards ? static_cast<std::uint8_t>(kObjectKeyLoop) : 0u) | (source.repeat ? static_cast<std::uint8_t>(kObjectKeyLoop) : 0u) |
(source.tweenAway ? static_cast<std::uint8_t>(kObjectKeyInterpolate) : 0u)); (source.interpolate ? static_cast<std::uint8_t>(kObjectKeyInterpolate) : 0u));
output.rgba = rgba(source.color); output.rgba = rgba(source.color);
return output; return output;
} }
@@ -0,0 +1,51 @@
// GhidraScript: DecompileBySymbol.java
// Usage (headless):
// analyzeHeadless <projDir> <projName> -process <program> -noanalysis -readOnly \
// -scriptPath <path> -postScript DecompileBySymbol.java <name-fragment>...
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);
}
}
}
}
+10
View File
@@ -72,6 +72,9 @@ def main() -> int:
override_count = 0 override_count = 0
overrides = collections.Counter() overrides = collections.Counter()
note_count = 0 note_count = 0
fly_in_count = 0
fly_trail_count = 0
fly_bounce_count = 0
samples = {} samples = {}
failures = [] failures = []
for path in paths: for path in paths:
@@ -89,6 +92,12 @@ def main() -> int:
if note[2] != 0: if note[2] != 0:
overrides[(raw_type, note[2])] += 1 overrides[(raw_type, note[2])] += 1
samples.setdefault(raw_type, (path, note)) 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"record_size={NOTE.size}")
print(f"files={len(paths)} valid={len(paths) - len(failures)} invalid={len(failures)} notes={note_count}") 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()) for key, value in sorted(types.items())
)) ))
print(f"type_override_nonzero={override_count}") 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( print("overrides=" + " ".join(
f"0x{raw:02x}/0x{override:02x}:{count}" f"0x{raw:02x}/0x{override:02x}:{count}"
for (raw, override), count in sorted(overrides.items()) for (raw, override), count in sorted(overrides.items())