From 0dcead3a7ad1f19c0df1dcd2ca0e1560b1898cf4 Mon Sep 17 00:00:00 2001 From: Jeffrey Wilcke Date: Mon, 12 Oct 2026 21:47:57 +0200 Subject: [PATCH] feat(save): format version 2 with cyberspace; refuse v1 by seed Co-Authored-By: Claude Fable 5.1 --- crates/cyberpunk/src/save.rs | 52 +++++++++++++++++++++++++++++++----- 1 file changed, 46 insertions(+), 6 deletions(-) diff --git a/crates/cyberpunk/src/save.rs b/crates/cyberpunk/src/save.rs index 3eea06b..feb5ec1 100644 --- a/crates/cyberpunk/src/save.rs +++ b/crates/cyberpunk/src/save.rs @@ -7,7 +7,9 @@ use std::path::{Path, PathBuf}; use std::time::SystemTime; pub const MAGIC: &[u8; 4] = b"CITY"; -pub const FORMAT_VERSION: u16 = 1; +pub const FORMAT_VERSION: u16 = 2; +/// Versions whose header this build can read (for listing); only `FORMAT_VERSION` loads. +const READABLE_VERSIONS: [u16; 2] = [1, 2]; #[derive(Debug, Serialize, Deserialize)] struct Header { @@ -58,12 +60,12 @@ pub fn save_city(path: &Path, city: &City) -> Result<()> { } /// Splits a file into (header, body) after checking magic and version. -fn split<'a>(bytes: &'a [u8], path: &Path) -> Result<(Header, &'a [u8])> { +fn split<'a>(bytes: &'a [u8], path: &Path) -> Result<(Header, u16, &'a [u8])> { if bytes.len() < 10 || &bytes[0..4] != MAGIC { bail!("{} is not a .city file", path.display()); } let version = u16::from_le_bytes([bytes[4], bytes[5]]); - if version != FORMAT_VERSION { + if !READABLE_VERSIONS.contains(&version) { bail!( "{} is .city version {version}; this build reads version {FORMAT_VERSION}", path.display() @@ -76,12 +78,19 @@ fn split<'a>(bytes: &'a [u8], path: &Path) -> Result<(Header, &'a [u8])> { } let header: Header = postcard::from_bytes(&bytes[10..header_end]) .with_context(|| format!("{}: corrupt header", path.display()))?; - Ok((header, &bytes[header_end..])) + Ok((header, version, &bytes[header_end..])) } pub fn load_city(path: &Path) -> Result { let bytes = std::fs::read(path).with_context(|| format!("reading {}", path.display()))?; - let (header, body) = split(&bytes, path)?; + let (header, version, body) = split(&bytes, path)?; + if version != FORMAT_VERSION { + bail!( + "{} was saved by an older version ({version}); regenerate it from seed {}", + path.display(), + header.seed + ); + } let city: City = postcard::from_bytes(body) .with_context(|| format!("{}: corrupt or truncated city data", path.display()))?; if (header.w, header.h) != (city.size.w, city.size.h) { @@ -131,7 +140,7 @@ pub fn list_cities(dir: &Path) -> Result> { let Ok(bytes) = std::fs::read(&path) else { continue; }; - let Ok((header, _)) = split(&bytes, &path) else { + let Ok((header, _, _)) = split(&bytes, &path) else { continue; }; let modified = entry @@ -258,4 +267,35 @@ mod tests { std::fs::write(&p, b"CI").unwrap(); assert!(load_city(&p).is_err()); } + + #[test] + fn version_one_files_list_but_refuse_to_load() { + let dir = std::env::temp_dir().join(format!("cyberpunk-v1-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let header = postcard::to_allocvec(&Header { + name: "Old Town".into(), + seed: 42, + w: 80, + h: 40, + }) + .unwrap(); + let mut bytes = Vec::new(); + bytes.extend_from_slice(MAGIC); + bytes.extend_from_slice(&1u16.to_le_bytes()); + bytes.extend_from_slice(&(header.len() as u32).to_le_bytes()); + bytes.extend_from_slice(&header); + bytes.extend_from_slice(&[0u8; 8]); + let path = dir.join("old.city"); + std::fs::write(&path, &bytes).unwrap(); + let listed = list_cities(&dir).unwrap(); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].seed, 42); + let err = load_city(&path).unwrap_err().to_string(); + assert!( + err.contains("older version") && err.contains("seed 42"), + "{err}" + ); + assert_eq!(FORMAT_VERSION, 2); + std::fs::remove_dir_all(&dir).ok(); + } }