- Replaced Bincode with Postcard for serialization/deserialization across configs and save operations. - Simplified struct derives by removing `bincode`-specific traits. - Updated `Cargo.toml` and `Cargo.lock` to include `postcard` and dependencies. - Added padding stripping for deserialization and improved error handling. - Adjusted serialization logic in `savegame_manager.rs` and related modules.
266 lines
9.7 KiB
Rust
266 lines
9.7 KiB
Rust
use alloc::string::ToString;
|
|
use alloc::vec::Vec;
|
|
use embedded_savegame::storage::{Flash, Storage};
|
|
use embedded_storage::nor_flash::{NorFlash, ReadNorFlash};
|
|
use esp_bootloader_esp_idf::partitions::{Error as PartitionError, FlashRegion};
|
|
use log::{error, info};
|
|
use serde::Serialize;
|
|
|
|
use crate::fat_error::{FatError, FatResult};
|
|
use crate::hal::shared_flash::MutexFlashStorage;
|
|
|
|
/// Size of each save slot in bytes (16 KB).
|
|
pub const SAVEGAME_SLOT_SIZE: usize = 16384;
|
|
//keep a little of space at the end due to partition table offsets
|
|
const SAFETY: usize = 5;
|
|
/// Number of slots in the 8 MB storage partition.
|
|
pub const SAVEGAME_SLOT_COUNT: usize = (8 * 1024 * 1024) / SAVEGAME_SLOT_SIZE - SAFETY; // 507
|
|
|
|
/// Metadata about a single existing save slot, returned by [`SavegameManager::list_saves`].
|
|
#[derive(Serialize, Debug, Clone)]
|
|
pub struct SaveInfo {
|
|
pub idx: usize,
|
|
pub len: u32,
|
|
/// UTC timestamp in RFC3339 format when the save was created
|
|
pub created_at: Option<alloc::string::String>,
|
|
}
|
|
|
|
const SAVE_MAGIC: [u8; 4] = *b"SGM1";
|
|
const SAVE_HEADER_LEN: usize = 6; // magic (4) + timestamp_len (u16)
|
|
|
|
struct ParsedSave<'a> {
|
|
created_at: &'a str,
|
|
data: &'a [u8],
|
|
}
|
|
|
|
fn strip_padding(data: &[u8]) -> &[u8] {
|
|
let mut end = data.len();
|
|
while end > 0 {
|
|
let b = data[end - 1];
|
|
if b == 0x00 || b == 0xFF {
|
|
end -= 1;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
&data[..end]
|
|
}
|
|
|
|
fn parse_save(data: &[u8]) -> FatResult<ParsedSave<'_>> {
|
|
if data.len() < SAVE_HEADER_LEN {
|
|
return Err(FatError::String {
|
|
error: "Save payload too short".into(),
|
|
});
|
|
}
|
|
if data[..4] != SAVE_MAGIC {
|
|
return Err(FatError::String {
|
|
error: "Save payload has invalid magic".into(),
|
|
});
|
|
}
|
|
|
|
let timestamp_len = u16::from_le_bytes([data[4], data[5]]) as usize;
|
|
let timestamp_end = SAVE_HEADER_LEN + timestamp_len;
|
|
if timestamp_end > data.len() {
|
|
return Err(FatError::String {
|
|
error: "Save payload timestamp length exceeds data".into(),
|
|
});
|
|
}
|
|
|
|
let created_at = core::str::from_utf8(&data[SAVE_HEADER_LEN..timestamp_end]).map_err(|e| {
|
|
FatError::String {
|
|
error: alloc::format!("Save payload contains invalid timestamp UTF-8: {e:?}"),
|
|
}
|
|
})?;
|
|
|
|
Ok(ParsedSave {
|
|
created_at,
|
|
data: strip_padding(&data[timestamp_end..]),
|
|
})
|
|
}
|
|
|
|
// ── Flash adapter ──────────────────────────────────────────────────────────────
|
|
|
|
/// Newtype wrapper around a [`PartitionError`] so we can implement the
|
|
/// [`core::fmt::Debug`] bound required by [`embedded_savegame::storage::Flash`].
|
|
#[derive(Debug)]
|
|
pub struct SavegameFlashError(#[allow(dead_code)] PartitionError);
|
|
|
|
/// Adapts a `&mut FlashRegion<'static, MutexFlashStorage>` to the
|
|
/// [`embedded_savegame::storage::Flash`] trait.
|
|
///
|
|
/// `erase(addr)` erases exactly one slot (`SAVEGAME_SLOT_SIZE` bytes) starting
|
|
/// at `addr`, which is what embedded-savegame expects for NOR flash.
|
|
pub struct SavegameFlashAdapter<'a> {
|
|
region: &'a mut FlashRegion<'static, MutexFlashStorage>,
|
|
}
|
|
|
|
impl Flash for SavegameFlashAdapter<'_> {
|
|
type Error = SavegameFlashError;
|
|
|
|
fn read(&mut self, addr: u32, buf: &mut [u8]) -> Result<(), Self::Error> {
|
|
ReadNorFlash::read(self.region, addr, buf).map_err(SavegameFlashError)
|
|
}
|
|
|
|
fn write(&mut self, addr: u32, data: &mut [u8]) -> Result<(), Self::Error> {
|
|
info!(
|
|
"Relative writing to flash at 0x{:x} with length {}",
|
|
addr,
|
|
data.len()
|
|
);
|
|
let error = NorFlash::write(self.region, addr, data);
|
|
if let Err(err) = error {
|
|
error!("error {:?}", err);
|
|
}
|
|
error.map_err(SavegameFlashError)
|
|
}
|
|
|
|
/// Erase one full slot at `addr`.
|
|
/// embedded-savegame calls this before writing to a slot, so we erase
|
|
/// the entire `SAVEGAME_SLOT_SIZE` bytes so subsequent writes land on
|
|
/// pre-erased (0xFF) pages.
|
|
/// Ensures addresses are aligned to ERASE_SIZE (4KB) boundaries.
|
|
fn erase(&mut self, addr: u32) -> Result<(), Self::Error> {
|
|
const ERASE_SIZE: u32 = 4096;
|
|
// Align start address down to erase boundary
|
|
let aligned_start = (addr / ERASE_SIZE) * ERASE_SIZE;
|
|
// Align end address up to erase boundary
|
|
let end = addr + SAVEGAME_SLOT_SIZE as u32;
|
|
let aligned_end = end.div_ceil(ERASE_SIZE) * ERASE_SIZE;
|
|
|
|
info!(
|
|
"Relative erasing flash at 0x{:x} (aligned to 0x{:x}-0x{:x})",
|
|
addr, aligned_start, aligned_end
|
|
);
|
|
|
|
if aligned_start != addr || aligned_end != end {
|
|
log::warn!("Flash erase address not aligned: addr=0x{:x}, slot_size=0x{:x}. Aligned to 0x{:x}-0x{:x}", addr, SAVEGAME_SLOT_SIZE, aligned_start, aligned_end);
|
|
}
|
|
|
|
match NorFlash::erase(self.region, aligned_start, aligned_end) {
|
|
Ok(_) => Ok(()),
|
|
Err(err) => {
|
|
error!(
|
|
"Flash erase failed: {:?}. 0x{:x}-0x{:x}",
|
|
err, aligned_start, aligned_end
|
|
);
|
|
Err(SavegameFlashError(err))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<SavegameFlashError> for FatError {
|
|
fn from(e: SavegameFlashError) -> Self {
|
|
FatError::String {
|
|
error: alloc::format!("Savegame flash error: {:?}", e),
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── SavegameManager ────────────────────────────────────────────────────────────
|
|
|
|
/// High-level save-game manager that stores JSON config blobs on the storage
|
|
/// partition using [`embedded_savegame`] for wear leveling and power-fail safety.
|
|
pub struct SavegameManager {
|
|
storage: Storage<SavegameFlashAdapter<'static>, SAVEGAME_SLOT_SIZE, SAVEGAME_SLOT_COUNT>,
|
|
}
|
|
|
|
impl SavegameManager {
|
|
pub fn new(region: &'static mut FlashRegion<'static, MutexFlashStorage>) -> Self {
|
|
Self {
|
|
storage: Storage::new(SavegameFlashAdapter { region }),
|
|
}
|
|
}
|
|
|
|
/// Persist `data` (JSON bytes) to the next available slot with a UTC timestamp.
|
|
///
|
|
/// `scan()` advances the internal wear-leveling pointer to the latest valid
|
|
/// slot before `append()` writes to the next free one.
|
|
/// Both operations are performed atomically on the same Storage instance.
|
|
pub fn save(&mut self, data: &[u8], timestamp: &str) -> FatResult<()> {
|
|
let timestamp_bytes = timestamp.as_bytes();
|
|
let timestamp_len: u16 =
|
|
timestamp_bytes
|
|
.len()
|
|
.try_into()
|
|
.map_err(|_| FatError::String {
|
|
error: "Timestamp too long for save header".into(),
|
|
})?;
|
|
|
|
let mut serialized =
|
|
Vec::with_capacity(SAVE_HEADER_LEN + timestamp_bytes.len() + data.len());
|
|
serialized.extend_from_slice(&SAVE_MAGIC);
|
|
serialized.extend_from_slice(×tamp_len.to_le_bytes());
|
|
serialized.extend_from_slice(timestamp_bytes);
|
|
serialized.extend_from_slice(data);
|
|
|
|
// Flash storage often requires length to be a multiple of 4.
|
|
let padding = (4 - (serialized.len() % 4)) % 4;
|
|
if padding > 0 {
|
|
serialized.extend_from_slice(&[0u8; 4][..padding]);
|
|
}
|
|
|
|
info!("Serialized config with size {} (padded)", serialized.len());
|
|
self.storage.append(&mut serialized)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Load the most recently saved data. Returns `None` if no valid save exists.
|
|
/// Unwraps the SaveWrapper and returns only the config data.
|
|
pub fn load_latest(&mut self) -> FatResult<Option<Vec<u8>>> {
|
|
let slot = self.storage.scan()?;
|
|
match slot {
|
|
None => Ok(None),
|
|
Some(slot) => self.load_slot(slot.idx),
|
|
}
|
|
}
|
|
|
|
/// Load a specific save by slot index. Returns `None` if the slot is
|
|
/// empty or contains an invalid checksum.
|
|
/// Unwraps the SaveWrapper and returns only the config data.
|
|
pub fn load_slot(&mut self, idx: usize) -> FatResult<Option<Vec<u8>>> {
|
|
let mut buf = alloc::vec![0u8; SAVEGAME_SLOT_SIZE];
|
|
match self.storage.read(idx, &mut buf)? {
|
|
None => Ok(None),
|
|
Some(data) => {
|
|
let parsed = parse_save(data)?;
|
|
Ok(Some(parsed.data.to_vec()))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Erase a specific slot by index, effectively deleting it.
|
|
pub fn delete_slot(&mut self, idx: usize) -> FatResult<()> {
|
|
self.storage.erase(idx).map_err(Into::into)
|
|
}
|
|
|
|
/// Iterate all slots and return metadata for every slot that contains a
|
|
/// valid save, using the Storage read API to avoid assuming internal slot structure.
|
|
/// Extracts timestamps from SaveWrapper if available.
|
|
pub fn list_saves(&mut self) -> FatResult<Vec<SaveInfo>> {
|
|
let mut saves = Vec::new();
|
|
let mut buf = alloc::vec![0u8; SAVEGAME_SLOT_SIZE];
|
|
for idx in 0..SAVEGAME_SLOT_COUNT {
|
|
if let Some(data) = self.storage.read(idx, &mut buf)? {
|
|
match parse_save(data) {
|
|
Ok(save) => {
|
|
saves.push(SaveInfo {
|
|
idx,
|
|
len: save.data.len() as u32,
|
|
created_at: Some(alloc::string::String::from(save.created_at)),
|
|
});
|
|
}
|
|
Err(err) => {
|
|
saves.push(SaveInfo {
|
|
idx,
|
|
len: 0,
|
|
created_at: Some(err.to_string()),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Ok(saves)
|
|
}
|
|
}
|