Files

16 KiB

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:

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:

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:

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:

+0x3a8 cached WORLD
+0x3e8 cached VIEW
+0x428 cached PROJECTION
+0x390 cached viewport

Wrapper setters identified:

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:

+0x04 eye vec3
+0x10 target vec3
+0x1c up vec3
+0x28 base fov, radians
+0x2c camera mode

Projection:

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:

D3DXMatrixLookAtLH(out, eye, target, up);

when camera mode is 0.

Known refs:

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:

+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:

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:

FUN_005df6a0 = vec3 subtract
FUN_005df660 = vec3 scale
FUN_005df6f0 = vec3 add

and builds matrices through the local matrix stack:

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:

+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:

+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:

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:

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:

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:

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:

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:

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.