use crate::asset::{AssetEntry, BankType}; use crate::cartridge::{ ASSETS_PA_MAGIC, ASSETS_PA_PRELUDE_SIZE, ASSETS_PA_SCHEMA_VERSION, AssetsPackHeader, AssetsPackPrelude, AssetsPayloadSource, Capability, Cartridge, CartridgeDTO, CartridgeError, CartridgeManifest, }; use crate::syscalls::{CapFlags, caps}; use std::collections::HashSet; use std::fs; use std::io::{Read, Seek, SeekFrom}; use std::path::Path; pub struct CartridgeLoader; impl CartridgeLoader { pub fn load(path: impl AsRef) -> Result { let path = path.as_ref(); if !path.exists() { return Err(CartridgeError::NotFound); } if path.is_dir() { DirectoryCartridgeLoader::load(path) } else if path.extension().is_some_and(|ext| ext == "pmc") { PackedCartridgeLoader::load(path) } else { Err(CartridgeError::InvalidFormat) } } } pub struct DirectoryCartridgeLoader; impl DirectoryCartridgeLoader { pub fn load(path: &Path) -> Result { let manifest_path = path.join("manifest.json"); if !manifest_path.exists() { return Err(CartridgeError::InvalidManifest); } let manifest_content = fs::read_to_string(manifest_path).map_err(|_| CartridgeError::IoError)?; let manifest: CartridgeManifest = serde_json::from_str(&manifest_content).map_err(|_| CartridgeError::InvalidManifest)?; // Additional validation as per requirements if manifest.magic != "PMTU" { return Err(CartridgeError::InvalidManifest); } if manifest.cartridge_version != 1 { return Err(CartridgeError::UnsupportedVersion); } let capabilities = normalize_capabilities(&manifest.capabilities)?; let program_path = path.join("program.pbx"); if !program_path.exists() { return Err(CartridgeError::MissingProgram); } let program = fs::read(program_path).map_err(|_| CartridgeError::IoError)?; let assets_pa_path = path.join("assets.pa"); let (assets, asset_table, preload) = if assets_pa_path.exists() { let parsed = parse_assets_pack(&assets_pa_path)?; ( AssetsPayloadSource::from_file( assets_pa_path.clone(), parsed.payload_offset, parsed.payload_len, ), parsed.header.asset_table, parsed.header.preload, ) } else { if capabilities & caps::ASSET != 0 { return Err(CartridgeError::MissingAssets); } (AssetsPayloadSource::empty(), Vec::new(), Vec::new()) }; let dto = CartridgeDTO { app_id: manifest.app_id, title: manifest.title, app_version: manifest.app_version, app_mode: manifest.app_mode, capabilities, program, assets, asset_table, preload, }; Ok(Cartridge::from(dto)) } } pub struct PackedCartridgeLoader; impl PackedCartridgeLoader { pub fn load(_path: &Path) -> Result { Err(CartridgeError::InvalidFormat) } } fn normalize_capabilities(capabilities: &[Capability]) -> Result { let mut seen = HashSet::new(); let mut normalized = caps::NONE; for capability in capabilities { if !seen.insert(*capability) { return Err(CartridgeError::InvalidManifest); } normalized |= match capability { Capability::None => caps::NONE, Capability::System => caps::SYSTEM, Capability::Gfx => caps::GFX, Capability::Audio => caps::AUDIO, Capability::Fs => caps::FS, Capability::Log => caps::LOG, Capability::Asset => caps::ASSET, Capability::Bank => caps::BANK, Capability::All => caps::ALL, }; } Ok(normalized) } struct ParsedAssetsPack { header: AssetsPackHeader, payload_offset: u64, payload_len: u64, } fn parse_assets_pack(path: &Path) -> Result { let mut file = fs::File::open(path).map_err(|_| CartridgeError::IoError)?; let mut prelude_bytes = [0_u8; ASSETS_PA_PRELUDE_SIZE]; file.read_exact(&mut prelude_bytes).map_err(|_| CartridgeError::InvalidFormat)?; let prelude = AssetsPackPrelude::from_bytes(&prelude_bytes).ok_or(CartridgeError::InvalidFormat)?; if prelude.magic != ASSETS_PA_MAGIC || prelude.schema_version != ASSETS_PA_SCHEMA_VERSION { return Err(CartridgeError::InvalidFormat); } let header_start = ASSETS_PA_PRELUDE_SIZE; let header_len = usize::try_from(prelude.header_len).map_err(|_| CartridgeError::InvalidFormat)?; let header_end = header_start.checked_add(header_len).ok_or(CartridgeError::InvalidFormat)?; let payload_offset = usize::try_from(prelude.payload_offset).map_err(|_| CartridgeError::InvalidFormat)?; let file_len = usize::try_from(file.metadata().map_err(|_| CartridgeError::IoError)?.len()) .map_err(|_| CartridgeError::InvalidFormat)?; if payload_offset < header_start || header_end > file_len || payload_offset > file_len { return Err(CartridgeError::InvalidFormat); } if header_end != payload_offset { return Err(CartridgeError::InvalidFormat); } file.seek(SeekFrom::Start(header_start as u64)).map_err(|_| CartridgeError::IoError)?; let mut header_bytes = vec![0_u8; header_len]; file.read_exact(&mut header_bytes).map_err(|_| CartridgeError::InvalidFormat)?; let header: AssetsPackHeader = serde_json::from_slice(&header_bytes).map_err(|_| CartridgeError::InvalidFormat)?; validate_preload(&header.asset_table, &header.preload)?; let payload_len = u64::try_from(file_len - payload_offset).map_err(|_| CartridgeError::InvalidFormat)?; Ok(ParsedAssetsPack { header, payload_offset: prelude.payload_offset, payload_len }) } fn validate_preload( asset_table: &[AssetEntry], preload: &[crate::asset::PreloadEntry], ) -> Result<(), CartridgeError> { let mut claimed_slots = HashSet::<(BankType, usize)>::new(); for item in preload { let entry = asset_table .iter() .find(|entry| entry.asset_id == item.asset_id) .ok_or(CartridgeError::InvalidFormat)?; if !claimed_slots.insert((entry.bank_type, item.slot)) { return Err(CartridgeError::InvalidFormat); } } Ok(()) } #[cfg(test)] mod tests { use super::*; use crate::asset::{AssetEntry, BankType, PreloadEntry}; use crate::cartridge::{ASSETS_PA_MAGIC, ASSETS_PA_SCHEMA_VERSION, AssetsPackPrelude}; use crate::tile_bank::TILE_BANK_PALETTE_COUNT_V1; use serde_json::json; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; static TEST_DIR_COUNTER: AtomicU64 = AtomicU64::new(0); struct TestCartridgeDir { path: PathBuf, } impl TestCartridgeDir { fn new(manifest: serde_json::Value) -> Self { let unique = TEST_DIR_COUNTER.fetch_add(1, Ordering::Relaxed); let timestamp = SystemTime::now() .duration_since(UNIX_EPOCH) .expect("system time must be after unix epoch") .as_nanos(); let path = std::env::temp_dir() .join(format!("prometeu-hal-cartridge-loader-{}-{}", timestamp, unique)); fs::create_dir_all(&path).expect("must create temporary cartridge directory"); fs::write( path.join("manifest.json"), serde_json::to_vec_pretty(&manifest).expect("manifest must serialize"), ) .expect("must write manifest.json"); fs::write(path.join("program.pbx"), [0x01_u8, 0x02, 0x03]).expect("must write program"); Self { path } } fn path(&self) -> &Path { &self.path } fn write_assets_pa( &self, asset_table: Vec, preload: Vec, payload: &[u8], ) { let header = serde_json::to_vec(&AssetsPackHeader { asset_table, preload }) .expect("assets header must serialize"); let payload_offset = (ASSETS_PA_PRELUDE_SIZE + header.len()) as u64; let prelude = AssetsPackPrelude { magic: ASSETS_PA_MAGIC, schema_version: ASSETS_PA_SCHEMA_VERSION, header_len: header.len() as u32, payload_offset, flags: 0, reserved: 0, header_checksum: 0, }; let mut bytes = prelude.to_bytes().to_vec(); bytes.extend_from_slice(&header); bytes.extend_from_slice(payload); fs::write(self.path.join("assets.pa"), bytes).expect("must write assets.pa"); } } impl Drop for TestCartridgeDir { fn drop(&mut self) { let _ = fs::remove_dir_all(&self.path); } } fn manifest_with_capabilities(capabilities: Option>) -> serde_json::Value { let mut manifest = json!({ "magic": "PMTU", "cartridge_version": 1, "app_id": 1001, "title": "Example", "app_version": "1.0.0", "app_mode": "Game" }); if let Some(capabilities) = capabilities { manifest["capabilities"] = json!(capabilities); } manifest } #[test] fn load_without_capabilities_defaults_to_none() { let dir = TestCartridgeDir::new(manifest_with_capabilities(None)); let cartridge = DirectoryCartridgeLoader::load(dir.path()).expect("cartridge must load"); assert_eq!(cartridge.capabilities, caps::NONE); } #[test] fn load_with_single_capability_normalizes_to_flag() { let dir = TestCartridgeDir::new(manifest_with_capabilities(Some(vec!["gfx"]))); let cartridge = DirectoryCartridgeLoader::load(dir.path()).expect("cartridge must load"); assert_eq!(cartridge.capabilities, caps::GFX); } #[test] fn load_with_multiple_capabilities_combines_flags() { let dir = TestCartridgeDir::new(manifest_with_capabilities(Some(vec!["gfx", "audio"]))); let cartridge = DirectoryCartridgeLoader::load(dir.path()).expect("cartridge must load"); assert_eq!(cartridge.capabilities, caps::GFX | caps::AUDIO); } #[test] fn load_with_all_capability_normalizes_to_all_flags() { let dir = TestCartridgeDir::new(manifest_with_capabilities(Some(vec!["all"]))); dir.write_assets_pa(vec![], vec![], &[]); let cartridge = DirectoryCartridgeLoader::load(dir.path()).expect("cartridge must load"); assert_eq!(cartridge.capabilities, caps::ALL); } #[test] fn load_with_none_capability_keeps_zero_flags() { let dir = TestCartridgeDir::new(manifest_with_capabilities(Some(vec!["none"]))); let cartridge = DirectoryCartridgeLoader::load(dir.path()).expect("cartridge must load"); assert_eq!(cartridge.capabilities, caps::NONE); } #[test] fn load_with_duplicate_capabilities_fails() { let dir = TestCartridgeDir::new(manifest_with_capabilities(Some(vec!["gfx", "gfx"]))); let error = DirectoryCartridgeLoader::load(dir.path()).unwrap_err(); assert!(matches!(error, CartridgeError::InvalidManifest)); } #[test] fn load_with_unknown_capability_fails() { let dir = TestCartridgeDir::new(manifest_with_capabilities(Some(vec!["network"]))); let error = DirectoryCartridgeLoader::load(dir.path()).unwrap_err(); assert!(matches!(error, CartridgeError::InvalidManifest)); } #[test] fn load_with_legacy_input_capability_fails() { let dir = TestCartridgeDir::new(manifest_with_capabilities(Some(vec!["input"]))); let error = DirectoryCartridgeLoader::load(dir.path()).unwrap_err(); assert!(matches!(error, CartridgeError::InvalidManifest)); } fn test_asset_entry(offset: u64, size: u64) -> AssetEntry { AssetEntry { asset_id: 7, asset_name: "tiles".to_string(), bank_type: BankType::TILES, offset, size, decoded_size: 16 * 16 + (TILE_BANK_PALETTE_COUNT_V1 as u64 * 16 * 2), codec: "NONE".to_string(), metadata: json!({ "tile_size": 16, "width": 16, "height": 16, "palette_count": TILE_BANK_PALETTE_COUNT_V1 }), } } #[test] fn load_with_asset_capability_requires_assets_pa() { let dir = TestCartridgeDir::new(manifest_with_capabilities(Some(vec!["asset"]))); let error = DirectoryCartridgeLoader::load(dir.path()).unwrap_err(); assert!(matches!(error, CartridgeError::MissingAssets)); } #[test] fn load_without_asset_capability_accepts_missing_assets_pa() { let dir = TestCartridgeDir::new(manifest_with_capabilities(Some(vec!["gfx"]))); let cartridge = DirectoryCartridgeLoader::load(dir.path()).expect("cartridge must load"); assert!(cartridge.assets.is_empty()); assert!(cartridge.asset_table.is_empty()); assert!(cartridge.preload.is_empty()); } #[test] fn load_reads_asset_table_and_preload_from_assets_pa_header() { let dir = TestCartridgeDir::new(manifest_with_capabilities(Some(vec!["asset"]))); let payload = vec![1_u8, 2, 3, 4]; dir.write_assets_pa( vec![test_asset_entry(0, payload.len() as u64)], vec![PreloadEntry { asset_id: 7, slot: 2 }], &payload, ); let cartridge = DirectoryCartridgeLoader::load(dir.path()).expect("cartridge must load"); let slice = cartridge .assets .open_slice(0, payload.len() as u64) .expect("payload slice must open") .read_all() .expect("payload slice must read"); assert_eq!(slice, payload); assert_eq!(cartridge.asset_table.len(), 1); assert_eq!(cartridge.asset_table[0].asset_name, "tiles"); assert_eq!(cartridge.preload.len(), 1); assert_eq!(cartridge.preload[0].asset_id, 7); assert_eq!(cartridge.preload[0].slot, 2); } #[test] fn load_rejects_preload_with_missing_asset_id() { let dir = TestCartridgeDir::new(manifest_with_capabilities(Some(vec!["asset"]))); dir.write_assets_pa( vec![test_asset_entry(0, 4)], vec![PreloadEntry { asset_id: 999, slot: 2 }], &[1_u8, 2, 3, 4], ); let error = DirectoryCartridgeLoader::load(dir.path()).unwrap_err(); assert!(matches!(error, CartridgeError::InvalidFormat)); } #[test] fn load_rejects_preload_slot_clash_per_bank_type() { let dir = TestCartridgeDir::new(manifest_with_capabilities(Some(vec!["asset"]))); let asset_table = vec![ test_asset_entry(0, 4), AssetEntry { asset_id: 8, asset_name: "other_tiles".to_string(), bank_type: BankType::TILES, offset: 4, size: 4, decoded_size: 16 * 16 + (TILE_BANK_PALETTE_COUNT_V1 as u64 * 16 * 2), codec: "NONE".to_string(), metadata: json!({ "tile_size": 16, "width": 16, "height": 16, "palette_count": TILE_BANK_PALETTE_COUNT_V1 }), }, ]; let preload = vec![PreloadEntry { asset_id: 7, slot: 2 }, PreloadEntry { asset_id: 8, slot: 2 }]; dir.write_assets_pa(asset_table, preload, &[1_u8, 2, 3, 4, 5, 6, 7, 8]); let error = DirectoryCartridgeLoader::load(dir.path()).unwrap_err(); assert!(matches!(error, CartridgeError::InvalidFormat)); } #[test] fn load_rejects_invalid_assets_pa_prelude() { let dir = TestCartridgeDir::new(manifest_with_capabilities(Some(vec!["asset"]))); fs::write(dir.path().join("assets.pa"), b"not-a-valid-pack").expect("must write assets.pa"); let error = DirectoryCartridgeLoader::load(dir.path()).unwrap_err(); assert!(matches!(error, CartridgeError::InvalidFormat)); } #[test] fn load_rejects_invalid_assets_pa_header_json() { let dir = TestCartridgeDir::new(manifest_with_capabilities(Some(vec!["asset"]))); let prelude = AssetsPackPrelude { magic: ASSETS_PA_MAGIC, schema_version: ASSETS_PA_SCHEMA_VERSION, header_len: 4, payload_offset: (ASSETS_PA_PRELUDE_SIZE + 4) as u64, flags: 0, reserved: 0, header_checksum: 0, }; let mut bytes = prelude.to_bytes().to_vec(); bytes.extend_from_slice(b"{no}"); fs::write(dir.path().join("assets.pa"), bytes).expect("must write assets.pa"); let error = DirectoryCartridgeLoader::load(dir.path()).unwrap_err(); assert!(matches!(error, CartridgeError::InvalidFormat)); } }