plasma-prp 0.1.0

Read, write, inspect, and manipulate Plasma engine PRP files used by Myst Online: Uru Live
Documentation
//! plUoid — Universal Object IDentifier.
//!
//! Uniquely identifies any object in the Plasma engine: Location + class type +
//! object name + optional clone IDs and load mask.
//!
//! C++ ref: pnKeyedObject/plUoid.h, plUoid.cpp

use std::fmt;
use std::io::{Read, Write};

use anyhow::Result;

use crate::resource::prp::PlasmaRead;

use super::load_mask::LoadMask;
use super::location::Location;

/// Contents flags for plUoid binary serialization.
/// NOTE: C++ defines kHasCloneIDs=0x1, kHasLoadMask=0x2.
const HAS_CLONE_IDS: u8 = 0x1;
const HAS_LOAD_MASK: u8 = 0x2;

/// A Unique Object IDentifier — identifies exactly one object in the engine.
#[derive(Clone)]
pub struct Uoid {
    pub location: Location,
    pub class_type: u16,
    pub object_name: String,
    pub object_id: u32,
    pub load_mask: LoadMask,
    pub clone_id: u16,
    pub clone_player_id: u32,
}

impl Uoid {
    /// Create an invalid (uninitialized) Uoid.
    pub fn invalid() -> Self {
        Self {
            location: Location::invalid(),
            class_type: 0,
            object_name: String::new(),
            object_id: 0,
            load_mask: LoadMask::ALWAYS,
            clone_id: 0,
            clone_player_id: 0,
        }
    }

    /// Create a Uoid with the given location, class type, and name.
    pub fn new(location: Location, class_type: u16, object_name: String) -> Self {
        Self {
            location,
            class_type,
            object_name,
            object_id: 0,
            load_mask: LoadMask::ALWAYS,
            clone_id: 0,
            clone_player_id: 0,
        }
    }

    pub fn is_valid(&self) -> bool {
        self.location.is_valid() && !self.object_name.is_empty()
    }

    pub fn is_clone(&self) -> bool {
        self.clone_id != 0
    }

    /// Read a plUoid from a stream.
    pub fn read(reader: &mut impl Read) -> Result<Self> {
        let contents = reader.read_u8()?;

        let location = Location::read(reader)?;

        let load_mask = if contents & HAS_LOAD_MASK != 0 {
            LoadMask::read(reader)?
        } else {
            LoadMask::ALWAYS
        };

        let class_type = reader.read_u16()?;
        let object_id = reader.read_u32()?;
        let object_name = reader.read_safe_string()?;

        let (clone_id, clone_player_id) = if contents & HAS_CLONE_IDS != 0 {
            let clone_id = reader.read_u16()?;
            let _reserved = reader.read_u16()?;
            let clone_player_id = reader.read_u32()?;
            (clone_id, clone_player_id)
        } else {
            (0, 0)
        };

        Ok(Self {
            location,
            class_type,
            object_name,
            object_id,
            load_mask,
            clone_id,
            clone_player_id,
        })
    }

    /// Write a plUoid to a stream.
    pub fn write(&self, writer: &mut impl Write) -> Result<()> {
        let mut contents: u8 = 0;
        if self.is_clone() {
            contents |= HAS_CLONE_IDS;
        }
        if self.load_mask.is_used() {
            contents |= HAS_LOAD_MASK;
        }
        writer.write_all(&[contents])?;

        self.location.write(writer)?;

        if contents & HAS_LOAD_MASK != 0 {
            self.load_mask.write(writer)?;
        }

        writer.write_all(&self.class_type.to_le_bytes())?;
        writer.write_all(&self.object_id.to_le_bytes())?;
        write_safe_string(writer, &self.object_name)?;

        if contents & HAS_CLONE_IDS != 0 {
            writer.write_all(&self.clone_id.to_le_bytes())?;
            writer.write_all(&0u16.to_le_bytes())?; // reserved
            writer.write_all(&self.clone_player_id.to_le_bytes())?;
        }

        Ok(())
    }
}

impl PartialEq for Uoid {
    fn eq(&self, other: &Self) -> bool {
        self.location == other.location
            && self.load_mask == other.load_mask
            && self.class_type == other.class_type
            && self.object_name == other.object_name
            && self.object_id == other.object_id
            && self.clone_id == other.clone_id
            && self.clone_player_id == other.clone_player_id
    }
}

