Skip to main content

rakata_formats/tpc/
mod.rs

1//! TPC binary reader and writer.
2//!
3//! TPC (texture pack container) is KotOR's native texture container format.
4//! This module currently focuses on container-level parity: header fields,
5//! payload boundaries, and optional trailing TXI footer bytes.
6//!
7//! ## KotOR Notes
8//! - Canonical K1 pixel-type mappings are explicit and enforced.
9//! - Unknown pixel-type combinations are rejected by default.
10//! - Mip payload sizing follows K1 native shift semantics (`>> 1` per level
11//!   without clamping), so extra mip levels after dimensions collapse to zero
12//!   contribute zero additional bytes.
13//!
14//! ## Shape of the container
15//!
16//! A fixed 128-byte header, the payload immediately after it at `0x80`, and
17//! optionally TXI text on the end. Nothing points anywhere. There is also no
18//! magic signature, so a `.tpc` is recognised by extension and by its header
19//! parsing plausibly.
20//!
21//! The mip shift semantics noted above are the part worth being careful with:
22//! reproducing the engine's unclamped `>> 1` is not pedantry, since a writer
23//! that clamps at one produces a payload the engine reads at the wrong offsets
24//! from the first collapsed level onward.
25//!
26//! Byte-level field map in `docs/src/formats/textures/tpc.md`, with the engine's
27//! own header read and the reason only DXT1 and DXT5 exist here.
28
29mod reader;
30mod writer;
31
32pub use reader::{read_tpc, read_tpc_from_bytes};
33pub use writer::{write_tpc, write_tpc_to_vec};
34
35use std::io::Write;
36use thiserror::Error;
37
38use rakata_core::{encode_text, DecodeTextError, EncodeTextError, TextEncoding};
39
40use crate::binary::{self, write_f32, write_u8, DecodeBinary, EncodeBinary};
41
42const FILE_HEADER_SIZE: usize = 128;
43const RESERVED_SIZE: usize = 114;
44const CUBEMAP_LAYER_COUNT: usize = 6;
45
46/// Header-derived TPC texture payload format classification.
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
48pub enum TpcHeaderPixelFormat {
49    /// 8-bit greyscale uncompressed.
50    Greyscale,
51    /// 24-bit RGB uncompressed.
52    Rgb,
53    /// 32-bit RGBA uncompressed.
54    Rgba,
55    /// Block-compressed DXT1.
56    Dxt1,
57    /// Block-compressed DXT5 payload (16-byte BC3 blocks).
58    ///
59    /// Engine-native parsing (`CAuroraProcessedTexture::ReadProcessedTextureHeader`,
60    /// `CResTPC::GetTPCAttrib`) maps the header flag byte to a `1`/`3`/`4` code and
61    /// treats `4` as the 16-byte compressed branch. K1's OpenGL upload path maps
62    /// that branch to the S3TC DXT5 internal format (`0x83F3`) with no DXT3
63    /// (`0x83F2`) enum observed in the texture format table.
64    Dxt5,
65}
66
67impl TpcHeaderPixelFormat {
68    fn is_compressed(self) -> bool {
69        matches!(self, Self::Dxt1 | Self::Dxt5)
70    }
71
72    fn bytes_per_pixel(self) -> Option<usize> {
73        match self {
74            Self::Greyscale => Some(1),
75            Self::Rgb => Some(3),
76            Self::Rgba => Some(4),
77            Self::Dxt1 | Self::Dxt5 => None,
78        }
79    }
80
81    fn bytes_per_block(self) -> Option<usize> {
82        match self {
83            Self::Dxt1 => Some(8),
84            Self::Dxt5 => Some(16),
85            _ => None,
86        }
87    }
88}
89
90/// Raw TPC pixel encoding code derived from `pixel_type` + compression state.
91#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
92pub struct TpcPixelFormatCode {
93    /// Whether payload is interpreted as compressed (`data_size != 0`).
94    pub compressed: bool,
95    /// Raw `pixel_type` value from header.
96    pub pixel_type: u8,
97}
98
99impl TpcPixelFormatCode {
100    /// Returns known format mapping when this code is currently supported.
101    pub const fn known_format(self) -> Option<TpcHeaderPixelFormat> {
102        match (self.compressed, self.pixel_type) {
103            (false, 1) => Some(TpcHeaderPixelFormat::Greyscale),
104            (false, 2) => Some(TpcHeaderPixelFormat::Rgb),
105            (false, 4) => Some(TpcHeaderPixelFormat::Rgba),
106            (true, 2) => Some(TpcHeaderPixelFormat::Dxt1),
107            (true, 4) => Some(TpcHeaderPixelFormat::Dxt5),
108            _ => None,
109        }
110    }
111}
112
113/// TPC header fields.
114#[derive(Debug, Clone, PartialEq)]
115pub struct TpcHeader {
116    /// Data size field at offset `0x00`.
117    ///
118    /// KotOR uses `0` for uncompressed payloads and non-zero for compressed.
119    pub data_size: u32,
120    /// Alpha test threshold.
121    pub alpha_test: f32,
122    /// Stored width.
123    pub width: u16,
124    /// Stored height.
125    pub height: u16,
126    /// Raw pixel encoding byte.
127    pub pixel_type: u8,
128    /// Mipmap level count.
129    pub mipmap_count: u8,
130    /// Reserved/padding bytes.
131    pub reserved: [u8; RESERVED_SIZE],
132}
133
134impl TpcHeader {
135    /// Returns whether this header represents compressed payload mode.
136    pub const fn compressed(&self) -> bool {
137        self.data_size != 0
138    }
139
140    /// Returns the raw pixel format code.
141    pub const fn pixel_format_code(&self) -> TpcPixelFormatCode {
142        TpcPixelFormatCode {
143            compressed: self.compressed(),
144            pixel_type: self.pixel_type,
145        }
146    }
147}
148
149/// In-memory TPC container.
150#[derive(Debug, Clone, PartialEq)]
151pub struct Tpc {
152    /// Parsed header fields.
153    pub header: TpcHeader,
154    /// Raw texture payload bytes between header and optional TXI footer.
155    pub payload: Vec<u8>,
156    /// Raw trailing TXI footer bytes.
157    pub txi_footer: Vec<u8>,
158}
159
160impl Tpc {
161    /// Creates a TPC object from explicit parts.
162    pub fn new(header: TpcHeader, payload: Vec<u8>, txi_footer: Vec<u8>) -> Self {
163        Self {
164            header,
165            payload,
166            txi_footer,
167        }
168    }
169
170    /// Returns known pixel format if this header code is currently supported.
171    pub fn known_pixel_format(&self) -> Option<TpcHeaderPixelFormat> {
172        self.header.pixel_format_code().known_format()
173    }
174
175    /// Returns `true` when this container encodes a cubemap payload shape.
176    pub fn is_cube_map(&self) -> bool {
177        let width = usize::from(self.header.width);
178        let height = usize::from(self.header.height);
179        self.header.compressed()
180            && width > 0
181            && height % CUBEMAP_LAYER_COUNT == 0
182            && (height / CUBEMAP_LAYER_COUNT == width)
183    }
184
185    /// Decodes the trailing TXI footer as Windows-1252 text.
186    pub fn txi_text(&self) -> String {
187        rakata_core::decode_text(&self.txi_footer, TextEncoding::Windows1252)
188    }
189
190    /// Decodes the trailing TXI footer as strict Windows-1252 text.
191    ///
192    /// # Errors
193    ///
194    /// [`DecodeTextError`] when the footer holds a byte Windows-1252 cannot
195    /// represent, naming its index. [`Self::txi_text`] is the lossy reading
196    /// for a caller that would rather see replacement characters.
197    pub fn txi_text_strict(&self) -> Result<String, DecodeTextError> {
198        rakata_core::decode_text_strict(&self.txi_footer, TextEncoding::Windows1252)
199    }
200
201    /// Replaces trailing TXI footer bytes from Windows-1252 text.
202    ///
203    /// # Errors
204    ///
205    /// [`EncodeTextError`] when `text` holds a character with no Windows-1252
206    /// form, naming it. The footer is left as it was in that case.
207    pub fn set_txi_text(&mut self, text: &str) -> Result<(), EncodeTextError> {
208        self.txi_footer = encode_text(text, TextEncoding::Windows1252)?;
209        Ok(())
210    }
211}
212
213impl DecodeBinary for Tpc {
214    type Error = TpcBinaryError;
215
216    fn decode_binary(bytes: &[u8]) -> Result<Self, Self::Error> {
217        read_tpc_from_bytes(bytes)
218    }
219}
220
221impl EncodeBinary for Tpc {
222    type Error = TpcBinaryError;
223
224    fn encode_binary(&self) -> Result<Vec<u8>, Self::Error> {
225        write_tpc_to_vec(self)
226    }
227}
228
229/// Errors produced while parsing or serializing TPC binary data.
230#[derive(Debug, Error)]
231pub enum TpcBinaryError {
232    /// I/O read/write failure.
233    #[error(transparent)]
234    Io(#[from] std::io::Error),
235    /// Header/body layout is invalid or truncated.
236    #[error("invalid TPC header: {0}")]
237    InvalidHeader(String),
238    /// Payload content is structurally invalid.
239    #[error("invalid TPC data: {0}")]
240    InvalidData(String),
241    /// Pixel-type + compression combination is not yet supported.
242    #[error(
243        "unsupported TPC pixel type: compressed={}, pixel_type={}",
244        .0.compressed,
245        .0.pixel_type
246    )]
247    UnsupportedPixelType(TpcPixelFormatCode),
248    /// Value cannot fit target integer width.
249    #[error("value overflow while handling field `{0}`")]
250    ValueOverflow(&'static str),
251}
252
253impl From<binary::BinaryLayoutError> for TpcBinaryError {
254    fn from(error: binary::BinaryLayoutError) -> Self {
255        Self::InvalidHeader(error.to_string())
256    }
257}
258
259fn read_header(bytes: &[u8]) -> Result<TpcHeader, TpcBinaryError> {
260    let data_size = binary::read_u32(bytes, 0)?;
261    let alpha_test = binary::read_f32(bytes, 4)?;
262    let width = binary::read_u16(bytes, 8)?;
263    let height = binary::read_u16(bytes, 10)?;
264    let pixel_type = bytes[12];
265    let mipmap_count = bytes[13];
266    let mut reserved = [0_u8; RESERVED_SIZE];
267    reserved.copy_from_slice(&bytes[14..FILE_HEADER_SIZE]);
268
269    Ok(TpcHeader {
270        data_size,
271        alpha_test,
272        width,
273        height,
274        pixel_type,
275        mipmap_count,
276        reserved,
277    })
278}
279
280fn write_header<W: Write>(writer: &mut W, header: &TpcHeader) -> Result<(), TpcBinaryError> {
281    binary::write_u32(writer, header.data_size)?;
282    write_f32(writer, header.alpha_test)?;
283    binary::write_u16(writer, header.width)?;
284    binary::write_u16(writer, header.height)?;
285    write_u8(writer, header.pixel_type)?;
286    write_u8(writer, header.mipmap_count)?;
287    writer.write_all(&header.reserved)?;
288    Ok(())
289}
290
291fn expected_payload_size(header: &TpcHeader) -> Result<usize, TpcBinaryError> {
292    let width = usize::from(header.width);
293    let mut height = usize::from(header.height);
294    if width == 0 || height == 0 {
295        return Err(TpcBinaryError::InvalidHeader(
296            "width/height must be non-zero".into(),
297        ));
298    }
299
300    let mip_levels = usize::from(header.mipmap_count);
301    if mip_levels == 0 {
302        return Err(TpcBinaryError::InvalidHeader(
303            "mipmap_count must be at least 1".into(),
304        ));
305    }
306
307    let code = header.pixel_format_code();
308    let format = code
309        .known_format()
310        .ok_or(TpcBinaryError::UnsupportedPixelType(code))?;
311
312    let layer_count = if format.is_compressed()
313        && height % CUBEMAP_LAYER_COUNT == 0
314        && (height / CUBEMAP_LAYER_COUNT == width)
315    {
316        height /= CUBEMAP_LAYER_COUNT;
317        CUBEMAP_LAYER_COUNT
318    } else {
319        1
320    };
321
322    let base_level_size = if format.is_compressed() {
323        binary::checked_to_usize(header.data_size, "data_size")
324            .map_err(|_| TpcBinaryError::ValueOverflow("data_size"))?
325    } else {
326        let bpp = format
327            .bytes_per_pixel()
328            .ok_or_else(|| TpcBinaryError::InvalidHeader("unexpected compressed format".into()))?;
329        checked_uncompressed_level_size(width, height, bpp)?
330    };
331
332    let mut per_layer_size = base_level_size;
333    let mut level_width = width;
334    let mut level_height = height;
335    for _ in 1..mip_levels {
336        // K1 native behavior shifts dimensions each mip level without clamping.
337        level_width >>= 1;
338        level_height >>= 1;
339        per_layer_size = per_layer_size
340            .checked_add(mip_level_size(format, level_width, level_height)?)
341            .ok_or(TpcBinaryError::ValueOverflow("mip level sum"))?;
342    }
343
344    per_layer_size
345        .checked_mul(layer_count)
346        .ok_or(TpcBinaryError::ValueOverflow("layer size sum"))
347}
348
349fn mip_level_size(
350    format: TpcHeaderPixelFormat,
351    width: usize,
352    height: usize,
353) -> Result<usize, TpcBinaryError> {
354    if let Some(bytes_per_pixel) = format.bytes_per_pixel() {
355        return checked_uncompressed_level_size(width, height, bytes_per_pixel);
356    }
357
358    let block_size = format
359        .bytes_per_block()
360        .ok_or_else(|| TpcBinaryError::InvalidHeader("missing block size".into()))?;
361    let block_width = width.div_ceil(4);
362    let block_height = height.div_ceil(4);
363    let block_count = block_width
364        .checked_mul(block_height)
365        .ok_or(TpcBinaryError::ValueOverflow("block count"))?;
366    block_count
367        .checked_mul(block_size)
368        .ok_or(TpcBinaryError::ValueOverflow("block bytes"))
369}
370
371fn checked_uncompressed_level_size(
372    width: usize,
373    height: usize,
374    bytes_per_pixel: usize,
375) -> Result<usize, TpcBinaryError> {
376    width
377        .checked_mul(height)
378        .and_then(|pixels| pixels.checked_mul(bytes_per_pixel))
379        .ok_or(TpcBinaryError::ValueOverflow("uncompressed level size"))
380}