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::collections::HashMap;
use std::path::Path;

/// SDL variable types matching Plasma's plVarDescriptor::Type enum.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SdlType {
    Int,
    Float,
    Bool,
    String32,
    Key,
    StateDescriptor,
    Creatable,
    Double,
    Time,
    Byte,
    Short,
    AgeTimeOfDay,
    Vector3,
    Point3,
    Rgb,
    Rgba,
    Quaternion,
    Rgb8,
    Matrix44,
}

impl SdlType {
    pub fn from_str(s: &str) -> Option<Self> {
        match s.to_uppercase().as_str() {
            "INT" => Some(Self::Int),
            "FLOAT" => Some(Self::Float),
            "BOOL" => Some(Self::Bool),
            "STRING32" => Some(Self::String32),
            "PLKEY" | "KEY" => Some(Self::Key),
            "STATEDESC" => Some(Self::StateDescriptor),
            "CREATABLE" => Some(Self::Creatable),
            "DOUBLE" => Some(Self::Double),
            "TIME" => Some(Self::Time),
            "BYTE" => Some(Self::Byte),
            "SHORT" => Some(Self::Short),
            "AGETIMEOFDAY" => Some(Self::AgeTimeOfDay),
            "VECTOR3" => Some(Self::Vector3),
            "POINT3" => Some(Self::Point3),
            "RGB" => Some(Self::Rgb),
            "RGBA" => Some(Self::Rgba),
            "QUATERNION" => Some(Self::Quaternion),
            "RGB8" => Some(Self::Rgb8),
            "MATRIX44" => Some(Self::Matrix44),
            _ => None,
        }
    }

    /// Size in bytes for fixed-size types.
    pub fn byte_size(&self) -> usize {
        match self {
            Self::Bool | Self::Byte => 1,
            Self::Short => 2,
            Self::Int | Self::Float => 4,
            Self::Double | Self::Time => 8,
            Self::Vector3 | Self::Point3 | Self::Rgb => 12,
            Self::Rgba | Self::Quaternion => 16,
            Self::Rgb8 => 3,
            Self::Matrix44 => 64,
            Self::String32 => 32,
            Self::Key | Self::StateDescriptor | Self::Creatable | Self::AgeTimeOfDay => 0,
        }
    }
}

/// A single variable in an SDL state descriptor.
#[derive(Debug, Clone)]
pub struct VarDescriptor {
    pub name: String,
    pub var_type: SdlType,
    pub count: usize,
    pub default_value: Option<String>,
    pub display_option: Option<String>,
    pub default_option: Option<String>,
}

/// A state descriptor from a .sdl file (STATEDESC block).
#[derive(Debug, Clone)]
pub struct StateDescriptor {
    pub name: String,
    pub version: u32,
    pub variables: Vec<VarDescriptor>,
}

/// Manages all loaded SDL state descriptors.
pub struct SdlManager {
    /// name → (version → descriptor)
    descriptors: HashMap<String, Vec<StateDescriptor>>,
}

impl SdlManager {
    pub fn new() -> Self {
        Self {
            descriptors: HashMap::new(),
        }
    }

    /// Load all .sdl files from a directory.
    pub fn load_directory(&mut self, path: &Path) -> Result<usize> {
        let mut count = 0;
        for entry in std::fs::read_dir(path)? {
            let entry = entry?;
            let file_path = entry.path();
            if file_path.extension().is_some_and(|ext| ext == "sdl") {
                match self.load_file(&file_path) {
                    Ok(n) => count += n,
                    Err(e) => log::warn!("Failed to load {:?}: {}", file_path, e),
                }
            }
        }
        log::info!("Loaded {} SDL descriptors from {}", count, path.display());
        Ok(count)
    }

    /// Load a single .sdl file (may contain multiple STATEDESC blocks).
    pub fn load_file(&mut self, path: &Path) -> Result<usize> {
        let content = std::fs::read_to_string(path)
            .with_context(|| format!("Failed to read {}", path.display()))?;
        let descs = parse_sdl(&content)?;
        let count = descs.len();
        for desc in descs {
            self.descriptors
                .entry(desc.name.clone())
                .or_default()
                .push(desc);
        }
        Ok(count)
    }

    /// Find a descriptor by name, optionally at a specific version.
    /// Returns the latest version if version is 0.
    pub fn find(&self, name: &str, version: u32) -> Option<&StateDescriptor> {
        let versions = self.descriptors.get(name)?;
        if version == 0 {
            versions.iter().max_by_key(|d| d.version)
        } else {
            versions.iter().find(|d| d.version == version)
        }
    }

    pub fn descriptor_count(&self) -> usize {
        self.descriptors.values().map(|v| v.len()).sum()
    }

    /// Add a descriptor directly (used by tests and network SDL loading).
    pub fn add_descriptor(&mut self, desc: StateDescriptor) {
        self.descriptors
            .entry(desc.name.clone())
            .or_default()
            .push(desc);
    }
}

