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