impl Eq for Uoid {}

impl std::hash::Hash for Uoid {
    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
        // Must hash the same fields as PartialEq compares.
        // C++ ref: plUoid::operator== (plUoid.cpp:229-238)
        self.location.hash(state);
        self.load_mask.hash(state);
        self.class_type.hash(state);
        self.object_name.hash(state);
        self.object_id.hash(state);
        self.clone_id.hash(state);
        self.clone_player_id.hash(state);
    }
}

impl Default for Uoid {
    fn default() -> Self {
        Self::invalid()
    }
}

impl fmt::Debug for Uoid {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(
            f,
            "Uoid(S:{:#x} F:{:#x} T:{:#06x} I:{} N:{:?} C:[{},{}])",
            self.location.sequence_number,
            self.location.flags,
            self.class_type,
            self.object_id,
            self.object_name,
            self.clone_player_id,
            self.clone_id,
        )
    }
}

impl fmt::Display for Uoid {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "[{:#06x}]{}", self.class_type, self.object_name)
    }
}

/// Read a plKey reference from a stream: bool non_nil, then plUoid if non-nil.
/// Returns None for null key references.
pub fn read_key_uoid(reader: &mut impl Read) -> Result<Option<Uoid>> {
    let non_nil = reader.read_u8()?;
    if non_nil == 0 {
        return Ok(None);
    }
    Ok(Some(Uoid::read(reader)?))
}

/// Write a plKey reference to a stream.
pub fn write_key_uoid(writer: &mut impl Write, uoid: Option<&Uoid>) -> Result<()> {
    match uoid {
        Some(uoid) => {
            writer.write_all(&[1u8])?;
            uoid.write(writer)?;
        }
        None => {
            writer.write_all(&[0u8])?;
        }
    }
    Ok(())
}

