kr580 2.0.0

Desktop KR580VM80 / Intel 8080 emulator.
Documentation
use k580_core::Cpu8080State;
use std::path::Path;
use thiserror::Error;

#[derive(Debug, Error)]
pub enum SubprogramError {
    #[error("not a .krs file")]
    NotAKrsFile,
    #[error("subprogram file is empty")]
    EmptyFile,
    #[error("subprogram range is invalid: {start:#06X}..{end:#06X}")]
    InvalidRange { start: u16, end: u16 },
    #[error("subprogram of {length} bytes does not fit at {start:#06X}")]
    MemoryOverflow { start: u16, length: u64 },
    #[error("subprogram I/O error: {0}")]
    Io(#[from] std::io::Error),
}

pub struct SubprogramSerializer;

impl SubprogramSerializer {
    pub fn supports_path(path: impl AsRef<Path>) -> bool {
        path.as_ref()
            .extension()
            .and_then(|extension| extension.to_str())
            .is_some_and(|extension| extension.eq_ignore_ascii_case("krs"))
    }

    pub fn load_into_state(
        path: impl AsRef<Path>,
        start: u16,
        state: &mut Cpu8080State,
    ) -> Result<(), SubprogramError> {
        validate_path(path.as_ref())?;
        let bytes = std::fs::read(path)?;
        checked_load_end(start, bytes.len() as u64)?;
        state
            .set_memory_block(start, &bytes)
            .map_err(|_| SubprogramError::MemoryOverflow {
                start,
                length: bytes.len() as u64,
            })?;
        Ok(())
    }

    pub fn file_end(path: impl AsRef<Path>, start: u16) -> Result<u16, SubprogramError> {
        validate_path(path.as_ref())?;
        checked_load_end(start, std::fs::metadata(path)?.len())
    }

    pub fn save_file(
        path: impl AsRef<Path>,
        state: &Cpu8080State,
        start: u16,
        end: u16,
    ) -> Result<(), SubprogramError> {
        validate_path(path.as_ref())?;
        if start > end {
            return Err(SubprogramError::InvalidRange { start, end });
        }
        let bytes = &state.memory.as_slice()[usize::from(start)..=usize::from(end)];
        std::fs::write(path, bytes)?;
        Ok(())
    }
}

fn checked_load_end(start: u16, length: u64) -> Result<u16, SubprogramError> {
    if length == 0 {
        return Err(SubprogramError::EmptyFile);
    }
    let end = u64::from(start)
        .checked_add(length - 1)
        .ok_or(SubprogramError::MemoryOverflow { start, length })?;
    u16::try_from(end).map_err(|_| SubprogramError::MemoryOverflow { start, length })
}

fn validate_path(path: &Path) -> Result<(), SubprogramError> {
    if SubprogramSerializer::supports_path(path) {
        Ok(())
    } else {
        Err(SubprogramError::NotAKrsFile)
    }
}

#[cfg(test)]
mod tests {
    use super::{SubprogramError, SubprogramSerializer};
    use k580_core::Cpu8080State;
    use std::fs;
    use std::time::{SystemTime, UNIX_EPOCH};

    fn temp_path(name: &str, extension: &str) -> std::path::PathBuf {
        let stamp = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        std::env::temp_dir().join(format!("kr580-{name}-{stamp}.{extension}"))
    }

    #[test]
    fn loads_raw_bytes_at_requested_address() {
        let path = temp_path("subprogram-load", "krs");
        fs::write(&path, [0x3E, 0x42, 0x76]).unwrap();
        let mut state = Cpu8080State::default();

        SubprogramSerializer::load_into_state(&path, 0x0100, &mut state).unwrap();

        assert_eq!(
            &state.memory.as_slice()[0x0100..=0x0102],
            &[0x3E, 0x42, 0x76]
        );
        fs::remove_file(path).unwrap();
    }

    #[test]
    fn rejects_subprogram_that_overflows_memory() {
        let path = temp_path("subprogram-overflow", "krs");
        fs::write(&path, [0x00, 0x01]).unwrap();
        let mut state = Cpu8080State::default();

        let error = SubprogramSerializer::load_into_state(&path, 0xFFFF, &mut state).unwrap_err();

        assert!(matches!(
            error,
            SubprogramError::MemoryOverflow {
                start: 0xFFFF,
                length: 2
            }
        ));
        fs::remove_file(path).unwrap();
    }

    #[test]
    fn reports_inclusive_file_end() {
        let path = temp_path("subprogram-end", "krs");
        fs::write(&path, [0x00, 0x01, 0x02]).unwrap();

        assert_eq!(
            SubprogramSerializer::file_end(&path, 0x0100).unwrap(),
            0x0102
        );
        fs::remove_file(path).unwrap();
    }

    #[test]
    fn saves_inclusive_memory_range_as_raw_bytes() {
        let path = temp_path("subprogram-save", "krs");
        let mut state = Cpu8080State::default();
        state.memory.as_mut_slice()[0x0200..=0x0202].copy_from_slice(&[0xC3, 0x00, 0x02]);

        SubprogramSerializer::save_file(&path, &state, 0x0200, 0x0202).unwrap();

        assert_eq!(fs::read(&path).unwrap(), [0xC3, 0x00, 0x02]);
        fs::remove_file(path).unwrap();
    }

    #[test]
    fn rejects_non_krs_path() {
        let path = temp_path("subprogram-extension", "bin");
        fs::write(&path, [0x00]).unwrap();

        let error = SubprogramSerializer::load_into_state(&path, 0, &mut Cpu8080State::default())
            .unwrap_err();

        assert!(matches!(error, SubprogramError::NotAKrsFile));
        fs::remove_file(path).unwrap();
    }

    #[test]
    fn recognizes_krs_extension_case_insensitively() {
        assert!(SubprogramSerializer::supports_path("program.krs"));
        assert!(SubprogramSerializer::supports_path("program.KRS"));
        assert!(!SubprogramSerializer::supports_path("program.580"));
    }
}