plasma-prp 0.1.0

Read, write, inspect, and manipulate Plasma engine PRP files used by Myst Online: Uru Live
Documentation
use anyhow::{Context, Result};
use std::path::Path;

/// Page loading flags.
pub const PAGE_PREVENT_AUTO_LOAD: u8 = 0x01;
pub const PAGE_LOAD_IF_SDL_PRESENT: u8 = 0x02;
pub const PAGE_IS_LOCAL_ONLY: u8 = 0x04;
pub const PAGE_IS_VOLATILE: u8 = 0x08;

/// A page entry from a .age file.
#[derive(Debug, Clone)]
pub struct PageEntry {
    pub name: String,
    pub seq_suffix: u32,
    pub flags: u8,
}

impl PageEntry {
    /// Whether this page should be loaded automatically when the age loads.
    pub fn auto_load(&self) -> bool {
        self.flags & PAGE_PREVENT_AUTO_LOAD == 0
    }
}

/// Parsed .age file describing an age's metadata and page list.
#[derive(Debug, Clone)]
pub struct AgeDescription {
    pub age_name: String,
    pub start_date_time: u32,
    pub day_length: f32,
    pub max_capacity: i32,
    pub linger_time: i32,
    pub sequence_prefix: i32,
    pub release_version: u32,
    pub pages: Vec<PageEntry>,
}

impl AgeDescription {
    /// Parse a .age file from disk.
    pub fn from_file(path: &Path) -> Result<Self> {
        let age_name = path.file_stem()
            .and_then(|s| s.to_str())
            .unwrap_or("unknown")
            .to_string();

        let content = std::fs::read_to_string(path)
            .with_context(|| format!("Failed to read {}", path.display()))?;

        Self::parse(&age_name, &content)
    }

    /// Parse .age file content.
    pub fn parse(age_name: &str, content: &str) -> Result<Self> {
        let mut desc = Self {
            age_name: age_name.to_string(),
            start_date_time: 0,
            day_length: 24.0,
            max_capacity: -1,
            linger_time: -1,
            sequence_prefix: 0,
            release_version: 0,
            pages: Vec::new(),
        };

        let mut next_suffix = 1u32;

        for line in content.lines() {
            let line = line.trim();
            if line.is_empty() || line.starts_with('#') || line.starts_with('[') {
                continue;
            }

            let (key, value) = match line.split_once('=') {
                Some(kv) => kv,
                None => continue,
            };

            match key.trim() {
                "StartDateTime" => {
                    desc.start_date_time = value.trim().parse().unwrap_or(0);
                }
                "DayLength" => {
                    desc.day_length = value.trim().parse().unwrap_or(24.0);
                }
                "MaxCapacity" => {
                    desc.max_capacity = value.trim().parse().unwrap_or(-1);
                }
                "LingerTime" => {
                    desc.linger_time = value.trim().parse().unwrap_or(-1);
                }
                "SequencePrefix" => {
                    desc.sequence_prefix = value.trim().parse().unwrap_or(0);
                }
                "ReleaseVersion" => {
                    desc.release_version = value.trim().parse().unwrap_or(0);
                }
                "Page" => {
                    let parts: Vec<&str> = value.trim().split(',').collect();
                    let name = parts[0].to_string();
                    let seq_suffix = if parts.len() > 1 {
                        parts[1].parse().unwrap_or(next_suffix)
                    } else {
                        next_suffix
                    };
                    let flags = if parts.len() > 2 {
                        parts[2].parse().unwrap_or(0)
                    } else {
                        0
                    };

                    next_suffix = seq_suffix + 1;

                    desc.pages.push(PageEntry { name, seq_suffix, flags });
                }
                _ => {}
            }
        }

        Ok(desc)
    }

    /// Get pages that should auto-load (no kPreventAutoLoad flag).
    pub fn auto_load_pages(&self) -> Vec<&PageEntry> {
        self.pages.iter().filter(|p| p.auto_load()).collect()
    }

    /// Calculate the plLocation sequence number for a page.
    pub fn page_location(&self, page: &PageEntry) -> u32 {
        if self.sequence_prefix < 0 {
            let combined = ((-self.sequence_prefix as u32) << 16) | (page.seq_suffix & 0xFFFF);
            combined // Negative prefixes use reserved locations
        } else {
            ((self.sequence_prefix as u32) << 16) | (page.seq_suffix & 0xFFFF)
        }
    }

    /// Get the .prp filename for a page.
    pub fn prp_filename(&self, page: &PageEntry) -> String {
        format!("{}_District_{}.prp", self.age_name, page.name)
    }

    /// Common pages appended to every age (Textures, BuiltIn).
    pub fn common_page_filenames(&self) -> Vec<String> {
        vec![
            format!("{}_District_Textures.prp", self.age_name),
            format!("{}_District_BuiltIn.prp", self.age_name),
        ]
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_parse_cleft_age() {
        let content = r#"StartDateTime=0000000000
DayLength=24.000000
MaxCapacity=1
LingerTime=180
SequencePrefix=7
ReleaseVersion=0
Page=BookRoom,2
Page=Cleft,1
Page=clftAtrusGoggles,30,1
Page=Desert,0,1
"#;
        let desc = AgeDescription::parse("Cleft", content).unwrap();
        assert_eq!(desc.age_name, "Cleft");
        assert_eq!(desc.sequence_prefix, 7);
        assert_eq!(desc.max_capacity, 1);
        assert_eq!(desc.linger_time, 180);
        assert_eq!(desc.pages.len(), 4);

        // BookRoom: auto-load (no flag)
        assert!(desc.pages[0].auto_load());
        assert_eq!(desc.pages[0].name, "BookRoom");
        assert_eq!(desc.pages[0].seq_suffix, 2);

        // Cleft: auto-load (no flag)
        assert!(desc.pages[1].auto_load());

        // clftAtrusGoggles: flag=1 (prevent auto-load)
        assert!(!desc.pages[2].auto_load());

        // Check auto-load list
        let auto = desc.auto_load_pages();
        assert_eq!(auto.len(), 2);

        // Check prp filename
        assert_eq!(desc.prp_filename(&desc.pages[0]), "Cleft_District_BookRoom.prp");

        // Check location
        assert_eq!(desc.page_location(&desc.pages[0]), (7 << 16) | 2);
    }
}