- 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
382 lines
12 KiB
Rust
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]);
|
|
}
|
|
}
|