Files
skids/src/formats/iff.rs
T
andy c36572f8dc Extract the track thumbnails and the ball sprite bank
- Track files: decode the selector thumbnails (a 640x64 16-colour
  picture, four sequential bitplanes, six 123x32 tiles in a 3x2 grid) into
  tracks/<name>/thumb.png, and show them in the menu at double height
  (they are Amiga hi-res)
- gfx/ball.spr: decode the 64-frame attached-sprite bank (spinning ball
  with the Acid logo) into gfx/ball.png; its colours are not in the file
- Command-line output no longer panics when piped into e.g. head
2026-10-19 20:43:34 +00:00

382 lines
12 KiB
Rust

//! IFF readers: 8SVX sounds and ILBM pictures.
use super::{be16, be32};
/// An 8-bit signed sample, as stored in an 8SVX `BODY`.
#[derive(Clone, Debug, PartialEq)]
pub struct Sound {
pub rate: u32,
pub data: Vec<i8>,
/// Loop start in samples, if the sound repeats.
pub loop_start: Option<usize>,
pub volume: f32,
}
#[derive(Clone, Debug)]
pub struct Picture {
pub w: usize,
pub h: usize,
/// Palette indices, w * h.
pub pixels: Vec<u8>,
/// 8-bit RGB triples.
pub palette: Vec<(u8, u8, u8)>,
}
/// (chunk id, chunk body) pairs.
type Chunks<'a> = Vec<(&'a [u8], &'a [u8])>;
fn chunks(d: &[u8]) -> Option<(&[u8], Chunks<'_>)> {
if d.len() < 12 || &d[0..4] != b"FORM" {
return None;
}
let end = (be32(d, 4) as usize + 8).min(d.len());
let form = &d[8..12];
let mut out = Vec::new();
let mut p = 12;
while p + 8 <= end {
let id = &d[p..p + 4];
let n = be32(d, p + 4) as usize;
let body = d.get(p + 8..p + 8 + n)?;
out.push((id, body));
p += 8 + n + (n & 1);
}
Some((form, out))
}
pub fn is_8svx(d: &[u8]) -> bool {
d.len() >= 12 && &d[0..4] == b"FORM" && &d[8..12] == b"8SVX"
}
pub fn read_8svx(d: &[u8]) -> Result<Sound, String> {
let (form, chunks) = chunks(d).ok_or("not an IFF file")?;
if form != b"8SVX" {
return Err("not an 8SVX sound".into());
}
let vhdr = chunks
.iter()
.find(|c| c.0 == b"VHDR")
.ok_or("no VHDR chunk")?
.1;
let body = chunks
.iter()
.find(|c| c.0 == b"BODY")
.ok_or("no BODY chunk")?
.1;
if vhdr.len() < 20 {
return Err("short VHDR".into());
}
let one_shot = be32(vhdr, 0) as usize;
let repeat = be32(vhdr, 4) as usize;
let rate = be16(vhdr, 12) as u32;
if vhdr[15] != 0 {
return Err("compressed 8SVX is not supported".into());
}
let volume = (be32(vhdr, 16) as f32 / 65536.0).clamp(0.0, 1.0);
// Only the first octave is used; multi-octave sounds store more after it.
let len = if one_shot + repeat > 0 {
(one_shot + repeat).min(body.len())
} else {
body.len()
};
let data = body[..len].iter().map(|&b| b as i8).collect();
let loop_start = (repeat > 2).then_some(one_shot);
Ok(Sound {
rate: rate.max(1000),
data,
loop_start,
volume,
})
}
pub fn read_ilbm(d: &[u8]) -> Result<Picture, String> {
let (form, chunks) = chunks(d).ok_or("not an IFF file")?;
if form != b"ILBM" {
return Err("not an ILBM picture".into());
}
let bmhd = chunks
.iter()
.find(|c| c.0 == b"BMHD")
.ok_or("no BMHD chunk")?
.1;
let body = chunks
.iter()
.find(|c| c.0 == b"BODY")
.ok_or("no BODY chunk")?
.1;
let w = be16(bmhd, 0) as usize;
let h = be16(bmhd, 2) as usize;
let planes = bmhd[8] as usize;
let masking = bmhd[9];
let compression = bmhd[10];
if planes == 0 || planes > 8 || w == 0 || h == 0 {
return Err("unsupported bitmap layout".into());
}
let palette = chunks
.iter()
.find(|c| c.0 == b"CMAP")
.map(|c| c.1.chunks_exact(3).map(|p| (p[0], p[1], p[2])).collect())
.unwrap_or_else(|| {
(0..1u16 << planes)
.map(|i| {
let v = (i * 255 / ((1 << planes) - 1)) as u8;
(v, v, v)
})
.collect()
});
let row_bytes = w.div_ceil(16) * 2;
let rows_per_line = planes + (masking == 1) as usize;
let mut raw = Vec::with_capacity(row_bytes * rows_per_line * h);
match compression {
0 => raw.extend_from_slice(body),
1 => {
// ByteRun1
let mut i = 0;
while i < body.len() && raw.len() < row_bytes * rows_per_line * h {
let n = body[i] as i8;
i += 1;
if n >= 0 {
let k = n as usize + 1;
raw.extend_from_slice(body.get(i..i + k).ok_or("ByteRun1 data ended early")?);
i += k;
} else if n != -128 {
let b = *body.get(i).ok_or("ByteRun1 data ended early")?;
raw.extend(std::iter::repeat_n(b, (1 - n as isize) as usize));
i += 1;
}
}
}
_ => return Err(format!("unknown ILBM compression {compression}")),
}
if raw.len() < row_bytes * rows_per_line * h {
return Err("BODY is shorter than the picture".into());
}
let mut pixels = vec![0u8; w * h];
for y in 0..h {
for p in 0..planes {
let row = &raw[(y * rows_per_line + p) * row_bytes..][..row_bytes];
for x in 0..w {
if row[x >> 3] & (0x80 >> (x & 7)) != 0 {
pixels[y * w + x] |= 1 << p;
}
}
}
}
Ok(Picture {
w,
h,
pixels,
palette,
})
}
/// Reads a bank of hardware sprites saved by the game (`gfx/ball.spr`) as
/// one picture sheet, 16 frames across.
///
/// Each frame is a 16-byte header (BE32 saved pointer, BE16 lines, BE16
/// strip count, BE16 flags, BE16 bytes per strip, 4 unused) followed by the
/// strips: 16-pixel sprites with two bitplane words per line. With flag bit
/// 15 set, strips pair up as attached sprites (16 colours, 16 pixels per
/// pair); otherwise each strip is a 4-colour, 16-pixel column. The bank
/// carries no colours, so the sheet gets a grey ramp.
pub fn read_sprite_bank(d: &[u8]) -> Result<Picture, String> {
if d.len() < 16 {
return Err("too short for a sprite bank".into());
}
let lines = be16(d, 4) as usize;
let strips = be16(d, 6) as usize;
let attached = be16(d, 8) & 0x8000 != 0 && strips.is_multiple_of(2);
let per_strip = be16(d, 10) as usize;
if lines == 0 || strips == 0 || per_strip != lines * 4 {
return Err("not a sprite bank".into());
}
let record = 16 + strips * per_strip;
let frames = d.len() / record;
let fw = if attached {
strips / 2 * 16
} else {
strips * 16
};
let (cols, rows) = (frames.min(16), frames.div_ceil(16));
let (w, h) = (cols * fw, rows * lines);
let mut pixels = vec![0u8; w * h];
for f in 0..frames {
let r = &d[f * record..(f + 1) * record];
if be16(r, 4) as usize != lines || be16(r, 6) as usize != strips {
return Err(format!("frame {f} has a different size"));
}
let strip_px = |s: usize, x: usize, y: usize| -> u8 {
let o = 16 + s * per_strip + y * 4;
let bit = 0x8000 >> x;
((be16(r, o) & bit != 0) as u8) | (((be16(r, o + 2) & bit != 0) as u8) << 1)
};
let (ox, oy) = ((f % 16) * fw, (f / 16) * lines);
for y in 0..lines {
for x in 0..fw {
let v = if attached {
let pair = x / 16;
strip_px(pair * 2, x % 16, y) | (strip_px(pair * 2 + 1, x % 16, y) << 2)
} else {
strip_px(x / 16, x % 16, y)
};
pixels[(oy + y) * w + ox + x] = v;
}
}
}
let n = if attached { 16 } else { 4 };
let palette = (0..n)
.map(|i| {
let v = (i * 255 / (n - 1)) as u8;
(v, v, v)
})
.collect();
Ok(Picture {
w,
h,
pixels,
palette,
})
}
/// Writes 8-bit unsigned mono WAV, with the loop start in a `smpl` chunk so
/// sample editors keep it.
pub fn sound_to_wav(s: &Sound) -> Vec<u8> {
let data: Vec<u8> = s.data.iter().map(|&v| (v as i16 + 128) as u8).collect();
let mut chunks: Vec<(&[u8; 4], Vec<u8>)> = Vec::new();
let mut fmt = Vec::new();
fmt.extend_from_slice(&1u16.to_le_bytes());
fmt.extend_from_slice(&1u16.to_le_bytes());
fmt.extend_from_slice(&s.rate.to_le_bytes());
fmt.extend_from_slice(&s.rate.to_le_bytes());
fmt.extend_from_slice(&1u16.to_le_bytes());
fmt.extend_from_slice(&8u16.to_le_bytes());
chunks.push((b"fmt ", fmt));
chunks.push((b"data", data));
if let Some(start) = s.loop_start {
let mut smpl = vec![0u8; 36];
smpl[28..32].copy_from_slice(&1u32.to_le_bytes());
let mut lp = vec![0u8; 24];
lp[8..12].copy_from_slice(&(start as u32).to_le_bytes());
lp[12..16].copy_from_slice(&(s.data.len() as u32 - 1).to_le_bytes());
smpl.extend(lp);
chunks.push((b"smpl", smpl));
}
let mut out = b"RIFF\0\0\0\0WAVE".to_vec();
for (id, body) in chunks {
out.extend_from_slice(id);
out.extend_from_slice(&(body.len() as u32).to_le_bytes());
out.extend_from_slice(&body);
if body.len() & 1 == 1 {
out.push(0);
}
}
let n = (out.len() - 8) as u32;
out[4..8].copy_from_slice(&n.to_le_bytes());
out
}
/// Reads mono or stereo 8/16-bit PCM WAV (stereo is mixed down).
pub fn wav_to_sound(d: &[u8]) -> Result<Sound, String> {
if d.len() < 12 || &d[0..4] != b"RIFF" || &d[8..12] != b"WAVE" {
return Err("not a WAV file".into());
}
let le16 = |o: usize| u16::from_le_bytes([d[o], d[o + 1]]);
let le32 = |o: usize| u32::from_le_bytes([d[o], d[o + 1], d[o + 2], d[o + 3]]);
let (mut fmt, mut data, mut loop_start) = (None, None, None);
let mut p = 12;
while p + 8 <= d.len() {
let n = le32(p + 4) as usize;
let body = p + 8..(p + 8 + n).min(d.len());
match &d[p..p + 4] {
b"fmt " if n >= 16 => {
fmt = Some((le16(p + 8), le16(p + 10), le32(p + 12), le16(p + 22)))
}
b"data" => data = Some(body),
b"smpl" if n >= 60 && le32(p + 8 + 28) > 0 => {
loop_start = Some(le32(p + 8 + 36 + 8) as usize)
}
_ => {}
}
p += 8 + n + (n & 1);
}
let (format, channels, rate, bits) = fmt.ok_or("no fmt chunk")?;
let body = &d[data.ok_or("no data chunk")?];
if format != 1 || channels == 0 || !(bits == 8 || bits == 16) {
return Err("only 8- or 16-bit PCM WAV is supported".into());
}
let step = channels as usize * (bits as usize / 8);
let sample = |frame: &[u8]| -> i32 {
let mut acc = 0i32;
for c in 0..channels as usize {
acc += if bits == 8 {
(frame[c] as i32 - 128) << 8
} else {
i16::from_le_bytes([frame[2 * c], frame[2 * c + 1]]) as i32
};
}
acc / channels as i32
};
let data: Vec<i8> = body
.chunks_exact(step)
.map(|f| (sample(f) >> 8) as i8)
.collect();
let loop_start = loop_start.filter(|&s| s + 2 < data.len());
Ok(Sound {
rate: rate.max(1000),
data,
loop_start,
volume: 1.0,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wav_round_trip() {
let s = Sound {
rate: 8363,
data: (-50..50).map(|v| v as i8).collect(),
loop_start: Some(20),
volume: 1.0,
};
let back = wav_to_sound(&sound_to_wav(&s)).unwrap();
assert_eq!(back, s);
}
#[test]
fn attached_sprite_bank() {
// One frame: 1 line, 2 attached strips; pixel 0 uses all four planes.
let mut d = vec![0u8; 16 + 2 * 4];
d[5] = 1;
d[7] = 2;
d[8] = 0x80;
d[11] = 4;
for o in [16, 18, 20, 22] {
d[o] = 0x80;
}
let p = read_sprite_bank(&d).unwrap();
assert_eq!((p.w, p.h), (16, 1));
assert_eq!(p.pixels[0], 15);
assert_eq!(p.pixels[1], 0);
}
#[test]
fn byterun1_picture() {
// 16x1, 1 plane: run of 2 x 0xff.
let mut d = b"FORM\0\0\0\0ILBMBMHD\0\0\0\x14".to_vec();
let mut bmhd = vec![0u8; 20];
bmhd[1] = 16;
bmhd[3] = 1;
bmhd[8] = 1;
bmhd[10] = 1;
d.extend(bmhd);
d.extend(b"BODY\0\0\0\x02\xff\xff");
let n = (d.len() - 8) as u32;
d[4..8].copy_from_slice(&n.to_be_bytes());
let p = read_ilbm(&d).unwrap();
assert_eq!(p.pixels, vec![1; 16]);
}
}