implements memcard

This commit is contained in:
bQUARKz 2026-03-09 08:25:33 +00:00
parent f714db77fc
commit 27d26b328b
Signed by: bquarkz
SSH Key Fingerprint: SHA256:Z7dgqoglWwoK6j6u4QC87OveEq74WOhFN+gitsxtkf8
20 changed files with 959 additions and 108 deletions

View File

@ -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,

View File

@ -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,
),
];

View File

@ -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",

View File

@ -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);
}

View File

@ -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<u8>,
pub bytes_read: u32,
}
pub struct MemcardWriteResult {
pub status: MemcardStatus,
pub bytes_written: u32,
}
#[derive(Debug, Clone)]
struct SlotImage {
payload: Vec<u8>,
generation: u64,
checksum: u32,
save_uuid: [u8; 16],
}
#[derive(Default)]
pub struct MemcardService {
staged: HashMap<(u32, u8), Vec<u8>>,
}
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<Option<SlotImage>, 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<u8> {
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<SlotImage, MemcardStatus> {
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<String, Vec<u8>>,
}
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<Vec<FsEntry>, FsError> {
Ok(Vec::new())
}
fn read_file(&self, path: &str) -> Result<Vec<u8>, 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);
}
}

View File

@ -1 +1,2 @@
pub mod fs;
pub mod memcard;

View File

@ -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<u32, String>,
pub next_handle: u32,
pub log_service: LogService,

View File

@ -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<f64, VmFau
_ => Err(VmFault::Trap(TRAP_TYPE, format!("Expected number for {}", field))),
}
}
fn expect_slot_index(args: &[Value], index: usize) -> Result<u8, VmFault> {
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<usize, VmFault> {
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<Vec<u8>, VmFault> {
fn nibble(c: u8) -> Option<u8> {
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)
}

View File

@ -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();

View File

@ -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<String, Vec<u8>>,
}
impl FsBackend for MemFsBackend {
fn mount(&mut self) -> Result<(), FsError> {
Ok(())
}
fn unmount(&mut self) {}
fn list_dir(&self, _path: &str) -> Result<Vec<FsEntry>, FsError> {
Ok(Vec::new())
}
fn read_file(&self, path: &str) -> Result<Vec<u8>, 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<u8>, 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)]);
}

View File

@ -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.

View File

@ -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:

View File

@ -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

View File

@ -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.

View File

@ -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:

View File

@ -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

View File

@ -7,143 +7,134 @@ Didactic companion: [`../learn/save-memory-and-memcard.md`](../learn/save-memory
## 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<Bytes>`) 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/<app_id>/memcard/slot_<NN>.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.]

View File

@ -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.

View File

@ -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.