Skip to main content

rgpre/import/
bsi.rs

1mod index;
2pub use index::{BsiIndex, BsiIndexEntry};
3mod glide;
4pub use glide::GlideTextureLayout;
5
6use super::palette::Palette;
7use crate::{Result, error::Error};
8
9#[derive(Debug, Clone)]
10/// Parsed image entry from a BSI or TEXBSI file.
11pub struct BsiImage {
12    pub name: String,
13    pub image_index: u16,
14    pub width: u16,
15    pub height: u16,
16    pub x_offset: i16,
17    pub y_offset: i16,
18    pub frame_count: u16,
19    /// Animation frame duration in milliseconds (BHDR offset 16).
20    /// Converted at runtime to DOS PIT timer ticks: round(ms × 18.2 / 1000).
21    /// Clamped to minimum 1. Range 0–500, typically 71.
22    pub anim_delay: i16,
23    /// `8.8` fixed-point texture coordinate scale factor (BHDR offsets 22–23, LE u16).
24    /// `scale` = `tex_scale` / `256.0`. Default `0x0100` (`1.0`) substituted when zero.
25    pub tex_scale: u16,
26    /// Pixel data encoding mode (BHDR offset 24).
27    /// `0` = raw uncompressed, `4` = animated offset table.
28    /// Values `1`–`3` are engine-supported but absent from shipped files.
29    pub data_encoding: i16,
30    pub is_animated: bool,
31    pub palette: Option<Vec<[u8; 3]>>,
32    pub pixel_data: Vec<u8>,
33    /// Additional animation frames beyond frame 0 (pixel data per frame).
34    /// Empty for static images. Frame 0 is always in `pixel_data`.
35    pub extra_frames: Vec<Vec<u8>>,
36}
37
38#[derive(Debug, Clone)]
39/// Parsed BSI file containing one or more image entries.
40pub struct BsiFile {
41    pub images: Vec<BsiImage>,
42}
43
44impl BsiImage {
45    /// Decodes frame 0 indexed pixels to RGBA using an external or embedded palette.
46    /// Index zero retains its palette RGB with alpha zero, including for filtering.
47    #[must_use]
48    pub fn decode_rgba(&self, palette: Option<&Palette>) -> Vec<u8> {
49        self.decode_pixels_rgba(&self.pixel_data, palette)
50    }
51
52    /// Decodes a specific animation frame to RGBA.
53    /// Frame 0 uses `pixel_data`; frames 1..N use `extra_frames`.
54    /// Returns `None` if the frame index is out of range.
55    #[must_use]
56    pub fn decode_frame_rgba(&self, frame: usize, palette: Option<&Palette>) -> Option<Vec<u8>> {
57        let pixels = if frame == 0 {
58            &self.pixel_data
59        } else {
60            self.extra_frames.get(frame - 1)?
61        };
62        Some(self.decode_pixels_rgba(pixels, palette))
63    }
64
65    pub(crate) fn palette_rgb(&self, index: u8, palette: Option<&Palette>) -> [u8; 3] {
66        if let Some(palette) = palette {
67            palette.colors[usize::from(index)]
68        } else if let Some(embedded) = &self.palette {
69            embedded[usize::from(index)]
70        } else {
71            [index; 3]
72        }
73    }
74
75    fn decode_pixels_rgba(&self, pixels: &[u8], palette: Option<&Palette>) -> Vec<u8> {
76        let width = usize::from(self.width);
77        let height = usize::from(self.height);
78        let mut rgba = vec![0_u8; width * height * 4];
79
80        for i in 0..width * height {
81            let idx = pixels.get(i).copied().unwrap_or(0);
82            let offset = i * 4;
83            let [r, g, b] = self.palette_rgb(idx, palette);
84            rgba[offset..offset + 4].copy_from_slice(&[r, g, b, if idx == 0 { 0 } else { 255 }]);
85        }
86
87        rgba
88    }
89}
90
91#[derive(Default)]
92struct RecordChunks<'a> {
93    is_animated: bool,
94    bhdr_data: Option<&'a [u8]>,
95    cmap_data: Option<&'a [u8]>,
96    pixel_data: Vec<u8>,
97}
98
99type BhdrFields = (i16, i16, u16, u16, u16, i16, u16, i16);
100
101fn read_array<const N: usize>(data: &[u8], offset: usize) -> Result<[u8; N]> {
102    data.get(offset..offset + N)
103        .and_then(|slice| slice.try_into().ok())
104        .ok_or_else(|| Error::Parse(format!("BSI: truncated data at offset 0x{offset:X}")))
105}
106
107fn read_be_u32(data: &[u8], offset: usize) -> Result<u32> {
108    Ok(u32::from_be_bytes(read_array(data, offset)?))
109}
110
111fn read_le_u32(data: &[u8], offset: usize) -> Result<u32> {
112    Ok(u32::from_le_bytes(read_array(data, offset)?))
113}
114
115fn read_le_i16(data: &[u8], offset: usize) -> Result<i16> {
116    Ok(i16::from_le_bytes(read_array(data, offset)?))
117}
118
119fn read_le_u16(data: &[u8], offset: usize) -> Result<u16> {
120    Ok(u16::from_le_bytes(read_array(data, offset)?))
121}
122
123fn parse_record_header(data: &[u8], pos: usize) -> Result<Option<(String, usize, usize)>> {
124    if pos + 13 > data.len() {
125        return Ok(None);
126    }
127
128    let name_bytes = &data[pos..pos + 9];
129    if name_bytes.iter().all(|&b| b == 0) {
130        return Ok(None);
131    }
132
133    let name = String::from_utf8_lossy(name_bytes)
134        .trim_matches('\0')
135        .to_string();
136    let record_size = usize::try_from(read_le_u32(data, pos + 9)?)
137        .map_err(|e| Error::Parse(format!("BSI record '{name}' size does not fit usize: {e}")))?;
138    let record_start = pos + 13;
139    let record_end = record_start
140        .checked_add(record_size)
141        .ok_or_else(|| Error::Parse(format!("BSI record '{name}' end offset overflow")))?;
142
143    if record_end > data.len() {
144        return Err(Error::Parse(format!(
145            "BSI record '{name}' extends past end of data (need {record_end}, have {})",
146            data.len()
147        )));
148    }
149
150    Ok(Some((name, record_start, record_end)))
151}
152
153fn parse_record_chunks(
154    data: &[u8],
155    record_start: usize,
156    record_end: usize,
157) -> Result<RecordChunks<'_>> {
158    let mut chunks = RecordChunks::default();
159    let mut sub_pos = record_start;
160
161    while sub_pos + 4 <= record_end {
162        let tag = &data[sub_pos..sub_pos + 4];
163        if tag == b"END " {
164            break;
165        }
166        if sub_pos + 8 > data.len() {
167            break;
168        }
169
170        let payload_size = usize::try_from(read_be_u32(data, sub_pos + 4)?)
171            .map_err(|e| Error::Parse(format!("BSI subchunk size does not fit usize: {e}")))?;
172        let payload_start = sub_pos + 8;
173        let payload_end = payload_start
174            .checked_add(payload_size)
175            .ok_or_else(|| Error::Parse("BSI subchunk payload end overflow".to_string()))?;
176
177        if payload_end <= data.len() {
178            match tag {
179                b"IFHD" => chunks.is_animated = true,
180                b"BHDR" => chunks.bhdr_data = Some(&data[payload_start..payload_end]),
181                b"CMAP" => chunks.cmap_data = Some(&data[payload_start..payload_end]),
182                b"DATA" => chunks.pixel_data = data[payload_start..payload_end].to_vec(),
183                _ => {}
184            }
185        }
186
187        sub_pos = payload_end;
188    }
189
190    Ok(chunks)
191}
192
193fn non_negative_i16_to_u16(value: i16) -> u16 {
194    u16::try_from(value.max(0)).unwrap_or(0)
195}
196
197fn parse_bhdr_fields(bhdr_data: Option<&[u8]>) -> Result<BhdrFields> {
198    if let Some(bhdr) = bhdr_data
199        && bhdr.len() >= 16
200    {
201        let extended = if bhdr.len() >= 26 {
202            (
203                read_le_i16(bhdr, 16)?,
204                read_le_u16(bhdr, 22)?,
205                read_le_i16(bhdr, 24)?,
206            )
207        } else {
208            (0, 0, 0)
209        };
210        return Ok((
211            read_le_i16(bhdr, 0)?,
212            read_le_i16(bhdr, 2)?,
213            non_negative_i16_to_u16(read_le_i16(bhdr, 4)?),
214            non_negative_i16_to_u16(read_le_i16(bhdr, 6)?),
215            non_negative_i16_to_u16(read_le_i16(bhdr, 14)?.max(1)),
216            extended.0,
217            extended.1,
218            extended.2,
219        ));
220    }
221
222    Ok((0, 0, 0, 0, 1, 0, 0, 0))
223}
224
225fn parse_embedded_palette(cmap_data: Option<&[u8]>) -> Option<Vec<[u8; 3]>> {
226    cmap_data.and_then(|data| {
227        if data.len() < 768 {
228            return None;
229        }
230
231        let mut palette = vec![[0_u8; 3]; 256];
232        for (i, entry) in palette.iter_mut().enumerate() {
233            entry[0] = data[i * 3];
234            entry[1] = data[i * 3 + 1];
235            entry[2] = data[i * 3 + 2];
236        }
237        Some(palette)
238    })
239}
240
241fn decode_animated_frames(
242    width: u16,
243    height: u16,
244    frame_count: u16,
245    pixel_data: Vec<u8>,
246) -> Result<(Vec<u8>, Vec<Vec<u8>>)> {
247    if frame_count <= 1 {
248        return Ok((pixel_data, Vec::new()));
249    }
250
251    let width_usize = usize::from(width);
252    let height_usize = usize::from(height);
253    let frame_count_usize = usize::from(frame_count);
254    let table_entries = height_usize.saturating_mul(frame_count_usize);
255    let table_bytes = table_entries.saturating_mul(4);
256
257    if width_usize == 0 || height_usize == 0 || pixel_data.len() < table_bytes {
258        return Ok((Vec::new(), Vec::new()));
259    }
260
261    let mut offsets = Vec::with_capacity(table_entries);
262    for i in 0..table_entries {
263        let start = i * 4;
264        let end = start + 4;
265        let bytes: [u8; 4] = pixel_data
266            .get(start..end)
267            .and_then(|slice| slice.try_into().ok())
268            .ok_or_else(|| {
269                Error::Parse(format!("BSI: truncated frame offset table at index {i}"))
270            })?;
271        let offset_i32 = i32::from_le_bytes(bytes).max(0);
272        let offset = usize::try_from(offset_i32)
273            .map_err(|e| Error::Parse(format!("BSI frame row offset conversion failed: {e}")))?;
274        offsets.push(offset);
275    }
276
277    let decode_frame = |frame_idx: usize| -> Vec<u8> {
278        let mut buffer = vec![0_u8; width_usize * height_usize];
279        for y in 0..height_usize {
280            let offset_index = frame_idx.saturating_mul(height_usize).saturating_add(y);
281            if offset_index >= offsets.len() {
282                break;
283            }
284            let row_offset = offsets[offset_index];
285            let row_end = row_offset.saturating_add(width_usize);
286            if row_end <= pixel_data.len() {
287                let dst = y.saturating_mul(width_usize);
288                buffer[dst..dst + width_usize].copy_from_slice(&pixel_data[row_offset..row_end]);
289            }
290        }
291        buffer
292    };
293
294    let frame0 = decode_frame(0);
295    let extra_frames: Vec<Vec<u8>> = (1..frame_count_usize).map(decode_frame).collect();
296    Ok((frame0, extra_frames))
297}
298
299fn parse_image_record(data: &[u8], pos: usize) -> Result<Option<(BsiImage, usize)>> {
300    let Some((name, record_start, record_end)) = parse_record_header(data, pos)? else {
301        return Ok(None);
302    };
303
304    let image_index = if name.len() >= 6 {
305        name[3..].parse().unwrap_or(0)
306    } else {
307        0
308    };
309
310    let image = parse_image_chunks(data, record_start, record_end, name, image_index)?;
311    Ok(Some((image, record_end)))
312}
313
314fn parse_image_chunks(
315    data: &[u8],
316    record_start: usize,
317    record_end: usize,
318    name: String,
319    image_index: u16,
320) -> Result<BsiImage> {
321    let chunks = parse_record_chunks(data, record_start, record_end)?;
322    if chunks.bhdr_data.is_none() {
323        return Err(Error::Parse("BSI: missing BHDR image header".to_string()));
324    }
325    let (x_offset, y_offset, width, height, frame_count, anim_delay, tex_scale, data_encoding) =
326        parse_bhdr_fields(chunks.bhdr_data)?;
327    let palette = parse_embedded_palette(chunks.cmap_data);
328    let (pixel_data, extra_frames) =
329        decode_animated_frames(width, height, frame_count, chunks.pixel_data)?;
330
331    Ok(BsiImage {
332        name,
333        image_index,
334        width,
335        height,
336        x_offset,
337        y_offset,
338        frame_count,
339        anim_delay,
340        tex_scale,
341        data_encoding,
342        is_animated: chunks.is_animated,
343        palette,
344        pixel_data,
345        extra_frames,
346    })
347}
348
349/// Parses a BSI/TEXBSI byte slice into image records.
350#[allow(clippy::missing_errors_doc)]
351pub fn parse_bsi_file(data: &[u8]) -> Result<BsiFile> {
352    // Standalone BSI files contain chunks directly, without a TEXBSI name envelope.
353    if data.starts_with(b"IFHD") || data.starts_with(b"BSIF") {
354        let image = parse_image_chunks(data, 0, data.len(), String::new(), 0)?;
355        if image.width == 0
356            || image.height == 0
357            || image.pixel_data.len() != usize::from(image.width) * usize::from(image.height)
358        {
359            return Err(Error::Parse("BSI: incomplete standalone image".to_string()));
360        }
361        return Ok(BsiFile {
362            images: vec![image],
363        });
364    }
365    let mut images = Vec::new();
366    let mut pos = 0_usize;
367
368    while let Some((image, record_end)) = parse_image_record(data, pos)? {
369        images.push(image);
370        pos = record_end;
371        if pos + 8 <= data.len() && &data[pos..pos + 4] == b"END " {
372            pos += 8;
373        }
374    }
375
376    Ok(BsiFile { images })
377}
378
379/// Exports decoded image metadata, palettes and frame sizes without bulk pixel arrays.
380/// Pixel data remains available through `BsiImage` frame decoding methods.
381#[must_use]
382pub fn export_bsi_metadata(file: &BsiFile) -> serde_json::Value {
383    serde_json::json!({"images": file.images.iter().map(export_bsi_image_metadata).collect::<Vec<_>>()})
384}
385
386/// Exports one decoded image's metadata and frame sizes, omitting pixel buffers.
387#[must_use]
388pub fn export_bsi_image_metadata(image: &BsiImage) -> serde_json::Value {
389    serde_json::json!({
390        "name": image.name,
391        "image_index": image.image_index,
392        "width": image.width,
393        "height": image.height,
394        "x_offset": image.x_offset,
395        "y_offset": image.y_offset,
396        "frame_count": image.frame_count,
397        "anim_delay": image.anim_delay,
398        "tex_scale": image.tex_scale,
399        "data_encoding": image.data_encoding,
400        "is_animated": image.is_animated,
401        "palette": image.palette,
402        "frame_byte_lengths": std::iter::once(image.pixel_data.len()).chain(image.extra_frames.iter().map(Vec::len)).collect::<Vec<_>>(),
403    })
404}
405
406#[cfg(test)]
407mod tests {
408    use super::*;
409
410    fn chunk(data: &mut Vec<u8>, tag: &[u8; 4], payload: &[u8]) {
411        data.extend(tag);
412        data.extend(u32::try_from(payload.len()).unwrap().to_be_bytes());
413        data.extend(payload);
414    }
415
416    #[test]
417    fn standalone_chunks_and_archive_records_decode_the_same_pixels() {
418        let mut chunks = Vec::new();
419        chunk(&mut chunks, b"IFHD", &[0; 44]);
420        let mut header = [0; 26];
421        header[4..6].copy_from_slice(&2_i16.to_le_bytes());
422        header[6..8].copy_from_slice(&1_i16.to_le_bytes());
423        header[14..16].copy_from_slice(&1_i16.to_le_bytes());
424        chunk(&mut chunks, b"BHDR", &header);
425        chunk(&mut chunks, b"DATA", &[1, 2]);
426        chunks.extend(b"END ");
427        let standalone = parse_bsi_file(&chunks).unwrap();
428        let mut archive = b"D02000\0\0\0".to_vec();
429        archive.extend(u32::try_from(chunks.len()).unwrap().to_le_bytes());
430        archive.extend(&chunks);
431        let archived = parse_bsi_file(&archive).unwrap();
432        for file in [standalone, archived] {
433            let metadata = export_bsi_metadata(&file);
434            assert_eq!(
435                metadata["images"][0]["frame_byte_lengths"],
436                serde_json::json!([2])
437            );
438            assert_eq!(metadata["images"][0]["width"], 2);
439            assert_eq!(file.images.len(), 1);
440            let image = &file.images[0];
441            assert_eq!((image.width, image.height), (2, 1));
442            assert_eq!(image.decode_rgba(None), [1, 1, 1, 255, 2, 2, 2, 255]);
443        }
444        assert!(parse_bsi_file(&chunks[..chunks.len() - 6]).is_err());
445        assert!(parse_bsi_file(b"IFHD\0\0\0\0END ").is_err());
446    }
447}