/// Test helper: parse SDL content string into descriptors.
#[cfg(test)]
pub(crate) fn parse_sdl_for_test(content: &str) -> Result<Vec<StateDescriptor>> {
    parse_sdl(content)
}

/// Parse SDL file content into state descriptors.
pub fn parse_sdl(content: &str) -> Result<Vec<StateDescriptor>> {
    let mut descriptors = Vec::new();
    let mut current: Option<(String, u32, Vec<VarDescriptor>)> = None;

    for line in content.lines() {
        let line = line.trim();

        // Skip comments and empty lines
        if line.is_empty() || line.starts_with('#') {
            continue;
        }

        if line.starts_with("STATEDESC") {
            let name = line.split_whitespace().nth(1).unwrap_or("").to_string();
            current = Some((name, 0, Vec::new()));
            continue;
        }

        if line == "{" {
            continue;
        }

        if line == "}" {
            if let Some((name, version, vars)) = current.take() {
                descriptors.push(StateDescriptor { name, version, variables: vars });
            }
            continue;
        }

        if let Some((_, ref mut version, ref mut vars)) = current {
            let tokens: Vec<&str> = line.split_whitespace().collect();

            if tokens.first() == Some(&"VERSION") {
                if let Some(v) = tokens.get(1) {
                    *version = v.parse().unwrap_or(0);
                }
            } else if tokens.first() == Some(&"VAR") && tokens.len() >= 3 {
                let type_str = tokens[1];
                let name_and_count = tokens[2];

                // Parse "varName[count]"
                let (var_name, count) = if let Some(bracket_pos) = name_and_count.find('[') {
                    let name = &name_and_count[..bracket_pos];
                    let count_str = &name_and_count[bracket_pos + 1..name_and_count.len() - 1];
                    let count = if count_str.is_empty() { 0 } else { count_str.parse().unwrap_or(1) };
                    (name.to_string(), count)
                } else {
                    (name_and_count.to_string(), 1)
                };

                let var_type = SdlType::from_str(type_str);

                // Parse optional DEFAULT=, DEFAULTOPTION=, DISPLAYOPTION=
                let mut default_value = None;
                let mut display_option = None;
                let mut default_option = None;
                for token in &tokens[3..] {
                    if let Some(val) = token.strip_prefix("DEFAULT=") {
                        default_value = Some(val.to_string());
                    } else if let Some(val) = token.strip_prefix("DEFAULTOPTION=") {
                        default_option = Some(val.to_string());
                    } else if let Some(val) = token.strip_prefix("DISPLAYOPTION=") {
                        display_option = Some(val.to_string());
                    }
                }

                if let Some(vt) = var_type {
                    vars.push(VarDescriptor {
                        name: var_name,
                        var_type: vt,
                        count,
                        default_value,
                        display_option,
                        default_option,
                    });
                } else {
                    // Could be a nested STATEDESC reference
                    vars.push(VarDescriptor {
                        name: var_name,
                        var_type: SdlType::StateDescriptor,
                        count,
                        default_value: None,
                        display_option: None,
                        default_option: None,
                    });
                }
            }
        }
    }

    Ok(descriptors)
}

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

    #[test]
    fn test_parse_sdl() {
        let content = r#"
# Comment
STATEDESC Cleft
{
    VERSION 24
    VAR BOOL    clftWindmillLocked[1]  DEFAULT=1 DISPLAYOPTION=red
    VAR BYTE    clftImagerPanelN[1]    DEFAULT=3 DISPLAYOPTION=red
    VAR BOOL    clftIsCleftDone[1]     DEFAULT=0 DISPLAYOPTION=red
}
"#;
        let descs = parse_sdl(content).unwrap();
        assert_eq!(descs.len(), 1);
        assert_eq!(descs[0].name, "Cleft");
        assert_eq!(descs[0].version, 24);
        assert_eq!(descs[0].variables.len(), 3);
        assert_eq!(descs[0].variables[0].name, "clftWindmillLocked");
        assert_eq!(descs[0].variables[0].var_type, SdlType::Bool);
        assert_eq!(descs[0].variables[0].count, 1);
        assert_eq!(descs[0].variables[0].default_value.as_deref(), Some("1"));
        assert_eq!(descs[0].variables[1].var_type, SdlType::Byte);
    }

    #[test]
    fn test_sdl_manager() {
        let mut mgr = SdlManager::new();
        let content = "STATEDESC Test\n{\nVERSION 5\nVAR INT foo[1] DEFAULT=42\n}\n";
        let descs = parse_sdl(content).unwrap();
        for d in descs {
            mgr.descriptors.entry(d.name.clone()).or_default().push(d);
        }
        let found = mgr.find("Test", 0).unwrap();
        assert_eq!(found.version, 5);
        assert_eq!(found.variables[0].name, "foo");
    }
}