diff --git a/crates/console/prometeu-hal/src/cartridge.rs b/crates/console/prometeu-hal/src/cartridge.rs index 1d5f2b76..35e1d198 100644 --- a/crates/console/prometeu-hal/src/cartridge.rs +++ b/crates/console/prometeu-hal/src/cartridge.rs @@ -2,6 +2,10 @@ use crate::asset::{AssetEntry, PreloadEntry}; use crate::syscalls::CapFlags; use serde::{Deserialize, Serialize}; +pub const ASSETS_PA_MAGIC: [u8; 4] = *b"ASPA"; +pub const ASSETS_PA_SCHEMA_VERSION: u32 = 1; +pub const ASSETS_PA_PRELUDE_SIZE: usize = 32; + #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] pub enum AppMode { Game, @@ -55,6 +59,58 @@ impl From for Cartridge { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AssetsPackPrelude { + pub magic: [u8; 4], + pub schema_version: u32, + pub header_len: u32, + pub payload_offset: u64, + pub flags: u32, + pub reserved: u32, + pub header_checksum: u32, +} + +impl AssetsPackPrelude { + pub fn from_bytes(bytes: &[u8]) -> Option { + if bytes.len() < ASSETS_PA_PRELUDE_SIZE { + return None; + } + + let mut magic = [0_u8; 4]; + magic.copy_from_slice(&bytes[0..4]); + + Some(Self { + magic, + schema_version: u32::from_le_bytes(bytes[4..8].try_into().ok()?), + header_len: u32::from_le_bytes(bytes[8..12].try_into().ok()?), + payload_offset: u64::from_le_bytes(bytes[12..20].try_into().ok()?), + flags: u32::from_le_bytes(bytes[20..24].try_into().ok()?), + reserved: u32::from_le_bytes(bytes[24..28].try_into().ok()?), + header_checksum: u32::from_le_bytes(bytes[28..32].try_into().ok()?), + }) + } + + pub fn to_bytes(self) -> [u8; ASSETS_PA_PRELUDE_SIZE] { + let mut bytes = [0_u8; ASSETS_PA_PRELUDE_SIZE]; + bytes[0..4].copy_from_slice(&self.magic); + bytes[4..8].copy_from_slice(&self.schema_version.to_le_bytes()); + bytes[8..12].copy_from_slice(&self.header_len.to_le_bytes()); + bytes[12..20].copy_from_slice(&self.payload_offset.to_le_bytes()); + bytes[20..24].copy_from_slice(&self.flags.to_le_bytes()); + bytes[24..28].copy_from_slice(&self.reserved.to_le_bytes()); + bytes[28..32].copy_from_slice(&self.header_checksum.to_le_bytes()); + bytes + } +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct AssetsPackHeader { + #[serde(default)] + pub asset_table: Vec, + #[serde(default)] + pub preload: Vec, +} + #[derive(Debug)] pub enum CartridgeError { NotFound, @@ -62,6 +118,7 @@ pub enum CartridgeError { InvalidManifest, UnsupportedVersion, MissingProgram, + MissingAssets, IoError, } @@ -90,8 +147,4 @@ pub struct CartridgeManifest { pub entrypoint: String, #[serde(default)] pub capabilities: Vec, - #[serde(default)] - pub asset_table: Vec, - #[serde(default)] - pub preload: Vec, } diff --git a/crates/console/prometeu-hal/src/cartridge_loader.rs b/crates/console/prometeu-hal/src/cartridge_loader.rs index d114ee59..d0ae94f1 100644 --- a/crates/console/prometeu-hal/src/cartridge_loader.rs +++ b/crates/console/prometeu-hal/src/cartridge_loader.rs @@ -1,4 +1,7 @@ -use crate::cartridge::{Capability, Cartridge, CartridgeDTO, CartridgeError, CartridgeManifest}; +use crate::cartridge::{ + ASSETS_PA_MAGIC, ASSETS_PA_PRELUDE_SIZE, ASSETS_PA_SCHEMA_VERSION, AssetsPackHeader, + AssetsPackPrelude, Capability, Cartridge, CartridgeDTO, CartridgeError, CartridgeManifest, +}; use crate::syscalls::{CapFlags, caps}; use std::collections::HashSet; use std::fs; @@ -55,10 +58,16 @@ impl DirectoryCartridgeLoader { let program = fs::read(program_path).map_err(|_| CartridgeError::IoError)?; let assets_pa_path = path.join("assets.pa"); - let assets = if assets_pa_path.exists() { - fs::read(assets_pa_path).map_err(|_| CartridgeError::IoError)? + let (assets, asset_table, preload) = if assets_pa_path.exists() { + let assets_pa = fs::read(assets_pa_path).map_err(|_| CartridgeError::IoError)?; + let parsed = parse_assets_pack(&assets_pa)?; + (parsed.payload.to_vec(), parsed.header.asset_table, parsed.header.preload) } else { - Vec::new() + if capabilities & caps::ASSET != 0 { + return Err(CartridgeError::MissingAssets); + } + + (Vec::new(), Vec::new(), Vec::new()) }; let dto = CartridgeDTO { @@ -70,8 +79,8 @@ impl DirectoryCartridgeLoader { capabilities, program, assets, - asset_table: manifest.asset_table, - preload: manifest.preload, + asset_table, + preload, }; Ok(Cartridge::from(dto)) @@ -111,9 +120,44 @@ fn normalize_capabilities(capabilities: &[Capability]) -> Result { + header: AssetsPackHeader, + payload: &'a [u8], +} + +fn parse_assets_pack(bytes: &[u8]) -> Result, CartridgeError> { + let prelude = + AssetsPackPrelude::from_bytes(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_end = header_start + .checked_add(prelude.header_len as usize) + .ok_or(CartridgeError::InvalidFormat)?; + let payload_offset = usize::try_from(prelude.payload_offset).map_err(|_| CartridgeError::InvalidFormat)?; + + if payload_offset < header_start || header_end > bytes.len() || payload_offset > bytes.len() { + return Err(CartridgeError::InvalidFormat); + } + if header_end != payload_offset { + return Err(CartridgeError::InvalidFormat); + } + + let header_bytes = &bytes[header_start..header_end]; + let header: AssetsPackHeader = + serde_json::from_slice(header_bytes).map_err(|_| CartridgeError::InvalidFormat)?; + + Ok(ParsedAssetsPack { header, payload: &bytes[payload_offset..] }) +} + #[cfg(test)] mod tests { use super::*; + use crate::asset::{AssetEntry, BankType, PreloadEntry}; + use crate::cartridge::{ASSETS_PA_MAGIC, ASSETS_PA_SCHEMA_VERSION, AssetsPackPrelude}; use serde_json::json; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; @@ -149,6 +193,31 @@ mod tests { 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 { @@ -205,6 +274,7 @@ mod tests { #[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"); @@ -246,4 +316,91 @@ mod tests { 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: size, + codec: "RAW".to_string(), + metadata: json!({ + "tile_size": 16, + "width": 16, + "height": 16 + }), + } + } + + #[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_name: "tiles".to_string(), slot: 2 }], + &payload, + ); + + let cartridge = DirectoryCartridgeLoader::load(dir.path()).expect("cartridge must load"); + + assert_eq!(cartridge.assets, 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].slot, 2); + } + + #[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)); + } }