Reference · Engine
Animation playback
partialfrom#32
How ccAnm evaluates an Anime chunk at a given time: object poses, texture
offsets, playback and looping, morph blending, and the notes a step hands
to the character's note function. tools/anim.py is the
reference, checked against the game's own functions in tools/eemu.py by
tools/test_anim.py. The port is piney_data::anim, checked against the
reference.
The chunk
Anime (0x0700) u32 object, u32 frames N, u32 words, then sub-chunks:
Top 0, the controller records, Top 1 .. Top N-1 each with that frame's
records, and an end Top: -1 plays once (3,138 of 4,091 in DATA.BIN),
-2 loops (953)
0x0102 object u32 obj, u32 flags, then one controller per 3-bit field:
bits 0-2 position, 3-5 rotation (degrees), 6-8 scale,
9-11 transparency (ccCoord::localtp, +0x88)
0x0202 material u32 material, u32 flags; bits 0-2 U, 3-5 V (floats)
0x1901 F_Morpher u32 morpher, u16 count, pad, count x (u32 target, f32 weight)
applied from its frame on
0x0108 F_Note u32 object, u32 event, u32 param: see Notes
0x0101 F_Obj u32 object, u32 (never read), f32 pos[3], f32 rot[3]
(degrees), f32 scale[3], f32 transparency, u32 flags: see
F_Obj
also 0x0601 F_Ambient, 0x0502 F_Camera, light controllers
0x0603 / 0x0605 / 0x0607 / 0x0609: see Cameras and lights
Controller kinds are 0 (absent), 1 (one value) or 2 (keyed: u32 n, then
n × (u32 frame, value)), and every keyed controller has at least two
keys. An absent controller means position 0, identity rotation, scale 1,
transparency 1. Every controller record sits under Top 0; ConvLCNum2ALCNum
(0x00144e90) compiles them at load.
Floats and vectors
ccAnmCtrlFVec3_SetCtrl / ccAnmCtrlFloat_SetCtrl turn the keys into a start
value and (u32 duration, delta) segments, with time in 1/256 frames:
if first key frame > 0: a hold segment up to it
per later key: (256 * (frame - previous frame), key - running sum)
at the end: a hold of N*256 - last*256
_Set (ccAnmCtrlFVec3_Set 0x00146890, ccAnmCtrlFloat_Set 0x00146530)
walks the segments while a segment's duration is below the remaining
time, adding its delta. Then:
value = acc + delta * (u32tof(t) / u32tof(dur))
All of this is in EE single precision. So interpolation is linear, holding the first key before it and the last after it. Durations are u32 differences: a key that goes back in time (a few hundred controllers) makes a segment of about 2^32 that is never passed, and two keys on one frame make a segment of 0 that always is. The value depends only on the target time, not on the steps taken to reach it.
Rotations
Rotations are not interpolated as Euler angles
(ccAnmCtrlRot_SetCtrl 0x001470c0, _Set 0x00146f30):
- Keys. Each key's degrees are quantised to 1/65536 of a turn,
s16(int(deg * 182.044)), wrapping. The key's matrix issceVu0RotMatrix, i.e. Rx·Ry·Rz. - Segments. Each segment stores the step between keys,
R_i · R_{i-1}ᵀ, as an axis and an angle (m2a0x00105ec0, via atan2, so at most half a turn). - Playback. A running matrix starts at the first key. Each passed step
multiplies on the left, and the current segment adds
axis_angle(axis, angle · t / dur). That is a constant-speed turn about one axis between key orientations, the short way round. - Final hold. The hold after the last key reuses the last step with the
wrapped duration
(last − N) · 256, so the pose creeps byangle · t / 2^32, too little to see. The port keeps it. - Single values. A single-value rotation is
sceVu0RotMatrix(deg · π / 180), not quantised.
sceVu0RotMatrix takes its cosine from a polynomial (_sceVu0ecossin
0x001109b8, coefficients at 0x002f7520) and its sine as sqrt(1 − cos²),
which is off by up to about 5e-4 near 0. That is the game's error, and the
port keeps it. The port computes keyed rotations in double: they match the
game to 1e-5 per matrix element plus 1e-7 per key passed, the game's
running matrix drifting in float. Every other value (positions, scales,
transparencies, texture offsets, single-value rotations, morph weights and
blends) matches bit for bit, as far as eemu models the EE and VU0.
The local matrix is T(position) · R · S(scale) (SetAnmCtrlWork
0x00150670). The pose goes to the object in ccAnmIndex.subst; ExtObj
targets are followed (ccGetExternalIndex 0x00101a50), and each record is its
own instance.
A clump's nodes can be ExtObj copies too: ccStream::GetChunkAdrsF(name)
(main 0x00144580) walks the stream's index (skipping # files) with
ccMatchIndex (0x00101ad0), which follows an ExtObj entry to the object
it copies. In the Grunties' files (cdogbod*) the first CMP_trall is an
animation's (cdgaevo0.max), whose 23 nodes are ExtObj copies of the
model file's objects. The port's Body::of takes the first object of the
clump's name and follows its nodes' ExtObj copies to their targets, so
such a body poses and draws the model's own objects (worklog 162); a
body whose clump's nodes are its own objects is unchanged.
Playback
- Time. Time is
frameNow · 256 + frameCnt._AnimateForward(0x00152270) addsccAnm::frameSpd(256, one frame per call, by default) and clamps to(N − 1) · 256. It returns 1 when a play-once animation ends. - Looping. An animation that loops (-2) is posed at
N − 1when it gets there, then reset to 0. So a loop isN − 1frames long. - Per-frame records. Morph weights and notes apply only when the whole
frame changes, never interpolated, and frame 0's records are never
applied:
ConvLCNum2ALCNumleaves out Top 0's records and Top 1, so the frame data starts with frame 1's records. A step that changes the frame callsDecodeFrameChunk(old, new, 0)(0x0014e1b0), which reads on from where the last read stopped until it reads a Top pastnew: the records of framesold + 1 .. new, then Topnew + 1(or the end Top, whose -1 or -2_AnimateForwardacts on). Every Anime chunk in DATA.BIN has a Top for every frame (4,091 of 4,091), so this depends only on the two frames. - Frame rate.
ccSystem::frameRateis 1 at boot, demo, desktop and toppage, and 2 afterccSetupNewGame. So the field probably runs at 30 frames a second (inferred).
Texture offsets
SetAnmCtrlWork writes ccMaterial.u/v = s16(int(x · 4096)) − cropU / cropV
from a 0x0202 record's U and V (21 files). See the Material chunk's crops on
the CCSF page. The draw adds them to each vertex's
texture coordinates through the model packet's STROW, (u >> 4) & 0xff in
1/256 of the texture (rendering, the desktop):
the Chaos Gate, the fields' waters (field_a, field_c), Aura's body, the
virus cores, the desktop and the title scroll this way.
F_Obj
An F_Obj record (0x0101, 13 words) gives an object its whole transform
from its frame on. ccStream::DecodeF_Obj (main 0x0014e950) reads it when
DecodeFrameChunk reaches that frame, like the notes and morph weights, so
frame 0's record is never read. For a plain object it calls
ccCoord::SetMatrix_PosRotZYXScale (0x00138120):
T(pos) · sceVu0RotMatrix(π · deg / 180) · S(scale), the composition the
controllers use with the record decoders' radians. It also stores the
transparency, clamped to 0..1, in localtp (+0x88). Flag bit 0 goes to
the object's +0xa2, with bit 1 set unless the position is zero and +0xa1
bit 0 is set. For an effect object (0xe00) it restarts the object's
animation.
Only four files have F_Obj records:
- Three enemies.
eex1,eii1andelgxmove their body only this way in their damage, down and magic clips, one record a frame, with no controller on the body.eex1'sANM_eex1dmg0lifts it 3.875 a frame and tips it 7.5° about y and −7.5° about z. - The desktop.
xddesk01has them too.
The port reads them into Animation::objs. obj_poses_at(time) gives each
driven object its last record read by then, and locals_at and
transforms_at use them for the objects no controller track drives. The
enemies' transparency (Foe::node_alphas) takes theirs. Before this, those
three enemies stood still as they took hits and died.
piney-data's f_obj_records_pose_the_body checks ANM_eex1dmg0.
Cameras and lights
The Anime chunks that carry F_Camera (0x0502) and the light records are
played by code that reads those itself, not by ccAnm's pose:
- Cameras. The streams' scenes (
piney-stream'sscene.rs) and the desktop's and the title's clips (piney-desktop'sanm.rs, itscamera). - Ambient and lights. F_Ambient and the distant (0x0603), direct
(0x0605) and omni (0x0609) light controllers come through
piney-world'stown::anim_lights. It serves the towns' light tables, the Chaos Gate, the story maps (evarea,evarea_b0) and the dungeons' special rooms. The fields take frame 0's ambient and distant light (piney-data'sfield/render.rs).
Frame speed
The field changes frameSpd (ccAnm +0x9c), which is 256 by default:
- Kite.
ccPlayer::AnimCtrlsets it by act and speed (piney-battle'skite.rs). - The town player. The same (
piney-world'smotion.rs). - Enemies and members. Their motion code sets it
(
combat/cast.rs). - The Administrator. His act −5 sets 384 (
merchant.rs).
Morphing
DecodeF_Morpher (0x0014e6e0) sets a morpher's targets and weights.
ccMorpher::Modify (0x0013af10) blends in integers:
target positions at another vertexScale: int(t * (tscale / bscale))
weights: s16(int(w * 4096))
each component: base + ((Σ s16(t − base) * w) >> 12) sum wraps at 32 bits,
the add saturates
Normals are not morphed.
Notes
0x0108 F_Note u32 object, u32 event, u32 param after its frame's Top
ccAnmNote (24 bytes, operator new) made by ccStream::DecodeF_Note (0x0014f830)
+0x00 next the list's link
+0x04 event the record's
+0x08 param the record's
+0x0c time the frame being read: the step's old frame for frame
old + 1's notes, the note's own frame after that
+0x10 coord ccAnmIndex[object].subst: the object's instance
+0x14 ccstag ccAnmIndex[object].ccstag
DATA.BIN holds 2,697 F_Note records, all of three words and all naming
OBJ_xnote, in 944 of the 4,091 Anime chunks (171 of them loops). Events:
2 (1,976), 0x8005 (487), 0x8003 (175), 0x8002 (57), 3 (2). 466 frames hold
more than one note, 16 notes sit at the last frame and 3 at frame 0 (ecm1
ANM_ecm1mag0, eug1 ANM_eug1dwn0, eugz ANM_eugzdwn0), which are
never handed on.
- The list.
DecodeF_Notepushes each note on the head ofccAnm.noteRoot(+0xa0), or of the stream's ownnoteRoot(+0xb4) when no anm is reading. - Collected.
_AnimateForwardfirst frees the list (ccAnmNote::DelAll0x00147ff0) and setsnoteRootto 0. Only a step that changesframeNowreads, so the list then holds the notes of framesold + 1 .. new,newclamped toN − 1: the last frame's notes come with the step that reaches it. A zero step, a step inside a frame, and a step held at a play-once animation's last frame leave it empty. A loop's reset rewinds the reader, so each pass hands on frames1 .. N − 1again. - Delivered.
ccAnm::NoteProcess(0x00152210) walks the list from the head and callsfuncNoteProcess(+0xa4) with each note, or does nothing when that is 0. The list is last in, first out, so the notes go out in reverse file order: the newest frame's first, and a frame's last record first. Kite'sANM_ctu1atc0(ctu1body) has (2, 2) then (0x8005, 1) at frame 5, so NoteProcess hands on the hit (0x8005) before the sound (2).ccStream::PlaySceneMain(0x001494c0) walks a stream's list (+0xb4) from the head the same way, through the stream's function (+0xa8). - Kept. Neither
NoteProcessnorccAnm::SetAnm(0x00150f50) touchesnoteRoot. The notes stay until the next_AnimateForward(or~ccAnm): a secondNoteProcesshands them on again, and one after aSetAnmwith no step between hands on the old clip's.ccAnm::ccAnm(0x0014fc80) setsfuncNoteProcessfromfuncDefaultNoteProcess(0x00378904, 0 in the executable)._AnimateFrame(0x00152400) frees the list when the frame asked for is the current one, and otherwise clearsfuncNoteProcessaround its own_AnimateForward(which does not call it). - Note functions. The characters call
SetAnm,_AnimateForward, thenNoteProcess(for exampleccPlayer::AnimCtrl, gcmn 0x005993c0).ccEnemyCheckNote(gcmn 0x004328b0),ccPlayer::CheckNote(0x0059c300),ccFellow::CheckNote(0x0041e1e0) andccSkillCheckNote(0x00573a10) switch oneventand readparam. A scan of every function that takes accAnmNote *finds onlyccSetStreamDemoNote(main 0x00184340) readingtime,coordorccstag.
tools/anim.py (Animation.passed_notes, forward_notes) and the port
(Animation::passed_notes, forward_notes, piney_world::pose::Play::forward_notes)
give the notes in delivery order. tools/test_anim.py notes runs each
animation with notes through the game's _AnimateForward and
NoteProcess, with a hooked funcNoteProcess recording each note:
- Steps. Random steps: 0, one frame, odd values within a frame, several frames at once, and past the end.
- Restarts. A play-once animation that has ended is restarted half the
time, the way
_AnimateForwardrestarts a loop. - Compared. Each note's event, param, time and object, in order, the frame counter and the return value.
- Result. All 944 animations: 131,577 steps (13,288 zero, 4,019 loop
resets, 15,529 restarts), 60,759 notes (11,812 steps with several), 0
mismatches. A second
NoteProcessgave the same notes all 26,586 times it was tried.
Unknown
- The spot light controller (0x0607) is not evaluated; no file of
Infection's rooms uses one. The direct light's (0x0605) is: flags bits
0-2 position, 3-5 rotation, 6-8 colour, 9-11 intensity, 12-14 and 15-17
the beam's start and end (
+0x144,+0x148), 18-20 and 21-23 its radii (+0x14c,+0x150;+0x154the second squared), asSetAnmCtrlWork(main 0x00150670) reads them. Infection's event rooms each have one (LGT_se1_3lig1and the like: flags 0x240041, (0, 0, 2500), white, radii 498 and 500,se2_2's 800 and 850);ccDirectLight::CheckRangeis in dungeon.md. - F_Obj's flag byte (+0xa2) is not modelled. Its effect-object branch (0xe00) is, for the streams (stream.md). Neither is whether a pose F_Obj left survives into the next clip when that clip does not drive the object (the port starts every clip from the rest pose).