Skip to main content

rakata_formats/lip/
mod.rs

1//! LIP binary reader and writer.
2//!
3//! LIP files store lip-sync animation keyframes for voiced dialogue. Each
4//! keyframe maps a timestamp to a viseme (mouth shape) index.
5//!
6//! ## Shape of the container
7//!
8//! A 16-byte header and a keyframe array directly after it. No offsets, because
9//! nothing here needs to point anywhere.
10//!
11//! The five-byte keyframe stride is load-bearing rather than incidental: the
12//! engine animates straight off the raw file buffer instead of parsing the
13//! array, so the entries are packed with no alignment padding and adding any
14//! would be read as garbage rather than as a variant.
15//!
16//! Byte-level field maps live in `docs/src/formats/audio/lip.md`, with the
17//! engine's own load sequence.
18
19mod reader;
20mod writer;
21
22pub use reader::{read_lip, read_lip_from_bytes};
23pub use writer::{write_lip, write_lip_to_vec};
24
25use num_enum::{IntoPrimitive, TryFromPrimitive};
26use thiserror::Error;
27
28use crate::binary::{self, DecodeBinary, EncodeBinary};
29
30/// LIP binary header size.
31const FILE_HEADER_SIZE: usize = 16;
32/// LIP keyframe entry size.
33const KEYFRAME_ENTRY_SIZE: usize = 5;
34/// LIP file signature.
35const LIP_MAGIC: [u8; 4] = *b"LIP ";
36/// LIP version used by KotOR.
37const LIP_VERSION_V10: [u8; 4] = *b"V1.0";
38
39/// Known LIP viseme (mouth shape) IDs.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, IntoPrimitive, TryFromPrimitive)]
41#[repr(u8)]
42pub enum LipShape {
43    /// Neutral/rest position.
44    Neutral = 0,
45    /// Wide "ee" mouth.
46    Ee = 1,
47    /// Relaxed "eh" mouth.
48    Eh = 2,
49    /// Open "ah" mouth.
50    Ah = 3,
51    /// Rounded "oh" mouth.
52    Oh = 4,
53    /// Pursed "oo" mouth.
54    Ooh = 5,
55    /// Slight smile "y" mouth.
56    Y = 6,
57    /// Teeth-together "s/ts" mouth.
58    Sts = 7,
59    /// Lower-lip/teeth "f/v" mouth.
60    Fv = 8,
61    /// Tongue-raised "n/ng" mouth.
62    Ng = 9,
63    /// Tongue-between-teeth "th" mouth.
64    Th = 10,
65    /// Closed-lips "m/p/b" mouth.
66    Mpb = 11,
67    /// Tongue-up "t/d" mouth.
68    Td = 12,
69    /// Rounded-relaxed "sh/ch/j" mouth.
70    Sh = 13,
71    /// Tongue-forward "l/r" mouth.
72    L = 14,
73    /// Back-tongue "k/g/h" mouth.
74    Kg = 15,
75}
76
77impl LipShape {
78    /// Returns the known shape for a raw ID, if defined.
79    pub fn from_raw_id(raw_id: u8) -> Option<Self> {
80        Self::try_from(raw_id).ok()
81    }
82
83    /// Returns the raw viseme ID stored in binary files.
84    pub fn raw_id(self) -> u8 {
85        u8::from(self)
86    }
87}
88
89/// Lossless LIP shape code wrapper.
90///
91/// This preserves unknown IDs during parse/write roundtrips while still
92/// exposing known viseme values where possible.
93#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
94pub struct LipShapeCode(u8);
95
96impl LipShapeCode {
97    /// Creates a shape code from raw on-disk value.
98    pub const fn from_raw_id(raw_id: u8) -> Self {
99        Self(raw_id)
100    }
101
102    /// Returns the raw on-disk value.
103    pub const fn raw_id(self) -> u8 {
104        self.0
105    }
106
107    /// Returns the known shape when this code maps to one.
108    pub fn known_shape(self) -> Option<LipShape> {
109        LipShape::from_raw_id(self.0)
110    }
111
112    /// Returns `true` when this code maps to a known shape.
113    pub fn is_known(self) -> bool {
114        self.known_shape().is_some()
115    }
116}
117
118impl From<LipShape> for LipShapeCode {
119    fn from(value: LipShape) -> Self {
120        Self(u8::from(value))
121    }
122}
123
124impl From<u8> for LipShapeCode {
125    fn from(value: u8) -> Self {
126        Self::from_raw_id(value)
127    }
128}
129
130/// One LIP keyframe entry.
131#[derive(Debug, Clone, PartialEq)]
132pub struct LipKeyframe {
133    /// Timestamp in seconds from animation start.
134    pub time: f32,
135    /// Viseme shape code.
136    pub shape: LipShapeCode,
137}
138
139/// In-memory LIP container.
140#[derive(Debug, Clone, PartialEq, Default)]
141pub struct Lip {
142    /// Total lip-sync duration in seconds.
143    pub length: f32,
144    /// Ordered keyframe entries.
145    pub keyframes: Vec<LipKeyframe>,
146}
147
148impl Lip {
149    /// Creates an empty LIP file.
150    pub fn new() -> Self {
151        Self::default()
152    }
153
154    /// Appends a keyframe with raw shape ID.
155    pub fn push_keyframe(&mut self, time: f32, shape_id: u8) {
156        self.push_keyframe_with_shape(time, LipShapeCode::from_raw_id(shape_id));
157    }
158
159    /// Appends a keyframe with explicit shape wrapper.
160    pub fn push_keyframe_with_shape(&mut self, time: f32, shape: LipShapeCode) {
161        self.keyframes.push(LipKeyframe { time, shape });
162    }
163}
164
165impl DecodeBinary for Lip {
166    type Error = LipBinaryError;
167
168    fn decode_binary(bytes: &[u8]) -> Result<Self, Self::Error> {
169        read_lip_from_bytes(bytes)
170    }
171}
172
173impl EncodeBinary for Lip {
174    type Error = LipBinaryError;
175
176    fn encode_binary(&self) -> Result<Vec<u8>, Self::Error> {
177        write_lip_to_vec(self)
178    }
179}
180
181/// Errors produced while parsing or serializing LIP binary data.
182#[derive(Debug, Error)]
183pub enum LipBinaryError {
184    /// I/O read/write failure.
185    #[error(transparent)]
186    Io(#[from] std::io::Error),
187    /// Header signature is not `LIP `.
188    #[error("invalid LIP magic: {0:?}")]
189    InvalidMagic([u8; 4]),
190    /// Header version is unsupported.
191    #[error("invalid LIP version: {0:?}")]
192    InvalidVersion([u8; 4]),
193    /// Header/body layout is invalid or truncated.
194    #[error("invalid LIP header: {0}")]
195    InvalidHeader(String),
196    /// LIP content is structurally invalid.
197    #[error("invalid LIP data: {0}")]
198    InvalidData(String),
199    /// Value cannot fit on-disk integer width.
200    #[error("value overflow while writing field `{0}`")]
201    ValueOverflow(&'static str),
202}
203
204impl From<binary::BinaryLayoutError> for LipBinaryError {
205    fn from(error: binary::BinaryLayoutError) -> Self {
206        Self::InvalidHeader(error.to_string())
207    }
208}