implements memcard
This commit is contained in:
parent
f714db77fc
commit
27d26b328b
@ -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,
|
||||
|
||||
@ -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,
|
||||
),
|
||||
];
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
425
crates/console/prometeu-system/src/services/memcard.rs
Normal file
425
crates/console/prometeu-system/src/services/memcard.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -1 +1,2 @@
|
||||
pub mod fs;
|
||||
pub mod memcard;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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)]);
|
||||
}
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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:
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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.]
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user