/// Write a Plasma safe string.
fn write_safe_string(writer: &mut impl Write, s: &str) -> Result<()> {
    let bytes = s.as_bytes();
    let len = bytes.len() + 1; // include null terminator
    let raw_len = (len as u16) | 0xF000; // set high nibble flag
    writer.write_all(&raw_len.to_le_bytes())?;

    // Write XOR-inverted bytes
    let mut inverted: Vec<u8> = bytes.iter().map(|b| !b).collect();
    inverted.push(!0u8); // null terminator, inverted
    writer.write_all(&inverted)?;

    Ok(())
}

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

    #[test]
    fn test_invalid() {
        let u = Uoid::invalid();
        assert!(!u.is_valid());
        assert!(!u.is_clone());
    }

    #[test]
    fn test_new() {
        let loc = Location::new(100, 0);
        let u = Uoid::new(loc, 0x004C, "TestObject".into());
        assert!(u.is_valid());
        assert!(!u.is_clone());
    }

    #[test]
    fn test_round_trip_simple() {
        let loc = Location::new(0x1234, 0);
        let u = Uoid::new(loc, 0x004C, "TestDrawable".into());

        let mut buf = Vec::new();
        u.write(&mut buf).unwrap();

        let mut cursor = Cursor::new(&buf);
        let u2 = Uoid::read(&mut cursor).unwrap();
        assert_eq!(u, u2);
        assert_eq!(u2.object_name, "TestDrawable");
        assert_eq!(u2.class_type, 0x004C);
    }

    #[test]
    fn test_round_trip_with_load_mask() {
        let mut u = Uoid::new(Location::new(100, 0), 0x0004, "TextureLOD".into());
        u.load_mask = LoadMask::new(0xF3, 0xF1);

        let mut buf = Vec::new();
        u.write(&mut buf).unwrap();

        let mut cursor = Cursor::new(&buf);
        let u2 = Uoid::read(&mut cursor).unwrap();
        assert_eq!(u, u2);
        assert!(u2.load_mask.is_used());
    }

    #[test]
    fn test_round_trip_with_clone() {
        let mut u = Uoid::new(Location::new(100, 0), 0x0001, "Avatar".into());
        u.clone_id = 5;
        u.clone_player_id = 12345;

        let mut buf = Vec::new();
        u.write(&mut buf).unwrap();

        let mut cursor = Cursor::new(&buf);
        let u2 = Uoid::read(&mut cursor).unwrap();
        assert_eq!(u, u2);
        assert!(u2.is_clone());
        assert_eq!(u2.clone_id, 5);
        assert_eq!(u2.clone_player_id, 12345);
    }

    #[test]
    fn test_key_ref_round_trip() {
        let u = Uoid::new(Location::new(42, 0), 0x0007, "Material01".into());

        let mut buf = Vec::new();
        write_key_uoid(&mut buf, Some(&u)).unwrap();

        let mut cursor = Cursor::new(&buf);
        let u2 = read_key_uoid(&mut cursor).unwrap();
        assert!(u2.is_some());
        assert_eq!(u, u2.unwrap());
    }

    #[test]
    fn test_null_key_ref() {
        let mut buf = Vec::new();
        write_key_uoid(&mut buf, None).unwrap();
        assert_eq!(buf.len(), 1);
        assert_eq!(buf[0], 0);

        let mut cursor = Cursor::new(&buf);
        let u = read_key_uoid(&mut cursor).unwrap();
        assert!(u.is_none());
    }

    /// Parse all ObjectKey entries from Cleft_District_Cleft.prp using both the old
    /// ObjectKey parser and the new Uoid type, verifying they produce identical results.
    #[test]
    fn test_parse_prp_keys_match() {
        use crate::resource::prp::PrpPage;
        use std::path::Path;

        let path = Path::new("../../Plasma/staging/client/dat/Cleft_District_Cleft.prp");
        if !path.exists() {
            eprintln!("Skipping PRP test: {:?} not found", path);
            return;
        }

        let page = PrpPage::from_file(path).unwrap();
        assert!(!page.keys.is_empty(), "Expected keys in Cleft page");

        // Verify every ObjectKey can be re-read as a Uoid at the key index position
        // by re-reading the raw index data
        let mut valid_count = 0;
        for key in &page.keys {
            // Skip entries with out-of-range class types (parser artifacts)
            if key.class_type > crate::core::class_index::ClassIndex::MAX_CLASS_INDEX {
                continue;
            }
            assert!(key.location_sequence != 0xFFFFFFFF,
                "Invalid location for key {}", key.object_name);
            assert!(!key.object_name.is_empty(),
                "Empty name for key with class 0x{:04X}", key.class_type);
            valid_count += 1;
        }

        eprintln!("Verified {}/{} keys from Cleft_District_Cleft.prp", valid_count, page.keys.len());
    }

    /// Test that Uoid::read correctly parses the binary format by reading a key reference
    /// from a real .prp file's object data (the self-key in a plDrawableSpans).
    #[test]
    fn test_parse_real_key_ref() {
        use crate::resource::prp::{PrpPage, class_types};
        use std::path::Path;

        let path = Path::new("../../Plasma/staging/client/dat/Cleft_District_Cleft.prp");
        if !path.exists() {
            eprintln!("Skipping PRP test: {:?} not found", path);
            return;
        }

        let page = PrpPage::from_file(path).unwrap();
        let drawable_keys: Vec<_> = page.keys_of_type(class_types::PL_DRAWABLE_SPANS);
        assert!(!drawable_keys.is_empty(), "Expected drawable spans in Cleft");

        for dkey in &drawable_keys {
            if let Some(data) = page.object_data(dkey) {
                let mut cursor = Cursor::new(data);

                // Skip creatable class index (i16)
                let _class_idx = cursor.read_u16().unwrap();

                // Read the self-key using our new Uoid type
                let uoid = read_key_uoid(&mut cursor).unwrap();
                assert!(uoid.is_some(), "Self-key should be non-nil for {}", dkey.object_name);

                let uoid = uoid.unwrap();
                assert_eq!(uoid.object_name, dkey.object_name,
                    "Uoid name should match ObjectKey name");
                assert_eq!(uoid.class_type, dkey.class_type,
                    "Uoid class type should match ObjectKey class type");
                assert_eq!(uoid.location.sequence_number, dkey.location_sequence,
                    "Uoid location should match ObjectKey location");
                assert_eq!(uoid.location.flags, dkey.location_flags,
                    "Uoid location flags should match ObjectKey location flags");
                assert_eq!(uoid.object_id, dkey.object_id,
                    "Uoid object_id should match ObjectKey object_id");
            }
        }

        eprintln!("Verified {} drawable key refs from Cleft", drawable_keys.len());
    }
}