Skip to main content

xdms/
header.rs

1//! DMS archive- and track-header parsing and the typed fields they decode to.
2//!
3//! All multi-byte fields are big-endian (DMS is a 68k/Amiga format); decoding is
4//! therefore host-endian-independent. Parsing is exposed as `TryFrom<&[u8]>`,
5//! which is also where length, magic, and CRC validation live.
6
7use core::fmt;
8
9use crate::crc::crc16;
10use crate::error::Error;
11
12/// Length of the DMS archive header, in bytes.
13pub const HEADER_LEN: usize = 56;
14/// Length of a DMS track header, in bytes.
15pub const TRACK_HEADER_LEN: usize = 20;
16
17const MAGIC_ARCHIVE: &[u8] = b"DMS!";
18const MAGIC_TRACK: &[u8] = b"TR";
19
20/// Compression mode of a single track (the C `cmode`).
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum Mode {
23    /// Stored without compression.
24    None,
25    /// RLE only ("SIMPLE").
26    Simple,
27    /// QUICK: small-window LZ.
28    Quick,
29    /// MEDIUM: LZ with static Huffman distances.
30    Medium,
31    /// DEEP: LZ with an adaptive Huffman tree.
32    Deep,
33    /// HEAVY1: LZH with a 4 KB dictionary.
34    Heavy1,
35    /// HEAVY2: LZH with an 8 KB dictionary.
36    Heavy2,
37}
38
39impl TryFrom<u8> for Mode {
40    type Error = Error;
41
42    fn try_from(value: u8) -> Result<Self, Error> {
43        Ok(match value {
44            0 => Self::None,
45            1 => Self::Simple,
46            2 => Self::Quick,
47            3 => Self::Medium,
48            4 => Self::Deep,
49            5 => Self::Heavy1,
50            6 => Self::Heavy2,
51            other => return Err(Error::UnknownMode(other)),
52        })
53    }
54}
55
56/// Filesystem/format the archived disk holds (the C `disktype`).
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum DiskType {
59    /// AmigaOS 1.x OFS (or a non-DOS disk).
60    Ofs,
61    /// AmigaOS 2.0 FFS.
62    Ffs,
63    /// AmigaOS 3.0 OFS, international mode.
64    OfsIntl,
65    /// AmigaOS 3.0 FFS, international mode.
66    FfsIntl,
67    /// AmigaOS 3.0 OFS with directory cache.
68    OfsDirCache,
69    /// AmigaOS 3.0 FFS with directory cache.
70    FfsDirCache,
71    /// FMS Amiga system file (not a DMS disk image).
72    Fms,
73    /// A value not used by any known DMS version.
74    Unknown(u16),
75}
76
77impl From<u16> for DiskType {
78    fn from(value: u16) -> Self {
79        match value {
80            0 | 1 => Self::Ofs,
81            2 => Self::Ffs,
82            3 => Self::OfsIntl,
83            4 => Self::FfsIntl,
84            5 => Self::OfsDirCache,
85            6 => Self::FfsDirCache,
86            7 => Self::Fms,
87            other => Self::Unknown(other),
88        }
89    }
90}
91
92impl fmt::Display for DiskType {
93    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
94        f.write_str(match self {
95            Self::Ofs => "AmigaOS 1.0 OFS",
96            Self::Ffs => "AmigaOS 2.0 FFS",
97            Self::OfsIntl => "AmigaOS 3.0 OFS / International",
98            Self::FfsIntl => "AmigaOS 3.0 FFS / International",
99            Self::OfsDirCache => "AmigaOS 3.0 OFS / Dir Cache",
100            Self::FfsDirCache => "AmigaOS 3.0 FFS / Dir Cache",
101            Self::Fms => "FMS Amiga System File",
102            Self::Unknown(_) => "Unknown",
103        })
104    }
105}
106
107/// Archive-wide "general info" flags (the C `geninfo` bitfield).
108#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109pub struct GenInfo(pub u16);
110
111impl GenInfo {
112    /// Empty (zero) blocks were dropped during compression.
113    pub const fn no_zero(self) -> bool {
114        self.0 & 0x01 != 0
115    }
116    /// Track data is encrypted (needs a password).
117    pub const fn encrypted(self) -> bool {
118        self.0 & 0x02 != 0
119    }
120    /// The archive was produced by appending to an existing one.
121    pub const fn appends(self) -> bool {
122        self.0 & 0x04 != 0
123    }
124    /// A banner track is present.
125    pub const fn banner(self) -> bool {
126        self.0 & 0x08 != 0
127    }
128    /// The disk is high-density.
129    pub const fn hd(self) -> bool {
130        self.0 & 0x10 != 0
131    }
132    /// The disk holds an MS-DOS filesystem.
133    pub const fn ms_dos(self) -> bool {
134        self.0 & 0x20 != 0
135    }
136    /// Created on a fixed device.
137    pub const fn dev_fixed(self) -> bool {
138        self.0 & 0x40 != 0
139    }
140    /// Produced by a registered copy of DMS.
141    pub const fn registered(self) -> bool {
142        self.0 & 0x80 != 0
143    }
144    /// A `FILEID.DIZ` description track is present.
145    pub const fn file_id(self) -> bool {
146        self.0 & 0x0100 != 0
147    }
148}
149
150impl From<u16> for GenInfo {
151    fn from(value: u16) -> Self {
152        Self(value)
153    }
154}
155
156impl From<GenInfo> for u16 {
157    fn from(value: GenInfo) -> Self {
158        value.0
159    }
160}
161
162/// Per-track control flags (the C `flags` byte).
163#[derive(Debug, Clone, Copy, PartialEq, Eq)]
164pub struct TrackFlags(pub u8);
165
166impl TrackFlags {
167    /// Keep decompressor state from the previous track (do not reinitialise).
168    pub const fn keep_state(self) -> bool {
169        self.0 & 0x01 != 0
170    }
171    /// HEAVY: rebuild the Huffman trees from this track's stream.
172    pub const fn heavy_rebuild_trees(self) -> bool {
173        self.0 & 0x02 != 0
174    }
175    /// HEAVY: apply an RLE pass after the LZH stage.
176    pub const fn heavy_rle(self) -> bool {
177        self.0 & 0x04 != 0
178    }
179    /// HEAVY: use the 8 KB dictionary (HEAVY2) rather than 4 KB (HEAVY1).
180    pub const fn heavy_big_dict(self) -> bool {
181        self.0 & 0x08 != 0
182    }
183}
184
185impl From<u8> for TrackFlags {
186    fn from(value: u8) -> Self {
187        Self(value)
188    }
189}
190
191impl From<TrackFlags> for u8 {
192    fn from(value: TrackFlags) -> Self {
193        value.0
194    }
195}
196
197/// Metadata from the 56-byte archive header.
198#[derive(Debug, Clone)]
199pub struct Info {
200    /// DMS version that created the archive, encoded as `major * 100 + minor`.
201    pub creator_version: u16,
202    /// Creation time as a Unix timestamp (seconds since the epoch).
203    pub date: u32,
204    /// Lowest track number present (may be wrong on appended archives).
205    pub first_track: u16,
206    /// Highest track number present (may be wrong on appended archives).
207    pub last_track: u16,
208    /// Total packed size of all tracks.
209    pub packed_size: u32,
210    /// Total unpacked size (typically 901,120 for a standard DD disk).
211    pub unpacked_size: u32,
212    /// Filesystem/format of the archived disk.
213    pub disk_type: DiskType,
214    /// Compression mode used by most tracks; `None` if the byte is unrecognised.
215    pub default_mode: Option<Mode>,
216    /// Archive-wide flags.
217    pub info: GenInfo,
218}
219
220impl TryFrom<&[u8]> for Info {
221    type Error = Error;
222
223    fn try_from(bytes: &[u8]) -> Result<Self, Error> {
224        if bytes.len() < HEADER_LEN {
225            return Err(Error::Truncated);
226        }
227        if &bytes[0..4] != MAGIC_ARCHIVE {
228            return Err(Error::NotDms);
229        }
230        let stored = be16(bytes, HEADER_LEN - 2);
231        // The header CRC covers everything between the magic and the CRC itself.
232        if crc16(&bytes[4..HEADER_LEN - 2]) != stored {
233            return Err(Error::HeaderCrc);
234        }
235        let default_mode = u8::try_from(be16(bytes, 52))
236            .ok()
237            .and_then(|mode| Mode::try_from(mode).ok());
238        Ok(Self {
239            creator_version: be16(bytes, 46),
240            date: be32(bytes, 12),
241            first_track: be16(bytes, 16),
242            last_track: be16(bytes, 18),
243            // pkfsize/unpkfsize are 3-byte fields; bytes 20 and 24 are unused.
244            packed_size: be24(bytes, 21),
245            unpacked_size: be24(bytes, 25),
246            disk_type: DiskType::from(be16(bytes, 50)),
247            default_mode,
248            info: GenInfo::from(be16(bytes, 10)),
249        })
250    }
251}
252
253impl fmt::Display for Info {
254    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
255        let (major, minor) = (self.creator_version / 100, self.creator_version % 100);
256        writeln!(f, "DMS version {major}.{minor:02}")?;
257        writeln!(f, "tracks {} to {}", self.first_track, self.last_track)?;
258        writeln!(
259            f,
260            "packed {} bytes, unpacked {} bytes",
261            self.packed_size, self.unpacked_size
262        )?;
263        writeln!(f, "disk type: {}", self.disk_type)?;
264        match self.default_mode {
265            Some(mode) => writeln!(f, "default mode: {mode:?}")?,
266            None => writeln!(f, "default mode: unknown")?,
267        }
268        write!(
269            f,
270            "encrypted: {}, banner: {}, FILEID.DIZ: {}",
271            self.info.encrypted(),
272            self.info.banner(),
273            self.info.file_id()
274        )
275    }
276}
277
278/// The 20-byte header preceding each track's packed data. Crate-internal.
279#[derive(Debug, Clone, Copy)]
280pub struct TrackHeader {
281    /// Track number; 80 = `FILEID.DIZ`, `0xFFFF` = banner.
282    pub number: u16,
283    /// Packed length as stored in the archive (the bytes that follow).
284    pub packed_len: u16,
285    /// Length after the first decompression stage (before any RLE pass).
286    pub intermediate_len: u16,
287    /// Final length after all stages.
288    pub unpacked_len: u16,
289    /// Control flags.
290    pub flags: TrackFlags,
291    /// Raw compression-mode byte; converted to [`Mode`] at decode time so an
292    /// odd non-data track never blocks parsing.
293    pub mode: u8,
294    /// Checksum of the unpacked data.
295    pub checksum: u16,
296    /// CRC of the packed data (as stored, i.e. still encrypted if applicable).
297    pub data_crc: u16,
298}
299
300impl TryFrom<&[u8]> for TrackHeader {
301    type Error = Error;
302
303    fn try_from(bytes: &[u8]) -> Result<Self, Error> {
304        if bytes.len() < TRACK_HEADER_LEN {
305            return Err(Error::Truncated);
306        }
307        if &bytes[0..2] != MAGIC_TRACK {
308            return Err(Error::NotTrack);
309        }
310        let stored = be16(bytes, TRACK_HEADER_LEN - 2);
311        if crc16(&bytes[0..TRACK_HEADER_LEN - 2]) != stored {
312            return Err(Error::TrackHeaderCrc);
313        }
314        Ok(Self {
315            number: be16(bytes, 2),
316            packed_len: be16(bytes, 6),
317            intermediate_len: be16(bytes, 8),
318            unpacked_len: be16(bytes, 10),
319            flags: TrackFlags::from(bytes[12]),
320            mode: bytes[13],
321            checksum: be16(bytes, 14),
322            data_crc: be16(bytes, 16),
323        })
324    }
325}
326
327fn be16(bytes: &[u8], at: usize) -> u16 {
328    u16::from_be_bytes([bytes[at], bytes[at + 1]])
329}
330
331fn be24(bytes: &[u8], at: usize) -> u32 {
332    (u32::from(bytes[at]) << 16) | (u32::from(bytes[at + 1]) << 8) | u32::from(bytes[at + 2])
333}
334
335fn be32(bytes: &[u8], at: usize) -> u32 {
336    u32::from_be_bytes([bytes[at], bytes[at + 1], bytes[at + 2], bytes[at + 3]])
337}
338
339#[cfg(test)]
340mod tests {
341    use super::{DiskType, Info, Mode, TrackHeader};
342    use crate::crc::crc16;
343    use crate::error::Error;
344
345    fn archive_header() -> [u8; 56] {
346        let mut h = [0u8; 56];
347        h[0..4].copy_from_slice(b"DMS!");
348        h[10..12].copy_from_slice(&0x0102u16.to_be_bytes()); // encrypted + FILEID.DIZ
349        h[12..16].copy_from_slice(&0x1234_5678u32.to_be_bytes()); // date
350        h[16..18].copy_from_slice(&2u16.to_be_bytes()); // first track
351        h[18..20].copy_from_slice(&83u16.to_be_bytes()); // last track
352        h[21..24].copy_from_slice(&[0x01, 0x02, 0x03]); // packed size (3 bytes)
353        h[25..28].copy_from_slice(&[0x0D, 0xC0, 0x00]); // unpacked size = 901120
354        h[46..48].copy_from_slice(&123u16.to_be_bytes()); // creator version 1.23
355        h[50..52].copy_from_slice(&4u16.to_be_bytes()); // disk type
356        h[52..54].copy_from_slice(&6u16.to_be_bytes()); // default mode = HEAVY2
357        let crc = crc16(&h[4..54]);
358        h[54..56].copy_from_slice(&crc.to_be_bytes());
359        h
360    }
361
362    #[test]
363    fn parses_archive_header() {
364        let info = Info::try_from(&archive_header()[..]).unwrap();
365        assert_eq!(info.creator_version, 123);
366        assert_eq!(info.date, 0x1234_5678);
367        assert_eq!(info.first_track, 2);
368        assert_eq!(info.last_track, 83);
369        assert_eq!(info.packed_size, 0x0001_0203);
370        assert_eq!(info.unpacked_size, 901_120);
371        assert_eq!(info.disk_type, DiskType::FfsIntl);
372        assert_eq!(info.default_mode, Some(Mode::Heavy2));
373        assert!(info.info.encrypted());
374        assert!(info.info.file_id());
375        assert!(!info.info.banner());
376    }
377
378    #[test]
379    fn rejects_bad_magic() {
380        let mut h = archive_header();
381        h[0] = b'X';
382        assert!(matches!(Info::try_from(&h[..]), Err(Error::NotDms)));
383    }
384
385    #[test]
386    fn rejects_bad_header_crc() {
387        let mut h = archive_header();
388        h[55] ^= 0xff;
389        assert!(matches!(Info::try_from(&h[..]), Err(Error::HeaderCrc)));
390    }
391
392    #[test]
393    fn rejects_truncated_header() {
394        assert!(matches!(
395            Info::try_from(&[0u8; 10][..]),
396            Err(Error::Truncated)
397        ));
398    }
399
400    #[test]
401    fn unknown_disk_type_round_trips_value() {
402        assert_eq!(DiskType::from(42), DiskType::Unknown(42));
403    }
404
405    #[test]
406    fn info_display_is_human_readable() {
407        let info = Info::try_from(&archive_header()[..]).unwrap();
408        let text = alloc::format!("{info}");
409        assert!(text.contains("DMS version 1.23"));
410        assert!(text.contains("encrypted: true"));
411    }
412
413    #[test]
414    fn unknown_mode_is_error() {
415        assert!(matches!(Mode::try_from(9), Err(Error::UnknownMode(9))));
416    }
417
418    fn track_header() -> [u8; 20] {
419        let mut t = [0u8; 20];
420        t[0..2].copy_from_slice(b"TR");
421        t[2..4].copy_from_slice(&5u16.to_be_bytes()); // number
422        t[6..8].copy_from_slice(&100u16.to_be_bytes()); // packed len
423        t[8..10].copy_from_slice(&200u16.to_be_bytes()); // intermediate len
424        t[10..12].copy_from_slice(&5000u16.to_be_bytes()); // unpacked len
425        t[12] = 0x05; // flags: keep_state | heavy_rle
426        t[13] = 5; // mode = HEAVY1
427        t[14..16].copy_from_slice(&0xABCDu16.to_be_bytes()); // checksum
428        t[16..18].copy_from_slice(&0x1234u16.to_be_bytes()); // data crc
429        let crc = crc16(&t[0..18]);
430        t[18..20].copy_from_slice(&crc.to_be_bytes());
431        t
432    }
433
434    #[test]
435    fn parses_track_header() {
436        let th = TrackHeader::try_from(&track_header()[..]).unwrap();
437        assert_eq!(th.number, 5);
438        assert_eq!(th.packed_len, 100);
439        assert_eq!(th.intermediate_len, 200);
440        assert_eq!(th.unpacked_len, 5000);
441        assert_eq!(th.mode, 5);
442        assert_eq!(th.checksum, 0xABCD);
443        assert_eq!(th.data_crc, 0x1234);
444        assert!(th.flags.keep_state());
445        assert!(th.flags.heavy_rle());
446        assert!(!th.flags.heavy_big_dict());
447    }
448
449    #[test]
450    fn track_bad_magic_is_not_track() {
451        let mut t = track_header();
452        t[0] = b'Z';
453        assert!(matches!(
454            TrackHeader::try_from(&t[..]),
455            Err(Error::NotTrack)
456        ));
457    }
458
459    #[test]
460    fn track_bad_crc() {
461        let mut t = track_header();
462        t[19] ^= 0xff;
463        assert!(matches!(
464            TrackHeader::try_from(&t[..]),
465            Err(Error::TrackHeaderCrc)
466        ));
467    }
468}