Skip to main content

rakata_formats/tlk/
mod.rs

1//! TLK V3.0 binary reader and writer.
2//!
3//! TLK is the global talk table format used for localized game strings.
4//! Entries store flags, optional voice-over resrefs, and text offsets into a
5//! shared string blob.
6//!
7//! ## Shape of the container
8//!
9//! A fixed 20-byte header, an entry table starting immediately after it, and a
10//! text blob at `entries_offset`. Each entry's `text_offset` is measured from
11//! the blob rather than from the start of the file, so the two offsets compose
12//! rather than one superseding the other.
13//!
14//! Two things to know before touching a reader here. The engine does not
15//! validate the version tag but does use it to pick the entry stride, so a
16//! mistyped version parses at the wrong width instead of failing. And there is
17//! no encoding declaration in the file at all: `language_id` selects it, and we
18//! enforce that mapping strictly rather than guessing, so a mislabelled table
19//! fails loudly instead of producing mojibake.
20//!
21//! Byte-level field maps for the header and the entry record live in
22//! `docs/src/formats/text/tlk.md`, with the engine's own load sequence.
23
24mod reader;
25mod writer;
26
27pub use reader::{read_tlk, read_tlk_from_bytes};
28pub use writer::{write_tlk, write_tlk_to_vec};
29
30use thiserror::Error;
31
32#[cfg(feature = "serde")]
33use serde::{Deserialize, Serialize};
34
35use rakata_core::{DecodeTextError, EncodeTextError, LanguageId, ResRef, ResRefError};
36
37use crate::binary::{self, DecodeBinary, EncodeBinary};
38
39/// TLK file type marker.
40const TLK_MAGIC: [u8; 4] = *b"TLK ";
41/// TLK format version used by KotOR.
42const TLK_VERSION_V3: [u8; 4] = *b"V3.0";
43/// TLK header size in bytes.
44const FILE_HEADER_SIZE: usize = 20;
45/// Per-entry header size in bytes.
46const ENTRY_SIZE: usize = 40;
47
48/// In-memory representation of a TLK talk table.
49#[derive(Debug, Clone, PartialEq)]
50#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
51pub struct Tlk {
52    /// Language identifier stored in TLK header.
53    pub language_id: LanguageId,
54    /// Ordered string entries (stringref index = vector index).
55    pub entries: Vec<TlkEntry>,
56}
57
58impl Tlk {
59    /// Creates an empty TLK with the provided language identifier.
60    pub fn new(language_id: impl Into<LanguageId>) -> Self {
61        Self {
62            language_id: language_id.into(),
63            entries: Vec::new(),
64        }
65    }
66}
67
68impl DecodeBinary for Tlk {
69    type Error = TlkBinaryError;
70
71    fn decode_binary(bytes: &[u8]) -> Result<Self, Self::Error> {
72        read_tlk_from_bytes(bytes)
73    }
74}
75
76impl EncodeBinary for Tlk {
77    type Error = TlkBinaryError;
78
79    fn encode_binary(&self) -> Result<Vec<u8>, Self::Error> {
80        write_tlk_to_vec(self)
81    }
82}
83
84/// One TLK string entry and its metadata flags.
85#[derive(Debug, Clone, PartialEq)]
86#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
87pub struct TlkEntry {
88    /// Localized text payload.
89    pub text: String,
90    /// Voice-over resource reference.
91    pub voiceover: ResRef,
92    /// TLK flag bit 0: text present.
93    pub text_present: bool,
94    /// TLK flag bit 1: sound present.
95    pub sound_present: bool,
96    /// TLK flag bit 2: sound length present.
97    pub sound_length_present: bool,
98    /// Voice-over length in seconds.
99    pub sound_length: f32,
100    /// Entry offset 0x14: volume variance (u32, reserved -- written by engine, not semantically used).
101    pub volume_var: u32,
102    /// Entry offset 0x18: pitch variance (u32, reserved -- written by engine, not semantically used).
103    pub pitch_var: u32,
104}
105
106impl TlkEntry {
107    /// Creates a TLK entry with sensible default flags from content.
108    pub fn new(text: impl Into<String>, voiceover: ResRef) -> Self {
109        let text = text.into();
110        Self {
111            text_present: !text.is_empty(),
112            sound_present: !voiceover.is_blank(),
113            sound_length_present: false,
114            sound_length: 0.0,
115            volume_var: 0,
116            pitch_var: 0,
117            text,
118            voiceover,
119        }
120    }
121
122    /// Returns a canonicalized entry that enforces internally consistent flags.
123    pub fn normalized(&self) -> Self {
124        let mut normalized = self.clone();
125        normalized.text_present = !normalized.text.is_empty();
126        normalized.sound_present = !normalized.voiceover.is_blank();
127        if !normalized.sound_present {
128            normalized.sound_length_present = false;
129            normalized.sound_length = 0.0;
130        } else {
131            normalized.sound_length_present =
132                normalized.sound_length_present || normalized.sound_length != 0.0;
133        }
134        normalized
135    }
136}
137
138/// Errors produced while parsing or serializing TLK binary data.
139#[derive(Debug, Error)]
140pub enum TlkBinaryError {
141    /// I/O read/write failure.
142    #[error(transparent)]
143    Io(#[from] std::io::Error),
144    /// Header magic does not match `TLK `.
145    #[error("invalid TLK magic: {0:?}")]
146    InvalidMagic([u8; 4]),
147    /// Header version is unsupported.
148    #[error("invalid TLK version: {0:?}")]
149    InvalidVersion([u8; 4]),
150    /// Header/offset table is structurally invalid.
151    #[error("invalid TLK header: {0}")]
152    InvalidHeader(String),
153    /// Value cannot be represented in binary field width.
154    #[error("value overflow while writing field `{0}`")]
155    ValueOverflow(&'static str),
156    /// Language ID maps to a text encoding that is not currently supported.
157    #[error("unsupported TLK language id {0} for text encoding")]
158    UnsupportedLanguageEncoding(u32),
159    /// Entry text contains characters that cannot be represented in the selected encoding.
160    #[error("entry {entry_index} text encoding failed: {source}")]
161    TextEncoding {
162        /// Index of the entry that failed to encode.
163        entry_index: usize,
164        /// Source encoding error with exact character location.
165        #[source]
166        source: EncodeTextError,
167    },
168    /// Entry text bytes could not be decoded without data loss.
169    #[error("entry {entry_index} text decoding failed: {source}")]
170    TextDecoding {
171        /// Index of the entry that failed to decode.
172        entry_index: usize,
173        /// Source decoding error with byte position details.
174        #[source]
175        source: DecodeTextError,
176    },
177    /// Entry sound resref bytes decoded but failed ResRef validation.
178    #[error("entry {entry_index} sound resref `{value}` failed validation: {source}")]
179    InvalidSoundResRef {
180        /// Index of the entry that failed to decode.
181        entry_index: usize,
182        /// Decoded sound resref token.
183        value: String,
184        /// ResRef validation error.
185        #[source]
186        source: ResRefError,
187    },
188}
189
190impl From<binary::BinaryLayoutError> for TlkBinaryError {
191    fn from(error: binary::BinaryLayoutError) -> Self {
192        Self::InvalidHeader(error.to_string())
193    }
194}
195
196//
197// Serde Support (JSON)
198//
199
200#[cfg(feature = "serde")]
201mod serde_impl {
202    use super::*;
203    use serde_json::{from_slice, from_str, to_string_pretty, to_vec};
204
205    /// Serializes a TLK to JSON string.
206    ///
207    /// # Errors
208    ///
209    /// [`TlkBinaryError::Io`] wrapping the `serde_json` message, which is the
210    /// variant this reaches for rather than one of its own. A `Tlk` holds
211    /// nothing that resists serialization, so it is not reachable in practice.
212    pub fn write_tlk_to_json(tlk: &Tlk) -> Result<String, TlkBinaryError> {
213        to_string_pretty(tlk).map_err(|e| TlkBinaryError::Io(std::io::Error::other(e)))
214    }
215
216    /// Serializes a TLK to JSON bytes.
217    ///
218    /// # Errors
219    ///
220    /// The same as [`write_tlk_to_json`].
221    pub fn write_tlk_to_json_vec(tlk: &Tlk) -> Result<Vec<u8>, TlkBinaryError> {
222        to_vec(tlk).map_err(|e| TlkBinaryError::Io(std::io::Error::other(e)))
223    }
224
225    /// Deserializes a TLK from JSON string.
226    ///
227    /// # Errors
228    ///
229    /// [`TlkBinaryError::Io`] wrapping the `serde_json` message, for JSON that
230    /// is malformed or does not have a `Tlk`'s shape. Nothing checks the
231    /// result against the binary format's limits, so a table this accepts can
232    /// still be refused by [`write_tlk`](crate::tlk::write_tlk).
233    pub fn read_tlk_from_json(json: &str) -> Result<Tlk, TlkBinaryError> {
234        from_str(json).map_err(|e| TlkBinaryError::Io(std::io::Error::other(e)))
235    }
236
237    /// Deserializes a TLK from JSON bytes.
238    ///
239    /// # Errors
240    ///
241    /// The same as [`read_tlk_from_json`].
242    pub fn read_tlk_from_json_bytes(bytes: &[u8]) -> Result<Tlk, TlkBinaryError> {
243        from_slice(bytes).map_err(|e| TlkBinaryError::Io(std::io::Error::other(e)))
244    }
245}
246
247#[cfg(feature = "serde")]
248pub use serde_impl::{
249    read_tlk_from_json, read_tlk_from_json_bytes, write_tlk_to_json, write_tlk_to_json_vec,
250};