diff --git a/crates/console/prometeu-hal/src/syscalls.rs b/crates/console/prometeu-hal/src/syscalls.rs index 7349a385..9565b8bb 100644 --- a/crates/console/prometeu-hal/src/syscalls.rs +++ b/crates/console/prometeu-hal/src/syscalls.rs @@ -47,6 +47,12 @@ pub enum Syscall { FsListDir = 0x4005, FsExists = 0x4006, FsDelete = 0x4007, + MemSlotCount = 0x4201, + MemSlotStat = 0x4202, + MemSlotRead = 0x4203, + MemSlotWrite = 0x4204, + MemSlotCommit = 0x4205, + MemSlotClear = 0x4206, LogWrite = 0x5001, LogWriteTag = 0x5002, AssetLoad = 0x6001, diff --git a/crates/console/prometeu-hal/src/syscalls/domains/fs.rs b/crates/console/prometeu-hal/src/syscalls/domains/fs.rs index cd060cbf..93ea1877 100644 --- a/crates/console/prometeu-hal/src/syscalls/domains/fs.rs +++ b/crates/console/prometeu-hal/src/syscalls/domains/fs.rs @@ -75,4 +75,76 @@ pub(crate) const ENTRIES: &[SyscallRegistryEntry] = &[ false, 20, ), + entry( + Syscall::MemSlotCount, + "mem", + "slot_count", + 1, + 0, + 2, + caps::FS, + Determinism::Deterministic, + false, + 1, + ), + entry( + Syscall::MemSlotStat, + "mem", + "slot_stat", + 1, + 1, + 5, + caps::FS, + Determinism::NonDeterministic, + false, + 5, + ), + entry( + Syscall::MemSlotRead, + "mem", + "slot_read", + 1, + 3, + 3, + caps::FS, + Determinism::NonDeterministic, + false, + 20, + ), + entry( + Syscall::MemSlotWrite, + "mem", + "slot_write", + 1, + 3, + 2, + caps::FS, + Determinism::NonDeterministic, + false, + 20, + ), + entry( + Syscall::MemSlotCommit, + "mem", + "slot_commit", + 1, + 1, + 1, + caps::FS, + Determinism::NonDeterministic, + false, + 20, + ), + entry( + Syscall::MemSlotClear, + "mem", + "slot_clear", + 1, + 1, + 1, + caps::FS, + Determinism::NonDeterministic, + false, + 20, + ), ]; diff --git a/crates/console/prometeu-hal/src/syscalls/registry.rs b/crates/console/prometeu-hal/src/syscalls/registry.rs index 920805f6..50d90367 100644 --- a/crates/console/prometeu-hal/src/syscalls/registry.rs +++ b/crates/console/prometeu-hal/src/syscalls/registry.rs @@ -32,6 +32,12 @@ impl Syscall { 0x4005 => Some(Self::FsListDir), 0x4006 => Some(Self::FsExists), 0x4007 => Some(Self::FsDelete), + 0x4201 => Some(Self::MemSlotCount), + 0x4202 => Some(Self::MemSlotStat), + 0x4203 => Some(Self::MemSlotRead), + 0x4204 => Some(Self::MemSlotWrite), + 0x4205 => Some(Self::MemSlotCommit), + 0x4206 => Some(Self::MemSlotClear), 0x5001 => Some(Self::LogWrite), 0x5002 => Some(Self::LogWriteTag), 0x6001 => Some(Self::AssetLoad), @@ -74,6 +80,12 @@ impl Syscall { Self::FsListDir => "FsListDir", Self::FsExists => "FsExists", Self::FsDelete => "FsDelete", + Self::MemSlotCount => "MemSlotCount", + Self::MemSlotStat => "MemSlotStat", + Self::MemSlotRead => "MemSlotRead", + Self::MemSlotWrite => "MemSlotWrite", + Self::MemSlotCommit => "MemSlotCommit", + Self::MemSlotClear => "MemSlotClear", Self::LogWrite => "LogWrite", Self::LogWriteTag => "LogWriteTag", Self::AssetLoad => "AssetLoad", diff --git a/crates/console/prometeu-hal/src/syscalls/tests.rs b/crates/console/prometeu-hal/src/syscalls/tests.rs index b71466db..775ee9f8 100644 --- a/crates/console/prometeu-hal/src/syscalls/tests.rs +++ b/crates/console/prometeu-hal/src/syscalls/tests.rs @@ -267,3 +267,32 @@ fn declared_resolver_rejects_legacy_status_first_signatures() { } } } + +#[test] +fn memcard_syscall_signatures_are_pinned() { + let slot_count = meta_for(Syscall::MemSlotCount); + assert_eq!(slot_count.module, "mem"); + assert_eq!(slot_count.name, "slot_count"); + assert_eq!(slot_count.arg_slots, 0); + assert_eq!(slot_count.ret_slots, 2); + + let slot_stat = meta_for(Syscall::MemSlotStat); + assert_eq!(slot_stat.arg_slots, 1); + assert_eq!(slot_stat.ret_slots, 5); + + let slot_read = meta_for(Syscall::MemSlotRead); + assert_eq!(slot_read.arg_slots, 3); + assert_eq!(slot_read.ret_slots, 3); + + let slot_write = meta_for(Syscall::MemSlotWrite); + assert_eq!(slot_write.arg_slots, 3); + assert_eq!(slot_write.ret_slots, 2); + + let slot_commit = meta_for(Syscall::MemSlotCommit); + assert_eq!(slot_commit.arg_slots, 1); + assert_eq!(slot_commit.ret_slots, 1); + + let slot_clear = meta_for(Syscall::MemSlotClear); + assert_eq!(slot_clear.arg_slots, 1); + assert_eq!(slot_clear.ret_slots, 1); +} diff --git a/crates/console/prometeu-system/src/services/memcard.rs b/crates/console/prometeu-system/src/services/memcard.rs new file mode 100644 index 00000000..661dba87 --- /dev/null +++ b/crates/console/prometeu-system/src/services/memcard.rs @@ -0,0 +1,425 @@ +use crate::fs::{FsError, VirtualFS}; +use std::collections::HashMap; + +pub const MEMCARD_SLOT_COUNT: usize = 32; +pub const MEMCARD_SLOT_CAPACITY_BYTES: usize = 32 * 1024; + +const SLOT_FILE_MAGIC: &[u8; 4] = b"PMMS"; +const SLOT_FILE_VERSION: u8 = 1; +const SLOT_HEADER_SIZE: usize = 4 + 1 + 16 + 8 + 4 + 4; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(i32)] +pub enum MemcardStatus { + Ok = 0, + Empty = 1, + NotFound = 2, + NoSpace = 3, + AccessDenied = 4, + Corrupt = 5, + Conflict = 6, + Unavailable = 7, + InvalidState = 8, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(i32)] +pub enum MemcardSlotState { + Empty = 0, + Staged = 1, + Committed = 2, + Corrupt = 3, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MemcardSlotStat { + pub state: MemcardSlotState, + pub used_bytes: u32, + pub generation: u64, + pub checksum: u32, +} + +pub struct MemcardReadResult { + pub status: MemcardStatus, + pub bytes: Vec, + pub bytes_read: u32, +} + +pub struct MemcardWriteResult { + pub status: MemcardStatus, + pub bytes_written: u32, +} + +#[derive(Debug, Clone)] +struct SlotImage { + payload: Vec, + generation: u64, + checksum: u32, + save_uuid: [u8; 16], +} + +#[derive(Default)] +pub struct MemcardService { + staged: HashMap<(u32, u8), Vec>, +} + +impl MemcardService { + pub fn new() -> Self { + Self::default() + } + + pub fn clear_all_staging(&mut self) { + self.staged.clear(); + } + + pub fn slot_count(&self) -> usize { + MEMCARD_SLOT_COUNT + } + + pub fn slot_stat(&self, fs: &VirtualFS, app_id: u32, slot: u8) -> MemcardSlotStat { + if let Some(staged_payload) = self.staged.get(&(app_id, slot)) { + let generation = match self.load_committed(fs, app_id, slot) { + Ok(Some(committed)) => committed.generation.saturating_add(1), + _ => 1, + }; + return MemcardSlotStat { + state: MemcardSlotState::Staged, + used_bytes: staged_payload.len() as u32, + generation, + checksum: checksum32(staged_payload), + }; + } + + match self.load_committed(fs, app_id, slot) { + Ok(Some(committed)) => MemcardSlotStat { + state: MemcardSlotState::Committed, + used_bytes: committed.payload.len() as u32, + generation: committed.generation, + checksum: committed.checksum, + }, + Ok(None) => MemcardSlotStat { + state: MemcardSlotState::Empty, + used_bytes: 0, + generation: 0, + checksum: 0, + }, + Err(MemcardStatus::Corrupt) => MemcardSlotStat { + state: MemcardSlotState::Corrupt, + used_bytes: 0, + generation: 0, + checksum: 0, + }, + Err(_) => MemcardSlotStat { + state: MemcardSlotState::Empty, + used_bytes: 0, + generation: 0, + checksum: 0, + }, + } + } + + pub fn slot_read( + &self, + fs: &VirtualFS, + app_id: u32, + slot: u8, + offset: usize, + max_bytes: usize, + ) -> MemcardReadResult { + if let Some(staged_payload) = self.staged.get(&(app_id, slot)) { + return Self::slice_payload(staged_payload, offset, max_bytes); + } + + match self.load_committed(fs, app_id, slot) { + Ok(Some(committed)) => Self::slice_payload(&committed.payload, offset, max_bytes), + Ok(None) => MemcardReadResult { + status: MemcardStatus::Empty, + bytes: Vec::new(), + bytes_read: 0, + }, + Err(status) => MemcardReadResult { status, bytes: Vec::new(), bytes_read: 0 }, + } + } + + pub fn slot_write( + &mut self, + fs: &VirtualFS, + app_id: u32, + slot: u8, + offset: usize, + data: &[u8], + ) -> MemcardWriteResult { + let end = match offset.checked_add(data.len()) { + Some(v) => v, + None => { + return MemcardWriteResult { status: MemcardStatus::NoSpace, bytes_written: 0 }; + } + }; + if end > MEMCARD_SLOT_CAPACITY_BYTES { + return MemcardWriteResult { status: MemcardStatus::NoSpace, bytes_written: 0 }; + } + + let mut payload = if let Some(staged_payload) = self.staged.get(&(app_id, slot)) { + staged_payload.clone() + } else { + match self.load_committed(fs, app_id, slot) { + Ok(Some(committed)) => committed.payload, + Ok(None) => Vec::new(), + Err(status) => { + return MemcardWriteResult { status, bytes_written: 0 }; + } + } + }; + + if offset > payload.len() { + payload.resize(offset, 0); + } + if end > payload.len() { + payload.resize(end, 0); + } + payload[offset..end].copy_from_slice(data); + + self.staged.insert((app_id, slot), payload); + MemcardWriteResult { status: MemcardStatus::Ok, bytes_written: data.len() as u32 } + } + + pub fn slot_commit(&mut self, fs: &mut VirtualFS, app_id: u32, slot: u8) -> MemcardStatus { + let Some(staged_payload) = self.staged.get(&(app_id, slot)).cloned() else { + return MemcardStatus::InvalidState; + }; + + let (save_uuid, generation) = match self.load_committed(fs, app_id, slot) { + Ok(Some(committed)) => (committed.save_uuid, committed.generation.saturating_add(1)), + Ok(None) => (make_save_uuid(app_id, slot), 1), + Err(status) => return status, + }; + + let checksum = checksum32(&staged_payload); + let encoded = encode_slot_file(SlotImage { + payload: staged_payload, + generation, + checksum, + save_uuid, + }); + + let path = slot_path(app_id, slot); + match fs.write_file(&path, &encoded) { + Ok(()) => { + self.staged.remove(&(app_id, slot)); + MemcardStatus::Ok + } + Err(err) => map_fs_error(err), + } + } + + pub fn slot_clear(&mut self, fs: &mut VirtualFS, app_id: u32, slot: u8) -> MemcardStatus { + self.staged.remove(&(app_id, slot)); + let path = slot_path(app_id, slot); + match fs.delete(&path) { + Ok(()) => MemcardStatus::Ok, + Err(FsError::NotFound) => MemcardStatus::Empty, + Err(err) => map_fs_error(err), + } + } + + fn slice_payload(payload: &[u8], offset: usize, max_bytes: usize) -> MemcardReadResult { + if offset >= payload.len() || max_bytes == 0 { + return MemcardReadResult { status: MemcardStatus::Ok, bytes: Vec::new(), bytes_read: 0 }; + } + let end = payload.len().min(offset.saturating_add(max_bytes)); + let bytes = payload[offset..end].to_vec(); + MemcardReadResult { status: MemcardStatus::Ok, bytes_read: bytes.len() as u32, bytes } + } + + fn load_committed( + &self, + fs: &VirtualFS, + app_id: u32, + slot: u8, + ) -> Result, MemcardStatus> { + let path = slot_path(app_id, slot); + match fs.read_file(&path) { + Ok(bytes) => decode_slot_file(&bytes).map(Some), + Err(FsError::NotFound) => Ok(None), + Err(err) => Err(map_fs_error(err)), + } + } +} + +fn slot_path(app_id: u32, slot: u8) -> String { + format!("/user/games/{}/memcard/slot_{:02}.pmem", app_id, slot) +} + +fn map_fs_error(err: FsError) -> MemcardStatus { + match err { + FsError::NotFound => MemcardStatus::NotFound, + FsError::AlreadyExists => MemcardStatus::Conflict, + FsError::PermissionDenied => MemcardStatus::AccessDenied, + FsError::NotMounted | FsError::IOError(_) | FsError::Other(_) | FsError::InvalidPath(_) => { + MemcardStatus::Unavailable + } + } +} + +fn checksum32(data: &[u8]) -> u32 { + let mut a: u32 = 1; + let mut b: u32 = 0; + const MOD: u32 = 65521; + for &byte in data { + a = (a + byte as u32) % MOD; + b = (b + a) % MOD; + } + (b << 16) | a +} + +fn make_save_uuid(app_id: u32, slot: u8) -> [u8; 16] { + let mut out = [0u8; 16]; + out[0..4].copy_from_slice(&app_id.to_le_bytes()); + out[4] = slot; + out[5..13].copy_from_slice(&(std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos() as u64) + .unwrap_or(0)) + .to_le_bytes()); + out[13] = 0x50; + out[14] = 0x4D; + out[15] = 0x31; + out +} + +fn encode_slot_file(slot: SlotImage) -> Vec { + let mut out = Vec::with_capacity(SLOT_HEADER_SIZE + slot.payload.len()); + out.extend_from_slice(SLOT_FILE_MAGIC); + out.push(SLOT_FILE_VERSION); + out.extend_from_slice(&slot.save_uuid); + out.extend_from_slice(&slot.generation.to_le_bytes()); + out.extend_from_slice(&slot.checksum.to_le_bytes()); + out.extend_from_slice(&(slot.payload.len() as u32).to_le_bytes()); + out.extend_from_slice(&slot.payload); + out +} + +fn decode_slot_file(bytes: &[u8]) -> Result { + if bytes.len() < SLOT_HEADER_SIZE { + return Err(MemcardStatus::Corrupt); + } + if &bytes[0..4] != SLOT_FILE_MAGIC { + return Err(MemcardStatus::Corrupt); + } + if bytes[4] != SLOT_FILE_VERSION { + return Err(MemcardStatus::Corrupt); + } + + let mut save_uuid = [0u8; 16]; + save_uuid.copy_from_slice(&bytes[5..21]); + let generation = u64::from_le_bytes(bytes[21..29].try_into().map_err(|_| MemcardStatus::Corrupt)?); + let checksum = u32::from_le_bytes(bytes[29..33].try_into().map_err(|_| MemcardStatus::Corrupt)?); + let payload_size = + u32::from_le_bytes(bytes[33..37].try_into().map_err(|_| MemcardStatus::Corrupt)?) as usize; + if payload_size > MEMCARD_SLOT_CAPACITY_BYTES { + return Err(MemcardStatus::Corrupt); + } + if bytes.len() != SLOT_HEADER_SIZE + payload_size { + return Err(MemcardStatus::Corrupt); + } + + let payload = bytes[SLOT_HEADER_SIZE..].to_vec(); + if checksum32(&payload) != checksum { + return Err(MemcardStatus::Corrupt); + } + + Ok(SlotImage { payload, generation, checksum, save_uuid }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::fs::{FsBackend, FsEntry}; + use std::collections::HashMap; + + struct MockBackend { + files: HashMap>, + } + + impl MockBackend { + fn new() -> Self { + Self { files: HashMap::new() } + } + } + + impl FsBackend for MockBackend { + fn mount(&mut self) -> Result<(), FsError> { + Ok(()) + } + fn unmount(&mut self) {} + fn list_dir(&self, _path: &str) -> Result, FsError> { + Ok(Vec::new()) + } + fn read_file(&self, path: &str) -> Result, FsError> { + self.files.get(path).cloned().ok_or(FsError::NotFound) + } + fn write_file(&mut self, path: &str, data: &[u8]) -> Result<(), FsError> { + self.files.insert(path.to_string(), data.to_vec()); + Ok(()) + } + fn delete(&mut self, path: &str) -> Result<(), FsError> { + self.files.remove(path).map(|_| ()).ok_or(FsError::NotFound) + } + fn exists(&self, path: &str) -> bool { + self.files.contains_key(path) + } + fn is_healthy(&self) -> bool { + true + } + } + + #[test] + fn slot_roundtrip_commit_and_generation() { + let mut fs = VirtualFS::new(); + fs.mount(Box::new(MockBackend::new())).expect("mount"); + let mut mem = MemcardService::new(); + + let write = mem.slot_write(&fs, 10, 0, 0, b"hello"); + assert_eq!(write.status, MemcardStatus::Ok); + assert_eq!(write.bytes_written, 5); + + let staged = mem.slot_stat(&fs, 10, 0); + assert_eq!(staged.state, MemcardSlotState::Staged); + assert_eq!(staged.generation, 1); + + assert_eq!(mem.slot_commit(&mut fs, 10, 0), MemcardStatus::Ok); + let committed = mem.slot_stat(&fs, 10, 0); + assert_eq!(committed.state, MemcardSlotState::Committed); + assert_eq!(committed.generation, 1); + + let write2 = mem.slot_write(&fs, 10, 0, 5, b"!"); + assert_eq!(write2.status, MemcardStatus::Ok); + assert_eq!(mem.slot_commit(&mut fs, 10, 0), MemcardStatus::Ok); + let committed2 = mem.slot_stat(&fs, 10, 0); + assert_eq!(committed2.generation, 2); + + let read = mem.slot_read(&fs, 10, 0, 0, 10); + assert_eq!(read.status, MemcardStatus::Ok); + assert_eq!(read.bytes, b"hello!"); + assert_eq!(read.bytes_read, 6); + } + + #[test] + fn slot_clear_missing_is_empty() { + let mut fs = VirtualFS::new(); + fs.mount(Box::new(MockBackend::new())).expect("mount"); + let mut mem = MemcardService::new(); + assert_eq!(mem.slot_clear(&mut fs, 1, 1), MemcardStatus::Empty); + } + + #[test] + fn slot_stat_reports_corruption() { + let mut fs = VirtualFS::new(); + let mut backend = MockBackend::new(); + backend.files.insert(slot_path(7, 2), vec![0, 1, 2, 3, 4]); + fs.mount(Box::new(backend)).expect("mount"); + let mem = MemcardService::new(); + let stat = mem.slot_stat(&fs, 7, 2); + assert_eq!(stat.state, MemcardSlotState::Corrupt); + } +} diff --git a/crates/console/prometeu-system/src/services/mod.rs b/crates/console/prometeu-system/src/services/mod.rs index d521fbd7..0eb81e85 100644 --- a/crates/console/prometeu-system/src/services/mod.rs +++ b/crates/console/prometeu-system/src/services/mod.rs @@ -1 +1,2 @@ pub mod fs; +pub mod memcard; diff --git a/crates/console/prometeu-system/src/virtual_machine_runtime.rs b/crates/console/prometeu-system/src/virtual_machine_runtime.rs index 02f6b9c2..753f0ed6 100644 --- a/crates/console/prometeu-system/src/virtual_machine_runtime.rs +++ b/crates/console/prometeu-system/src/virtual_machine_runtime.rs @@ -6,6 +6,7 @@ mod tick; use crate::CrashReport; use crate::fs::{FsState, VirtualFS}; +use crate::services::memcard::MemcardService; use prometeu_hal::cartridge::AppMode; use prometeu_hal::log::LogService; use prometeu_hal::telemetry::{CertificationConfig, Certifier, TelemetryFrame}; @@ -21,6 +22,7 @@ pub struct VirtualMachineRuntime { pub last_frame_cpu_time_us: u64, pub fs: VirtualFS, pub fs_state: FsState, + pub memcard: MemcardService, pub open_files: HashMap, pub next_handle: u32, pub log_service: LogService, diff --git a/crates/console/prometeu-system/src/virtual_machine_runtime/dispatch.rs b/crates/console/prometeu-system/src/virtual_machine_runtime/dispatch.rs index 5caf7151..7d1c88e0 100644 --- a/crates/console/prometeu-system/src/virtual_machine_runtime/dispatch.rs +++ b/crates/console/prometeu-system/src/virtual_machine_runtime/dispatch.rs @@ -1,6 +1,8 @@ use super::*; use prometeu_bytecode::{TRAP_INVALID_SYSCALL, TRAP_OOB, TRAP_TYPE, Value}; +use crate::services::memcard::{MemcardSlotState, MemcardStatus}; use prometeu_hal::asset::{AssetLoadError, AssetOpStatus, BankType, LoadStatus, SlotRef}; +use prometeu_hal::cartridge::AppMode; use prometeu_hal::color::Color; use prometeu_hal::log::{LogLevel, LogSource}; use prometeu_hal::sprite::Sprite; @@ -346,6 +348,100 @@ impl NativeInterface for VirtualMachineRuntime { } Ok(()) } + Syscall::MemSlotCount => { + if self.current_cartridge_app_mode != AppMode::Game { + ret.push_int(MemcardStatus::AccessDenied as i64); + ret.push_int(0); + return Ok(()); + } + ret.push_int(MemcardStatus::Ok as i64); + ret.push_int(self.memcard.slot_count() as i64); + Ok(()) + } + Syscall::MemSlotStat => { + let slot = expect_slot_index(args, 0)?; + if self.current_cartridge_app_mode != AppMode::Game { + ret.push_int(MemcardStatus::AccessDenied as i64); + ret.push_int(MemcardSlotState::Empty as i64); + ret.push_int(0); + ret.push_int(0); + ret.push_int(0); + return Ok(()); + } + let stat = self.memcard.slot_stat(&self.fs, self.current_app_id, slot); + let status = if stat.state == MemcardSlotState::Corrupt { + MemcardStatus::Corrupt + } else { + MemcardStatus::Ok + }; + ret.push_int(status as i64); + ret.push_int(stat.state as i64); + ret.push_int(stat.used_bytes as i64); + ret.push_int(stat.generation as i64); + ret.push_int(stat.checksum as i64); + Ok(()) + } + Syscall::MemSlotRead => { + let slot = expect_slot_index(args, 0)?; + let offset = expect_non_negative_usize(args, 1, "offset")?; + let max_bytes = expect_non_negative_usize(args, 2, "max_bytes")?; + if self.current_cartridge_app_mode != AppMode::Game { + ret.push_int(MemcardStatus::AccessDenied as i64); + ret.push_string(String::new()); + ret.push_int(0); + return Ok(()); + } + let read = + self.memcard.slot_read(&self.fs, self.current_app_id, slot, offset, max_bytes); + ret.push_int(read.status as i64); + ret.push_string(hex_encode(&read.bytes)); + ret.push_int(read.bytes_read as i64); + Ok(()) + } + Syscall::MemSlotWrite => { + let slot = expect_slot_index(args, 0)?; + let offset = expect_non_negative_usize(args, 1, "offset")?; + let payload_hex = expect_string(args, 2, "payload_hex")?; + if self.current_cartridge_app_mode != AppMode::Game { + ret.push_int(MemcardStatus::AccessDenied as i64); + ret.push_int(0); + return Ok(()); + } + let payload = hex_decode(&payload_hex)?; + let write = + self.memcard.slot_write(&self.fs, self.current_app_id, slot, offset, &payload); + ret.push_int(write.status as i64); + ret.push_int(write.bytes_written as i64); + Ok(()) + } + Syscall::MemSlotCommit => { + let slot = expect_slot_index(args, 0)?; + if self.current_cartridge_app_mode != AppMode::Game { + ret.push_int(MemcardStatus::AccessDenied as i64); + return Ok(()); + } + let status = { + let memcard = &mut self.memcard; + let fs = &mut self.fs; + memcard.slot_commit(fs, self.current_app_id, slot) + }; + ret.push_int(status as i64); + Ok(()) + } + Syscall::MemSlotClear => { + let slot = expect_slot_index(args, 0)?; + if self.current_cartridge_app_mode != AppMode::Game { + ret.push_int(MemcardStatus::AccessDenied as i64); + return Ok(()); + } + let status = { + let memcard = &mut self.memcard; + let fs = &mut self.fs; + memcard.slot_clear(fs, self.current_app_id, slot) + }; + ret.push_int(status as i64); + Ok(()) + } Syscall::LogWrite => { self.syscall_log_write( expect_int(args, 0)?, @@ -454,3 +550,56 @@ fn expect_number(args: &[Value], index: usize, field: &str) -> Result Err(VmFault::Trap(TRAP_TYPE, format!("Expected number for {}", field))), } } + +fn expect_slot_index(args: &[Value], index: usize) -> Result { + let slot = expect_int(args, index)?; + if !(0..32).contains(&slot) { + return Err(VmFault::Trap(TRAP_OOB, format!("slot index out of bounds: {}", slot))); + } + Ok(slot as u8) +} + +fn expect_non_negative_usize(args: &[Value], index: usize, field: &str) -> Result { + let val = expect_int(args, index)?; + if val < 0 { + return Err(VmFault::Trap(TRAP_OOB, format!("{} must be non-negative", field))); + } + Ok(val as usize) +} + +fn hex_encode(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut out = String::with_capacity(bytes.len() * 2); + for &b in bytes { + out.push(HEX[(b >> 4) as usize] as char); + out.push(HEX[(b & 0x0f) as usize] as char); + } + out +} + +fn hex_decode(s: &str) -> Result, VmFault> { + fn nibble(c: u8) -> Option { + match c { + b'0'..=b'9' => Some(c - b'0'), + b'a'..=b'f' => Some(10 + c - b'a'), + b'A'..=b'F' => Some(10 + c - b'A'), + _ => None, + } + } + + let bytes = s.as_bytes(); + if bytes.len() % 2 != 0 { + return Err(VmFault::Trap(TRAP_TYPE, "payload_hex must have even length".to_string())); + } + let mut out = Vec::with_capacity(bytes.len() / 2); + let mut i = 0usize; + while i < bytes.len() { + let hi = nibble(bytes[i]) + .ok_or_else(|| VmFault::Trap(TRAP_TYPE, "payload_hex contains invalid hex".to_string()))?; + let lo = nibble(bytes[i + 1]) + .ok_or_else(|| VmFault::Trap(TRAP_TYPE, "payload_hex contains invalid hex".to_string()))?; + out.push((hi << 4) | lo); + i += 2; + } + Ok(out) +} diff --git a/crates/console/prometeu-system/src/virtual_machine_runtime/lifecycle.rs b/crates/console/prometeu-system/src/virtual_machine_runtime/lifecycle.rs index 217c9b50..b62511b8 100644 --- a/crates/console/prometeu-system/src/virtual_machine_runtime/lifecycle.rs +++ b/crates/console/prometeu-system/src/virtual_machine_runtime/lifecycle.rs @@ -15,6 +15,7 @@ impl VirtualMachineRuntime { last_frame_cpu_time_us: 0, fs: VirtualFS::new(), fs_state: FsState::Unmounted, + memcard: MemcardService::new(), open_files: HashMap::new(), next_handle: 1, log_service: LogService::new(4096), @@ -90,6 +91,7 @@ impl VirtualMachineRuntime { self.open_files.clear(); self.next_handle = 1; + self.memcard.clear_all_staging(); self.current_app_id = 0; self.current_cartridge_title.clear(); diff --git a/crates/console/prometeu-system/src/virtual_machine_runtime/tests.rs b/crates/console/prometeu-system/src/virtual_machine_runtime/tests.rs index 84a5cea4..93f523d4 100644 --- a/crates/console/prometeu-system/src/virtual_machine_runtime/tests.rs +++ b/crates/console/prometeu-system/src/virtual_machine_runtime/tests.rs @@ -4,12 +4,52 @@ use prometeu_bytecode::Value; use prometeu_bytecode::assembler::assemble; use prometeu_bytecode::model::{BytecodeModule, ConstantPoolEntry, FunctionMeta, SyscallDecl}; use prometeu_drivers::hardware::Hardware; +use crate::fs::{FsBackend, FsEntry, FsError}; use prometeu_hal::AudioOpStatus; use prometeu_hal::asset::AssetOpStatus; use prometeu_hal::InputSignals; use prometeu_hal::cartridge::Cartridge; use prometeu_hal::syscalls::caps; use prometeu_vm::VmInitError; +use std::collections::HashMap; + +#[derive(Default)] +struct MemFsBackend { + files: HashMap>, +} + +impl FsBackend for MemFsBackend { + fn mount(&mut self) -> Result<(), FsError> { + Ok(()) + } + + fn unmount(&mut self) {} + + fn list_dir(&self, _path: &str) -> Result, FsError> { + Ok(Vec::new()) + } + + fn read_file(&self, path: &str) -> Result, FsError> { + self.files.get(path).cloned().ok_or(FsError::NotFound) + } + + fn write_file(&mut self, path: &str, data: &[u8]) -> Result<(), FsError> { + self.files.insert(path.to_string(), data.to_vec()); + Ok(()) + } + + fn delete(&mut self, path: &str) -> Result<(), FsError> { + self.files.remove(path).map(|_| ()).ok_or(FsError::NotFound) + } + + fn exists(&self, path: &str) -> bool { + self.files.contains_key(path) + } + + fn is_healthy(&self) -> bool { + true + } +} fn cartridge_with_program(program: Vec, capabilities: u64) -> Cartridge { Cartridge { @@ -404,3 +444,102 @@ fn tick_gfx_set_sprite_type_mismatch_surfaces_trap_not_panic() { } assert!(matches!(runtime.last_crash_report, Some(CrashReport::VmTrap { .. }))); } + +#[test] +fn tick_memcard_slot_roundtrip_for_game_profile() { + let mut runtime = VirtualMachineRuntime::new(None); + runtime.mount_fs(Box::new(MemFsBackend::default())); + let mut vm = VirtualMachine::default(); + let mut hardware = Hardware::new(); + let signals = InputSignals::default(); + let code = assemble( + "PUSH_I32 0\nPUSH_I32 0\nPUSH_CONST 0\nHOSTCALL 0\nPOP_N 2\nPUSH_I32 0\nHOSTCALL 1\nPOP_N 1\nPUSH_I32 0\nPUSH_I32 0\nPUSH_I32 10\nHOSTCALL 2\nHALT", + ) + .expect("assemble"); + let program = serialized_single_function_module_with_consts( + code, + vec![ConstantPoolEntry::String("6869".into())], // "hi" in hex + vec![ + SyscallDecl { + module: "mem".into(), + name: "slot_write".into(), + version: 1, + arg_slots: 3, + ret_slots: 2, + }, + SyscallDecl { + module: "mem".into(), + name: "slot_commit".into(), + version: 1, + arg_slots: 1, + ret_slots: 1, + }, + SyscallDecl { + module: "mem".into(), + name: "slot_read".into(), + version: 1, + arg_slots: 3, + ret_slots: 3, + }, + ], + ); + let cartridge = Cartridge { + app_id: 42, + title: "Memcard Game".into(), + app_version: "1.0.0".into(), + app_mode: AppMode::Game, + entrypoint: "".into(), + capabilities: caps::FS, + program, + assets: vec![], + asset_table: vec![], + preload: vec![], + }; + + runtime.initialize_vm(&mut vm, &cartridge).expect("runtime must initialize"); + let report = runtime.tick(&mut vm, &signals, &mut hardware); + assert!(report.is_none(), "memcard roundtrip must not crash"); + assert!(vm.is_halted()); + assert_eq!( + vm.operand_stack_top(3), + vec![Value::Int64(2), Value::String("6869".into()), Value::Int64(0)] + ); +} + +#[test] +fn tick_memcard_access_is_denied_for_non_game_profile() { + let mut runtime = VirtualMachineRuntime::new(None); + let mut vm = VirtualMachine::default(); + let mut hardware = Hardware::new(); + let signals = InputSignals::default(); + let code = assemble("HOSTCALL 0\nHALT").expect("assemble"); + let program = serialized_single_function_module( + code, + vec![SyscallDecl { + module: "mem".into(), + name: "slot_count".into(), + version: 1, + arg_slots: 0, + ret_slots: 2, + }], + ); + let cartridge = Cartridge { + app_id: 101, + title: "System App".into(), + app_version: "1.0.0".into(), + app_mode: AppMode::System, + entrypoint: "".into(), + capabilities: caps::FS, + program, + assets: vec![], + asset_table: vec![], + preload: vec![], + }; + + runtime.initialize_vm(&mut vm, &cartridge).expect("runtime must initialize"); + let report = runtime.tick(&mut vm, &signals, &mut hardware); + assert!(report.is_none(), "non-game memcard call must return status"); + assert!(vm.is_halted()); + // top-first: count then status + assert_eq!(vm.operand_stack_top(2), vec![Value::Int64(0), Value::Int64(4)]); +} diff --git a/docs/runtime/agendas/014-app-home-filesystem-surface-and-semantics.md b/docs/runtime/agendas/014-app-home-filesystem-surface-and-semantics.md index 98a1fecd..7a1445e4 100644 --- a/docs/runtime/agendas/014-app-home-filesystem-surface-and-semantics.md +++ b/docs/runtime/agendas/014-app-home-filesystem-surface-and-semantics.md @@ -116,7 +116,7 @@ No perfil `app` (`home` sandbox), esta agenda passa a ser a fonte normativa para ## Fora de Escopo -- slots de memcard para `game` (fechado na decisao `011`); +- slots de memcard para `game` (fechado na `spec 08`); - acesso cross-app; - sync remoto/cloud; - DB embutido como contrato de plataforma. diff --git a/docs/runtime/agendas/README.md b/docs/runtime/agendas/README.md index 3d245529..5e23282b 100644 --- a/docs/runtime/agendas/README.md +++ b/docs/runtime/agendas/README.md @@ -32,9 +32,9 @@ Justificativa curta: - `011` foi fechada pela decisao `006`. - `012` e o primeiro consumidor da base stateful VM-owned fechada em `006`. -- `013` foi fechada pela decisao `011` (memcard `32 x 32KB`, ownership, identidade e copia fora do jogo). +- `013` foi fechada e absorvida por `spec 08` (historico em `learn/011`). - `014` fecha o contrato de `home` para apps sem abrir FS global. -- a decisao `007` fixa o nucleo de fault policy de `fs`; os detalhes ficam distribuidos entre decisao `011` (`game`) e agenda `014` (`app`). +- a decisao `007` fixa o nucleo de fault policy de `fs`; os detalhes ficam distribuidos entre `spec 08` (`game`) e agenda `014` (`app`). - a decisao `008` fixa o contrato status-first de `gfx`. - a decisao `009` fixa o contrato status-first de `audio`. - a decisao `010` fixa o contrato status-first de `asset`. @@ -48,7 +48,7 @@ DependĂȘncias principais: - `014` depende das decisoes `003`/`007`, de `16a`, de `12` (Hub/OS) e de `13` (`app_mode`) - `007` depende da estabilizacao minima das agendas de superficie/fault por dominio - `008` depende de contrato fechado de `13-cartridge.md` + comportamento equivalente ao loader de diretorio -- `009` depende da decisao `003`, de `16a` e da decisao `006`, e deve alinhar com decisao `011`/agenda `014` quando usar `fs` +- `009` depende da decisao `003`, de `16a` e da decisao `006`, e deve alinhar com `spec 08`/agenda `014` quando usar `fs` - `010` depende de `16a` e da `009` Regra de uso: diff --git a/docs/runtime/decisions/003-vm-owned-byte-transfer-protocol.md b/docs/runtime/decisions/003-vm-owned-byte-transfer-protocol.md index 6c23c458..a15dfd6d 100644 --- a/docs/runtime/decisions/003-vm-owned-byte-transfer-protocol.md +++ b/docs/runtime/decisions/003-vm-owned-byte-transfer-protocol.md @@ -142,7 +142,7 @@ Para suportar este protocolo, a VM precisa expor API canonica para: As seguintes agendas devem consumir esta decisao: -- `011-game-memcard-slots-surface-and-semantics.md` (decisao que absorveu a agenda `013`) +- `spec 08-save-memory-and-memcard.md` (historico arquitetural em `learn/011`) - `014-app-home-filesystem-surface-and-semantics.md` - discussoes futuras de data bank diff --git a/docs/runtime/decisions/007-filesystem-fault-core-policy.md b/docs/runtime/decisions/007-filesystem-fault-core-policy.md index c3e4edd3..fb9a0a28 100644 --- a/docs/runtime/decisions/007-filesystem-fault-core-policy.md +++ b/docs/runtime/decisions/007-filesystem-fault-core-policy.md @@ -10,7 +10,7 @@ A agenda `003-filesystem-fault-semantics.md` existia para fechar fault semantics Com o split de `fs` em: -- decisao `011` (`game` memcard slots); +- `spec 08` (`game` memcard slots); - agenda `014` (`app` home sandbox); ficou claro que: @@ -66,7 +66,7 @@ Se o dominio nao conseguir concluir a escrita segundo seu contrato de atomicidad - retorna `status` de falha; - nao mascara o resultado como sucesso. -### 6. Responsabilidade por perfil (movida para decisao `011` e agenda `014`) +### 6. Responsabilidade por perfil (movida para `spec 08` e agenda `014`) Fica sob responsabilidade das agendas de superficie: @@ -89,10 +89,10 @@ A agenda `003-filesystem-fault-semantics.md` e considerada absorvida e removida. ### Custos - exige disciplina para manter a matriz de faults distribuida em duas agendas; -- exige revisao de consistencia entre decisao `011` e agenda `014` antes de PR final de implementacao. +- exige revisao de consistencia entre `spec 08` e agenda `014` antes de PR final de implementacao. ## Follow-up Obrigatorio -- `011-game-memcard-slots-surface-and-semantics.md` fecha a matriz de fault/status do perfil `game`; +- `spec 08-save-memory-and-memcard.md` fecha a matriz de fault/status do perfil `game`; - `014-app-home-filesystem-surface-and-semantics.md` deve incluir a matriz de fault/status do perfil `app`; - specs `16`/`16a` devem absorver esta decisao quando o contrato estiver implementado e estavel. diff --git a/docs/runtime/decisions/README.md b/docs/runtime/decisions/README.md index 6e2b58be..2a6e8b65 100644 --- a/docs/runtime/decisions/README.md +++ b/docs/runtime/decisions/README.md @@ -19,7 +19,6 @@ Decisoes ativas: - `003-vm-owned-byte-transfer-protocol.md` - `006-vm-owned-stateful-core-contract.md` - `007-filesystem-fault-core-policy.md` -- `011-game-memcard-slots-surface-and-semantics.md` Decisoes implementadas e aposentadas (migradas para `learn/`): @@ -32,6 +31,9 @@ Decisoes implementadas e aposentadas (migradas para `learn/`): - `010-asset-status-first-fault-and-return-contract.md` - spec: `../specs/15-asset-management.md` - impl: `crates/console/prometeu-system/src/virtual_machine_runtime/dispatch.rs`, `crates/console/prometeu-hal/src/syscalls/domains/asset.rs`, `crates/console/prometeu-hal/src/asset_bridge.rs`, `crates/console/prometeu-drivers/src/asset.rs` +- `011-game-memcard-slots-surface-and-semantics.md` + - spec: `../specs/08-save-memory-and-memcard.md`, `../specs/16-host-abi-and-syscalls.md`, `../specs/16a-syscall-policies.md` + - impl: `crates/console/prometeu-system/src/services/memcard.rs`, `crates/console/prometeu-system/src/virtual_machine_runtime/dispatch.rs`, `crates/console/prometeu-hal/src/syscalls/domains/fs.rs` Decisoes aposentadas que ja viraram spec: diff --git a/docs/runtime/decisions/011-game-memcard-slots-surface-and-semantics.md b/docs/runtime/learn/011-game-memcard-slots-surface-and-semantics.md similarity index 100% rename from docs/runtime/decisions/011-game-memcard-slots-surface-and-semantics.md rename to docs/runtime/learn/011-game-memcard-slots-surface-and-semantics.md diff --git a/docs/runtime/learn/README.md b/docs/runtime/learn/README.md index d6ccb323..2fcbcfb9 100644 --- a/docs/runtime/learn/README.md +++ b/docs/runtime/learn/README.md @@ -24,6 +24,7 @@ Ela existe para explicar o modelo mental da fantasy handheld, suas influĂȘncias, - [`008-gfx-status-first-fault-and-return-contract.md`](008-gfx-status-first-fault-and-return-contract.md) - [`009-audio-status-first-fault-and-return-contract.md`](009-audio-status-first-fault-and-return-contract.md) - [`010-asset-status-first-fault-and-return-contract.md`](010-asset-status-first-fault-and-return-contract.md) +- [`011-game-memcard-slots-surface-and-semantics.md`](011-game-memcard-slots-surface-and-semantics.md) ## Rules diff --git a/docs/runtime/specs/08-save-memory-and-memcard.md b/docs/runtime/specs/08-save-memory-and-memcard.md index e685b19d..a0cf9607 100644 --- a/docs/runtime/specs/08-save-memory-and-memcard.md +++ b/docs/runtime/specs/08-save-memory-and-memcard.md @@ -1,149 +1,140 @@ # Save Memory and MEMCARD -Domain: virtual hardware: save memory +Domain: virtual hardware: save memory Function: normative Didactic companion: [`../learn/save-memory-and-memcard.md`](../learn/save-memory-and-memcard.md) ## 1 Scope -This chapter defines the runtime-facing persistent save surface of PROMETEU. +This chapter defines the `game` persistence surface in PROMETEU. -The MEMCARD contract is: +MEMCARD is a runtime-owned service layered on top of the runtime filesystem. -- explicit persistence controlled by the game; -- fixed capacity determined by profile; -- host-backed slot storage; -- mandatory `commit()` to persist writes; -- explicit cost and integrity metadata. +Games do not get broad path-based filesystem access for saves. They use slot operations only. -Current model: +## 2 Profile Contract (`game`) -- Slot A is the required primary save device; -- additional slots remain future-facing surface. +The v1 contract is fixed: -Each slot corresponds to a host file such as: +- `32` slots per game (`slot_index: 0..31`); +- each slot has up to `32KB` payload; +- ownership is isolated by `app_id`; +- persistence is explicit through `slot_commit`; +- copy/export/import is a Hub/OS responsibility (outside game userland). -``` -MyGame_A.mem -MyGame_B.mem -``` +## 3 Slot Identity and Metadata -The runtime mounts this file as a **persistent storage device**. +Each slot has runtime-owned metadata: -## 2 Capacity and CAP +- `app_id` owner; +- `slot_index`; +- `save_uuid`; +- `generation`; +- `checksum`; +- `payload_size`; +- `state`. -The size of the MEMCARD is **fixed**, defined by the execution profile (CAP). +State values: -### 2.1 Suggested sizes +- `0 = EMPTY` +- `1 = STAGED` +- `2 = COMMITTED` +- `3 = CORRUPT` -| Profile | Size | +`generation` and `checksum` are not manually controlled by game code: + +- `generation` increments on successful `slot_commit`; +- `checksum` is recalculated by runtime for committed payload integrity. + +## 4 Host ABI Surface (v1) + +Canonical module: `mem` (version `1`). + +| Operation | Signature | | --- | --- | -| JAM | 8 KB | -| STANDARD | 32 KB | -| ADVANCED | 128 KB | +| `slot_count` | `mem.slot_count() -> (status:int, count:int)` | +| `slot_stat` | `mem.slot_stat(slot:int) -> (status:int, state:int, used_bytes:int, generation:int, checksum:int)` | +| `slot_read` | `mem.slot_read(slot:int, offset:int, max_bytes:int) -> (status:int, payload_hex:string, bytes_read:int)` | +| `slot_write` | `mem.slot_write(slot:int, offset:int, payload_hex:string) -> (status:int, bytes_written:int)` | +| `slot_commit` | `mem.slot_commit(slot:int) -> status:int` | +| `slot_clear` | `mem.slot_clear(slot:int) -> status:int` | -The game **cannot exceed** this size. +`status` is always the first return slot. -Attempts to write above the limit result in an error. +### 4.1 Byte transport note (v1) -## 3 Peripheral API (v0.1) +Current ABI transport for memcard payload is `payload_hex:string`. -### 3.1 Logical Interface +This is a temporary transport form until VM-owned bytes (`HeapRef`) is exposed in the host ABI path. -The MEMCARD exposes a **simple single-blob API**: +## 5 Operation Semantics -``` -mem.read_all() -> byte[] -mem.write_all(byte[]) -mem.commit() -mem.clear() -mem.size() -> int -``` +### `slot_count` -### 3.2 Operation Semantics +- returns slot capacity visible to game profile (`32`); +- does not depend on slot content. -#### `read_all()` +### `slot_stat` -- Returns all persisted content -- If the card is empty, returns a zeroed buffer -- Cycle cost proportional to size +- returns current state and integrity/version metadata for one slot. -#### `write_all(bytes)` +### `slot_read` -- Writes the buffer **to temporary memory** -- Does not persist immediately -- Fails if `bytes.length > mem.size()` +- reads from staged content when present, otherwise committed content; +- returns empty payload with success when offset is beyond current payload end. -#### `commit()` +### `slot_write` -- Persists data to the device -- **Mandatory** operation -- Simulates hardware flush -- May fail (e.g., I/O, simulated corruption) +- writes into slot staging buffer (does not persist directly); +- supports offset writes; +- does not allow resulting payload above `32KB`. -#### `clear()` +### `slot_commit` -- Zeroes the card content -- Requires `commit()` to persist +- persists staged payload for one slot; +- commit is atomic at slot operation level (all-or-error, no silent partial success). -#### `size()` +### `slot_clear` -- Returns total card capacity in bytes +- clears staged content and removes committed slot payload. -## 4 Explicit Commit Rule +## 6 Status Codes (Domain-Owned) -PROMETEU **does not save automatically**. +Minimum status catalog: -Without `commit()`: +- `0 = OK` +- `1 = EMPTY` +- `2 = NOT_FOUND` +- `3 = NO_SPACE` +- `4 = ACCESS_DENIED` +- `5 = CORRUPT` +- `6 = CONFLICT` +- `7 = UNAVAILABLE` +- `8 = INVALID_STATE` -- data remains volatile -- can be lost upon exiting the game -- simulates abrupt hardware shutdown +## 7 Fault Classification -## 5 Execution Cost (Cycles) +MEMCARD follows `16a`: -All MEMCARD operations have an explicit cost. +- `Trap`: structural violations (invalid slot index, invalid call shape, invalid window value type/range); +- `status`: operational domain conditions; +- `Panic`: runtime invariant break only. -### 5.1 Example (illustrative values) +Silent no-op is forbidden for operations with operational failure paths. -| Operation | Cost | -| --- | --- | -| read_all | 1 cycle / 256 bytes | -| write_all | 1 cycle / 256 bytes | -| commit | fixed + proportional cost | +## 8 Runtime Layering and Storage Mapping -These costs appear: +MEMCARD is a domain layer over runtime FS. -- in the profiler -- in the frame timeline -- in the CAP report +Runtime host mapping for v1 is namespace-isolated by `app_id`, for example: -## 6 `.mem` File Format +`/user/games//memcard/slot_.pmem` -The MEMCARD file has a simple and robust format. +The exact host-side file naming is internal to runtime/host as long as ownership and isolation guarantees are preserved. -### 6.1 Header +## 9 Explicit Persistence Rule -| Field | Size | -| --- | --- | -| Magic (`PMEM`) | 4 bytes | -| Version | 1 byte | -| Cart ID | 8 bytes | -| Payload Size | 4 bytes | -| CRC32 | 4 bytes | +PROMETEU does not auto-save slot staging data. -### 6.2 Payload - -- Binary buffer defined by the game -- Fixed size -- Content interpreted only by the game - -## 7 Integrity and Security - -- CRC validates corruption -- Cart ID prevents using wrong save -- Version allows future format evolution -- Runtime can: - - warn of corruption - - allow card reset +Without `slot_commit`, writes remain staged and are not guaranteed as persisted committed state.] \ No newline at end of file diff --git a/docs/runtime/specs/16-host-abi-and-syscalls.md b/docs/runtime/specs/16-host-abi-and-syscalls.md index 81923b01..46469736 100644 --- a/docs/runtime/specs/16-host-abi-and-syscalls.md +++ b/docs/runtime/specs/16-host-abi-and-syscalls.md @@ -156,6 +156,22 @@ Return shape must also follow the operational policy in [`16a-syscall-policies.m - operations with no real operational error path may remain `void` (`ret_slots = 0`); - stack shape remains strict in both cases and must match syscall metadata exactly. +### MEMCARD game surface (`mem`, v1) + +The game memcard profile uses module `mem` with status-first return shapes. +`mem` is a domain layer backed by runtime `fs` (it is not a separate storage backend). + +Canonical operations in v1 are: + +- `mem.slot_count() -> (status, count)` +- `mem.slot_stat(slot) -> (status, state, used_bytes, generation, checksum)` +- `mem.slot_read(slot, offset, max_bytes) -> (status, payload_hex, bytes_read)` +- `mem.slot_write(slot, offset, payload_hex) -> (status, bytes_written)` +- `mem.slot_commit(slot) -> status` +- `mem.slot_clear(slot) -> status` + +Semantics and domain status catalog are defined by [`08-save-memory-and-memcard.md`](08-save-memory-and-memcard.md). + ## 7 Syscalls as Callable Entities (Not First-Class) Syscalls behave like call sites, not like first-class guest values. diff --git a/docs/runtime/specs/16a-syscall-policies.md b/docs/runtime/specs/16a-syscall-policies.md index 709be357..aee31be6 100644 --- a/docs/runtime/specs/16a-syscall-policies.md +++ b/docs/runtime/specs/16a-syscall-policies.md @@ -66,12 +66,16 @@ Example groups: - `gfx` - `audio` - `asset` -- `memcard` +- `fs` (includes game memcard module `mem` in v1) Capability checks exist to constrain which host-managed surfaces a cartridge may use. Input in v1 is VM-owned intrinsic surface and is not capability-gated through syscall policy. +Game memcard operations (`mem.*`) are status-first and use `fs` capability in v1. +`mem` remains layered on runtime `fs`; no parallel persistence channel is introduced. +Domain surface, status catalog and slot semantics are defined in [`08-save-memory-and-memcard.md`](08-save-memory-and-memcard.md). + ## 3 Interaction with the Garbage Collector The VM heap and host-managed memory are separate.