implements PR-011b assets.pa bootstrap and header loading

This commit is contained in:
bQUARKz 2026-03-11 06:44:35 +00:00
parent 9b65005ffb
commit d3321c4400
Signed by: bquarkz
SSH Key Fingerprint: SHA256:Z7dgqoglWwoK6j6u4QC87OveEq74WOhFN+gitsxtkf8
2 changed files with 220 additions and 10 deletions

View File

@ -2,6 +2,10 @@ use crate::asset::{AssetEntry, PreloadEntry};
use crate::syscalls::CapFlags; use crate::syscalls::CapFlags;
use serde::{Deserialize, Serialize}; 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)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
pub enum AppMode { pub enum AppMode {
Game, Game,
@ -55,6 +59,58 @@ impl From<CartridgeDTO> 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<Self> {
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<AssetEntry>,
#[serde(default)]
pub preload: Vec<PreloadEntry>,
}
#[derive(Debug)] #[derive(Debug)]
pub enum CartridgeError { pub enum CartridgeError {
NotFound, NotFound,
@ -62,6 +118,7 @@ pub enum CartridgeError {
InvalidManifest, InvalidManifest,
UnsupportedVersion, UnsupportedVersion,
MissingProgram, MissingProgram,
MissingAssets,
IoError, IoError,
} }
@ -90,8 +147,4 @@ pub struct CartridgeManifest {
pub entrypoint: String, pub entrypoint: String,
#[serde(default)] #[serde(default)]
pub capabilities: Vec<Capability>, pub capabilities: Vec<Capability>,
#[serde(default)]
pub asset_table: Vec<AssetEntry>,
#[serde(default)]
pub preload: Vec<PreloadEntry>,
} }

View File

@ -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 crate::syscalls::{CapFlags, caps};
use std::collections::HashSet; use std::collections::HashSet;
use std::fs; use std::fs;
@ -55,10 +58,16 @@ impl DirectoryCartridgeLoader {
let program = fs::read(program_path).map_err(|_| CartridgeError::IoError)?; let program = fs::read(program_path).map_err(|_| CartridgeError::IoError)?;
let assets_pa_path = path.join("assets.pa"); let assets_pa_path = path.join("assets.pa");
let assets = if assets_pa_path.exists() { let (assets, asset_table, preload) = if assets_pa_path.exists() {
fs::read(assets_pa_path).map_err(|_| CartridgeError::IoError)? 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 { } else {
Vec::new() if capabilities & caps::ASSET != 0 {
return Err(CartridgeError::MissingAssets);
}
(Vec::new(), Vec::new(), Vec::new())
}; };
let dto = CartridgeDTO { let dto = CartridgeDTO {
@ -70,8 +79,8 @@ impl DirectoryCartridgeLoader {
capabilities, capabilities,
program, program,
assets, assets,
asset_table: manifest.asset_table, asset_table,
preload: manifest.preload, preload,
}; };
Ok(Cartridge::from(dto)) Ok(Cartridge::from(dto))
@ -111,9 +120,44 @@ fn normalize_capabilities(capabilities: &[Capability]) -> Result<CapFlags, Cartr
Ok(normalized) Ok(normalized)
} }
struct ParsedAssetsPack<'a> {
header: AssetsPackHeader,
payload: &'a [u8],
}
fn parse_assets_pack(bytes: &[u8]) -> Result<ParsedAssetsPack<'_>, 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::asset::{AssetEntry, BankType, PreloadEntry};
use crate::cartridge::{ASSETS_PA_MAGIC, ASSETS_PA_SCHEMA_VERSION, AssetsPackPrelude};
use serde_json::json; use serde_json::json;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, Ordering};
@ -149,6 +193,31 @@ mod tests {
fn path(&self) -> &Path { fn path(&self) -> &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 { impl Drop for TestCartridgeDir {
@ -205,6 +274,7 @@ mod tests {
#[test] #[test]
fn load_with_all_capability_normalizes_to_all_flags() { fn load_with_all_capability_normalizes_to_all_flags() {
let dir = TestCartridgeDir::new(manifest_with_capabilities(Some(vec!["all"]))); 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"); let cartridge = DirectoryCartridgeLoader::load(dir.path()).expect("cartridge must load");
@ -246,4 +316,91 @@ mod tests {
assert!(matches!(error, CartridgeError::InvalidManifest)); 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));
}
} }