Skip to main content

rakata_formats/
binary.rs

1//! Shared binary helpers and lightweight codec traits.
2//!
3//! This module is the low-level utility layer used across format modules for:
4//! - checked primitive reads (`u16`, `u32`, `u64`, `f32`, fourcc),
5//! - bounds validation for offset/size pairs,
6//! - narrow encode/decode traits used for composition.
7//!
8//! ## Layering Overview
9//! ```text
10//! format module (erf/rim/gff/tlk/twoda)
11//!   -> format-specific error mapping
12//!   -> binary::{read_*, write_*, check_*}
13//!   -> raw byte slice / writer
14//! ```
15
16use std::io::Write;
17use thiserror::Error;
18
19use rakata_core::{encode_text, EncodeTextError, TextEncoding};
20
21/// Shared binary layout/read error used by low-level format helpers.
22#[derive(Debug, Clone, PartialEq, Eq, Error)]
23pub enum BinaryLayoutError {
24    /// Integer/index arithmetic overflow while computing offsets or sizes.
25    #[error("{0} overflow")]
26    Overflow(&'static str),
27    /// Requested read exceeds available input bytes.
28    #[error("unexpected EOF while reading {0}")]
29    UnexpectedEof(&'static str),
30    /// Named region exceeds the allowed bounds.
31    #[error("{0} exceeds file bounds")]
32    BoundsExceeded(String),
33}
34
35/// Minimal decode trait for binary format value types.
36pub trait DecodeBinary: Sized {
37    /// Error type used by the format.
38    type Error;
39
40    /// Decodes a value from raw bytes.
41    ///
42    /// # Errors
43    ///
44    /// [`Self::Error`], on terms the implementing format decides. Trailing
45    /// bytes past the value are the implementation's to accept or refuse.
46    fn decode_binary(bytes: &[u8]) -> Result<Self, Self::Error>;
47}
48
49/// Minimal encode trait for binary format value types.
50pub trait EncodeBinary {
51    /// Error type used by the format.
52    type Error;
53
54    /// Encodes a value into an owned byte buffer.
55    ///
56    /// # Errors
57    ///
58    /// [`Self::Error`], on terms the implementing format decides. There is no
59    /// I/O here, so a failure is a value the format cannot represent.
60    fn encode_binary(&self) -> Result<Vec<u8>, Self::Error>;
61}
62
63/// Validates that `[offset, offset + size)` is within `total_len`.
64///
65/// # Errors
66///
67/// [`BinaryLayoutError::BoundsExceeded`] carrying `label`, both for a range
68/// that runs past the end and for one whose end overflows `usize`.
69pub fn check_range_in_bounds(
70    total_len: usize,
71    offset: usize,
72    size: usize,
73    label: &str,
74) -> Result<(), BinaryLayoutError> {
75    if offset.checked_add(size).is_none_or(|end| end > total_len) {
76        return Err(BinaryLayoutError::BoundsExceeded(label.to_string()));
77    }
78    Ok(())
79}
80
81/// Validates that `[offset, offset + size)` is within `bytes`.
82///
83/// # Errors
84///
85/// [`BinaryLayoutError::BoundsExceeded`] carrying `label`, on the terms
86/// [`check_range_in_bounds`] gives.
87pub fn check_slice_in_bounds(
88    bytes: &[u8],
89    offset: usize,
90    size: usize,
91    label: &str,
92) -> Result<(), BinaryLayoutError> {
93    check_range_in_bounds(bytes.len(), offset, size, label)
94}
95
96/// Converts a 32-bit offset/count value to `usize` with overflow checking.
97///
98/// # Errors
99///
100/// [`BinaryLayoutError::Overflow`] carrying `field` where `usize` is narrower
101/// than 32 bits. Unreachable on the targets this workspace builds for, and
102/// checked so that a 16-bit one would fail rather than truncate.
103pub fn checked_to_usize(value: u32, field: &'static str) -> Result<usize, BinaryLayoutError> {
104    usize::try_from(value).map_err(|_| BinaryLayoutError::Overflow(field))
105}
106
107/// Reads a 4-byte tag at `offset`.
108///
109/// # Errors
110///
111/// [`BinaryLayoutError::UnexpectedEof`] when fewer than four bytes remain.
112pub fn read_fourcc(bytes: &[u8], offset: usize) -> Result<[u8; 4], BinaryLayoutError> {
113    read_array::<4>(bytes, offset, "fourcc")
114}
115
116/// Validates that a parsed fourcc exactly matches `expected`.
117///
118/// # Errors
119///
120/// `Err(actual)` on a mismatch, so callers can map it into a format-specific
121/// error variant without re-reading the source bytes.
122pub fn expect_fourcc(actual: [u8; 4], expected: [u8; 4]) -> Result<(), [u8; 4]> {
123    if actual == expected {
124        Ok(())
125    } else {
126        Err(actual)
127    }
128}
129
130/// Validates that a parsed fourcc matches one of `expected`.
131///
132/// # Errors
133///
134/// `Err(actual)` when no candidate matches.
135pub fn expect_any_fourcc(actual: [u8; 4], expected: &[[u8; 4]]) -> Result<(), [u8; 4]> {
136    if expected.contains(&actual) {
137        Ok(())
138    } else {
139        Err(actual)
140    }
141}
142
143/// Reads little-endian `u16` at `offset`.
144///
145/// # Errors
146///
147/// [`BinaryLayoutError::UnexpectedEof`] when fewer than two bytes remain.
148pub fn read_u16(bytes: &[u8], offset: usize) -> Result<u16, BinaryLayoutError> {
149    Ok(u16::from_le_bytes(read_array::<2>(bytes, offset, "u16")?))
150}
151
152/// Reads a single byte at `offset`.
153///
154/// # Errors
155///
156/// [`BinaryLayoutError::UnexpectedEof`] when `offset` is past the end.
157pub fn read_u8(bytes: &[u8], offset: usize) -> Result<u8, BinaryLayoutError> {
158    read_array::<1>(bytes, offset, "u8").map(|[b]| b)
159}
160
161/// Reads little-endian `u32` at `offset`.
162///
163/// # Errors
164///
165/// [`BinaryLayoutError::UnexpectedEof`] when fewer than four bytes remain.
166pub fn read_u32(bytes: &[u8], offset: usize) -> Result<u32, BinaryLayoutError> {
167    Ok(u32::from_le_bytes(read_array::<4>(bytes, offset, "u32")?))
168}
169
170/// Reads little-endian `u64` at `offset`.
171///
172/// # Errors
173///
174/// [`BinaryLayoutError::UnexpectedEof`] when fewer than eight bytes remain.
175pub fn read_u64(bytes: &[u8], offset: usize) -> Result<u64, BinaryLayoutError> {
176    Ok(u64::from_le_bytes(read_array::<8>(bytes, offset, "u64")?))
177}
178
179/// Reads little-endian `i32` at `offset`.
180///
181/// # Errors
182///
183/// [`BinaryLayoutError::UnexpectedEof`] when fewer than four bytes remain.
184pub fn read_i32(bytes: &[u8], offset: usize) -> Result<i32, BinaryLayoutError> {
185    Ok(i32::from_le_bytes(read_array::<4>(bytes, offset, "i32")?))
186}
187
188/// Reads little-endian `f32` at `offset`.
189///
190/// # Errors
191///
192/// [`BinaryLayoutError::UnexpectedEof`] when fewer than four bytes remain. Any
193/// four bytes decode, `NaN` and the infinities included.
194pub fn read_f32(bytes: &[u8], offset: usize) -> Result<f32, BinaryLayoutError> {
195    Ok(f32::from_le_bytes(read_array::<4>(bytes, offset, "f32")?))
196}
197
198/// Writes a single byte.
199///
200/// # Errors
201///
202/// Propagates the writer's I/O error.
203pub fn write_u8<W: Write>(writer: &mut W, value: u8) -> std::io::Result<()> {
204    writer.write_all(&[value])
205}
206
207/// Writes little-endian `u16`.
208///
209/// # Errors
210///
211/// Propagates the writer's I/O error.
212pub fn write_u16<W: Write>(writer: &mut W, value: u16) -> std::io::Result<()> {
213    writer.write_all(&value.to_le_bytes())
214}
215
216/// Writes little-endian `u32`.
217///
218/// # Errors
219///
220/// Propagates the writer's I/O error.
221pub fn write_u32<W: Write>(writer: &mut W, value: u32) -> std::io::Result<()> {
222    writer.write_all(&value.to_le_bytes())
223}
224
225/// Writes little-endian `i32`.
226///
227/// # Errors
228///
229/// Propagates the writer's I/O error.
230pub fn write_i32<W: Write>(writer: &mut W, value: i32) -> std::io::Result<()> {
231    writer.write_all(&value.to_le_bytes())
232}
233
234/// Writes little-endian `u64`.
235///
236/// # Errors
237///
238/// Propagates the writer's I/O error.
239pub fn write_u64<W: Write>(writer: &mut W, value: u64) -> std::io::Result<()> {
240    writer.write_all(&value.to_le_bytes())
241}
242
243/// Writes little-endian `f32`.
244///
245/// # Errors
246///
247/// Propagates the writer's I/O error.
248pub fn write_f32<W: Write>(writer: &mut W, value: f32) -> std::io::Result<()> {
249    writer.write_all(&value.to_le_bytes())
250}
251
252/// Writes a 4-byte tag.
253///
254/// # Errors
255///
256/// Propagates the writer's I/O error.
257pub fn write_fourcc<W: Write>(writer: &mut W, tag: [u8; 4]) -> std::io::Result<()> {
258    writer.write_all(&tag)
259}
260
261/// Reads a null-terminated string from a fixed-size field in a byte buffer.
262///
263/// Scans up to `max_len` bytes starting at `offset` for the first null byte,
264/// then decodes the preceding bytes as Windows-1252 (the engine's native
265/// codepage). If no null byte is found, the entire `max_len` slice is decoded.
266///
267/// This is the standard binary format string primitive used across KotOR
268/// formats (model names, texture names, resource labels, etc.).
269pub fn read_fixed_c_string(bytes: &[u8], offset: usize, max_len: usize) -> String {
270    let end = (offset + max_len).min(bytes.len());
271    let slice = &bytes[offset..end];
272    let nul_pos = slice.iter().position(|&b| b == 0).unwrap_or(slice.len());
273    rakata_core::text::decode_text(&slice[..nul_pos], TextEncoding::Windows1252)
274}
275
276/// Reads a variable-length null-terminated string from a byte buffer.
277///
278/// Scans from `offset` to the first null byte (or end of buffer), then
279/// decodes the preceding bytes as Windows-1252.
280pub fn read_c_string(bytes: &[u8], offset: usize) -> String {
281    let end = bytes[offset..]
282        .iter()
283        .position(|&b| b == 0)
284        .unwrap_or(bytes.len() - offset);
285    rakata_core::text::decode_text(&bytes[offset..offset + end], TextEncoding::Windows1252)
286}
287
288/// Writes a null-terminated string into a fixed-size field, zero-padded.
289///
290/// Truncates `s` to `field_size - 1` bytes to guarantee a null terminator.
291/// The remaining bytes are filled with zeros.
292///
293/// # Errors
294///
295/// Propagates the writer's I/O error. Truncation is not one: an over-long `s`
296/// is cut silently.
297pub fn write_fixed_c_string<W: Write>(
298    writer: &mut W,
299    s: &str,
300    field_size: usize,
301) -> std::io::Result<()> {
302    let bytes = s.as_bytes();
303    let write_len = bytes.len().min(field_size.saturating_sub(1));
304    writer.write_all(&bytes[..write_len])?;
305    let pad = field_size - write_len;
306    for _ in 0..pad {
307        writer.write_all(&[0])?;
308    }
309    Ok(())
310}
311
312/// Encodes `text` as Windows-1252 and writes it to `writer`.
313///
314/// # Errors
315///
316/// Whatever `map_text_error` builds from `context` where a character has no
317/// Windows-1252 form, and the writer's I/O error converted through
318/// `From<std::io::Error>`. Encoding runs first, so a text failure writes
319/// nothing.
320pub fn write_cp1252<W: Write, E, F>(
321    writer: &mut W,
322    text: &str,
323    context: String,
324    map_text_error: F,
325) -> Result<(), E>
326where
327    E: From<std::io::Error>,
328    F: FnOnce(String, EncodeTextError) -> E,
329{
330    let encoded = encode_text(text, TextEncoding::Windows1252)
331        .map_err(|source| map_text_error(context, source))?;
332    writer.write_all(&encoded).map_err(E::from)
333}
334
335/// Returns `true` when two resource keys match by type and ASCII
336/// case-insensitive resource name.
337pub fn matches_resource_key<T: Eq>(
338    entry_resref: &str,
339    entry_type: T,
340    query_resref: &str,
341    query_type: T,
342) -> bool {
343    entry_type == query_type && entry_resref.eq_ignore_ascii_case(query_resref)
344}
345
346fn read_array<const N: usize>(
347    bytes: &[u8],
348    offset: usize,
349    context: &'static str,
350) -> Result<[u8; N], BinaryLayoutError> {
351    let end = offset
352        .checked_add(N)
353        .ok_or(BinaryLayoutError::Overflow(context))?;
354    let raw = bytes
355        .get(offset..end)
356        .ok_or(BinaryLayoutError::UnexpectedEof(context))?;
357    let mut out = [0_u8; N];
358    out.copy_from_slice(raw);
359    Ok(out)
360}
361
362#[cfg(test)]
363mod tests {
364    use super::*;
365
366    #[test]
367    fn reads_primitives_from_little_endian_bytes() {
368        let bytes = [
369            b'R', b'I', b'M', b' ', // fourcc
370            0x34, 0x12, // u16
371            0x78, 0x56, 0x34, 0x12, // u32
372            0x08, 0x07, 0x06, 0x05, 0x04, 0x03, 0x02, 0x01, // u64
373            0x00, 0x00, 0x80, 0x3f, // f32 = 1.0
374        ];
375
376        assert_eq!(read_fourcc(&bytes, 0).expect("fourcc"), *b"RIM ");
377        assert_eq!(read_u16(&bytes, 4).expect("u16"), 0x1234);
378        assert_eq!(read_u32(&bytes, 6).expect("u32"), 0x1234_5678);
379        assert_eq!(read_u64(&bytes, 10).expect("u64"), 0x0102_0304_0506_0708);
380        assert_eq!(read_f32(&bytes, 18).expect("f32"), 1.0);
381    }
382
383    #[test]
384    fn validates_single_or_multiple_fourcc_values() {
385        assert_eq!(expect_fourcc(*b"RIM ", *b"RIM "), Ok(()));
386        assert_eq!(expect_fourcc(*b"RIM ", *b"GFF "), Err(*b"RIM "));
387
388        assert_eq!(expect_any_fourcc(*b"V1.1", &[*b"V1  ", *b"V1.1"]), Ok(()));
389        assert_eq!(
390            expect_any_fourcc(*b"V9.9", &[*b"V1  ", *b"V1.1"]),
391            Err(*b"V9.9")
392        );
393    }
394
395    #[test]
396    fn reports_eof_and_overflow_for_invalid_reads() {
397        let bytes = [0_u8; 4];
398
399        let eof = read_u32(&bytes, 2).expect_err("expected EOF");
400        assert!(matches!(eof, BinaryLayoutError::UnexpectedEof("u32")));
401
402        let overflow = read_u32(&bytes, usize::MAX).expect_err("expected overflow");
403        assert!(matches!(overflow, BinaryLayoutError::Overflow("u32")));
404    }
405
406    #[test]
407    fn validates_slice_bounds() {
408        let bytes = [0_u8; 16];
409
410        check_slice_in_bounds(&bytes, 4, 8, "table").expect("in bounds");
411
412        let err = check_slice_in_bounds(&bytes, 12, 8, "table").expect_err("must fail");
413        assert_eq!(err, BinaryLayoutError::BoundsExceeded("table".into()));
414    }
415
416    #[test]
417    fn converts_u32_to_usize_with_error() {
418        assert_eq!(checked_to_usize(42, "count").expect("convert"), 42usize);
419    }
420
421    #[test]
422    fn writes_little_endian_primitives() {
423        let mut out = Vec::new();
424        write_u8(&mut out, 0xAB).expect("write u8");
425        write_u16(&mut out, 0x1234).expect("write u16");
426        write_u32(&mut out, 0x1234_5678).expect("write u32");
427        write_u64(&mut out, 0x0102_0304_0506_0708).expect("write u64");
428        write_f32(&mut out, 1.0_f32).expect("write f32");
429        write_fourcc(&mut out, *b"RIM ").expect("write fourcc");
430        assert_eq!(
431            out,
432            vec![
433                0xAB, // u8
434                0x34, 0x12, // u16
435                0x78, 0x56, 0x34, 0x12, // u32
436                0x08, 0x07, 0x06, 0x05, 0x04, 0x03, 0x02, 0x01, // u64
437                0x00, 0x00, 0x80, 0x3f, // f32 = 1.0
438                b'R', b'I', b'M', b' ', // fourcc
439            ]
440        );
441    }
442
443    #[derive(Debug, PartialEq, Eq)]
444    enum Cp1252TestError {
445        Io,
446        Encode { context: String },
447    }
448
449    impl From<std::io::Error> for Cp1252TestError {
450        fn from(_value: std::io::Error) -> Self {
451            Self::Io
452        }
453    }
454
455    #[test]
456    fn writes_cp1252_text_with_context_mapping() {
457        let mut out = Vec::new();
458        write_cp1252(&mut out, "café", "payload".into(), |context, _source| {
459            Cp1252TestError::Encode { context }
460        })
461        .expect("cp1252 should encode");
462        assert_eq!(out, b"caf\xe9");
463    }
464
465    #[test]
466    fn reports_cp1252_encoding_failures_with_context() {
467        let mut out = Vec::new();
468        let err = write_cp1252(
469            &mut out,
470            "emoji \u{1f600}",
471            "payload".into(),
472            |context, _source| Cp1252TestError::Encode { context },
473        )
474        .expect_err("must fail");
475        assert_eq!(
476            err,
477            Cp1252TestError::Encode {
478                context: "payload".into()
479            }
480        );
481    }
482
483    #[test]
484    fn matches_resource_keys_case_insensitively() {
485        assert!(matches_resource_key(
486            "P_Bastila",
487            2014_u16,
488            "p_bastila",
489            2014_u16
490        ));
491        assert!(!matches_resource_key(
492            "P_Bastila",
493            2014_u16,
494            "p_bastila",
495            2015_u16
496        ));
497        assert!(!matches_resource_key(
498            "P_Bastila",
499            2014_u16,
500            "p_carth",
501            2014_u16
502        ));
503    }
504}