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}