Cross-platform (Windows/macOS/Linux) engine that plays the original Amiga data straight from the ADF images: - ADF (OFS/FFS), LHA and CrunchMania (CrM!/CrM2) readers - Track decoding: 24 tracks of 1024x512 16-colour planar art, height maps and lap gates on an isometric ground plane - Vehicle decoding: 8 cars, 32 headings x 25 tilt frames of 64x32 AGA sprites - Reconstructed handling, AI, race rules, menus and synthesised sound (disk 1 with the executable, palettes and audio is not in the set) - info/extract/bench/trace/render tools, format notes, CI builds
170 lines
5.4 KiB
Rust
170 lines
5.4 KiB
Rust
//! LHA/LZH archive reader (header levels 0, 1 and 2).
|
|
//!
|
|
//! The game disks only use the `-lh0-` (stored) method because every file is
|
|
//! already CrunchMania-packed, so compressed methods are reported rather than
|
|
//! decoded.
|
|
|
|
use super::{latin1, le16, le32};
|
|
use std::fmt;
|
|
|
|
#[derive(Debug)]
|
|
pub enum LhaError {
|
|
Truncated(usize),
|
|
BadHeader(usize),
|
|
}
|
|
|
|
impl fmt::Display for LhaError {
|
|
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
|
match self {
|
|
LhaError::Truncated(at) => write!(f, "archive truncated at byte {at}"),
|
|
LhaError::BadHeader(at) => write!(f, "unrecognised LHA header at byte {at}"),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for LhaError {}
|
|
|
|
pub struct LhaEntry {
|
|
/// Path inside the archive, `/`-separated.
|
|
pub path: String,
|
|
pub method: String,
|
|
/// File contents for stored entries, `None` for methods we do not decode.
|
|
pub data: Option<Vec<u8>>,
|
|
}
|
|
|
|
pub fn parse(d: &[u8]) -> Result<Vec<LhaEntry>, LhaError> {
|
|
let mut out = Vec::new();
|
|
let mut p = 0usize;
|
|
while p < d.len() && d[p] != 0 {
|
|
if p + 22 > d.len() {
|
|
return Err(LhaError::Truncated(p));
|
|
}
|
|
let method = &d[p + 2..p + 7];
|
|
if method[0] != b'-' || method[4] != b'-' {
|
|
return Err(LhaError::BadHeader(p));
|
|
}
|
|
let method = latin1(method);
|
|
let mut packed = le32(d, p + 7) as usize;
|
|
let level = d[p + 20];
|
|
|
|
let mut name = String::new();
|
|
let mut dir = String::new();
|
|
let data_start;
|
|
|
|
match level {
|
|
0 | 1 => {
|
|
let header_len = d[p] as usize + 2;
|
|
let name_len = d[p + 21] as usize;
|
|
if p + 22 + name_len > d.len() || p + header_len > d.len() {
|
|
return Err(LhaError::Truncated(p));
|
|
}
|
|
name = latin1(&d[p + 22..p + 22 + name_len]);
|
|
let mut q = p + header_len;
|
|
if level == 1 {
|
|
// Extended headers follow the base header and are counted
|
|
// in the packed size.
|
|
let mut next = le16(d, q - 2) as usize;
|
|
while next != 0 {
|
|
if q + next > d.len() || next < 3 {
|
|
return Err(LhaError::Truncated(q));
|
|
}
|
|
read_ext(&d[q..q + next - 2], &mut name, &mut dir);
|
|
packed = packed.checked_sub(next).ok_or(LhaError::BadHeader(q))?;
|
|
q += next;
|
|
next = le16(d, q - 2) as usize;
|
|
}
|
|
}
|
|
data_start = q;
|
|
}
|
|
2 => {
|
|
let header_len = le16(d, p) as usize;
|
|
if p + header_len > d.len() || header_len < 26 {
|
|
return Err(LhaError::Truncated(p));
|
|
}
|
|
let mut q = p + 26;
|
|
let mut next = le16(d, p + 24) as usize;
|
|
while next != 0 {
|
|
if q + next > p + header_len || next < 3 {
|
|
return Err(LhaError::BadHeader(q));
|
|
}
|
|
read_ext(&d[q..q + next - 2], &mut name, &mut dir);
|
|
q += next;
|
|
next = le16(d, q - 2) as usize;
|
|
}
|
|
data_start = p + header_len;
|
|
}
|
|
_ => return Err(LhaError::BadHeader(p)),
|
|
}
|
|
|
|
let data_end = data_start + packed;
|
|
if data_end > d.len() {
|
|
return Err(LhaError::Truncated(data_start));
|
|
}
|
|
let path = join_path(&dir, &name);
|
|
let data = match method.as_str() {
|
|
"-lh0-" | "-lz4-" => Some(d[data_start..data_end].to_vec()),
|
|
_ => None,
|
|
};
|
|
if method != "-lhd-" {
|
|
out.push(LhaEntry { path, method, data });
|
|
}
|
|
p = data_end;
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
fn read_ext(ext: &[u8], name: &mut String, dir: &mut String) {
|
|
match ext.first() {
|
|
Some(1) => *name = latin1(&ext[1..]),
|
|
Some(2) => *dir = latin1(&ext[1..]),
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn join_path(dir: &str, name: &str) -> String {
|
|
let norm = |s: &str| -> String {
|
|
s.chars()
|
|
.map(|c| if c == '\\' || c == '\u{ff}' { '/' } else { c })
|
|
.collect::<String>()
|
|
.trim_matches('/')
|
|
.to_string()
|
|
};
|
|
let (dir, name) = (norm(dir), norm(name));
|
|
if dir.is_empty() {
|
|
name
|
|
} else {
|
|
format!("{dir}/{name}")
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn level0_entry(name: &str, body: &[u8]) -> Vec<u8> {
|
|
let mut h = vec![0u8; 22];
|
|
h[2..7].copy_from_slice(b"-lh0-");
|
|
h[7..11].copy_from_slice(&(body.len() as u32).to_le_bytes());
|
|
h[11..15].copy_from_slice(&(body.len() as u32).to_le_bytes());
|
|
h[20] = 0;
|
|
h[21] = name.len() as u8;
|
|
h.extend_from_slice(name.as_bytes());
|
|
h.extend_from_slice(&[0, 0]); // CRC, unchecked
|
|
h[0] = (h.len() - 2) as u8;
|
|
h.extend_from_slice(body);
|
|
h
|
|
}
|
|
|
|
#[test]
|
|
fn parses_stored_level0() {
|
|
let mut arc = level0_entry("dir\\a.bin", b"hello");
|
|
arc.extend(level0_entry("b", b"world!"));
|
|
arc.push(0);
|
|
let e = parse(&arc).unwrap();
|
|
assert_eq!(e.len(), 2);
|
|
assert_eq!(e[0].path, "dir/a.bin");
|
|
assert_eq!(e[0].data.as_deref(), Some(&b"hello"[..]));
|
|
assert_eq!(e[1].data.as_deref(), Some(&b"world!"[..]));
|
|
}
|
|
}
|