2026-03-24 13:40:56 +00:00

501 lines
17 KiB
Rust

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<Path>) -> Result<Cartridge, CartridgeError> {
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<Cartridge, CartridgeError> {
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<Cartridge, CartridgeError> {
Err(CartridgeError::InvalidFormat)
}
}
fn normalize_capabilities(capabilities: &[Capability]) -> Result<CapFlags, CartridgeError> {
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<ParsedAssetsPack, CartridgeError> {
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<AssetEntry>,
preload: Vec<PreloadEntry>,
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<Vec<&str>>) -> 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));
}
}