feat(save): format version 2 with cyberspace; refuse v1 by seed
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -7,7 +7,9 @@ use std::path::{Path, PathBuf};
|
|||||||
use std::time::SystemTime;
|
use std::time::SystemTime;
|
||||||
|
|
||||||
pub const MAGIC: &[u8; 4] = b"CITY";
|
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)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
struct Header {
|
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.
|
/// 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 {
|
if bytes.len() < 10 || &bytes[0..4] != MAGIC {
|
||||||
bail!("{} is not a .city file", path.display());
|
bail!("{} is not a .city file", path.display());
|
||||||
}
|
}
|
||||||
let version = u16::from_le_bytes([bytes[4], bytes[5]]);
|
let version = u16::from_le_bytes([bytes[4], bytes[5]]);
|
||||||
if version != FORMAT_VERSION {
|
if !READABLE_VERSIONS.contains(&version) {
|
||||||
bail!(
|
bail!(
|
||||||
"{} is .city version {version}; this build reads version {FORMAT_VERSION}",
|
"{} is .city version {version}; this build reads version {FORMAT_VERSION}",
|
||||||
path.display()
|
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])
|
let header: Header = postcard::from_bytes(&bytes[10..header_end])
|
||||||
.with_context(|| format!("{}: corrupt header", path.display()))?;
|
.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<City> {
|
pub fn load_city(path: &Path) -> Result<City> {
|
||||||
let bytes = std::fs::read(path).with_context(|| format!("reading {}", path.display()))?;
|
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)
|
let city: City = postcard::from_bytes(body)
|
||||||
.with_context(|| format!("{}: corrupt or truncated city data", path.display()))?;
|
.with_context(|| format!("{}: corrupt or truncated city data", path.display()))?;
|
||||||
if (header.w, header.h) != (city.size.w, city.size.h) {
|
if (header.w, header.h) != (city.size.w, city.size.h) {
|
||||||
@@ -131,7 +140,7 @@ pub fn list_cities(dir: &Path) -> Result<Vec<CityFileInfo>> {
|
|||||||
let Ok(bytes) = std::fs::read(&path) else {
|
let Ok(bytes) = std::fs::read(&path) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let Ok((header, _)) = split(&bytes, &path) else {
|
let Ok((header, _, _)) = split(&bytes, &path) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let modified = entry
|
let modified = entry
|
||||||
@@ -258,4 +267,35 @@ mod tests {
|
|||||||
std::fs::write(&p, b"CI").unwrap();
|
std::fs::write(&p, b"CI").unwrap();
|
||||||
assert!(load_city(&p).is_err());
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user