13 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[]andfieldNear[]are almost always zero.- Therefore
fieldNear[0]is not the gameplay FOV. The current Vectorail GC loader uses it asfov; 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:
projTypeis usually1.fModeis usually1or2, with some0.aModeis usually0, but1..6also 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 D3DVIEWmatrix.tune + 0x110: saved D3DPROJECTIONmatrix.tune + 0x0d0: alternate saved D3DVIEWmatrix.tune + 0x090: alternate saved D3DPROJECTIONmatrix.
Functions confirmed:
FUN_006449f0callsGetTransform(2, tune+0x150)andGetTransform(3, tune+0x110).FUN_00645e00does the same after setting a 2D ortho projection.FUN_00648d40also snapshotsVIEW/PROJECTIONat render start.FUN_0064da90snapshotsVIEW/PROJECTIONinto+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. If the two directions are collinear it falls back to
(0,0,1). rotationB then rotates that up vector around the normalized view
axis.
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 and uses
explicit left-handed view/projection builders; the NO depth variant is the
OpenGL backend adaptation of the original D3D left-handed matrices.
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. Remaining camera work is validation against captured original
frames.
Switch and Android cross-version validation
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.