Skip to main content

rakata_formats/tga/
mod.rs

1//! TGA (Targa) reader and writer.
2//!
3//! This module provides a Rust-centric TGA API for KotOR texture workflows:
4//! - parse many TGA source variants into a normalized top-left RGBA8 buffer,
5//! - write lossless-passthrough or canonical output.
6//!
7//! Parsing is backed by `tinytga` for robust header/color-map/RLE handling.
8//!
9//! ## KotOR Notes
10//! - Reader defaults target canonical vanilla KotOR (K1) image types
11//!   (`1`, `2`, `3`, `9`, `10`) and reject non-canonical variants such as
12//!   grayscale RLE (`type 11`) unless compatibility mode is explicitly enabled.
13//! - Writer default: lossless passthrough when pixels are unmodified (the 18-byte
14//!   header and raw image sections are re-emitted verbatim). For new files or
15//!   edited pixels, output is canonical uncompressed true-color (type `2`, 32-bit,
16//!   top-left origin). K1 ignores `image_type` and `image_descriptor` entirely
17//!   (confirmed Ghidra evidence -- see
18//!   `docs/src/formats/textures/tga.md`).
19//!
20//! ## Shape of the file
21//!
22//! The standard Truevision structure: an 18-byte header, then optional image ID
23//! and colour map regions, the pixel data, and an optional footer KotOR never
24//! emits.
25//!
26//! What matters here is how little of the header the engine reads. It takes the
27//! dimensions and `pixel_depth`, validates only that the depth is 8, 24 or 32,
28//! and disregards every other field including `image_type` and the origin bit in
29//! `image_descriptor`. Orientation therefore cannot be expressed in the file:
30//! the engine's own writer hardcodes the descriptor to zero and flips the raster
31//! on the way out. Field map in `docs/src/formats/textures/tga.md`.
32
33mod reader;
34mod writer;
35
36pub use reader::{
37    read_tga, read_tga_from_bytes, read_tga_from_bytes_with_options, read_tga_with_options,
38};
39pub use writer::{write_tga, write_tga_to_vec};
40
41use thiserror::Error;
42use tinytga::{Bpp, ParseError};
43
44use crate::binary::{DecodeBinary, EncodeBinary};
45
46/// Parsed TGA data category from the source header.
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
48pub enum TgaDataType {
49    /// No image data.
50    NoData,
51    /// Color-mapped image data.
52    ColorMapped,
53    /// True-color image data.
54    TrueColor,
55    /// Black-and-white (grayscale) image data.
56    BlackAndWhite,
57}
58
59/// Source TGA compression mode.
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
61pub enum TgaCompression {
62    /// Uncompressed image data.
63    Uncompressed,
64    /// Run-length encoded image data.
65    Rle,
66}
67
68/// Source TGA image origin.
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
70pub enum TgaOrigin {
71    /// Origin at bottom-left.
72    BottomLeft,
73    /// Origin at bottom-right.
74    BottomRight,
75    /// Origin at top-left.
76    TopLeft,
77    /// Origin at top-right.
78    TopRight,
79}
80
81/// Source TGA bit depth.
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
83pub enum TgaBitsPerPixel {
84    /// 8 bits per pixel.
85    Bits8,
86    /// 16 bits per pixel.
87    Bits16,
88    /// 24 bits per pixel.
89    Bits24,
90    /// 32 bits per pixel.
91    Bits32,
92}
93
94impl TgaBitsPerPixel {
95    pub(super) fn try_from_tinytga(value: Bpp) -> Result<Self, TgaBinaryError> {
96        match value {
97            Bpp::Bits8 => Ok(Self::Bits8),
98            Bpp::Bits16 => Ok(Self::Bits16),
99            Bpp::Bits24 => Ok(Self::Bits24),
100            Bpp::Bits32 => Ok(Self::Bits32),
101            _ => Err(TgaBinaryError::InvalidHeader(
102                "unsupported bits-per-pixel value in source header".into(),
103            )),
104        }
105    }
106
107    /// Returns the numeric bit depth.
108    pub fn bits(self) -> u8 {
109        match self {
110            Self::Bits8 => 8,
111            Self::Bits16 => 16,
112            Self::Bits24 => 24,
113            Self::Bits32 => 32,
114        }
115    }
116}
117
118/// Source TGA header metadata captured during parsing.
119#[derive(Debug, Clone, PartialEq, Eq, Hash)]
120pub struct TgaHeader {
121    /// Image ID length field.
122    pub id_len: u8,
123    /// Whether the source declared a color map.
124    pub has_color_map: bool,
125    /// Source data type.
126    pub data_type: TgaDataType,
127    /// Source compression mode.
128    pub compression: TgaCompression,
129    /// Source color-map start index.
130    pub color_map_start: u16,
131    /// Source color-map entry count.
132    pub color_map_len: u16,
133    /// Source color-map entry depth.
134    pub color_map_depth: Option<TgaBitsPerPixel>,
135    /// Source X origin.
136    pub x_origin: u16,
137    /// Source Y origin.
138    pub y_origin: u16,
139    /// Image width in pixels.
140    pub width: u16,
141    /// Image height in pixels.
142    pub height: u16,
143    /// Source pixel depth for image data entries.
144    pub pixel_depth: TgaBitsPerPixel,
145    /// Source origin interpretation.
146    pub image_origin: TgaOrigin,
147    /// Source alpha channel depth field.
148    pub alpha_channel_depth: u8,
149}
150
151/// In-memory TGA image.
152///
153/// Pixel data is always normalized to top-left RGBA8888 row-major order.
154#[derive(Debug, Clone, PartialEq, Eq)]
155pub struct Tga {
156    /// Source header metadata.
157    pub header: TgaHeader,
158    /// Source image ID bytes.
159    pub image_id: Vec<u8>,
160    /// Normalized RGBA8888 pixel buffer.
161    pub rgba_pixels: Vec<u8>,
162}
163
164impl Tga {
165    /// Creates a canonical RGBA image.
166    ///
167    /// # Errors
168    ///
169    /// [`TgaBinaryError::InvalidData`] when `rgba_pixels` is not exactly
170    /// `width * height * 4` bytes, and [`TgaBinaryError::ValueOverflow`] when
171    /// that product will not fit `usize`.
172    pub fn new_rgba(width: u16, height: u16, rgba_pixels: Vec<u8>) -> Result<Self, TgaBinaryError> {
173        validate_rgba_len(width, height, &rgba_pixels)?;
174
175        Ok(Self {
176            header: TgaHeader {
177                id_len: 0,
178                has_color_map: false,
179                data_type: TgaDataType::TrueColor,
180                compression: TgaCompression::Uncompressed,
181                color_map_start: 0,
182                color_map_len: 0,
183                color_map_depth: None,
184                x_origin: 0,
185                y_origin: 0,
186                width,
187                height,
188                pixel_depth: TgaBitsPerPixel::Bits32,
189                image_origin: TgaOrigin::TopLeft,
190                alpha_channel_depth: 8,
191            },
192            image_id: Vec::new(),
193            rgba_pixels,
194        })
195    }
196}
197
198impl DecodeBinary for Tga {
199    type Error = TgaBinaryError;
200
201    fn decode_binary(bytes: &[u8]) -> Result<Self, Self::Error> {
202        read_tga_from_bytes(bytes)
203    }
204}
205
206impl EncodeBinary for Tga {
207    type Error = TgaBinaryError;
208
209    fn encode_binary(&self) -> Result<Vec<u8>, Self::Error> {
210        write_tga_to_vec(self)
211    }
212}
213
214/// Errors produced while parsing or serializing TGA data.
215#[derive(Debug, Error)]
216pub enum TgaBinaryError {
217    /// I/O read/write failure.
218    #[error(transparent)]
219    Io(#[from] std::io::Error),
220    /// TGA parser-level error from `tinytga`.
221    #[error("TGA parse error: {0:?}")]
222    Parse(ParseError),
223    /// Invalid or unsupported source header/data relationship.
224    #[error("invalid TGA header: {0}")]
225    InvalidHeader(String),
226    /// Invalid image data body.
227    #[error("invalid TGA data: {0}")]
228    InvalidData(String),
229    /// Value cannot fit target integer width.
230    #[error("value overflow while handling field `{0}`")]
231    ValueOverflow(&'static str),
232}
233
234impl From<ParseError> for TgaBinaryError {
235    fn from(value: ParseError) -> Self {
236        Self::Parse(value)
237    }
238}
239
240/// TGA reader option set.
241#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
242pub struct TgaReadOptions {
243    /// Input policy for accepted source variants.
244    pub input: TgaReadMode,
245}
246
247impl Default for TgaReadOptions {
248    fn default() -> Self {
249        Self {
250            input: TgaReadMode::CanonicalK1,
251        }
252    }
253}
254
255/// TGA reader input policy.
256#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
257pub enum TgaReadMode {
258    /// Accept only vanilla-safe K1 source variants.
259    ///
260    /// This mode admits image types `1`, `2`, `3`, `9`, and `10` and keeps
261    /// true-color RLE constrained to 24/32-bit payloads.
262    CanonicalK1,
263    /// Accept broader TGA variants for tooling compatibility.
264    Compatibility,
265}
266
267pub(super) fn checked_pixel_count(width: usize, height: usize) -> Result<usize, TgaBinaryError> {
268    width
269        .checked_mul(height)
270        .ok_or(TgaBinaryError::ValueOverflow("pixel count"))
271}
272
273pub(super) fn validate_rgba_len(
274    width: u16,
275    height: u16,
276    rgba: &[u8],
277) -> Result<(), TgaBinaryError> {
278    let pixel_count = checked_pixel_count(usize::from(width), usize::from(height))?;
279    let expected_len = pixel_count
280        .checked_mul(4)
281        .ok_or(TgaBinaryError::ValueOverflow("rgba length"))?;
282
283    if expected_len != rgba.len() {
284        return Err(TgaBinaryError::InvalidData(format!(
285            "RGBA length mismatch: expected {expected_len}, got {}",
286            rgba.len()
287        )));
288    }
289
290    Ok(())
291}