Add Rust revival engine for Super Skidmarks

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
This commit is contained in:
2026-10-19 18:31:17 +00:00
parent f8ba2ecf4d
commit f88ba23282
22 changed files with 5656 additions and 0 deletions
+169
View File
@@ -0,0 +1,169 @@
//! 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!"[..]));
}
}