LIP (Lip Synching)
LIP files provide keyframed facial morph data directly bound to audio streams, instructing character models how to physically animate their mouths to match speech.
At a Glance
| Property | Value |
|---|---|
| Extension(s) | .lip |
| Magic Signature | LIP V1.0 |
| Type | Facial Animation Keyframes |
| Rust Reference | View rakata_formats::Lip in Rustdocs |
File Layout
Two blocks and no offsets to follow: a 16-byte header, then the keyframe array immediately after it. Nothing in the file points anywhere, because nothing needs to.
| Offset | Field | Type | Notes |
|---|---|---|---|
0x00 | signature | char[8] | LIP V1.0, checked as one eight-byte run rather than as a magic and version pair. |
0x08 | animation length | f32 | Total duration in seconds. |
0x0C | entry_count | u32 | |
0x10 | keyframes | array | 5 bytes each, packed with no padding. |
Keyframe Entry (5 bytes each)
| Offset | Field | Type | Notes |
|---|---|---|---|
0x00 | time | f32 | Timestamp in seconds, from the start of the animation. |
0x04 | shape | u8 | Viseme index 0–15. See below. |
The 16 visemes
Every one of these occurs across a full install’s 1,073,460 keyframes, and no value outside the range appears anywhere. The names are phonetic groupings rather than individual sounds: one mouth shape serves every consonant made the same way.
| # | Shape | # | Shape | # | Shape | # | Shape |
|---|---|---|---|---|---|---|---|
| 0 | Neutral, mouth closed | 4 | “oh” | 8 | “f”, “v” | 12 | “t”, “d” |
| 1 | “ee” | 5 | “ooh” | 9 | “ng” | 13 | “sh” |
| 2 | “eh” | 6 | “y” | 10 | “th” | 14 | “l” |
| 3 | “ah” | 7 | “s”, “t”, “s” cluster | 11 | “m”, “p”, “b” | 15 | “k”, “g” |
Warning
The on-disk layout is the in-memory layout The engine reads the keyframes straight out of the file buffer, five bytes at a time from
0x10, once, when a line starts. That makes the five-byte stride load-bearing in a way most formats’ are not: there is no padding to the natural four-byte alignment af32would normally want, and adding any would not produce a slightly different file, it would produce one the engine reads as garbage from the second keyframe onward.
How a dialogue line finds its lip file
A conversation entry’s lip file is the .lip with the same name as the line’s sound: its Sound, or VO_ResRef when that’s empty (see dlg.md). Unlike the voice, it isn’t streamed from a folder: the lip file is an ordinary resource lookup, so it can live in Override or a module like anything else (see the resource tiers). A file that doesn’t start with LIP V1.0 isn’t used.
When the voice opens, the mouth starts moving on the first frame the stream reports it’s playing. When no voice file opens, the lip file is applied straight away, so a line with a lip file and no voice still moves the speaker’s mouth, in silence. Either way it only drives the mouth. Nothing about a lip file plays audio.
The keys have to span the whole animation. The first key’s time must be exactly 0, and the last must land on the header’s animation length, to within about one part in a hundred thousand. A lip file that misses either moves nothing, and nothing says why. Every lip file vanilla ships passes both.
Engine Audits & Decompilation
Read from CLIP::LoadLip at 0x0070c590 and CSWCCreature::LipSync in swkotor.exe. Provenance: derived, not attested. The rows below have not been separately re-derived, so they sit on the reverse-engineering queue.
| Pipeline Event | Ghidra Provenance & Engine Behavior |
|---|---|
| Loading Keeps a Pointer | LoadLip checks that the first eight bytes read LIP V1.0, keeps a pointer to the keys at +0x10, and returns. It never loops. The animation length at +0x08 and the key count at +0x0C are read from the buffer where the keys are used. |
| One Pass When a Line Starts | CSWCCreature::LipSync walks every five-byte key in place, turning each time into a fraction of the animation length and each viseme into (viseme + 1) / 16. It then checks that the first fraction is exactly 0 and the last within about 1e-5 of 1. If either fails it applies nothing and returns failure. Otherwise it snaps the last time to exactly 1, clears any viseme animation already running, and hands both arrays to the speaker’s animation, which keeps its own copy. Nothing reads the file buffer again. |