Files

436 lines
16 KiB
Markdown

# GC Camera Reverse Notes
Status: work in progress. This file records facts extracted from `game471.exe`
and `docs/stage.pat` so the Vectorail camera port does not keep accumulating
guesswork.
## Stage Camera Records
`docs/stage.pat` defines a 59-byte camera key:
```c
struct Camera {
u32 timeMs;
u8 aMode;
u8 fMode;
float dist;
float rotationA[2];
float originOff[3];
u8 projType;
float fieldFar[3];
float fieldNear[3];
float rotationB[1];
};
```
Observed across stage files:
- `fieldFar[]` and `fieldNear[]` are almost always zero.
- Therefore `fieldNear[0]` is not the gameplay FOV. The current Vectorail GC
loader uses it as `fov`; that is almost certainly wrong.
- Useful camera fields are `dist`, `rotationA[0]`, `rotationA[1]`,
`originOff[0..2]`, `rotationB`, and the mode bytes.
- Common defaults seen in real stages:
- `projType` is usually `1`.
- `fMode` is usually `1` or `2`, with some `0`.
- `aMode` is usually `0`, but `1..6` also appear.
Example from `ac_10pt8tion_hard.dat`:
```text
time=0 aMode=0 fMode=0 dist=22 rotA=(0,90) originOff=(0,0,9) projType=1 rotB=0
```
## Runtime Matrix Path
The apparent gameplay matrix slots in the tune object are not where the camera
is calculated:
- `tune + 0x150`: saved D3D `VIEW` matrix.
- `tune + 0x110`: saved D3D `PROJECTION` matrix.
- `tune + 0x0d0`: alternate saved D3D `VIEW` matrix.
- `tune + 0x090`: alternate saved D3D `PROJECTION` matrix.
Functions confirmed:
- `FUN_006449f0` calls `GetTransform(2, tune+0x150)` and
`GetTransform(3, tune+0x110)`.
- `FUN_00645e00` does the same after setting a 2D ortho projection.
- `FUN_00648d40` also snapshots `VIEW/PROJECTION` at render start.
- `FUN_0064da90` snapshots `VIEW/PROJECTION` into `+0xd0/+0x90`.
So these offsets are consumers/snapshots, not the camera mechanics.
The D3D device calls use the normal Direct3D 9 transform ids:
```text
0xb0 on IDirect3DDevice9 vtable = SetTransform
+0xe4 on IDirect3DDevice9 vtable = GetRenderState
+0xb4 on IDirect3DDevice9 vtable = GetTransform
2 = D3DTS_VIEW
3 = D3DTS_PROJECTION
0x100 = D3DTS_WORLD
```
`FUN_004e72c0` / `FUN_004e5af0` are a central D3D state cache, not camera
math. The cache fields are:
```text
+0x3a8 cached WORLD
+0x3e8 cached VIEW
+0x428 cached PROJECTION
+0x390 cached viewport
```
Wrapper setters identified:
```text
FUN_004e3c10 = set view matrix, copies 16 floats to +0x310 and calls SetTransform(2)
FUN_004e3bc0 = set projection matrix, copies 16 floats to +0x350 and calls SetTransform(3)
FUN_004e3c60 = copy 16-float matrix to +0x180, no direct D3D SetTransform
```
These are useful for tracing the render path, but current xrefs show most
gameplay code uses the global D3D device helpers and snapshots rather than
directly calling these wrappers.
## Common 3D Camera
`CCommon3DCamera` is identified around `0063d820..0063d9d0`.
Struct layout:
```text
+0x04 eye vec3
+0x10 target vec3
+0x1c up vec3
+0x28 base fov, radians
+0x2c camera mode
```
Projection:
```c
D3DXMatrixPerspectiveFovLH(
out,
camera->fovRad * fovMul,
viewportWidth / viewportHeight,
nearZ,
farZ);
```
Important difference from Vectorail: the original uses the runtime viewport
aspect, not a hardcoded `1280.0 / 720.0`.
View:
```c
D3DXMatrixLookAtLH(out, eye, target, up);
```
when camera mode is `0`.
Known refs:
```text
FUN_0063d820 projection, caller FUN_00577840
FUN_0063d9d0 view/look-at, caller FUN_00577840
FUN_0063d8a0 constructor, caller FUN_00578570
```
Only one `D3DXMatrixPerspectiveFovLH` call is present in the binary, and it is
in `FUN_0063d820`. Only two `D3DXMatrixLookAtLH` calls are present:
`FUN_0063d9d0` and `FUN_0065b1f0`. This makes `CCommon3DCamera` the strongest
candidate for the gameplay camera path, but its stage-game caller has not been
found yet.
## Stage Object Transform Detour (Corrected)
`FUN_00643570` is not a camera or stage-object evaluator. It belongs to the
temporary rail-strip construction path. The actual scene-object loop is
`FUN_006445b0`; it iterates `0xdc`-byte runtime objects from
`stageResource + 0x140` and calls `FUN_00643b20` for their transforms and
`FUN_00643fd0` for their colors.
Inside `FUN_00643b20`:
```text
+0xa4/+0xa8/+0xac count/times/vec3 track-like stream
+0xb0/+0xb4/+0xb8 count/times/vec3 stream
+0xbc/+0xc0/+0xc4 count/times/vec3 stream
```
It samples every keyframe array through `FUN_005e9100`. Movement, scale, and
rotation are linearly interpolated vec3 values. The final Euler rotation is
then converted to a quaternion using the engine's `(-Y, X, Z)` convention and
converted to a matrix; there is no quaternion slerp. Base and
animated translations, rotations and scales are separate matrices which are
multiplied in this exact sequence:
```text
T(base) * T(move) * R(base) * R(animated) * S(base) * S(animated)
```
The color evaluator selects a keyed RGBA value in
place of the base color and linearly interpolates it when enabled.
The recovered helper operations include:
```text
FUN_005df6a0 = vec3 subtract
FUN_005df660 = vec3 scale
FUN_005df6f0 = vec3 add
```
and builds matrices through the local matrix stack:
```text
FUN_005e0650 = translation matrix
FUN_005e0610 = scale matrix
FUN_005e0330 = Euler-degrees-to-rotation-matrix entry
FUN_005e0220 = quaternion from the (-Y, X, Z) half angles
FUN_005df790 = quaternion-to-column-major-matrix
FUN_005e0680 = in-place right matrix multiply
```
The first boolean in transform/color key records becomes runtime metadata byte
`+5` and identifies repeating key ranges. The second becomes byte `+4` and
enables interpolation from the active key to the next. Visibility uses a
separate fixed fade window: `_DAT_006fcae8` is `250.0f` milliseconds.
The renderer also checks runtime object `+0xd4`, populated from the newer stage
supplemental parent-index stream. When present, it evaluates the parent and
child independently, multiplies their matrices, and multiplies their RGBA via
`FUN_0043a540`. It does not recursively walk more than one parent level.
## Stage Camera Evaluator (Confirmed)
`FUN_005e9e20(stage, result, timeMs, interpolate)` is the stage-camera
evaluator. The output is 44 bytes:
```text
+0x00 eye vec3
+0x0c target vec3
+0x18 up vec3
+0x24 projType byte
+0x28 projBlend float (0 or 1 outside an fMode=1 transition)
```
`FUN_005e0ca0` passes those first three vectors to `FUN_005e0800`, the game's
look-at matrix builder. This proves the vector order and bypasses the earlier
`CCommon3DCamera` false lead.
The 59-byte wire key becomes this 0x44-byte runtime key:
```text
+0x00 aMode (int)
+0x04 fMode (int)
+0x08 dist
+0x0c rotationA.x
+0x10 rotationA.y
+0x14 rotationA.z = 0
+0x18 originOff vec3
+0x24 projType byte
+0x28 fieldFar vec3
+0x34 fieldNear vec3
+0x40 rotationB
```
`FUN_005ed4c0` reads every serialized field directly and does not reject
non-finite floats. This matters for `ac_comet_{easy,normal,hard}.dat`: their
first key at 0 ms intentionally has NaN in both `rotationA` components, and the
next key starts at 1316 ms. The Linux loader must retain that first key; dropping
it activates the second camera too early and changes the intro.
Let `T(t)` be the linearly interpolated track position and `R` be the orbit
vector calculated from `rotationA` and `dist`. The seven `aMode` branches are:
```text
aMode 0: target = T(t) + originOff; eye = target + R
aMode 1: target = T(keyTime) + originOff; eye = target + R
aMode 2: for camera index > 1, return camera(keyTime - 1ms, no interpolation);
otherwise the same as mode 0
aMode 3: target = T(t) + originOff;
eye = T(keyTime) + originOff + R
aMode 4: target = fieldFar; eye = fieldNear
aMode 5: target = T(t) + originOff; eye = fieldNear
aMode 6: target = fieldFar; eye = T(t) + R
```
This confirms that `originOff` is a world-space addition, not a rail-local
offset.
### Orbit axis convention
`FUN_005dfaa0` creates a quaternion from the Euler tuple
`(-rotationA.y, rotationA.x, rotationA.z)` in degrees. `FUN_005e01d0` then
transforms `(0, 0, dist)` by its matrix. In particular, the first 10pt8tion
key `rotationA=(0,90), dist=22` produces `R=(0,22,0)`: the camera is directly
above the rail, not behind it.
### Interpolation modes
Interpolation only happens strictly between the active key and the following
key:
```text
fMode 0: evaluate the active key directly
fMode 1: evaluate camera states at both key timestamps, remove endpoint roll,
linearly interpolate eye/target/up, normalize up, then apply the
linearly interpolated rotationB
fMode 2: linearly interpolate all raw float/vector fields, retain the active
key's aMode/fMode/projType, then evaluate that mixed key
```
There is no shortest-angle interpolation in `fMode=2`; the rotation floats are
mixed as ordinary scalars.
### Up vector and roll
`FUN_005e0ad0` rebuilds `up` by projecting world-up `(0,1,0)` onto the plane
normal to `target-eye`. 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
`FUN_0063fd60(cameraState, fovMul)` consumes the complete 44-byte result of
the stage-camera evaluator. It selects or blends two projections:
```text
projBlend <= 0: orthographic
projBlend >= 1: perspective
0 < projBlend < 1:
ortho + (perspective - ortho) * clamp(projBlend^3, 0, 1)
```
The perspective matrix uses vertical FOV `75 * fovMul`, the live viewport
aspect, near `1` and far `1000`. The orthographic half-height is
`distance(eye,target) * tan(FOV/2)` and its half-width is that value times the
viewport aspect. Consequently objects on the target plane keep the same
screen size while the projection changes, but depth shrinking disappears in
orthographic sections.
The distance is not clamped to the near plane. A zero-length camera therefore
also produces zero orthographic extents in the original. Likewise, its vector
normalizer returns `(0,0,0)` for a zero-length input instead of propagating a
NaN as `glm::normalize` does. The Linux port mirrors both edge cases.
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
blend values before the projection builder applies the cubic curve.
The gameplay owner initializes its `fovMul` field at `+0x238` to `1.0` and the
normal update path passes it unchanged to `FUN_0063fd60`; therefore the normal
stage camera uses a 75-degree vertical FOV. This scalar is runtime state rather
than part of the 59-byte camera record. The same is true of viewport aspect and
the fixed near/far planes.
### Gameplay item projection modifiers
The value checked at global gameplay-state offset `+0xcd8` is the selected item
ID from `data/boot/item.dat`, not a camera or screen-mode enum:
- `4`, `MIRROR`: flip the stage horizontally;
- `6`, `REVERSE`: flip the stage horizontally and vertically.
At the end of `FUN_0063ff90`, both items negate the first column of the 3D
projection at gameplay-camera offset `+0x90` and the 2D orthographic projection
at `+0x110`. `REVERSE` also negates their second columns. The view matrix at
`+0xd0` is not changed. Course geometry switches from `D3DCULL_CCW` to
`D3DCULL_CW` only for `MIRROR`; the two-axis `REVERSE` transform preserves
triangle winding.
## Vectorail Port Status
The player now imports the raw camera keys (including non-finite sentinel
values) and ports the confirmed `aMode`,
`fMode`, orbit, up/roll, projection type/blend, FOV and clipping-plane
behavior. GC cameras are evaluated directly without the legacy follow-camera
smoothing. 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
Android ARM64 functions by class layout, control flow, and camera-mode
behavior.
Switch stores camera timestamps separately from a 0x44-byte runtime payload,
where Android uses an interleaved 0x48-byte record. The evaluator itself still
implements the same `aMode` branches `0..6`, `fMode` 1/2 interpolation, up and
roll construction, projection type/blend, and the mode-2 one-millisecond
hold. This provides an independent confirmation of the arcade reconstruction
above.
FOV is platform policy rather than a stage field. Android chooses between
`60.0`, `68.5`, and `75.0` degrees for its 3:2, 16:9, and iPhone-X layout
modes. That mobile-only aspect switch does not override the arcade
executable's confirmed 75-degree gameplay FOV used by the Linux arcade target.
See `re_gc_switch.md` for the Switch addresses and asset inventory.