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)]
10pub 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 pub anim_delay: i16,
23 pub tex_scale: u16,
26 pub data_encoding: i16,
30 pub is_animated: bool,
31 pub palette: Option<Vec<[u8; 3]>>,
32 pub pixel_data: Vec<u8>,
33 pub extra_frames: Vec<Vec<u8>>,
36}
37
38#[derive(Debug, Clone)]
39pub struct BsiFile {
41 pub images: Vec<BsiImage>,
42}
43
44impl BsiImage {
45 #[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 #[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#[allow(clippy::missing_errors_doc)]
351pub fn parse_bsi_file(data: &[u8]) -> Result<BsiFile> {
352 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#[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#[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}