diff --git a/Cargo.lock b/Cargo.lock index 018ae5f..0e64c4c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -492,6 +492,8 @@ dependencies = [ "hexwork-content", "hexwork-core", "serde", + "serde_json", + "sha2", "zstd", ] diff --git a/crates/hexwork-save/Cargo.toml b/crates/hexwork-save/Cargo.toml index 0f66709..d4bfa82 100644 --- a/crates/hexwork-save/Cargo.toml +++ b/crates/hexwork-save/Cargo.toml @@ -10,3 +10,5 @@ hexwork-content = { path = "../hexwork-content" } bincode.workspace = true zstd.workspace = true serde.workspace = true +serde_json.workspace = true +sha2.workspace = true diff --git a/crates/hexwork-save/src/demo.rs b/crates/hexwork-save/src/demo.rs index 01a1a80..b7232a2 100644 --- a/crates/hexwork-save/src/demo.rs +++ b/crates/hexwork-save/src/demo.rs @@ -1,4 +1,9 @@ -//! `.hexdemo` replays: seed + input-tick log + content-pack hash (docs/04 §4.7). +//! Legacy component `.hexdemo` helpers. +//! +//! [`Demo`] stores terminal/debug strings and has no format version. It remains +//! solely so current callers compile and MUST NOT be used as a production +//! replay. Use [`crate::save::encode_replay`] and +//! [`crate::save::decode_replay`] for typed, versioned replay evidence. //! //! Design traces: docs/04-software-architecture.md §4.7 ("`.hexdemo`: seed + //! input-tick log + content pack hash; replay verifies hash then steps sim diff --git a/crates/hexwork-save/src/knowledge.rs b/crates/hexwork-save/src/knowledge.rs index 81051e2..91fba43 100644 --- a/crates/hexwork-save/src/knowledge.rs +++ b/crates/hexwork-save/src/knowledge.rs @@ -24,8 +24,169 @@ //! round-trips, no RNG/time/IO/threads. use std::collections::BTreeSet; +use std::fmt; +use std::path::Path; -use super::LineageVault; +use super::{write_bytes_atomic, LineageVault}; + +/// Current lineage-vault JSON wrapper version. No older production migration exists. +pub const VAULT_FORMAT_VERSION: u32 = 1; +/// Maximum entries in each persisted lineage collection. +pub const MAX_VAULT_ENTRIES: usize = 16_384; +/// Maximum bytes in one map/recipe/hymn/ghost identifier. +pub const MAX_VAULT_ENTRY_BYTES: usize = 4 * 1024; +/// Maximum lineage-vault file size accepted before JSON parsing (8 MiB). +pub const MAX_VAULT_BYTES: usize = 8 * 1024 * 1024; + +#[derive(serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct VaultFile { + version: u32, + mapped_regions: Vec, + recipes: Vec, + translated_hymns: Vec, + ghosts: Vec, +} + +/// Actionable lineage-vault load/write failure. +#[derive(Debug)] +pub enum VaultError { + Io(std::io::Error), + TooLarge { + actual: usize, + limit: usize, + }, + Malformed(String), + OlderVersion { + found: u32, + current: u32, + }, + NewerVersion { + found: u32, + current: u32, + }, + Invalid { + collection: &'static str, + index: usize, + reason: String, + }, +} + +impl fmt::Display for VaultError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Io(e) => write!(f, "lineage vault I/O failed: {e}"), + Self::TooLarge { actual, limit } => write!(f, "lineage vault is {actual} bytes; limit is {limit}"), + Self::Malformed(e) => write!(f, "malformed lineage vault JSON: {e}"), + Self::OlderVersion { found, current } => write!(f, "lineage vault version {found} is older than supported version {current}; no silent migration exists"), + Self::NewerVersion { found, current } => write!(f, "lineage vault version {found} is newer than supported version {current}; update HEXWORK"), + Self::Invalid { collection, index, reason } => write!(f, "invalid lineage vault {collection}[{index}]: {reason}"), + } + } +} + +impl std::error::Error for VaultError {} + +impl From for VaultError { + fn from(value: std::io::Error) -> Self { + Self::Io(value) + } +} + +/// Load a versioned lineage vault. A missing path is a fresh lineage; all other +/// I/O, malformed JSON, and unsupported versions are errors. +pub fn load_vault(path: impl AsRef) -> Result { + let bytes = match std::fs::read(path) { + Ok(bytes) => bytes, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(LineageVault::default()), + Err(e) => return Err(VaultError::Io(e)), + }; + if bytes.len() > MAX_VAULT_BYTES { + return Err(VaultError::TooLarge { + actual: bytes.len(), + limit: MAX_VAULT_BYTES, + }); + } + let file: VaultFile = + serde_json::from_slice(&bytes).map_err(|e| VaultError::Malformed(e.to_string()))?; + if file.version < VAULT_FORMAT_VERSION { + return Err(VaultError::OlderVersion { + found: file.version, + current: VAULT_FORMAT_VERSION, + }); + } + if file.version > VAULT_FORMAT_VERSION { + return Err(VaultError::NewerVersion { + found: file.version, + current: VAULT_FORMAT_VERSION, + }); + } + canonical_vault(LineageVault { + mapped_regions: file.mapped_regions, + recipes: file.recipes, + translated_hymns: file.translated_hymns, + ghosts: file.ghosts, + }) +} + +/// Canonicalize, validate, and atomically replace a lineage-vault JSON file. +pub fn write_vault_atomic(path: impl AsRef, vault: &LineageVault) -> Result<(), VaultError> { + let vault = canonical_vault(vault.clone())?; + let file = VaultFile { + version: VAULT_FORMAT_VERSION, + mapped_regions: vault.mapped_regions, + recipes: vault.recipes, + translated_hymns: vault.translated_hymns, + ghosts: vault.ghosts, + }; + let mut bytes = + serde_json::to_vec_pretty(&file).map_err(|e| VaultError::Malformed(e.to_string()))?; + bytes.push(b'\n'); + if bytes.len() > MAX_VAULT_BYTES { + return Err(VaultError::TooLarge { + actual: bytes.len(), + limit: MAX_VAULT_BYTES, + }); + } + write_bytes_atomic(path, &bytes).map_err(VaultError::Io) +} + +fn canonical_vault(mut vault: LineageVault) -> Result { + canonical_entries("mapped_regions", &mut vault.mapped_regions)?; + canonical_entries("recipes", &mut vault.recipes)?; + canonical_entries("translated_hymns", &mut vault.translated_hymns)?; + canonical_entries("ghosts", &mut vault.ghosts)?; + Ok(vault) +} + +fn canonical_entries(collection: &'static str, values: &mut Vec) -> Result<(), VaultError> { + if values.len() > MAX_VAULT_ENTRIES { + return Err(VaultError::Invalid { + collection, + index: values.len(), + reason: format!("entry count exceeds {MAX_VAULT_ENTRIES}"), + }); + } + for (index, value) in values.iter().enumerate() { + if value.trim().is_empty() { + return Err(VaultError::Invalid { + collection, + index, + reason: "blank entry".into(), + }); + } + if value.len() > MAX_VAULT_ENTRY_BYTES { + return Err(VaultError::Invalid { + collection, + index, + reason: format!("{} bytes exceeds {MAX_VAULT_ENTRY_BYTES}", value.len()), + }); + } + } + values.sort(); + values.dedup(); + Ok(()) +} /// Knowledge a lineage has accumulated across delvers: mapped regions, /// identified recipes, translated hymns (docs/03 §3.1, §3.9). @@ -223,6 +384,98 @@ mod tests { assert_eq!(v.ghosts, ["ghost-tomm"], "ghosts must survive into_vault"); } + fn scratch_dir(tag: &str) -> std::path::PathBuf { + let path = + std::env::temp_dir().join(format!("hexwork-vault-test-{}-{tag}", std::process::id())); + let _ = std::fs::remove_dir_all(&path); + std::fs::create_dir_all(&path).expect("scratch directory"); + path + } + + #[test] + fn vault_io_is_versioned_deterministic_and_atomic() { + let dir = scratch_dir("roundtrip"); + let path = dir.join("lineage.json"); + let vault = vault_with( + &["z-map", "a-map", "z-map"], + &["r2", "r1", "r1"], + &["h2", "h1"], + &["ghost-z", "ghost-a", "ghost-z"], + ); + write_vault_atomic(&path, &vault).expect("initial write"); + let first = std::fs::read(&path).expect("read bytes"); + assert!(String::from_utf8_lossy(&first).contains("\"version\": 1")); + let loaded = load_vault(&path).expect("load"); + assert_eq!(loaded.mapped_regions, ["a-map", "z-map"]); + assert_eq!(loaded.recipes, ["r1", "r2"]); + assert_eq!(loaded.translated_hymns, ["h1", "h2"]); + assert_eq!(loaded.ghosts, ["ghost-a", "ghost-z"]); + write_vault_atomic(&path, &loaded).expect("replacement write"); + assert_eq!(std::fs::read(&path).unwrap(), first); + assert_eq!( + std::fs::read_dir(&dir).unwrap().count(), + 1, + "no temp remains" + ); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn vault_missing_is_fresh_and_malformed_or_versioned_is_error() { + let dir = scratch_dir("errors"); + let path = dir.join("missing.json"); + assert!(load_vault(&path).unwrap().mapped_regions.is_empty()); + std::fs::write(&path, b"not json").unwrap(); + assert!(matches!(load_vault(&path), Err(VaultError::Malformed(_)))); + for (version, newer) in [(0, false), (2, true)] { + std::fs::write( + &path, + format!("{{\"version\":{version},\"mapped_regions\":[],\"recipes\":[],\"translated_hymns\":[],\"ghosts\":[]}}"), + ) + .unwrap(); + if newer { + assert!(matches!( + load_vault(&path), + Err(VaultError::NewerVersion { .. }) + )); + } else { + assert!(matches!( + load_vault(&path), + Err(VaultError::OlderVersion { .. }) + )); + } + } + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn vault_rejects_blank_oversize_and_collection_cap() { + let dir = scratch_dir("limits"); + let path = dir.join("lineage.json"); + let mut vault = LineageVault::default(); + vault.mapped_regions.push(" ".into()); + assert!(matches!( + write_vault_atomic(&path, &vault), + Err(VaultError::Invalid { .. }) + )); + vault.mapped_regions = vec!["x".repeat(MAX_VAULT_ENTRY_BYTES + 1)]; + assert!(matches!( + write_vault_atomic(&path, &vault), + Err(VaultError::Invalid { .. }) + )); + vault.mapped_regions = vec!["x".into(); MAX_VAULT_ENTRIES + 1]; + assert!(matches!( + write_vault_atomic(&path, &vault), + Err(VaultError::Invalid { .. }) + )); + assert!( + !path.exists(), + "validation failure creates no target or temp" + ); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0); + std::fs::remove_dir_all(dir).unwrap(); + } + #[test] fn gear_never_persists() { // Compile-time: `Knowledge` has exactly three fields (maps, recipes, diff --git a/crates/hexwork-save/src/lib.rs b/crates/hexwork-save/src/lib.rs index e8bbefb..099cd63 100644 --- a/crates/hexwork-save/src/lib.rs +++ b/crates/hexwork-save/src/lib.rs @@ -3,9 +3,27 @@ pub mod demo; pub mod knowledge; +pub mod protocol; pub mod save; -/// Save format version. Bump on breaking changes with a migration. +pub use knowledge::{ + load_vault, write_vault_atomic, VaultError, MAX_VAULT_BYTES, MAX_VAULT_ENTRIES, + MAX_VAULT_ENTRY_BYTES, VAULT_FORMAT_VERSION, +}; +pub use protocol::{ + decode_replay, decode_run, encode_replay, encode_run, event_prefix_digest, timeline_costs, + write_bytes_atomic, DecodedRun, HashedState, PersistenceError, ProductionReplay, RunIdentity, + SessionEvent, SessionEventKind, StoryCheckpoint, TimelineCosts, MAX_COMPRESSED_BYTES, + MAX_DECOMPRESSED_BYTES, MAX_SESSION_EVENTS, MAX_TOTAL_CHUNKS, REPLAY_FORMAT_VERSION, + RUN_FORMAT_VERSION, +}; + +/// Legacy component save format version. +/// +/// This version belongs only to [`save::SaveHeader`] and its string input +/// tail. It is retained while existing CLI callers migrate and MUST NOT be +/// used for production resume. Production formats have independent version +/// constants in [`protocol`]. pub const SAVE_VERSION: u32 = 1; /// Minimal snapshot header. Full bincode+zstd lands in Phase 11. diff --git a/crates/hexwork-save/src/protocol.rs b/crates/hexwork-save/src/protocol.rs new file mode 100644 index 0000000..0204b90 --- /dev/null +++ b/crates/hexwork-save/src/protocol.rs @@ -0,0 +1,4496 @@ +//! Authoritative Stage 3c1 v3 run-save and replay wire protocols. +//! +//! The top-level envelopes retain immutable run identity, ordered lifecycle +//! events, and (for Story runs) one nonrecursive town checkpoint. The explicit +//! wire records below mirror core plain-data types without requiring persistence +//! derives in `hexwork-core`. Every enum is an explicit numeric tag and every +//! `f32` is stored as its exact IEEE-754 bits. + +use std::collections::BTreeMap; +use std::fmt; +use std::io::{Read, Write}; +use std::path::Path; + +use bincode::de::Decoder; +use bincode::error::DecodeError; +use bincode::{Decode, Encode}; +use hexwork_core::action::{CommittedAction, Direction}; +use hexwork_core::actor::{Actor, Stance}; +use hexwork_core::bargain::{Debt, Ledger}; +use hexwork_core::character::{Ancestry, Calling, Character}; +use hexwork_core::checkpoint::{CauseImage, ChunkImage, LawImage, StateImage}; +use hexwork_core::connector::{Connector, ConnectorKind}; +use hexwork_core::dungeon::{Chalk, MulePath, VaultChoice}; +use hexwork_core::hymn::{HymnCard, HymnCase}; +use hexwork_core::inventory::Pack; +use hexwork_core::item::{Condition, Instance}; +use hexwork_core::light::LightSource; +use hexwork_core::modes::{ChapelDebt, Mode}; +use hexwork_core::npc::{Job, ShopMemory, Townsfolk}; +use hexwork_core::pressure::{PressureEdge, PressureNode}; +use hexwork_core::reputation::{Faction, GodId, Reputation, Standing}; +use hexwork_core::rot::CorpseClock; +use hexwork_core::runtime::{ + ActiveContract, ActorIdentity, ActorRole, ContractStatus, RunLocation, Wallet, +}; +use hexwork_core::services::Service; +use hexwork_core::sound::SoundEvent; +use hexwork_core::state::{FarField, GossipPacket, Spill, State, CANONICAL_HASH_VERSION}; +use hexwork_core::voxel::{Voxel, CHUNK_VOL}; +use sha2::{Digest, Sha256}; +use std::ops::{Deref, DerefMut}; + +/// Current authoritative run-save format. Pre-release v1/v2 artifacts have no +/// migration and are rejected explicitly after the bounded raw header is read. +pub const RUN_FORMAT_VERSION: u32 = 3; +/// Current production replay format. V3 stores only identity, lifecycle events, +/// and terminal tick/hash; presentation evidence is derived during replay. +pub const REPLAY_FORMAT_VERSION: u32 = 3; +/// Largest compressed run/replay accepted at an untrusted boundary (64 MiB). +pub const MAX_COMPRESSED_BYTES: usize = 64 * 1024 * 1024; +/// Largest decompressed run/replay accepted while streaming (256 MiB). This is +/// also the exact bincode encode/decode claim limit, so the encoder cannot emit +/// an envelope rejected by a smaller parser backstop. +pub const MAX_DECOMPRESSED_BYTES: usize = 256 * 1024 * 1024; +/// Maximum chunks in one State image. +pub const MAX_CHUNKS: usize = 64; +/// Aggregate chunks across current and optional Story checkpoint images. The +/// current generated Story shape is 54+54 chunks; 128 also admits the bounded +/// worst case of 64+64 without resetting the budget between images. +pub const MAX_TOTAL_CHUNKS: usize = 128; +/// Maximum rows in any ordinary state table. +pub const MAX_ROWS: usize = 131_072; +/// Maximum ordered lifecycle events in one run or replay. +pub const MAX_SESSION_EVENTS: usize = 65_536; +/// Maximum UTF-8 bytes in one string field. +pub const MAX_STRING_BYTES: usize = 4 * 1024; +/// Maximum cumulative string bytes in an envelope. +pub const MAX_TOTAL_STRING_BYTES: usize = 16 * 1024 * 1024; +/// Aggregate rows across all top-level `StateWire` tables. +pub const MAX_TOTAL_STATE_ROWS: usize = 262_144; +/// Aggregate nested rows across the single envelope. 16,000,000 exceeds all +/// 128 permitted complete 48³ chunks (14,155,776 voxels) while leaving a finite +/// margin for the separately capped nested category rows. +pub const MAX_TOTAL_NESTED_ROWS: usize = 16_000_000; +/// Aggregate spill gas-flow rows. +pub const MAX_TOTAL_GAS_FLOWS: usize = 262_144; +/// Aggregate mule waypoints. +pub const MAX_TOTAL_MULE_WAYPOINTS: usize = 65_536; +/// Aggregate reputation town-ledger rows. +pub const MAX_TOTAL_TOWN_LEDGER_ROWS: usize = 262_144; +/// Aggregate items carried across all packs. +pub const MAX_TOTAL_PACK_ITEMS: usize = 262_144; +/// Aggregate hymn cards and card miscast rows. +pub const MAX_TOTAL_HYMN_CARDS: usize = 65_536; +pub const MAX_TOTAL_HYMN_MISCASTS: usize = 262_144; +/// Aggregate bargain debt rows. +pub const MAX_TOTAL_LEDGER_DEBTS: usize = 262_144; +/// Aggregate shop broke/stole/killed memory rows. +pub const MAX_TOTAL_SHOP_ROWS: usize = 262_144; +/// Aggregate item tags across loose and packed items. +pub const MAX_TOTAL_ITEM_TAGS: usize = 262_144; + +const MAX_ITEM_TAGS: usize = 256; +const MAX_PACK_ITEMS: usize = 8_192; +const MAX_HYMN_CARDS: usize = 256; +const MAX_HYMN_MISCASTS: usize = 256; +const MAX_LEDGER_DEBTS: usize = 4_096; +const MAX_TOWN_LEDGER_ROWS: usize = 8_192; +const MAX_SHOP_ROWS: usize = 65_536; +const ZSTD_LEVEL: i32 = 3; +const RUN_MAGIC: [u8; 8] = *b"HXWRUN02"; +const REPLAY_MAGIC: [u8; 8] = *b"HXDEMO01"; + +const BUDGET_NONE: u8 = 0; +const BUDGET_STATE_ROWS: u8 = 1; +const BUDGET_EVENTS: u8 = 2; +const BUDGET_NESTED_ROWS: u8 = 3; +const BUDGET_GAS_FLOWS: u8 = 4; +const BUDGET_MULE_WAYPOINTS: u8 = 5; +const BUDGET_TOWN_LEDGER: u8 = 6; +const BUDGET_PACK_ITEMS: u8 = 7; +const BUDGET_HYMN_CARDS: u8 = 8; +const BUDGET_HYMN_MISCASTS: u8 = 9; +const BUDGET_LEDGER_DEBTS: u8 = 10; +const BUDGET_SHOP_ROWS: u8 = 11; +const BUDGET_ITEM_TAGS: u8 = 12; +const BUDGET_CHUNKS: u8 = 13; + +#[derive(Default)] +struct DecodeBudget { + strings: usize, + chunks: usize, + state_rows: usize, + nested_rows: usize, + gas_flows: usize, + mule_waypoints: usize, + town_ledger: usize, + pack_items: usize, + hymn_cards: usize, + hymn_miscasts: usize, + ledger_debts: usize, + shop_rows: usize, + item_tags: usize, +} + +impl DecodeBudget { + fn add_counter( + counter: &mut usize, + len: usize, + limit: usize, + label: &str, + ) -> Result<(), DecodeError> { + *counter = counter + .checked_add(len) + .ok_or(DecodeError::Other("wire aggregate length overflow"))?; + if *counter > limit { + Err(DecodeError::OtherString(format!( + "{label} aggregate {} exceeds limit {limit}", + *counter + ))) + } else { + Ok(()) + } + } + + fn add(&mut self, kind: u8, len: usize) -> Result<(), DecodeError> { + let (counter, limit, label) = match kind { + // Each envelope has exactly one event vector, so its field prefix + // cap is also its aggregate cap; no second counter is necessary. + BUDGET_NONE | BUDGET_EVENTS => return Ok(()), + BUDGET_CHUNKS => (&mut self.chunks, MAX_TOTAL_CHUNKS, "total chunks"), + BUDGET_STATE_ROWS => ( + &mut self.state_rows, + MAX_TOTAL_STATE_ROWS, + "total state rows", + ), + BUDGET_NESTED_ROWS => ( + &mut self.nested_rows, + MAX_TOTAL_NESTED_ROWS, + "total nested rows", + ), + BUDGET_GAS_FLOWS => (&mut self.gas_flows, MAX_TOTAL_GAS_FLOWS, "spill gas flows"), + BUDGET_MULE_WAYPOINTS => ( + &mut self.mule_waypoints, + MAX_TOTAL_MULE_WAYPOINTS, + "mule waypoints", + ), + BUDGET_TOWN_LEDGER => ( + &mut self.town_ledger, + MAX_TOTAL_TOWN_LEDGER_ROWS, + "reputation town-ledger rows", + ), + BUDGET_PACK_ITEMS => (&mut self.pack_items, MAX_TOTAL_PACK_ITEMS, "pack items"), + BUDGET_HYMN_CARDS => (&mut self.hymn_cards, MAX_TOTAL_HYMN_CARDS, "hymn cards"), + BUDGET_HYMN_MISCASTS => ( + &mut self.hymn_miscasts, + MAX_TOTAL_HYMN_MISCASTS, + "hymn miscasts", + ), + BUDGET_LEDGER_DEBTS => ( + &mut self.ledger_debts, + MAX_TOTAL_LEDGER_DEBTS, + "ledger debts", + ), + BUDGET_SHOP_ROWS => (&mut self.shop_rows, MAX_TOTAL_SHOP_ROWS, "shop memory rows"), + BUDGET_ITEM_TAGS => (&mut self.item_tags, MAX_TOTAL_ITEM_TAGS, "item tags"), + _ => return Err(DecodeError::Other("unknown wire budget category")), + }; + Self::add_counter(counter, len, limit, label)?; + if kind == BUDGET_CHUNKS { + Self::add_counter( + &mut self.state_rows, + len, + MAX_TOTAL_STATE_ROWS, + "total state rows", + )?; + } + if !matches!(kind, BUDGET_STATE_ROWS | BUDGET_NESTED_ROWS | BUDGET_CHUNKS) { + Self::add_counter( + &mut self.nested_rows, + len, + MAX_TOTAL_NESTED_ROWS, + "total nested rows", + )?; + } + Ok(()) + } + + fn add_strings(&mut self, len: usize) -> Result<(), DecodeError> { + Self::add_counter( + &mut self.strings, + len, + MAX_TOTAL_STRING_BYTES, + "total strings", + ) + } +} + +#[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord)] +struct BoundedString(String); + +impl Deref for BoundedString { + type Target = str; + fn deref(&self) -> &Self::Target { + &self.0 + } +} +impl DerefMut for BoundedString { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} +impl From for BoundedString { + fn from(value: String) -> Self { + Self(value) + } +} +impl From<&str> for BoundedString { + fn from(value: &str) -> Self { + Self(value.to_owned()) + } +} +impl From for String { + fn from(value: BoundedString) -> Self { + value.0 + } +} +impl PartialEq for BoundedString { + fn eq(&self, other: &str) -> bool { + self.0 == other + } +} +impl PartialEq<&str> for BoundedString { + fn eq(&self, other: &&str) -> bool { + self.0 == *other + } +} +impl BoundedString { + fn into_inner(self) -> String { + self.0 + } +} +impl Encode for BoundedString { + fn encode( + &self, + encoder: &mut E, + ) -> Result<(), bincode::error::EncodeError> { + (self.0.len() as u64).encode(encoder)?; + bincode::enc::write::Writer::write(encoder.writer(), self.0.as_bytes()) + } +} +impl Decode for BoundedString { + fn decode>(decoder: &mut D) -> Result { + let raw_len = u64::decode(decoder)?; + let len = usize::try_from(raw_len).map_err(|_| DecodeError::OutsideUsizeRange(raw_len))?; + if len > MAX_STRING_BYTES { + return Err(DecodeError::OtherString(format!( + "wire string length {len} exceeds per-field limit {MAX_STRING_BYTES}" + ))); + } + decoder.context().add_strings(len)?; + decoder.claim_container_read::(len)?; + let mut bytes = vec![0; len]; + bincode::de::read::Reader::read(decoder.reader(), &mut bytes)?; + String::from_utf8(bytes) + .map(Self) + .map_err(|e| DecodeError::Utf8 { + inner: e.utf8_error(), + }) + } +} +bincode::impl_borrow_decode_with_context!(BoundedString, DecodeBudget); + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +struct BoundedVec(Vec); + +impl Default for BoundedVec { + fn default() -> Self { + Self(Vec::new()) + } +} + +impl Deref for BoundedVec { + type Target = [T]; + fn deref(&self) -> &Self::Target { + &self.0 + } +} +impl DerefMut for BoundedVec { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} +impl From> for BoundedVec { + fn from(value: Vec) -> Self { + Self(value) + } +} +impl BoundedVec { + fn into_inner(self) -> Vec { + self.0 + } +} +impl FromIterator for BoundedVec { + fn from_iter>(iter: I) -> Self { + Self(iter.into_iter().collect()) + } +} +impl IntoIterator for BoundedVec { + type Item = T; + type IntoIter = std::vec::IntoIter; + fn into_iter(self) -> Self::IntoIter { + self.0.into_iter() + } +} +impl<'a, T, const LIMIT: usize, const BUDGET: u8> IntoIterator + for &'a BoundedVec +{ + type Item = &'a T; + type IntoIter = std::slice::Iter<'a, T>; + fn into_iter(self) -> Self::IntoIter { + self.0.iter() + } +} +impl Encode for BoundedVec { + fn encode( + &self, + encoder: &mut E, + ) -> Result<(), bincode::error::EncodeError> { + (self.0.len() as u64).encode(encoder)?; + for item in &self.0 { + item.encode(encoder)?; + } + Ok(()) + } +} +impl, const LIMIT: usize, const BUDGET: u8> Decode + for BoundedVec +{ + fn decode>(decoder: &mut D) -> Result { + let raw_len = u64::decode(decoder)?; + let len = usize::try_from(raw_len).map_err(|_| DecodeError::OutsideUsizeRange(raw_len))?; + if len > LIMIT { + return Err(DecodeError::OtherString(format!( + "wire vector length {len} exceeds per-field limit {LIMIT}" + ))); + } + decoder.context().add(BUDGET, len)?; + decoder.claim_container_read::(len)?; + let mut values = Vec::with_capacity(len); + for _ in 0..len { + decoder.unclaim_bytes_read(std::mem::size_of::()); + values.push(T::decode(decoder)?); + } + Ok(Self(values)) + } +} +impl<'de, T, const LIMIT: usize, const BUDGET: u8> bincode::BorrowDecode<'de, DecodeBudget> + for BoundedVec +where + T: Decode, +{ + fn borrow_decode>( + decoder: &mut D, + ) -> Result { + Self::decode(decoder) + } +} + +/// Immutable canonical inputs needed to reconstruct a run. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RunIdentity { + pub seed: u64, + pub mode: Mode, + pub ancestry: Ancestry, + pub calling: Calling, + pub display_name: String, + pub content_hash: String, +} + +/// A complete explicit core image with its independently verified hash. +#[derive(Clone, Debug)] +pub struct HashedState { + pub tick: u64, + pub hash: u64, + pub image: StateImage, +} + +impl HashedState { + /// Capture and hash a validated live State without serializing core types. + pub fn from_state(state: &State) -> Result { + let image = state + .to_image() + .map_err(|e| PersistenceError::CoreState(format!("{e:?}")))?; + let hash = state + .try_hash_state() + .map_err(|e| PersistenceError::CoreState(format!("{e:?}")))?; + Ok(Self { + tick: image.tick, + hash, + image, + }) + } + + /// Validate an owned image through core and attach its recomputed hash. + pub fn from_image(image: StateImage) -> Result { + let tick = image.tick; + let state = State::from_image(image.clone()) + .map_err(|e| PersistenceError::CoreImage(format!("{e:?}")))?; + let hash = state + .try_hash_state() + .map_err(|e| PersistenceError::CoreState(format!("{e:?}")))?; + Ok(Self { tick, hash, image }) + } + + /// Consume this checked image and restore the live core State. + pub fn into_state(self) -> Result { + validate_hashed_image(&self, "hashed_state")?; + State::from_image(self.image).map_err(|e| PersistenceError::CoreImage(format!("{e:?}"))) + } +} + +/// Nonrecursive Story checkpoint active at a retained event prefix. +#[derive(Clone, Debug)] +pub struct StoryCheckpoint { + pub state: HashedState, + pub after_event_count: u32, +} + +/// Ordered lifecycle row. Sequence authority is the vector index, not a tick. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SessionEvent { + pub pre_tick: u64, + pub post_tick: u64, + pub kind: SessionEventKind, +} + +impl SessionEvent { + #[must_use] + pub fn action(pre_tick: u64, post_tick: u64, action: CommittedAction) -> Self { + Self { + pre_tick, + post_tick, + kind: SessionEventKind::Action(action), + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum SessionEventKind { + Action(CommittedAction), + StoryResurrect { + checkpoint_after_event_count: u32, + checkpoint_hash: u64, + dead_state_hash: u64, + restored_state_hash: u64, + }, +} + +/// Cheap checked replay-work accounting. Session-owned trusted limits compare +/// their ceilings to these exact costs before world construction or stepping. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct TimelineCosts { + pub full_steps: u64, + pub unattended_steps: u64, + pub total_elapsed_ticks: u64, +} + +/// Decoded authoritative run. Current State is restored exactly; retained events +/// and the optional Story checkpoint are not replayed during ordinary resume. +#[derive(Debug)] +pub struct DecodedRun { + pub identity: RunIdentity, + pub current: State, + pub story_checkpoint: Option, + pub events: Vec, +} + +/// Production replay input. It intentionally contains no snapshot, checkpoint, +/// subtitle list, or duplicated rule-chain rows. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ProductionReplay { + pub identity: RunIdentity, + pub events: Vec, + pub expected_final_tick: u64, + pub expected_final_hash: u64, +} + +/// Structured failure at the untrusted persistence boundary. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum PersistenceError { + InputTooLarge { + actual: usize, + limit: usize, + }, + DecompressedTooLarge { + limit: usize, + }, + Compression(String), + Decompression(String), + Decode(String), + TrailingBytes { + consumed: usize, + total: usize, + }, + WrongMagic { + format: &'static str, + }, + OlderVersion { + format: &'static str, + found: u32, + current: u32, + }, + NewerVersion { + format: &'static str, + found: u32, + current: u32, + }, + CanonicalHashVersion { + found: u64, + current: u64, + }, + ContentHashMismatch { + stored: String, + current: String, + }, + MetadataMismatch { + field: &'static str, + envelope: u64, + image: u64, + }, + CheckpointHashMismatch { + expected: u64, + actual: u64, + }, + Limit { + context: String, + actual: usize, + limit: usize, + }, + Invalid { + context: String, + }, + CoreImage(String), + CoreState(String), +} + +impl fmt::Display for PersistenceError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InputTooLarge { actual, limit } => write!(f, "compressed input is {actual} bytes; limit is {limit}"), + Self::DecompressedTooLarge { limit } => write!(f, "decompressed input exceeds {limit} bytes"), + Self::Compression(e) => write!(f, "compression failed: {e}"), + Self::Decompression(e) => write!(f, "decompression failed: {e}"), + Self::Decode(e) => write!(f, "bounded bincode decode failed: {e}"), + Self::TrailingBytes { consumed, total } => write!(f, "trailing bytes after record: consumed {consumed} of {total}"), + Self::WrongMagic { format } => write!(f, "input is not a {format} envelope"), + Self::OlderVersion { format, found, current } => write!(f, "{format} version {found} is older than supported version {current}; no silent migration exists"), + Self::NewerVersion { format, found, current } => write!(f, "{format} version {found} is newer than supported version {current}; update HEXWORK"), + Self::CanonicalHashVersion { found, current } => write!(f, "core canonical-hash version {found} does not match current version {current}"), + Self::ContentHashMismatch { stored, current } => write!(f, "content hash mismatch: stored {stored}, current {current}"), + Self::MetadataMismatch { field, envelope, image } => write!(f, "run envelope {field} {envelope} does not match checkpoint image {image}"), + Self::CheckpointHashMismatch { expected, actual } => write!(f, "checkpoint canonical hash mismatch: expected {expected}, recomputed {actual}"), + Self::Limit { context, actual, limit } => write!(f, "{context} has {actual} entries/bytes; limit is {limit}"), + Self::Invalid { context } => write!(f, "invalid persistence field: {context}"), + Self::CoreImage(e) => write!(f, "core rejected checkpoint image: {e}"), + Self::CoreState(e) => write!(f, "core state validation failed: {e}"), + } + } +} + +impl std::error::Error for PersistenceError {} + +#[derive(Encode, Decode)] +#[bincode(decode_context = "DecodeBudget")] +struct EnvelopeHeader { + magic: [u8; 8], + format_version: u32, + canonical_hash_version: u64, +} + +#[derive(Encode, Decode)] +#[bincode(decode_context = "DecodeBudget")] +struct RunWire { + header: EnvelopeHeader, + identity: RunIdentityWire, + current: HashedStateWire, + story_checkpoint: Option, + events: BoundedVec, +} + +#[derive(Encode, Decode)] +#[bincode(decode_context = "DecodeBudget")] +struct ReplayWire { + header: EnvelopeHeader, + identity: RunIdentityWire, + events: BoundedVec, + expected_final_tick: u64, + expected_final_hash: u64, +} + +#[derive(Encode, Decode)] +#[bincode(decode_context = "DecodeBudget")] +struct RunIdentityWire { + seed: u64, + mode: u8, + ancestry: u8, + calling: u8, + display_name: BoundedString, + content_hash: BoundedString, +} + +#[derive(Encode, Decode)] +#[bincode(decode_context = "DecodeBudget")] +struct HashedStateWire { + tick: u64, + hash: u64, + image: StateWire, +} + +#[derive(Encode, Decode)] +#[bincode(decode_context = "DecodeBudget")] +struct StoryCheckpointWire { + state: HashedStateWire, + after_event_count: u32, +} + +#[derive(Clone, Encode, Decode)] +#[bincode(decode_context = "DecodeBudget")] +struct SessionEventWire { + pre_tick: u64, + post_tick: u64, + kind_tag: u8, + action: ActionWire, + checkpoint_after_event_count: u32, + checkpoint_hash: u64, + dead_state_hash: u64, + restored_state_hash: u64, +} + +#[derive(Clone, Encode, Decode)] +#[bincode(decode_context = "DecodeBudget")] +struct ActionWire { + tag: u8, + direction: u8, + service: u8, + text: BoundedString, + id: u64, +} + +#[derive(Encode, Decode)] +#[bincode(decode_context = "DecodeBudget")] +struct StateWire { + tick: u64, + seed: u64, + rng_master: [u64; 4], + ecs_entity_count: u32, + chunks: BoundedVec, + actors: BoundedVec, + identities: BoundedVec<(u64, IdentityWire), MAX_ROWS, BUDGET_STATE_ROWS>, + wallets: BoundedVec<(u64, WalletWire), MAX_ROWS, BUDGET_STATE_ROWS>, + contracts: BoundedVec<(BoundedString, ContractWire), MAX_ROWS, BUDGET_STATE_ROWS>, + location: LocationWire, + connectors: BoundedVec, + pressure_nodes: BoundedVec, + pressure_edges: BoundedVec, + causes: BoundedVec, + far: FarWire, + laws: BoundedVec, + fall_odo: BoundedVec<(u64, u32), MAX_ROWS, BUDGET_STATE_ROWS>, + lights: BoundedVec, + sounds: BoundedVec, + corpses: BoundedVec<(CellWire, CorpseWire), MAX_ROWS, BUDGET_STATE_ROWS>, + signal_sources: BoundedVec, + spill: SpillWire, + characters: BoundedVec<(u64, CharacterWire), MAX_ROWS, BUDGET_STATE_ROWS>, + reputations: BoundedVec<(u64, ReputationWire), MAX_ROWS, BUDGET_STATE_ROWS>, + items: BoundedVec<(u64, ItemWire), MAX_ROWS, BUDGET_STATE_ROWS>, + ground_items: BoundedVec<(u64, CellWire), MAX_ROWS, BUDGET_STATE_ROWS>, + packs: BoundedVec<(u64, PackWire), MAX_ROWS, BUDGET_STATE_ROWS>, + idf: BoundedVec<(BoundedString, u8), MAX_ROWS, BUDGET_STATE_ROWS>, + hymn_cases: BoundedVec<(u64, HymnCaseWire), MAX_ROWS, BUDGET_STATE_ROWS>, + cast_queue: BoundedVec, + bargains: BoundedVec<(u64, LedgerWire), MAX_ROWS, BUDGET_STATE_ROWS>, + deep_weather: i8, + npcs: BoundedVec<(u64, NpcWire), MAX_ROWS, BUDGET_STATE_ROWS>, + shops: BoundedVec<(CellWire, ShopWire), MAX_ROWS, BUDGET_STATE_ROWS>, + gossip: BoundedVec, + chalk: BoundedVec<(CellWire, BoundedString), MAX_ROWS, BUDGET_STATE_ROWS>, + mule: Option>, + vault_choice: u8, + chapel_debts: BoundedVec<(u64, ChapelDebtWire), MAX_ROWS, BUDGET_STATE_ROWS>, + next_id: u64, + step_mode: bool, +} + +#[derive(Clone, Encode, Decode, PartialEq, Eq, PartialOrd, Ord)] +#[bincode(decode_context = "DecodeBudget")] +struct CellWire { + x: i32, + y: i32, + z: i32, +} +#[derive(Encode, Decode)] +#[bincode(decode_context = "DecodeBudget")] +struct ChunkWire { + coord: CellWire, + voxels: BoundedVec, +} +#[derive(Encode, Decode)] +#[bincode(decode_context = "DecodeBudget")] +struct VoxelWire { + material: u8, + occ: u8, + fluid_kind: u8, + fluid_amt: u8, + gas_kind: u8, + gas_amt: u8, + light: u8, + scent: u8, + flags: u8, + pad: u8, + pressure: i16, + flow: i16, + extra: u16, +} +#[derive(Encode, Decode)] +#[bincode(decode_context = "DecodeBudget")] +struct ActorWire { + id: u64, + pos: [u32; 3], + radius: u32, + stance: u8, + hp: i32, + alive: bool, +} +#[derive(Encode, Decode)] +#[bincode(decode_context = "DecodeBudget")] +struct IdentityWire { + template: BoundedString, + name: BoundedString, + role: u8, +} +#[derive(Encode, Decode)] +#[bincode(decode_context = "DecodeBudget")] +struct WalletWire { + copper: u32, + scrip: u32, +} +#[derive(Encode, Decode)] +#[bincode(decode_context = "DecodeBudget")] +struct ContractWire { + id: BoundedString, + taker: u64, + accepted_tick: u64, + floor: u8, + pay_cu: u32, + target: u32, + progress: u32, + status: u8, + reward_town: BoundedString, + standing_delta: i16, +} +#[derive(Encode, Decode)] +#[bincode(decode_context = "DecodeBudget")] +struct LocationWire { + tag: u8, + town: BoundedString, + depth: u8, +} +#[derive(Encode, Decode)] +#[bincode(decode_context = "DecodeBudget")] +struct ConnectorWire { + id: u64, + kind: u8, + from: [u32; 3], + to: [u32; 3], + length: u32, + anchor_quality: u8, + load_kg: u32, + blocked: bool, +} +#[derive(Encode, Decode)] +#[bincode(decode_context = "DecodeBudget")] +struct PressureNodeWire { + id: u64, + pressure: u32, +} +#[derive(Encode, Decode)] +#[bincode(decode_context = "DecodeBudget")] +struct PressureEdgeWire { + from: u64, + to: u64, + openness: u32, + hidden: bool, +} +#[derive(Encode, Decode)] +#[bincode(decode_context = "DecodeBudget")] +struct CauseWire { + tick: u64, + rule: BoundedString, + detail: BoundedString, +} +#[derive(Encode, Decode)] +#[bincode(decode_context = "DecodeBudget")] +struct FarWire { + day: u64, + day_tick: u64, +} +#[derive(Encode, Decode)] +#[bincode(decode_context = "DecodeBudget")] +struct LawWire { + rule: BoundedString, + tick: u64, +} +#[derive(Encode, Decode)] +#[bincode(decode_context = "DecodeBudget")] +struct LightWire { + x: i32, + y: i32, + z: i32, + radius: u8, + warm: bool, +} +#[derive(Encode, Decode)] +#[bincode(decode_context = "DecodeBudget")] +struct SoundWire { + x: i32, + y: i32, + z: i32, + loudness: u8, + timbre: u8, +} +#[derive(Encode, Decode)] +#[bincode(decode_context = "DecodeBudget")] +struct CorpseWire { + age_ticks: u64, + bursts_at: u64, +} +#[derive(Encode, Decode)] +#[bincode(decode_context = "DecodeBudget")] +struct SpillWire { + gas_flows: BoundedVec<(u64, u64, u32), MAX_ROWS, BUDGET_GAS_FLOWS>, + light_pending: bool, + signal_pending: bool, + scent_pending: bool, +} +#[derive(Encode, Decode)] +#[bincode(decode_context = "DecodeBudget")] +struct CharacterWire { + ancestry: u8, + calling: u8, + attrs: [u8; 5], + skills: [u8; 14], + uses: [u32; 14], + pips: [u8; 14], +} +#[derive(Encode, Decode)] +#[bincode(decode_context = "DecodeBudget")] +struct StandingWire { + faction: u8, + rank: i8, + debt_cu: i32, +} +#[derive(Encode, Decode)] +#[bincode(decode_context = "DecodeBudget")] +struct GodCreditWire { + god: u8, + credit: i16, +} +#[derive(Encode, Decode)] +#[bincode(decode_context = "DecodeBudget")] +struct ReputationWire { + factions: [StandingWire; 5], + town_ledger: BoundedVec<(BoundedString, i16), MAX_TOWN_LEDGER_ROWS, BUDGET_TOWN_LEDGER>, + god_credit: [GodCreditWire; 5], +} +#[derive(Encode, Decode)] +#[bincode(decode_context = "DecodeBudget")] +struct ItemWire { + id: u64, + template: BoundedString, + material: u8, + identified: bool, + cursed: bool, + blessed: bool, + condition: u8, + silvered: bool, + illegal: bool, + tags: BoundedVec, + fuel: u8, + wet_ticks: u64, +} +#[derive(Encode, Decode)] +#[bincode(decode_context = "DecodeBudget")] +struct PackWire { + items: BoundedVec<(u64, ItemWire), MAX_PACK_ITEMS, BUDGET_PACK_ITEMS>, + equipped_weapon: Option, + equipped_armor: Option, + max_weight_kg: u32, + max_bulk: u8, +} +#[derive(Encode, Decode)] +#[bincode(decode_context = "DecodeBudget")] +struct HymnCardWire { + template: BoundedString, + conduct_base: u8, + slots: u8, + fragile: bool, + miscasts: BoundedVec, +} +#[derive(Encode, Decode)] +#[bincode(decode_context = "DecodeBudget")] +struct HymnCaseWire { + cards: BoundedVec, + max_slots: u8, +} +#[derive(Encode, Decode)] +#[bincode(decode_context = "DecodeBudget")] +struct CastWire { + caster: u64, + template: BoundedString, + target: CellWire, +} +#[derive(Encode, Decode)] +#[bincode(decode_context = "DecodeBudget")] +struct DebtWire { + god: u8, + boon: BoundedString, + owed: BoundedString, + ticks_left: Option, + altar_receipt: bool, +} +#[derive(Encode, Decode)] +#[bincode(decode_context = "DecodeBudget")] +struct LedgerWire { + debts: BoundedVec, +} +#[derive(Encode, Decode)] +#[bincode(decode_context = "DecodeBudget")] +struct NpcWire { + name: BoundedString, + job: u8, + home: CellWire, + work: CellWire, + debt_cu: i32, +} +#[derive(Encode, Decode)] +#[bincode(decode_context = "DecodeBudget")] +struct ShopWire { + broke: BoundedVec<(u64, u8), MAX_SHOP_ROWS, BUDGET_SHOP_ROWS>, + stole: BoundedVec<(u64, bool), MAX_SHOP_ROWS, BUDGET_SHOP_ROWS>, + killed: BoundedVec<(u64, bool), MAX_SHOP_ROWS, BUDGET_SHOP_ROWS>, +} +#[derive(Encode, Decode)] +#[bincode(decode_context = "DecodeBudget")] +struct GossipWire { + from: BoundedString, + to: BoundedString, + sent_day: u64, + ledger_delta: i16, +} +#[derive(Encode, Decode)] +#[bincode(decode_context = "DecodeBudget")] +struct ChapelDebtWire { + burials_owed: u8, + chapel: BoundedString, +} + +/// Encode an exact current State, immutable identity, optional nonrecursive +/// Story checkpoint, and the full retained lifecycle history. +pub fn encode_run( + identity: &RunIdentity, + current: &State, + story_checkpoint: Option<&StoryCheckpoint>, + events: &[SessionEvent], +) -> Result, PersistenceError> { + let current_hashed = HashedState::from_state(current)?; + validate_run(identity, ¤t_hashed, story_checkpoint, events)?; + let mut budget = EncodeBudget::default(); + budget.check_identity(identity, "run.identity")?; + budget.check_events(events, "run.events")?; + budget.check_image(¤t_hashed.image)?; + if let Some(checkpoint) = story_checkpoint { + budget.check_image(&checkpoint.state.image)?; + } + let file = RunWire { + header: header(RUN_MAGIC, RUN_FORMAT_VERSION), + identity: identity_to_wire(identity), + current: hashed_state_to_wire(current_hashed)?, + story_checkpoint: story_checkpoint.map(story_checkpoint_to_wire).transpose()?, + events: events.iter().map(event_to_wire).collect::>()?, + }; + encode_bounded(file) +} + +/// Decode, integrity-check, reconstruct, validate, and hash a v3 run save. +pub fn decode_run( + bytes: &[u8], + current_content_hash: &str, +) -> Result { + valid_string(current_content_hash, "current_content_hash", true)?; + let raw = decompress_bounded(bytes)?; + gate_raw_header(&raw, RUN_MAGIC, "run-save", RUN_FORMAT_VERSION)?; + let file: RunWire = decode_raw_bounded(&raw)?; + let identity = identity_from_wire(file.identity, "run.identity")?; + validate_content_hash(&identity.content_hash, current_content_hash)?; + let current_hashed = hashed_state_from_wire(file.current, "run.current")?; + let story_checkpoint = file + .story_checkpoint + .map(story_checkpoint_from_wire) + .transpose()?; + let events = wires_to_events(file.events.into_inner(), "run.events")?; + validate_run_structure( + &identity, + ¤t_hashed, + story_checkpoint.as_ref(), + &events, + )?; + let current = State::from_image(current_hashed.image) + .map_err(|e| PersistenceError::CoreImage(format!("run.current: {e:?}")))?; + Ok(DecodedRun { + identity, + current, + story_checkpoint, + events, + }) +} + +/// Encode production replay input; no checkpoint, snapshot, or presentation +/// evidence is stored. +pub fn encode_replay(replay: &ProductionReplay) -> Result, PersistenceError> { + validate_identity(&replay.identity, "replay.identity")?; + validate_events( + &replay.events, + replay.identity.mode, + replay.expected_final_tick, + "replay.events", + )?; + let mut budget = EncodeBudget::default(); + budget.check_identity(&replay.identity, "replay.identity")?; + budget.check_events(&replay.events, "replay.events")?; + let wire = ReplayWire { + header: header(REPLAY_MAGIC, REPLAY_FORMAT_VERSION), + identity: identity_to_wire(&replay.identity), + events: replay + .events + .iter() + .map(event_to_wire) + .collect::>()?, + expected_final_tick: replay.expected_final_tick, + expected_final_hash: replay.expected_final_hash, + }; + encode_bounded(wire) +} + +/// Decode and validate production replay input against the current content pack. +pub fn decode_replay( + bytes: &[u8], + current_content_hash: &str, +) -> Result { + valid_string(current_content_hash, "current_content_hash", true)?; + let raw = decompress_bounded(bytes)?; + gate_raw_header(&raw, REPLAY_MAGIC, "replay", REPLAY_FORMAT_VERSION)?; + let wire: ReplayWire = decode_raw_bounded(&raw)?; + let identity = identity_from_wire(wire.identity, "replay.identity")?; + validate_content_hash(&identity.content_hash, current_content_hash)?; + let events = wires_to_events(wire.events.into_inner(), "replay.events")?; + validate_events( + &events, + identity.mode, + wire.expected_final_tick, + "replay.events", + )?; + Ok(ProductionReplay { + identity, + events, + expected_final_tick: wire.expected_final_tick, + expected_final_hash: wire.expected_final_hash, + }) +} + +fn encode_bounded(value: T) -> Result, PersistenceError> { + let raw = bincode::encode_to_vec( + value, + bincode::config::standard().with_limit::(), + ) + .map_err(|e| PersistenceError::Decode(e.to_string()))?; + if raw.len() > MAX_DECOMPRESSED_BYTES { + return Err(PersistenceError::Limit { + context: "encoded envelope".into(), + actual: raw.len(), + limit: MAX_DECOMPRESSED_BYTES, + }); + } + let compressed = zstd::stream::encode_all(raw.as_slice(), ZSTD_LEVEL) + .map_err(|e| PersistenceError::Compression(e.to_string()))?; + if compressed.len() > MAX_COMPRESSED_BYTES { + return Err(PersistenceError::InputTooLarge { + actual: compressed.len(), + limit: MAX_COMPRESSED_BYTES, + }); + } + Ok(compressed) +} + +fn decompress_bounded(bytes: &[u8]) -> Result, PersistenceError> { + if bytes.len() > MAX_COMPRESSED_BYTES { + return Err(PersistenceError::InputTooLarge { + actual: bytes.len(), + limit: MAX_COMPRESSED_BYTES, + }); + } + let decoder = zstd::stream::read::Decoder::new(bytes) + .map_err(|e| PersistenceError::Decompression(e.to_string()))?; + let mut raw = Vec::new(); + decoder + .take((MAX_DECOMPRESSED_BYTES as u64) + 1) + .read_to_end(&mut raw) + .map_err(|e| PersistenceError::Decompression(e.to_string()))?; + if raw.len() > MAX_DECOMPRESSED_BYTES { + return Err(PersistenceError::DecompressedTooLarge { + limit: MAX_DECOMPRESSED_BYTES, + }); + } + Ok(raw) +} + +fn decode_raw_bounded>(raw: &[u8]) -> Result { + let (value, used) = bincode::decode_from_slice_with_context( + raw, + bincode::config::standard().with_limit::(), + DecodeBudget::default(), + ) + .map_err(|e| PersistenceError::Decode(e.to_string()))?; + if used != raw.len() { + return Err(PersistenceError::TrailingBytes { + consumed: used, + total: raw.len(), + }); + } + Ok(value) +} + +fn gate_raw_header( + raw: &[u8], + magic: [u8; 8], + format: &'static str, + current: u32, +) -> Result<(), PersistenceError> { + let (header, _) = bincode::decode_from_slice_with_context::( + raw, + bincode::config::standard().with_limit::(), + DecodeBudget::default(), + ) + .map_err(|e| PersistenceError::Decode(e.to_string()))?; + if header.magic != magic { + return Err(PersistenceError::WrongMagic { format }); + } + gate_version(format, header.format_version, current)?; + gate_hash_version(header.canonical_hash_version) +} + +fn gate_version(format: &'static str, found: u32, current: u32) -> Result<(), PersistenceError> { + if found < current { + Err(PersistenceError::OlderVersion { + format, + found, + current, + }) + } else if found > current { + Err(PersistenceError::NewerVersion { + format, + found, + current, + }) + } else { + Ok(()) + } +} +fn gate_hash_version(found: u64) -> Result<(), PersistenceError> { + if found == CANONICAL_HASH_VERSION { + Ok(()) + } else { + Err(PersistenceError::CanonicalHashVersion { + found, + current: CANONICAL_HASH_VERSION, + }) + } +} +fn metadata(envelope: u64, image: u64, field: &'static str) -> Result<(), PersistenceError> { + if envelope == image { + Ok(()) + } else { + Err(PersistenceError::MetadataMismatch { + field, + envelope, + image, + }) + } +} +fn limit(actual: usize, limit: usize, context: &str) -> Result<(), PersistenceError> { + if actual <= limit { + Ok(()) + } else { + Err(PersistenceError::Limit { + context: context.into(), + actual, + limit, + }) + } +} +fn valid_string(value: &str, context: &str, nonblank: bool) -> Result<(), PersistenceError> { + limit(value.len(), MAX_STRING_BYTES, context)?; + if nonblank && value.trim().is_empty() { + Err(PersistenceError::Invalid { + context: format!("{context} is blank"), + }) + } else { + Ok(()) + } +} + +fn header(magic: [u8; 8], format_version: u32) -> EnvelopeHeader { + EnvelopeHeader { + magic, + format_version, + canonical_hash_version: CANONICAL_HASH_VERSION, + } +} + +fn validate_content_hash(stored: &str, current: &str) -> Result<(), PersistenceError> { + if stored == current { + Ok(()) + } else { + Err(PersistenceError::ContentHashMismatch { + stored: stored.to_owned(), + current: current.to_owned(), + }) + } +} + +fn validate_identity(identity: &RunIdentity, context: &str) -> Result<(), PersistenceError> { + valid_string( + &identity.display_name, + &format!("{context}.display_name"), + true, + )?; + valid_string( + &identity.content_hash, + &format!("{context}.content_hash"), + true, + ) +} + +fn identity_to_wire(identity: &RunIdentity) -> RunIdentityWire { + RunIdentityWire { + seed: identity.seed, + mode: mode_tag(identity.mode), + ancestry: identity.ancestry.canonical_tag(), + calling: identity.calling.canonical_tag(), + display_name: identity.display_name.clone().into(), + content_hash: identity.content_hash.clone().into(), + } +} + +fn identity_from_wire( + wire: RunIdentityWire, + context: &str, +) -> Result { + let identity = RunIdentity { + seed: wire.seed, + mode: mode_from_tag(wire.mode, &format!("{context}.mode"))?, + ancestry: ancestry(wire.ancestry, &format!("{context}.ancestry"))?, + calling: calling(wire.calling, &format!("{context}.calling"))?, + display_name: wire.display_name.into_inner(), + content_hash: wire.content_hash.into_inner(), + }; + validate_identity(&identity, context)?; + Ok(identity) +} + +fn hashed_state_to_wire(state: HashedState) -> Result { + Ok(HashedStateWire { + tick: state.tick, + hash: state.hash, + image: image_to_wire(state.image)?, + }) +} + +fn hashed_state_from_wire( + wire: HashedStateWire, + context: &str, +) -> Result { + let image = wire_to_image(wire.image)?; + metadata(wire.tick, image.tick, "tick")?; + let restored = State::from_image(image.clone()) + .map_err(|e| PersistenceError::CoreImage(format!("{context}: {e:?}")))?; + let actual = restored + .try_hash_state() + .map_err(|e| PersistenceError::CoreState(format!("{context}: {e:?}")))?; + if actual != wire.hash { + return Err(PersistenceError::CheckpointHashMismatch { + expected: wire.hash, + actual, + }); + } + Ok(HashedState { + tick: wire.tick, + hash: wire.hash, + image, + }) +} + +fn story_checkpoint_to_wire( + checkpoint: &StoryCheckpoint, +) -> Result { + Ok(StoryCheckpointWire { + state: hashed_state_to_wire(checkpoint.state.clone())?, + after_event_count: checkpoint.after_event_count, + }) +} + +fn story_checkpoint_from_wire( + wire: StoryCheckpointWire, +) -> Result { + Ok(StoryCheckpoint { + state: hashed_state_from_wire(wire.state, "run.story_checkpoint.state")?, + after_event_count: wire.after_event_count, + }) +} + +fn validate_hashed_image(state: &HashedState, context: &str) -> Result<(), PersistenceError> { + metadata(state.tick, state.image.tick, "tick")?; + let restored = State::from_image(state.image.clone()) + .map_err(|e| PersistenceError::CoreImage(format!("{context}: {e:?}")))?; + let actual = restored + .try_hash_state() + .map_err(|e| PersistenceError::CoreState(format!("{context}: {e:?}")))?; + if actual != state.hash { + return Err(PersistenceError::CheckpointHashMismatch { + expected: state.hash, + actual, + }); + } + Ok(()) +} + +fn action_semantic_cost(action: &CommittedAction) -> Result<(u64, u64), PersistenceError> { + match action { + CommittedAction::Wait => Ok((1, 0)), + CommittedAction::UseService { + service: Service::Inn, + } => Ok((0, hexwork_core::services::sleep_ticks())), + CommittedAction::UseService { service } => Err(PersistenceError::Invalid { + context: format!("unsupported production service timing: {service:?}"), + }), + _ => Ok((0, 0)), + } +} + +fn checked_add_u64(total: &mut u64, amount: u64, context: &str) -> Result<(), PersistenceError> { + *total = total + .checked_add(amount) + .ok_or_else(|| PersistenceError::Invalid { + context: format!("{context} overflows u64"), + })?; + Ok(()) +} + +/// Validate a lifecycle timeline and return exact cheap replay-work costs. +pub fn timeline_costs( + events: &[SessionEvent], + mode: Mode, + final_tick: u64, +) -> Result { + validate_events_inner(events, mode, final_tick, "events") +} + +fn validate_events( + events: &[SessionEvent], + mode: Mode, + final_tick: u64, + context: &str, +) -> Result { + validate_events_inner(events, mode, final_tick, context) +} + +fn validate_events_inner( + events: &[SessionEvent], + mode: Mode, + final_tick: u64, + context: &str, +) -> Result { + limit(events.len(), MAX_SESSION_EVENTS, context)?; + let mut cursor = 0u64; + let mut costs = TimelineCosts::default(); + for (index, event) in events.iter().enumerate() { + if event.pre_tick < cursor { + return Err(PersistenceError::Invalid { + context: format!("{context}[{index}].pre_tick precedes cursor {cursor}"), + }); + } + let idle = event.pre_tick - cursor; + checked_add_u64(&mut costs.full_steps, idle, "full-step cost")?; + checked_add_u64(&mut costs.total_elapsed_ticks, idle, "elapsed cost")?; + match &event.kind { + SessionEventKind::Action(action) => { + let (full, unattended) = action_semantic_cost(action)?; + let semantic = + full.checked_add(unattended) + .ok_or_else(|| PersistenceError::Invalid { + context: format!("{context}[{index}] semantic cost overflows"), + })?; + let expected_post = event.pre_tick.checked_add(semantic).ok_or_else(|| { + PersistenceError::Invalid { + context: format!("{context}[{index}].post_tick overflows"), + } + })?; + if event.post_tick != expected_post { + return Err(PersistenceError::Invalid { + context: format!( + "{context}[{index}] action span is {}, expected {semantic}", + event.post_tick.saturating_sub(event.pre_tick) + ), + }); + } + action_to_wire(action)?; + checked_add_u64(&mut costs.full_steps, full, "full-step cost")?; + checked_add_u64(&mut costs.unattended_steps, unattended, "unattended cost")?; + checked_add_u64(&mut costs.total_elapsed_ticks, semantic, "elapsed cost")?; + } + SessionEventKind::StoryResurrect { + checkpoint_after_event_count, + .. + } => { + if mode != Mode::Story { + return Err(PersistenceError::Invalid { + context: format!("{context}[{index}] resurrection is Story-only"), + }); + } + if event.post_tick > event.pre_tick { + return Err(PersistenceError::Invalid { + context: format!("{context}[{index}] resurrection advances time"), + }); + } + validate_checkpoint_prefix( + events, + usize::try_from(*checkpoint_after_event_count).map_err(|_| { + PersistenceError::Invalid { + context: format!("{context}[{index}] checkpoint prefix is invalid"), + } + })?, + index, + event.post_tick, + &format!("{context}[{index}].checkpoint_after_event_count"), + )?; + } + } + cursor = event.post_tick; + } + if final_tick < cursor { + return Err(PersistenceError::Invalid { + context: format!("{context} final tick {final_tick} precedes cursor {cursor}"), + }); + } + let final_gap = final_tick - cursor; + checked_add_u64(&mut costs.full_steps, final_gap, "full-step cost")?; + checked_add_u64(&mut costs.total_elapsed_ticks, final_gap, "elapsed cost")?; + Ok(costs) +} + +fn validate_checkpoint_prefix( + events: &[SessionEvent], + prefix: usize, + upper_bound: usize, + checkpoint_tick: u64, + context: &str, +) -> Result<(), PersistenceError> { + if prefix > upper_bound || prefix > events.len() { + return Err(PersistenceError::Invalid { + context: format!("{context} {prefix} is outside retained prefix {upper_bound}"), + }); + } + if prefix == 0 { + if checkpoint_tick != 0 { + return Err(PersistenceError::Invalid { + context: format!("{context} zero prefix requires tick zero"), + }); + } + return Ok(()); + } + let prior = &events[prefix - 1]; + if !matches!( + prior.kind, + SessionEventKind::Action(CommittedAction::UseTransition { .. }) + ) || prior.post_tick != checkpoint_tick + { + return Err(PersistenceError::Invalid { + context: format!("{context} must end on transition at checkpoint tick"), + }); + } + Ok(()) +} + +fn action_to_wire(action: &CommittedAction) -> Result { + let mut wire = ActionWire { + tag: 0, + direction: 0, + service: 0, + text: BoundedString::default(), + id: 0, + }; + match action { + CommittedAction::Move(direction) => { + wire.tag = 1; + wire.direction = direction_tag(direction); + } + CommittedAction::Wait => wire.tag = 2, + CommittedAction::InspectBoard { town } => { + valid_string(town, "action.inspect_board.town", true)?; + wire.tag = 3; + wire.text = town.clone().into(); + } + CommittedAction::AcceptContract { id } => { + valid_string(id, "action.accept_contract.id", true)?; + wire.tag = 4; + wire.text = id.clone().into(); + } + CommittedAction::UseTransition { transition_id } => { + if *transition_id == 0 { + return Err(PersistenceError::Invalid { + context: "action transition id is zero".into(), + }); + } + wire.tag = 5; + wire.id = *transition_id; + } + CommittedAction::ResolveAttack { target_actor } => { + if *target_actor == 0 { + return Err(PersistenceError::Invalid { + context: "action target actor is zero".into(), + }); + } + wire.tag = 6; + wire.id = *target_actor; + } + CommittedAction::TurnIn { contract_id } => { + valid_string(contract_id, "action.turn_in.contract_id", true)?; + wire.tag = 7; + wire.text = contract_id.clone().into(); + } + CommittedAction::UseService { service } => { + wire.tag = 8; + wire.service = service_tag(*service); + } + } + Ok(wire) +} + +fn wire_to_action(wire: ActionWire, context: &str) -> Result { + let unused = |direction, service, text: &str, id| { + if direction == 0 && service == 0 && text.is_empty() && id == 0 { + Ok(()) + } else { + Err(PersistenceError::Invalid { + context: format!("{context} has noncanonical unused payload"), + }) + } + }; + match wire.tag { + 1 => { + if wire.service != 0 || !wire.text.is_empty() || wire.id != 0 { + return Err(PersistenceError::Invalid { + context: format!("{context} move has unused payload"), + }); + } + Ok(CommittedAction::Move(direction( + wire.direction, + &format!("{context}.direction"), + )?)) + } + 2 => { + unused(wire.direction, wire.service, &wire.text, wire.id)?; + Ok(CommittedAction::Wait) + } + 3 | 4 | 7 => { + if wire.direction != 0 || wire.service != 0 || wire.id != 0 { + return Err(PersistenceError::Invalid { + context: format!("{context} text action has unused payload"), + }); + } + valid_string(&wire.text, &format!("{context}.text"), true)?; + let text = wire.text.into_inner(); + Ok(match wire.tag { + 3 => CommittedAction::InspectBoard { town: text }, + 4 => CommittedAction::AcceptContract { id: text }, + _ => CommittedAction::TurnIn { contract_id: text }, + }) + } + 5 | 6 => { + if wire.direction != 0 || wire.service != 0 || !wire.text.is_empty() || wire.id == 0 { + return Err(PersistenceError::Invalid { + context: format!("{context} id action payload is noncanonical"), + }); + } + Ok(if wire.tag == 5 { + CommittedAction::UseTransition { + transition_id: wire.id, + } + } else { + CommittedAction::ResolveAttack { + target_actor: wire.id, + } + }) + } + 8 => { + if wire.direction != 0 || !wire.text.is_empty() || wire.id != 0 { + return Err(PersistenceError::Invalid { + context: format!("{context} service has unused payload"), + }); + } + Ok(CommittedAction::UseService { + service: service(wire.service, &format!("{context}.service"))?, + }) + } + tag => Err(PersistenceError::Invalid { + context: format!("{context}.tag unknown {tag}"), + }), + } +} + +fn event_to_wire(event: &SessionEvent) -> Result { + let mut wire = SessionEventWire { + pre_tick: event.pre_tick, + post_tick: event.post_tick, + kind_tag: 0, + action: ActionWire { + tag: 0, + direction: 0, + service: 0, + text: BoundedString::default(), + id: 0, + }, + checkpoint_after_event_count: 0, + checkpoint_hash: 0, + dead_state_hash: 0, + restored_state_hash: 0, + }; + match &event.kind { + SessionEventKind::Action(action) => { + wire.kind_tag = 1; + wire.action = action_to_wire(action)?; + } + SessionEventKind::StoryResurrect { + checkpoint_after_event_count, + checkpoint_hash, + dead_state_hash, + restored_state_hash, + } => { + wire.kind_tag = 2; + wire.checkpoint_after_event_count = *checkpoint_after_event_count; + wire.checkpoint_hash = *checkpoint_hash; + wire.dead_state_hash = *dead_state_hash; + wire.restored_state_hash = *restored_state_hash; + } + } + Ok(wire) +} + +fn wire_to_event( + wire: SessionEventWire, + index: usize, + context: &str, +) -> Result { + let kind = match wire.kind_tag { + 1 => { + if wire.checkpoint_after_event_count != 0 + || wire.checkpoint_hash != 0 + || wire.dead_state_hash != 0 + || wire.restored_state_hash != 0 + { + return Err(PersistenceError::Invalid { + context: format!("{context}[{index}] action has lifecycle payload"), + }); + } + SessionEventKind::Action(wire_to_action( + wire.action, + &format!("{context}[{index}].action"), + )?) + } + 2 => { + if wire.action.tag != 0 + || wire.action.direction != 0 + || wire.action.service != 0 + || !wire.action.text.is_empty() + || wire.action.id != 0 + { + return Err(PersistenceError::Invalid { + context: format!("{context}[{index}] resurrection has action payload"), + }); + } + SessionEventKind::StoryResurrect { + checkpoint_after_event_count: wire.checkpoint_after_event_count, + checkpoint_hash: wire.checkpoint_hash, + dead_state_hash: wire.dead_state_hash, + restored_state_hash: wire.restored_state_hash, + } + } + tag => { + return Err(PersistenceError::Invalid { + context: format!("{context}[{index}].kind_tag unknown {tag}"), + }) + } + }; + Ok(SessionEvent { + pre_tick: wire.pre_tick, + post_tick: wire.post_tick, + kind, + }) +} + +fn wires_to_events( + rows: Vec, + context: &str, +) -> Result, PersistenceError> { + limit(rows.len(), MAX_SESSION_EVENTS, context)?; + rows.into_iter() + .enumerate() + .map(|(index, wire)| wire_to_event(wire, index, context)) + .collect() +} + +/// SHA-256 over an explicit canonical wire encoding of the requested event +/// prefix. Events appended after `count` do not affect the digest. +pub fn event_prefix_digest( + events: &[SessionEvent], + count: usize, +) -> Result<[u8; 32], PersistenceError> { + limit(events.len(), MAX_SESSION_EVENTS, "events")?; + if count > events.len() { + return Err(PersistenceError::Invalid { + context: format!("event digest prefix {count} exceeds {}", events.len()), + }); + } + let mut budget = EncodeBudget::default(); + budget.check_events(&events[..count], "event_digest.events")?; + let mut hasher = Sha256::new(); + hasher.update(b"hexwork-session-event-prefix-v3\0"); + hasher.update((count as u64).to_le_bytes()); + for event in &events[..count] { + let wire = event_to_wire(event)?; + let encoded = bincode::encode_to_vec(wire, bincode::config::standard()) + .map_err(|e| PersistenceError::Decode(e.to_string()))?; + hasher.update((encoded.len() as u64).to_le_bytes()); + hasher.update(encoded); + } + Ok(hasher.finalize().into()) +} + +fn validate_run( + identity: &RunIdentity, + current: &HashedState, + checkpoint: Option<&StoryCheckpoint>, + events: &[SessionEvent], +) -> Result<(), PersistenceError> { + validate_hashed_image(current, "run.current")?; + if let Some(checkpoint) = checkpoint { + validate_hashed_image(&checkpoint.state, "run.story_checkpoint.state")?; + } + validate_run_structure(identity, current, checkpoint, events) +} + +fn validate_run_structure( + identity: &RunIdentity, + current: &HashedState, + checkpoint: Option<&StoryCheckpoint>, + events: &[SessionEvent], +) -> Result<(), PersistenceError> { + validate_identity(identity, "run.identity")?; + metadata(identity.seed, current.image.seed, "seed")?; + validate_events(events, identity.mode, current.tick, "run.events")?; + let current_template = validate_player_image(¤t.image, identity, false, "run.current")?; + match identity.mode { + Mode::Story => { + let checkpoint = checkpoint.ok_or_else(|| PersistenceError::Invalid { + context: "Story run requires exactly one active checkpoint".into(), + })?; + metadata(identity.seed, checkpoint.state.image.seed, "seed")?; + let checkpoint_template = validate_player_image( + &checkpoint.state.image, + identity, + true, + "run.story_checkpoint", + )?; + if checkpoint_template != current_template { + return Err(PersistenceError::Invalid { + context: "current and checkpoint Player templates differ".into(), + }); + } + let prefix = usize::try_from(checkpoint.after_event_count).map_err(|_| { + PersistenceError::Invalid { + context: "checkpoint event prefix is invalid".into(), + } + })?; + validate_checkpoint_prefix( + events, + prefix, + events.len(), + checkpoint.state.tick, + "run.story_checkpoint.after_event_count", + )?; + } + Mode::Classic | Mode::Ironcartographer => { + if checkpoint.is_some() { + return Err(PersistenceError::Invalid { + context: "Classic/Iron run cannot contain a Story checkpoint".into(), + }); + } + } + } + Ok(()) +} + +fn validate_player_image<'a>( + image: &'a StateImage, + identity: &RunIdentity, + checkpoint: bool, + context: &str, +) -> Result<&'a str, PersistenceError> { + if checkpoint && image.run_location != RunLocation::Town("milthollow".into()) { + return Err(PersistenceError::Invalid { + context: format!("{context} must be canonical Town milthollow"), + }); + } + let players: Vec<_> = image + .actor_identities + .iter() + .filter(|(_, actor_identity)| actor_identity.role == ActorRole::Player) + .collect(); + if players.len() != 1 { + return Err(PersistenceError::Invalid { + context: format!("{context} must contain exactly one Player"), + }); + } + let (player_id, actor_identity) = players[0]; + if actor_identity.display_name != identity.display_name { + return Err(PersistenceError::Invalid { + context: format!("{context} Player display name differs from run identity"), + }); + } + let actor = image + .actors + .iter() + .find(|actor| actor.id == *player_id) + .ok_or_else(|| PersistenceError::Invalid { + context: format!("{context} Player identity has no actor"), + })?; + let character = image + .characters + .get(player_id) + .ok_or_else(|| PersistenceError::Invalid { + context: format!("{context} Player has no Character"), + })?; + if character.ancestry != identity.ancestry || character.calling != identity.calling { + return Err(PersistenceError::Invalid { + context: format!("{context} Player ancestry/calling differs from run identity"), + }); + } + if checkpoint && (!actor.alive || actor.hp <= 0) { + return Err(PersistenceError::Invalid { + context: format!("{context} Player must be living with positive HP"), + }); + } + Ok(&actor_identity.template_id) +} + +fn checked_total( + total: &mut usize, + amount: usize, + cap: usize, + context: &str, +) -> Result<(), PersistenceError> { + *total = total + .checked_add(amount) + .ok_or_else(|| PersistenceError::Limit { + context: context.into(), + actual: usize::MAX, + limit: cap, + })?; + limit(*total, cap, context) +} + +fn validate_image_limits(i: &StateImage) -> Result<(), PersistenceError> { + limit(i.chunks.len(), MAX_CHUNKS, "state.chunks")?; + let mut state_rows = 0; + for (name, rows) in [ + ("chunks", i.chunks.len()), + ("actors", i.actors.len()), + ("identities", i.actor_identities.len()), + ("wallets", i.wallets.len()), + ("contracts", i.contracts.len()), + ("connectors", i.connectors.len()), + ("pressure_nodes", i.pressure_nodes.len()), + ("pressure_edges", i.pressure_edges.len()), + ("causes", i.causes.len()), + ("laws", i.laws.len()), + ("fall_odo", i.fall_odo.len()), + ("lights", i.lights.len()), + ("sounds", i.sounds.len()), + ("corpses", i.corpses.len()), + ("signal_sources", i.signal_sources.len()), + ("characters", i.characters.len()), + ("reputations", i.reputations.len()), + ("items", i.items.len()), + ("ground_items", i.ground_items.len()), + ("packs", i.packs.len()), + ("idf", i.idf.len()), + ("hymn_cases", i.hymn_cases.len()), + ("cast_queue", i.cast_queue.len()), + ("bargains", i.bargains.len()), + ("npcs", i.npcs.len()), + ("shops", i.shops.len()), + ("gossip", i.gossip.len()), + ("chalk", i.chalk.marks.len()), + ("chapel_debts", i.chapel_debts.len()), + ] { + limit(rows, MAX_ROWS, &format!("state.{name}"))?; + checked_total( + &mut state_rows, + rows, + MAX_TOTAL_STATE_ROWS, + "total state rows", + )?; + } + + let mut nested_rows = 0; + for (index, chunk) in i.chunks.iter().enumerate() { + limit( + chunk.voxels.len(), + CHUNK_VOL, + &format!("state.chunks[{index}].voxels"), + )?; + checked_total( + &mut nested_rows, + chunk.voxels.len(), + MAX_TOTAL_NESTED_ROWS, + "total nested rows", + )?; + } + let gas_flows = i.spill.gas_flows.len(); + limit(gas_flows, MAX_ROWS, "state.spill.gas_flows")?; + limit(gas_flows, MAX_TOTAL_GAS_FLOWS, "spill gas flows")?; + checked_total( + &mut nested_rows, + gas_flows, + MAX_TOTAL_NESTED_ROWS, + "total nested rows", + )?; + let mule_waypoints = i.mule.as_ref().map_or(0, |mule| mule.waypoints.len()); + limit(mule_waypoints, MAX_TOTAL_MULE_WAYPOINTS, "mule waypoints")?; + checked_total( + &mut nested_rows, + mule_waypoints, + MAX_TOTAL_NESTED_ROWS, + "total nested rows", + )?; + + let mut town_rows = 0; + let mut pack_items = 0; + let mut hymn_cards = 0; + let mut hymn_miscasts = 0; + let mut ledger_debts = 0; + let mut shop_rows = 0; + let mut item_tags = 0; + for reputation in i.reputations.values() { + limit( + reputation.town_ledger.len(), + MAX_TOWN_LEDGER_ROWS, + "reputation.town_ledger", + )?; + checked_total( + &mut town_rows, + reputation.town_ledger.len(), + MAX_TOTAL_TOWN_LEDGER_ROWS, + "reputation town-ledger rows", + )?; + } + for item in i.items.values() { + limit(item.tags.len(), MAX_ITEM_TAGS, "item.tags")?; + checked_total( + &mut item_tags, + item.tags.len(), + MAX_TOTAL_ITEM_TAGS, + "item tags", + )?; + } + for pack in i.packs.values() { + limit(pack.items.len(), MAX_PACK_ITEMS, "pack.items")?; + checked_total( + &mut pack_items, + pack.items.len(), + MAX_TOTAL_PACK_ITEMS, + "pack items", + )?; + for item in pack.items.values() { + limit(item.tags.len(), MAX_ITEM_TAGS, "pack.item.tags")?; + checked_total( + &mut item_tags, + item.tags.len(), + MAX_TOTAL_ITEM_TAGS, + "item tags", + )?; + } + } + for case in i.hymn_cases.values() { + limit(case.cards.len(), MAX_HYMN_CARDS, "hymn_case.cards")?; + checked_total( + &mut hymn_cards, + case.cards.len(), + MAX_TOTAL_HYMN_CARDS, + "hymn cards", + )?; + for card in &case.cards { + limit(card.miscasts.len(), MAX_HYMN_MISCASTS, "hymn_card.miscasts")?; + checked_total( + &mut hymn_miscasts, + card.miscasts.len(), + MAX_TOTAL_HYMN_MISCASTS, + "hymn miscasts", + )?; + } + } + for ledger in i.bargains.values() { + limit(ledger.debts.len(), MAX_LEDGER_DEBTS, "ledger.debts")?; + checked_total( + &mut ledger_debts, + ledger.debts.len(), + MAX_TOTAL_LEDGER_DEBTS, + "ledger debts", + )?; + } + for shop in i.shops.values() { + for (name, rows) in [ + ("shop.broke", shop.broke.len()), + ("shop.stole", shop.stole.len()), + ("shop.killed", shop.killed.len()), + ] { + limit(rows, MAX_SHOP_ROWS, name)?; + checked_total( + &mut shop_rows, + rows, + MAX_TOTAL_SHOP_ROWS, + "shop memory rows", + )?; + } + } + for (amount, cap, context) in [ + ( + town_rows, + MAX_TOTAL_TOWN_LEDGER_ROWS, + "reputation town-ledger rows", + ), + (pack_items, MAX_TOTAL_PACK_ITEMS, "pack items"), + (hymn_cards, MAX_TOTAL_HYMN_CARDS, "hymn cards"), + (hymn_miscasts, MAX_TOTAL_HYMN_MISCASTS, "hymn miscasts"), + (ledger_debts, MAX_TOTAL_LEDGER_DEBTS, "ledger debts"), + (shop_rows, MAX_TOTAL_SHOP_ROWS, "shop memory rows"), + (item_tags, MAX_TOTAL_ITEM_TAGS, "item tags"), + ] { + limit(amount, cap, context)?; + checked_total( + &mut nested_rows, + amount, + MAX_TOTAL_NESTED_ROWS, + "total nested rows", + )?; + } + let mut strings = EncodeBudget::default(); + strings.check_image_strings(i) +} + +fn image_to_wire(i: StateImage) -> Result { + validate_image_limits(&i)?; + Ok(StateWire { + tick: i.tick, + seed: i.seed, + rng_master: i.rng_master, + ecs_entity_count: i.ecs_entity_count, + chunks: i + .chunks + .into_iter() + .map(|c| ChunkWire { + coord: cell(c.coord), + voxels: c.voxels.into_iter().map(voxel_to_wire).collect(), + }) + .collect(), + actors: i + .actors + .into_iter() + .map(|a| ActorWire { + id: a.id, + pos: a.pos.map(f32::to_bits), + radius: a.radius.to_bits(), + stance: a.stance.canonical_tag(), + hp: a.hp, + alive: a.alive, + }) + .collect(), + identities: i + .actor_identities + .into_iter() + .map(|(k, v)| { + ( + k, + IdentityWire { + template: v.template_id.into(), + name: v.display_name.into(), + role: v.role.canonical_tag(), + }, + ) + }) + .collect(), + wallets: i + .wallets + .into_iter() + .map(|(k, v)| { + ( + k, + WalletWire { + copper: v.copper(), + scrip: v.scrip(), + }, + ) + }) + .collect(), + contracts: i + .contracts + .into_iter() + .map(|(k, v)| { + ( + k.into(), + ContractWire { + id: v.id.into(), + taker: v.taker, + accepted_tick: v.accepted_tick, + floor: v.floor, + pay_cu: v.pay_cu, + target: v.objective_target, + progress: v.objective_progress, + status: v.status.canonical_tag(), + reward_town: v.reward_town.into(), + standing_delta: v.standing_delta, + }, + ) + }) + .collect(), + location: location_to_wire(i.run_location), + connectors: i + .connectors + .into_iter() + .map(|v| ConnectorWire { + id: v.id, + kind: v.kind.canonical_tag(), + from: v.from.map(f32::to_bits), + to: v.to.map(f32::to_bits), + length: v.length.to_bits(), + anchor_quality: v.anchor_quality, + load_kg: v.load_kg.to_bits(), + blocked: v.blocked, + }) + .collect(), + pressure_nodes: i + .pressure_nodes + .into_iter() + .map(|v| PressureNodeWire { + id: v.id, + pressure: v.pressure.to_bits(), + }) + .collect(), + pressure_edges: i + .pressure_edges + .into_iter() + .map(|v| PressureEdgeWire { + from: v.from, + to: v.to, + openness: v.openness.to_bits(), + hidden: v.hidden, + }) + .collect(), + causes: i + .causes + .into_iter() + .map(|v| CauseWire { + tick: v.tick, + rule: v.rule.into(), + detail: v.detail.into(), + }) + .collect(), + far: FarWire { + day: i.far.day, + day_tick: i.far.day_tick, + }, + laws: i + .laws + .into_iter() + .map(|v| LawWire { + rule: v.rule.into(), + tick: v.first_seen_tick, + }) + .collect(), + fall_odo: i + .fall_odo + .into_iter() + .map(|(k, v)| (k, v.to_bits())) + .collect(), + lights: i + .lights + .into_iter() + .map(|v| LightWire { + x: v.x, + y: v.y, + z: v.z, + radius: v.radius, + warm: v.warm, + }) + .collect(), + sounds: i + .sounds + .into_iter() + .map(|v| SoundWire { + x: v.x, + y: v.y, + z: v.z, + loudness: v.loudness, + timbre: v.timbre, + }) + .collect(), + corpses: i + .corpses + .into_iter() + .map(|(k, v)| { + ( + cell(k), + CorpseWire { + age_ticks: v.age_ticks, + bursts_at: v.bursts_at, + }, + ) + }) + .collect(), + signal_sources: i.signal_sources.into_iter().map(cell).collect(), + spill: SpillWire { + gas_flows: i + .spill + .gas_flows + .into_iter() + .map(|(a, b, c)| (a, b, c.to_bits())) + .collect(), + light_pending: i.spill.light_pending, + signal_pending: i.spill.signal_pending, + scent_pending: i.spill.scent_pending, + }, + characters: i + .characters + .into_iter() + .map(|(k, v)| { + ( + k, + CharacterWire { + ancestry: v.ancestry.canonical_tag(), + calling: v.calling.canonical_tag(), + attrs: v.attrs, + skills: v.skills, + uses: v.uses, + pips: v.pips, + }, + ) + }) + .collect(), + reputations: i + .reputations + .into_iter() + .map(|(k, v)| (k, reputation_to_wire(v))) + .collect(), + items: i + .items + .into_iter() + .map(|(k, v)| (k, item_to_wire(v))) + .collect(), + ground_items: i + .ground_items + .into_iter() + .map(|(k, v)| (k, cell(v))) + .collect(), + packs: i + .packs + .into_iter() + .map(|(k, v)| (k, pack_to_wire(v))) + .collect(), + idf: i.idf.into_iter().map(|(k, v)| (k.into(), v)).collect(), + hymn_cases: i + .hymn_cases + .into_iter() + .map(|(k, v)| { + ( + k, + HymnCaseWire { + cards: v + .cards + .into_iter() + .map(|c| HymnCardWire { + template: c.template.into(), + conduct_base: c.conduct_base, + slots: c.slots, + fragile: c.fragile, + miscasts: c.miscasts.into_iter().map(Into::into).collect(), + }) + .collect(), + max_slots: v.max_slots, + }, + ) + }) + .collect(), + cast_queue: i + .cast_queue + .into_iter() + .map(|(caster, template, target)| CastWire { + caster, + template: template.into(), + target: cell((target[0], target[1], target[2])), + }) + .collect(), + bargains: i + .bargains + .into_iter() + .map(|(k, v)| { + ( + k, + LedgerWire { + debts: v + .debts + .into_iter() + .map(|d| DebtWire { + god: d.god.canonical_tag(), + boon: d.boon.into(), + owed: d.owed.into(), + ticks_left: d.ticks_left, + altar_receipt: d.altar_receipt, + }) + .collect(), + }, + ) + }) + .collect(), + deep_weather: i.deep_weather, + npcs: i + .npcs + .into_iter() + .map(|(k, v)| { + ( + k, + NpcWire { + name: v.name.into(), + job: v.job.canonical_tag(), + home: cell(v.home), + work: cell(v.work), + debt_cu: v.debt_cu, + }, + ) + }) + .collect(), + shops: i + .shops + .into_iter() + .map(|(k, v)| { + ( + cell(k), + ShopWire { + broke: v.broke.into_iter().collect(), + stole: v.stole.into_iter().collect(), + killed: v.killed.into_iter().collect(), + }, + ) + }) + .collect(), + gossip: i + .gossip + .into_iter() + .map(|v| GossipWire { + from: v.from.into(), + to: v.to.into(), + sent_day: v.sent_day, + ledger_delta: v.ledger_delta, + }) + .collect(), + chalk: i + .chalk + .marks + .into_iter() + .map(|(k, v)| (cell(k), v.into())) + .collect(), + mule: i.mule.map(|v| v.waypoints.into_iter().map(cell).collect()), + vault_choice: i.vault_choice.map_or(0, VaultChoice::canonical_tag), + chapel_debts: i + .chapel_debts + .into_iter() + .map(|(k, v)| { + ( + k, + ChapelDebtWire { + burials_owed: v.burials_owed, + chapel: v.chapel.into(), + }, + ) + }) + .collect(), + next_id: i.next_id, + step_mode: i.step_mode, + }) +} + +fn wire_to_image(w: StateWire) -> Result { + limit(w.chunks.len(), MAX_CHUNKS, "state.chunks")?; + tables_limit(&w)?; + let mut strings = EncodeBudget::default(); + strings.check_state(&w)?; + let chunks = w + .chunks + .into_iter() + .enumerate() + .map(|(i, c)| { + if c.voxels.len() != CHUNK_VOL { + return Err(PersistenceError::Invalid { + context: format!( + "state.chunks[{i}].voxels length {} != {CHUNK_VOL}", + c.voxels.len() + ), + }); + } + Ok(ChunkImage { + coord: uncell(c.coord), + voxels: c.voxels.into_iter().map(wire_to_voxel).collect(), + }) + }) + .collect::>()?; + Ok(StateImage { + tick: w.tick, + seed: w.seed, + rng_master: w.rng_master, + ecs_entity_count: w.ecs_entity_count, + chunks, + actors: w + .actors + .into_iter() + .enumerate() + .map(|(i, a)| { + Ok(Actor { + id: a.id, + pos: a.pos.map(f32::from_bits), + radius: f32::from_bits(a.radius), + stance: stance(a.stance, &format!("state.actors[{i}].stance"))?, + hp: a.hp, + alive: a.alive, + }) + }) + .collect::>()?, + actor_identities: identities_from(w.identities)?, + wallets: rows_map(w.wallets, "state.wallets")? + .into_iter() + .map(|(k, v)| (k, Wallet::new(v.copper, v.scrip))) + .collect(), + contracts: contracts_from(w.contracts)?, + run_location: location_from(w.location)?, + connectors: w + .connectors + .into_iter() + .enumerate() + .map(|(i, v)| { + Ok(Connector { + id: v.id, + kind: connector_kind(v.kind, &format!("state.connectors[{i}].kind"))?, + from: v.from.map(f32::from_bits), + to: v.to.map(f32::from_bits), + length: f32::from_bits(v.length), + anchor_quality: v.anchor_quality, + load_kg: f32::from_bits(v.load_kg), + blocked: v.blocked, + }) + }) + .collect::>()?, + pressure_nodes: w + .pressure_nodes + .into_iter() + .map(|v| PressureNode { + id: v.id, + pressure: f32::from_bits(v.pressure), + }) + .collect(), + pressure_edges: w + .pressure_edges + .into_iter() + .map(|v| PressureEdge { + from: v.from, + to: v.to, + openness: f32::from_bits(v.openness), + hidden: v.hidden, + }) + .collect(), + causes: w + .causes + .into_iter() + .map(|v| CauseImage { + tick: v.tick, + rule: v.rule.into_inner(), + detail: v.detail.into_inner(), + }) + .collect(), + far: FarField { + day: w.far.day, + day_tick: w.far.day_tick, + }, + laws: w + .laws + .into_iter() + .map(|v| LawImage { + rule: v.rule.into_inner(), + first_seen_tick: v.tick, + }) + .collect(), + fall_odo: rows_map(w.fall_odo, "state.fall_odo")? + .into_iter() + .map(|(k, v)| (k, f32::from_bits(v))) + .collect(), + lights: w + .lights + .into_iter() + .map(|v| LightSource { + x: v.x, + y: v.y, + z: v.z, + radius: v.radius, + warm: v.warm, + }) + .collect(), + sounds: w + .sounds + .into_iter() + .map(|v| SoundEvent { + x: v.x, + y: v.y, + z: v.z, + loudness: v.loudness, + timbre: v.timbre, + }) + .collect(), + corpses: rows_map(w.corpses, "state.corpses")? + .into_iter() + .map(|(k, v)| { + ( + uncell(k), + CorpseClock { + age_ticks: v.age_ticks, + bursts_at: v.bursts_at, + }, + ) + }) + .collect(), + signal_sources: w.signal_sources.into_iter().map(uncell).collect(), + spill: Spill { + gas_flows: w + .spill + .gas_flows + .into_iter() + .map(|(a, b, c)| (a, b, f32::from_bits(c))) + .collect(), + light_pending: w.spill.light_pending, + signal_pending: w.spill.signal_pending, + scent_pending: w.spill.scent_pending, + }, + characters: characters_from(w.characters)?, + reputations: reputations_from(w.reputations)?, + items: items_from(w.items, "state.items")?, + ground_items: rows_map(w.ground_items, "state.ground_items")? + .into_iter() + .map(|(k, v)| (k, uncell(v))) + .collect(), + packs: packs_from(w.packs)?, + idf: rows_map(w.idf, "state.idf")? + .into_iter() + .map(|(k, v)| (k.into_inner(), v)) + .collect(), + hymn_cases: hymns_from(w.hymn_cases)?, + cast_queue: w + .cast_queue + .into_iter() + .map(|v| { + ( + v.caster, + v.template.into_inner(), + [v.target.x, v.target.y, v.target.z], + ) + }) + .collect(), + bargains: bargains_from(w.bargains)?, + deep_weather: w.deep_weather, + npcs: npcs_from(w.npcs)?, + shops: shops_from(w.shops)?, + gossip: w + .gossip + .into_iter() + .map(|v| GossipPacket { + from: v.from.into_inner(), + to: v.to.into_inner(), + sent_day: v.sent_day, + ledger_delta: v.ledger_delta, + }) + .collect(), + chalk: Chalk { + marks: rows_map(w.chalk, "state.chalk")? + .into_iter() + .map(|(k, v)| (uncell(k), v.into_inner())) + .collect(), + }, + mule: w.mule.map(|v| MulePath { + waypoints: v.into_iter().map(uncell).collect(), + }), + vault_choice: vault_choice(w.vault_choice)?, + chapel_debts: rows_map(w.chapel_debts, "state.chapel_debts")? + .into_iter() + .map(|(k, v)| { + ( + k, + ChapelDebt { + burials_owed: v.burials_owed, + chapel: v.chapel.into_inner(), + }, + ) + }) + .collect(), + next_id: w.next_id, + step_mode: w.step_mode, + }) +} + +fn tables_limit(w: &StateWire) -> Result<(), PersistenceError> { + for (n, l) in [ + ("actors", w.actors.len()), + ("identities", w.identities.len()), + ("wallets", w.wallets.len()), + ("contracts", w.contracts.len()), + ("connectors", w.connectors.len()), + ("pressure_nodes", w.pressure_nodes.len()), + ("pressure_edges", w.pressure_edges.len()), + ("causes", w.causes.len()), + ("laws", w.laws.len()), + ("fall_odo", w.fall_odo.len()), + ("lights", w.lights.len()), + ("sounds", w.sounds.len()), + ("corpses", w.corpses.len()), + ("signal_sources", w.signal_sources.len()), + ("characters", w.characters.len()), + ("reputations", w.reputations.len()), + ("items", w.items.len()), + ("ground_items", w.ground_items.len()), + ("packs", w.packs.len()), + ("idf", w.idf.len()), + ("hymn_cases", w.hymn_cases.len()), + ("cast_queue", w.cast_queue.len()), + ("bargains", w.bargains.len()), + ("npcs", w.npcs.len()), + ("shops", w.shops.len()), + ("gossip", w.gossip.len()), + ("chalk", w.chalk.len()), + ("chapel_debts", w.chapel_debts.len()), + ] { + limit(l, MAX_ROWS, &format!("state.{n}"))?; + } + Ok(()) +} + +#[derive(Default)] +struct EncodeBudget { + strings: usize, + chunks: usize, + state_rows: usize, + nested_rows: usize, + gas_flows: usize, + mule_waypoints: usize, + town_ledger: usize, + pack_items: usize, + hymn_cards: usize, + hymn_miscasts: usize, + ledger_debts: usize, + shop_rows: usize, + item_tags: usize, +} +impl EncodeBudget { + fn check_identity( + &mut self, + identity: &RunIdentity, + context: &str, + ) -> Result<(), PersistenceError> { + self.add( + &identity.display_name, + &format!("{context}.display_name"), + true, + )?; + self.add( + &identity.content_hash, + &format!("{context}.content_hash"), + true, + ) + } + + fn check_events( + &mut self, + rows: &[SessionEvent], + context: &str, + ) -> Result<(), PersistenceError> { + limit(rows.len(), MAX_SESSION_EVENTS, context)?; + for (index, row) in rows.iter().enumerate() { + if let SessionEventKind::Action(action) = &row.kind { + let text = match action { + CommittedAction::InspectBoard { town } => Some(town.as_str()), + CommittedAction::AcceptContract { id } => Some(id.as_str()), + CommittedAction::TurnIn { contract_id } => Some(contract_id.as_str()), + _ => None, + }; + if let Some(text) = text { + self.add(text, &format!("{context}[{index}].action.text"), true)?; + } + } + } + Ok(()) + } + fn add(&mut self, s: &str, c: &str, blank: bool) -> Result<(), PersistenceError> { + valid_string(s, c, blank)?; + self.strings = + self.strings + .checked_add(s.len()) + .ok_or_else(|| PersistenceError::Limit { + context: "total strings".into(), + actual: usize::MAX, + limit: MAX_TOTAL_STRING_BYTES, + })?; + limit(self.strings, MAX_TOTAL_STRING_BYTES, "total strings") + } + fn check_image(&mut self, i: &StateImage) -> Result<(), PersistenceError> { + validate_image_limits(i)?; + checked_total( + &mut self.chunks, + i.chunks.len(), + MAX_TOTAL_CHUNKS, + "total chunks", + )?; + for rows in [ + i.chunks.len(), + i.actors.len(), + i.actor_identities.len(), + i.wallets.len(), + i.contracts.len(), + i.connectors.len(), + i.pressure_nodes.len(), + i.pressure_edges.len(), + i.causes.len(), + i.laws.len(), + i.fall_odo.len(), + i.lights.len(), + i.sounds.len(), + i.corpses.len(), + i.signal_sources.len(), + i.characters.len(), + i.reputations.len(), + i.items.len(), + i.ground_items.len(), + i.packs.len(), + i.idf.len(), + i.hymn_cases.len(), + i.cast_queue.len(), + i.bargains.len(), + i.npcs.len(), + i.shops.len(), + i.gossip.len(), + i.chalk.marks.len(), + i.chapel_debts.len(), + ] { + checked_total( + &mut self.state_rows, + rows, + MAX_TOTAL_STATE_ROWS, + "total state rows", + )?; + } + for chunk in &i.chunks { + checked_total( + &mut self.nested_rows, + chunk.voxels.len(), + MAX_TOTAL_NESTED_ROWS, + "total nested rows", + )?; + } + let gas_flows = i.spill.gas_flows.len(); + checked_total( + &mut self.gas_flows, + gas_flows, + MAX_TOTAL_GAS_FLOWS, + "spill gas flows", + )?; + checked_total( + &mut self.nested_rows, + gas_flows, + MAX_TOTAL_NESTED_ROWS, + "total nested rows", + )?; + let mule = i.mule.as_ref().map_or(0, |m| m.waypoints.len()); + checked_total( + &mut self.mule_waypoints, + mule, + MAX_TOTAL_MULE_WAYPOINTS, + "mule waypoints", + )?; + checked_total( + &mut self.nested_rows, + mule, + MAX_TOTAL_NESTED_ROWS, + "total nested rows", + )?; + for reputation in i.reputations.values() { + let rows = reputation.town_ledger.len(); + checked_total( + &mut self.town_ledger, + rows, + MAX_TOTAL_TOWN_LEDGER_ROWS, + "reputation town-ledger rows", + )?; + checked_total( + &mut self.nested_rows, + rows, + MAX_TOTAL_NESTED_ROWS, + "total nested rows", + )?; + } + for item in i.items.values() { + self.add_item_rows(item)?; + } + for pack in i.packs.values() { + checked_total( + &mut self.pack_items, + pack.items.len(), + MAX_TOTAL_PACK_ITEMS, + "pack items", + )?; + checked_total( + &mut self.nested_rows, + pack.items.len(), + MAX_TOTAL_NESTED_ROWS, + "total nested rows", + )?; + for item in pack.items.values() { + self.add_item_rows(item)?; + } + } + for case in i.hymn_cases.values() { + checked_total( + &mut self.hymn_cards, + case.cards.len(), + MAX_TOTAL_HYMN_CARDS, + "hymn cards", + )?; + checked_total( + &mut self.nested_rows, + case.cards.len(), + MAX_TOTAL_NESTED_ROWS, + "total nested rows", + )?; + for card in &case.cards { + checked_total( + &mut self.hymn_miscasts, + card.miscasts.len(), + MAX_TOTAL_HYMN_MISCASTS, + "hymn miscasts", + )?; + checked_total( + &mut self.nested_rows, + card.miscasts.len(), + MAX_TOTAL_NESTED_ROWS, + "total nested rows", + )?; + } + } + for ledger in i.bargains.values() { + checked_total( + &mut self.ledger_debts, + ledger.debts.len(), + MAX_TOTAL_LEDGER_DEBTS, + "ledger debts", + )?; + checked_total( + &mut self.nested_rows, + ledger.debts.len(), + MAX_TOTAL_NESTED_ROWS, + "total nested rows", + )?; + } + for shop in i.shops.values() { + let rows = shop + .broke + .len() + .checked_add(shop.stole.len()) + .and_then(|v| v.checked_add(shop.killed.len())) + .ok_or_else(|| PersistenceError::Invalid { + context: "shop row count overflows".into(), + })?; + checked_total( + &mut self.shop_rows, + rows, + MAX_TOTAL_SHOP_ROWS, + "shop memory rows", + )?; + checked_total( + &mut self.nested_rows, + rows, + MAX_TOTAL_NESTED_ROWS, + "total nested rows", + )?; + } + self.check_image_strings(i) + } + + fn check_image_strings(&mut self, i: &StateImage) -> Result<(), PersistenceError> { + for value in i.actor_identities.values() { + self.add(&value.template_id, "identity.template", true)?; + self.add(&value.display_name, "identity.name", true)?; + } + for (key, value) in &i.contracts { + self.add(key, "contract.key", true)?; + self.add(&value.id, "contract.id", true)?; + self.add(&value.reward_town, "contract.reward_town", true)?; + } + if let RunLocation::Town(town) = &i.run_location { + self.add(town, "location.town", true)?; + } + for value in &i.causes { + self.add(&value.rule, "cause.rule", true)?; + self.add(&value.detail, "cause.detail", false)?; + } + for value in &i.laws { + self.add(&value.rule, "law.rule", true)?; + } + for value in i.reputations.values() { + for town in value.town_ledger.keys() { + self.add(town, "reputation.town", true)?; + } + } + for value in i.items.values() { + self.image_item(value)?; + } + for pack in i.packs.values() { + for value in pack.items.values() { + self.image_item(value)?; + } + } + for template in i.idf.keys() { + self.add(template, "idf.template", true)?; + } + for case in i.hymn_cases.values() { + for card in &case.cards { + self.add(&card.template, "hymn.template", true)?; + for miscast in &card.miscasts { + self.add(miscast, "hymn.miscast", true)?; + } + } + } + for (_, template, _) in &i.cast_queue { + self.add(template, "cast.template", true)?; + } + for ledger in i.bargains.values() { + for debt in &ledger.debts { + self.add(&debt.boon, "debt.boon", true)?; + self.add(&debt.owed, "debt.owed", true)?; + } + } + for npc in i.npcs.values() { + self.add(&npc.name, "npc.name", true)?; + } + for gossip in &i.gossip { + self.add(&gossip.from, "gossip.from", true)?; + self.add(&gossip.to, "gossip.to", true)?; + } + for text in i.chalk.marks.values() { + self.add(text, "chalk.text", true)?; + } + for debt in i.chapel_debts.values() { + self.add(&debt.chapel, "chapel_debt.chapel", true)?; + } + Ok(()) + } + + fn add_item_rows(&mut self, value: &Instance) -> Result<(), PersistenceError> { + checked_total( + &mut self.item_tags, + value.tags.len(), + MAX_TOTAL_ITEM_TAGS, + "item tags", + )?; + checked_total( + &mut self.nested_rows, + value.tags.len(), + MAX_TOTAL_NESTED_ROWS, + "total nested rows", + ) + } + + fn image_item(&mut self, value: &Instance) -> Result<(), PersistenceError> { + self.add(&value.template, "item.template", true)?; + limit(value.tags.len(), MAX_ITEM_TAGS, "item.tags")?; + for tag in &value.tags { + self.add(tag, "item.tag", true)?; + } + Ok(()) + } + + fn check_state(&mut self, w: &StateWire) -> Result<(), PersistenceError> { + for (_, v) in &w.identities { + self.add(&v.template, "identity.template", true)?; + self.add(&v.name, "identity.name", true)?; + } + for (k, v) in &w.contracts { + self.add(k, "contract.key", true)?; + self.add(&v.id, "contract.id", true)?; + self.add(&v.reward_town, "contract.reward_town", true)?; + } + if w.location.tag == 1 { + self.add(&w.location.town, "location.town", true)?; + } + for v in &w.causes { + self.add(&v.rule, "cause.rule", true)?; + self.add(&v.detail, "cause.detail", false)?; + } + for v in &w.laws { + self.add(&v.rule, "law.rule", true)?; + } + for (_, v) in &w.reputations { + for (k, _) in &v.town_ledger { + self.add(k, "reputation.town", true)?; + } + } + for (_, v) in &w.items { + self.item(v)?; + } + for (_, p) in &w.packs { + for (_, v) in &p.items { + self.item(v)?; + } + } + for (k, _) in &w.idf { + self.add(k, "idf.template", true)?; + } + for (_, h) in &w.hymn_cases { + for c in &h.cards { + self.add(&c.template, "hymn.template", true)?; + for m in &c.miscasts { + self.add(m, "hymn.miscast", true)?; + } + } + } + for c in &w.cast_queue { + self.add(&c.template, "cast.template", true)?; + } + for (_, l) in &w.bargains { + for d in &l.debts { + self.add(&d.boon, "debt.boon", true)?; + self.add(&d.owed, "debt.owed", true)?; + } + } + for (_, n) in &w.npcs { + self.add(&n.name, "npc.name", true)?; + } + for g in &w.gossip { + self.add(&g.from, "gossip.from", true)?; + self.add(&g.to, "gossip.to", true)?; + } + for (_, s) in &w.chalk { + self.add(s, "chalk.text", true)?; + } + for (_, d) in &w.chapel_debts { + self.add(&d.chapel, "chapel_debt.chapel", true)?; + } + Ok(()) + } + fn item(&mut self, v: &ItemWire) -> Result<(), PersistenceError> { + self.add(&v.template, "item.template", true)?; + limit(v.tags.len(), 256, "item.tags")?; + for t in &v.tags { + self.add(t, "item.tag", true)?; + } + Ok(()) + } +} + +fn rows_map(rows: I, table: &str) -> Result, PersistenceError> +where + K: Ord + Clone, + I: IntoIterator, + I::IntoIter: ExactSizeIterator, +{ + let rows = rows.into_iter(); + limit(rows.len(), MAX_ROWS, table)?; + let mut out = BTreeMap::new(); + let mut prior = None; + for (i, (k, v)) in rows.enumerate() { + if prior.as_ref().is_some_and(|p| k <= *p) { + return Err(PersistenceError::Invalid { + context: format!("{table}[{i}] is duplicate or noncanonical order"), + }); + } + prior = Some(k.clone()); + out.insert(k, v); + } + Ok(out) +} +fn identities_from( + rows: BoundedVec<(u64, IdentityWire), LIMIT, BUDGET>, +) -> Result, PersistenceError> { + rows_map(rows, "state.identities")? + .into_iter() + .enumerate() + .map(|(i, (key, value))| { + Ok(( + key, + ActorIdentity::new( + value.template, + value.name, + role(value.role, &format!("state.identities[{i}].role"))?, + ), + )) + }) + .collect() +} + +fn contracts_from( + rows: BoundedVec<(BoundedString, ContractWire), LIMIT, BUDGET>, +) -> Result, PersistenceError> { + rows_map(rows, "state.contracts")? + .into_iter() + .map(|(k, v)| { + Ok(( + k.into_inner(), + ActiveContract { + id: v.id.into_inner(), + taker: v.taker, + accepted_tick: v.accepted_tick, + floor: v.floor, + pay_cu: v.pay_cu, + objective_target: v.target, + objective_progress: v.progress, + status: contract_status(v.status, "contract.status")?, + reward_town: v.reward_town.into_inner(), + standing_delta: v.standing_delta, + }, + )) + }) + .collect::>() +} +fn characters_from( + rows: BoundedVec<(u64, CharacterWire), LIMIT, BUDGET>, +) -> Result, PersistenceError> { + rows_map(rows, "state.characters")? + .into_iter() + .map(|(k, v)| { + Ok(( + k, + Character { + ancestry: ancestry(v.ancestry, "character.ancestry")?, + calling: calling(v.calling, "character.calling")?, + attrs: v.attrs, + skills: v.skills, + uses: v.uses, + pips: v.pips, + }, + )) + }) + .collect() +} +fn reputations_from( + rows: BoundedVec<(u64, ReputationWire), LIMIT, BUDGET>, +) -> Result, PersistenceError> { + rows_map(rows, "state.reputations")? + .into_iter() + .map(|(k, v)| { + let fs = v + .factions + .into_iter() + .enumerate() + .map(|(i, s)| { + let expected = i as u8; + if s.faction != expected { + return Err(PersistenceError::Invalid { + context: format!( + "state.reputations[{k}].factions[{i}] expected tag {expected}, found {}", + s.faction + ), + }); + } + Ok(( + faction(s.faction, &format!("reputation.factions[{i}].tag"))?, + Standing { + rank: s.rank, + debt_cu: s.debt_cu, + }, + )) + }) + .collect::, PersistenceError>>()? + .try_into() + .map_err(|_| PersistenceError::Invalid { + context: "reputation factions length".into(), + })?; + let gs = v + .god_credit + .into_iter() + .enumerate() + .map(|(i, g)| { + let expected = i as u8; + if g.god != expected { + return Err(PersistenceError::Invalid { + context: format!( + "state.reputations[{k}].god_credit[{i}] expected tag {expected}, found {}", + g.god + ), + }); + } + Ok(( + god(g.god, &format!("reputation.gods[{i}].tag"))?, + g.credit, + )) + }) + .collect::, PersistenceError>>()? + .try_into() + .map_err(|_| PersistenceError::Invalid { + context: "reputation gods length".into(), + })?; + Ok(( + k, + Reputation { + factions: fs, + town_ledger: rows_map(v.town_ledger, "reputation.town_ledger")? + .into_iter() + .map(|(town, standing)| (town.into_inner(), standing)) + .collect(), + god_credit: gs, + }, + )) + }) + .collect() +} +fn items_from( + rows: BoundedVec<(u64, ItemWire), LIMIT, BUDGET>, + table: &str, +) -> Result, PersistenceError> { + rows_map(rows, table)? + .into_iter() + .map(|(k, v)| Ok((k, item_from_wire(v)?))) + .collect() +} +fn packs_from( + rows: BoundedVec<(u64, PackWire), LIMIT, BUDGET>, +) -> Result, PersistenceError> { + rows_map(rows, "state.packs")? + .into_iter() + .map(|(k, v)| { + Ok(( + k, + Pack { + items: items_from(v.items, "pack.items")?, + equipped_weapon: v.equipped_weapon, + equipped_armor: v.equipped_armor, + max_weight_kg: f32::from_bits(v.max_weight_kg), + max_bulk: v.max_bulk, + }, + )) + }) + .collect() +} +fn hymns_from( + rows: BoundedVec<(u64, HymnCaseWire), LIMIT, BUDGET>, +) -> Result, PersistenceError> { + rows_map(rows, "state.hymn_cases")? + .into_iter() + .map(|(k, v)| { + limit(v.cards.len(), 256, "hymn_case.cards")?; + Ok(( + k, + HymnCase { + cards: v + .cards + .into_iter() + .map(|c| HymnCard { + template: c.template.into_inner(), + conduct_base: c.conduct_base, + slots: c.slots, + fragile: c.fragile, + miscasts: c + .miscasts + .into_iter() + .map(BoundedString::into_inner) + .collect(), + }) + .collect(), + max_slots: v.max_slots, + }, + )) + }) + .collect() +} +fn bargains_from( + rows: BoundedVec<(u64, LedgerWire), LIMIT, BUDGET>, +) -> Result, PersistenceError> { + rows_map(rows, "state.bargains")? + .into_iter() + .map(|(k, v)| { + limit(v.debts.len(), MAX_ROWS, "ledger.debts")?; + Ok(( + k, + Ledger { + debts: v + .debts + .into_iter() + .map(|d| { + Ok(Debt { + god: god(d.god, "debt.god")?, + boon: d.boon.into_inner(), + owed: d.owed.into_inner(), + ticks_left: d.ticks_left, + altar_receipt: d.altar_receipt, + }) + }) + .collect::>()?, + }, + )) + }) + .collect() +} +fn npcs_from( + rows: BoundedVec<(u64, NpcWire), LIMIT, BUDGET>, +) -> Result, PersistenceError> { + rows_map(rows, "state.npcs")? + .into_iter() + .map(|(k, v)| { + Ok(( + k, + Townsfolk { + name: v.name.into_inner(), + job: job(v.job, "npc.job")?, + home: uncell(v.home), + work: uncell(v.work), + debt_cu: v.debt_cu, + }, + )) + }) + .collect() +} +fn shops_from( + rows: BoundedVec<(CellWire, ShopWire), LIMIT, BUDGET>, +) -> Result, PersistenceError> { + rows_map(rows, "state.shops")? + .into_iter() + .map(|(k, v)| { + Ok(( + uncell(k), + ShopMemory { + broke: rows_map(v.broke, "shop.broke")?, + stole: rows_map(v.stole, "shop.stole")?, + killed: rows_map(v.killed, "shop.killed")?, + }, + )) + }) + .collect() +} + +fn cell(v: (i32, i32, i32)) -> CellWire { + CellWire { + x: v.0, + y: v.1, + z: v.2, + } +} +fn uncell(v: CellWire) -> (i32, i32, i32) { + (v.x, v.y, v.z) +} +fn voxel_to_wire(v: Voxel) -> VoxelWire { + VoxelWire { + material: v.material, + occ: v.occ, + fluid_kind: v.fluid_kind, + fluid_amt: v.fluid_amt, + gas_kind: v.gas_kind, + gas_amt: v.gas_amt, + light: v.light, + scent: v.scent, + flags: v.flags, + pad: v.pad, + pressure: v.pressure, + flow: v.flow, + extra: v.extra, + } +} +fn wire_to_voxel(v: VoxelWire) -> Voxel { + Voxel { + material: v.material, + occ: v.occ, + fluid_kind: v.fluid_kind, + fluid_amt: v.fluid_amt, + gas_kind: v.gas_kind, + gas_amt: v.gas_amt, + light: v.light, + scent: v.scent, + flags: v.flags, + pad: v.pad, + pressure: v.pressure, + flow: v.flow, + extra: v.extra, + } +} +fn item_to_wire(v: Instance) -> ItemWire { + ItemWire { + id: v.id, + template: v.template.into(), + material: v.material, + identified: v.identified, + cursed: v.cursed, + blessed: v.blessed, + condition: v.condition.canonical_tag(), + silvered: v.silvered, + illegal: v.illegal, + tags: v.tags.into_iter().map(Into::into).collect(), + fuel: v.fuel, + wet_ticks: v.wet_ticks, + } +} +fn item_from_wire(v: ItemWire) -> Result { + Ok(Instance { + id: v.id, + template: v.template.into_inner(), + material: v.material, + identified: v.identified, + cursed: v.cursed, + blessed: v.blessed, + condition: condition(v.condition, "item.condition")?, + silvered: v.silvered, + illegal: v.illegal, + tags: v.tags.into_iter().map(BoundedString::into_inner).collect(), + fuel: v.fuel, + wet_ticks: v.wet_ticks, + }) +} +fn pack_to_wire(v: Pack) -> PackWire { + PackWire { + items: v + .items + .into_iter() + .map(|(k, v)| (k, item_to_wire(v))) + .collect(), + equipped_weapon: v.equipped_weapon, + equipped_armor: v.equipped_armor, + max_weight_kg: v.max_weight_kg.to_bits(), + max_bulk: v.max_bulk, + } +} +fn reputation_to_wire(v: Reputation) -> ReputationWire { + ReputationWire { + factions: v.factions.map(|(f, s)| StandingWire { + faction: f.canonical_tag(), + rank: s.rank, + debt_cu: s.debt_cu, + }), + town_ledger: v + .town_ledger + .into_iter() + .map(|(town, standing)| (town.into(), standing)) + .collect(), + god_credit: v.god_credit.map(|(g, c)| GodCreditWire { + god: g.canonical_tag(), + credit: c, + }), + } +} +fn location_to_wire(v: RunLocation) -> LocationWire { + match v { + RunLocation::Uninitialized => LocationWire { + tag: 0, + town: BoundedString::default(), + depth: 0, + }, + RunLocation::Town(town) => LocationWire { + tag: 1, + town: town.into(), + depth: 0, + }, + RunLocation::Deep(depth) => LocationWire { + tag: 2, + town: BoundedString::default(), + depth, + }, + } +} +fn location_from(v: LocationWire) -> Result { + match v.tag { + 0 if v.town.is_empty() && v.depth == 0 => Ok(RunLocation::Uninitialized), + 1 if v.depth == 0 && !v.town.trim().is_empty() => { + Ok(RunLocation::Town(v.town.into_inner())) + } + 2 if v.town.is_empty() && v.depth > 0 => Ok(RunLocation::Deep(v.depth)), + _ => Err(PersistenceError::Invalid { + context: "state.location tag/payload is noncanonical".into(), + }), + } +} + +macro_rules! tagged { + ($name:ident, $ty:ty, $ctx:ident, {$($tag:literal => $value:path),+ $(,)?}) => { + fn $name(tag: u8, $ctx: &str) -> Result<$ty, PersistenceError> { + match tag { + $($tag => Ok($value),)+ + _ => Err(PersistenceError::Invalid { + context: format!("{} unknown tag {tag}", $ctx), + }), + } + } + }; +} +tagged!(stance,Stance,c,{0=>Stance::Stand,1=>Stance::Crouch,2=>Stance::Climb,3=>Stance::Swim,4=>Stance::Prone,5=>Stance::Rope}); +tagged!(role,ActorRole,c,{0=>ActorRole::Player,1=>ActorRole::Npc,2=>ActorRole::Monster}); +tagged!(contract_status,ContractStatus,c,{0=>ContractStatus::Active,1=>ContractStatus::Completed,2=>ContractStatus::TurnedIn,3=>ContractStatus::Failed}); +tagged!(connector_kind,ConnectorKind,c,{0=>ConnectorKind::Stairwell,1=>ConnectorKind::Shaft,2=>ConnectorKind::Well,3=>ConnectorKind::Vent,4=>ConnectorKind::ElevatorCar,5=>ConnectorKind::Counterweight,6=>ConnectorKind::RopeAnchor}); +tagged!(ancestry,Ancestry,c,{0=>Ancestry::Mudborn,1=>Ancestry::CantBorn,2=>Ancestry::PaleTouched,3=>Ancestry::Foundling}); +tagged!(calling,Calling,c,{0=>Calling::Ratcatcher,1=>Calling::Lampbearer,2=>Calling::Tinker,3=>Calling::CorpseTender,4=>Calling::TollWard,5=>Calling::ChoirDeserter}); +tagged!(faction,Faction,c,{0=>Faction::Lampwrights,1=>Faction::Congregation,2=>Faction::Wardens,3=>Faction::Audit,4=>Faction::Choir}); +tagged!(god,GodId,c,{0=>GodId::Lamplighter,1=>GodId::Tollkeeper,2=>GodId::Choir,3=>GodId::Rustmother,4=>GodId::Audit}); +tagged!(condition,Condition,c,{0=>Condition::Sound,1=>Condition::Rust,2=>Condition::Damp,3=>Condition::Cracked,4=>Condition::Blessed,5=>Condition::Humming,6=>Condition::Owed,7=>Condition::Spent,8=>Condition::Perishable}); +tagged!(job,Job,c,{0=>Job::Lamplighter,1=>Job::Miller,2=>Job::Tinker,3=>Job::Lampwright,4=>Job::Warden,5=>Job::Mender,6=>Job::Fence,7=>Job::Auctioneer,8=>Job::Innkeep,9=>Job::CorpseTender,10=>Job::Chaplain,11=>Job::CoachDriver,12=>Job::Beggar}); +tagged!(service,Service,c,{1=>Service::Inn,2=>Service::Tinker,3=>Service::Lampwright,4=>Service::Warden,5=>Service::Mender,6=>Service::Fence,7=>Service::Auction}); +tagged!(direction,Direction,c,{1=>Direction::North,2=>Direction::South,3=>Direction::West,4=>Direction::East,5=>Direction::NorthWest,6=>Direction::NorthEast,7=>Direction::SouthWest,8=>Direction::SouthEast}); +fn mode_from_tag(tag: u8, c: &str) -> Result { + match tag { + 1 => Ok(Mode::Classic), + 2 => Ok(Mode::Story), + 3 => Ok(Mode::Ironcartographer), + _ => Err(PersistenceError::Invalid { + context: format!("{c} unknown tag {tag}"), + }), + } +} +fn mode_tag(v: Mode) -> u8 { + match v { + Mode::Classic => 1, + Mode::Story => 2, + Mode::Ironcartographer => 3, + } +} +fn service_tag(v: Service) -> u8 { + match v { + Service::Inn => 1, + Service::Tinker => 2, + Service::Lampwright => 3, + Service::Warden => 4, + Service::Mender => 5, + Service::Fence => 6, + Service::Auction => 7, + } +} +fn direction_tag(v: &Direction) -> u8 { + match v { + Direction::North => 1, + Direction::South => 2, + Direction::West => 3, + Direction::East => 4, + Direction::NorthWest => 5, + Direction::NorthEast => 6, + Direction::SouthWest => 7, + Direction::SouthEast => 8, + } +} +fn vault_choice(tag: u8) -> Result, PersistenceError> { + match tag { + 0 => Ok(None), + 1 => Ok(Some(VaultChoice::Seal)), + 2 => Ok(Some(VaultChoice::Crown)), + _ => Err(PersistenceError::Invalid { + context: format!("state.vault_choice unknown tag {tag}"), + }), + } +} + +/// Fallible same-directory atomic byte replacement for save/demo/vault writers. +pub fn write_bytes_atomic(path: impl AsRef, bytes: &[u8]) -> Result<(), std::io::Error> { + let path = path.as_ref(); + let parent = path + .parent() + .filter(|p| !p.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + let name = path.file_name().ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "atomic target has no file name", + ) + })?; + let mut last = None; + for attempt in 0..1000u32 { + let temp = parent.join(format!( + ".{}.{}.{}.tmp", + name.to_string_lossy(), + std::process::id(), + attempt + )); + let open = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&temp); + let mut file = match open { + Ok(v) => v, + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => { + last = Some(e); + continue; + } + Err(e) => return Err(e), + }; + let result = (|| { + file.write_all(bytes)?; + file.flush()?; + file.sync_all()?; + drop(file); + std::fs::rename(&temp, path)?; + if let Ok(dir) = std::fs::File::open(parent) { + let _ = dir.sync_all(); + } + Ok(()) + })(); + if result.is_err() { + let _ = std::fs::remove_file(&temp); + } + return result; + } + Err(last.unwrap_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::AlreadyExists, + "could not reserve atomic temp file", + ) + })) +} + +#[cfg(test)] +mod tests { + use super::*; + use hexwork_core::character; + use hexwork_core::runtime::{ActorIdentity, ActorRole}; + + fn identity(mode: Mode, seed: u64) -> RunIdentity { + RunIdentity { + seed, + mode, + ancestry: Ancestry::Mudborn, + calling: Calling::Ratcatcher, + display_name: "Ada".into(), + content_hash: "pack".into(), + } + } + + fn player_state(seed: u64, location: RunLocation) -> (State, u64) { + let mut state = State::new(seed); + let player = state.spawn_actor_with_identity( + [0.0; 3], + ActorIdentity::new("delver", "Ada", ActorRole::Player), + ); + state.characters.insert( + player, + character::new(Ancestry::Mudborn, Calling::Ratcatcher), + ); + match location { + RunLocation::Town(town) => state.initialize_town(town).unwrap(), + RunLocation::Deep(depth) => { + let mut image = state.to_image().unwrap(); + image.run_location = RunLocation::Deep(depth); + state = State::from_image(image).unwrap(); + } + RunLocation::Uninitialized => {} + } + (state, player) + } + + fn raw(value: T) -> Vec { + let bytes = bincode::encode_to_vec(value, bincode::config::standard()).unwrap(); + zstd::stream::encode_all(bytes.as_slice(), 1).unwrap() + } + + fn run_wire( + identity: &RunIdentity, + current: &State, + checkpoint: Option<&StoryCheckpoint>, + events: &[SessionEvent], + ) -> RunWire { + RunWire { + header: header(RUN_MAGIC, RUN_FORMAT_VERSION), + identity: identity_to_wire(identity), + current: hashed_state_to_wire(HashedState::from_state(current).unwrap()).unwrap(), + story_checkpoint: checkpoint + .map(story_checkpoint_to_wire) + .transpose() + .unwrap(), + events: events + .iter() + .map(event_to_wire) + .collect::>() + .unwrap(), + } + } + + fn transition(tick: u64) -> SessionEvent { + SessionEvent::action( + tick, + tick, + CommittedAction::UseTransition { transition_id: 7 }, + ) + } + + fn resurrect(pre: u64, post: u64, prefix: u32, checkpoint_hash: u64) -> SessionEvent { + SessionEvent { + pre_tick: pre, + post_tick: post, + kind: SessionEventKind::StoryResurrect { + checkpoint_after_event_count: prefix, + checkpoint_hash, + dead_state_hash: 11, + restored_state_hash: 12, + }, + } + } + + #[derive(Encode)] + struct V2RunFixture { + magic: [u8; 8], + version: u32, + canonical_hash_version: u64, + seed: u64, + mode: u8, + content_hash: BoundedString, + checkpoint_tick: u64, + checkpoint_hash: u64, + image: StateWire, + journal: BoundedVec, + } + + #[derive(Encode)] + struct V2ReplayFixture { + magic: [u8; 8], + version: u32, + canonical_hash_version: u64, + seed: u64, + mode: u8, + ancestry: u8, + calling: u8, + display_name: BoundedString, + content_hash: BoundedString, + journal: BoundedVec, + expected_final_tick: u64, + expected_final_hash: u64, + rule_chain: BoundedVec, + } + + #[derive(Encode)] + struct V2ActionWire { + tick: u64, + tag: u8, + direction: u8, + service: u8, + text: BoundedString, + id: u64, + } + + #[derive(Encode)] + struct V2SubtitleWire { + tick: u64, + rule: BoundedString, + detail: BoundedString, + subtitle: BoundedString, + } + + #[test] + fn v2_run_and_replay_reject_as_older_before_body_decode() { + let old_state = State::new(1); + let run = raw(V2RunFixture { + magic: RUN_MAGIC, + version: 2, + canonical_hash_version: CANONICAL_HASH_VERSION, + seed: 1, + mode: 1, + content_hash: "pack".into(), + checkpoint_tick: 0, + checkpoint_hash: old_state.try_hash_state().unwrap(), + image: image_to_wire(old_state.to_image().unwrap()).unwrap(), + journal: BoundedVec::default(), + }); + assert!(matches!( + decode_run(&run, "pack"), + Err(PersistenceError::OlderVersion { + found: 2, + current: 3, + .. + }) + )); + let replay = raw(V2ReplayFixture { + magic: REPLAY_MAGIC, + version: 2, + canonical_hash_version: CANONICAL_HASH_VERSION, + seed: 1, + mode: 1, + ancestry: 0, + calling: 0, + display_name: "Ada".into(), + content_hash: "pack".into(), + journal: BoundedVec::default(), + expected_final_tick: 0, + expected_final_hash: 0, + rule_chain: vec![V2SubtitleWire { + tick: 0, + rule: "gravity".into(), + detail: "fell".into(), + subtitle: "Gravity keeps receipts.".into(), + }] + .into(), + }); + assert!(matches!( + decode_replay(&replay, "pack"), + Err(PersistenceError::OlderVersion { + found: 2, + current: 3, + .. + }) + )); + } + + #[test] + fn v3_event_tags_payloads_and_caps_are_canonical() { + let action = SessionEvent::action(0, 1, CommittedAction::Wait); + let action_wire = event_to_wire(&action).unwrap(); + assert_eq!(action_wire.kind_tag, 1); + assert_eq!(action_wire.action.tag, 2); + assert_eq!(action_wire.checkpoint_hash, 0); + let life = resurrect(1, 0, 0, 5); + let life_wire = event_to_wire(&life).unwrap(); + assert_eq!(life_wire.kind_tag, 2); + assert_eq!(life_wire.action.tag, 0); + assert_eq!(life_wire.checkpoint_hash, 5); + + let mut bad_action = action_wire.clone(); + bad_action.dead_state_hash = 1; + assert!(wire_to_event(bad_action, 0, "events").is_err()); + let mut bad_life = life_wire.clone(); + bad_life.action.tag = 2; + assert!(wire_to_event(bad_life, 0, "events").is_err()); + let mut unknown = life_wire; + unknown.kind_tag = 99; + assert!(wire_to_event(unknown, 0, "events").is_err()); + + let prefix = + bincode::encode_to_vec((MAX_SESSION_EVENTS + 1) as u64, bincode::config::standard()) + .unwrap(); + let decoded = bincode::decode_from_slice_with_context::< + DecodeBudget, + BoundedVec, + _, + >( + &prefix, + bincode::config::standard(), + DecodeBudget::default(), + ); + assert!(decoded + .err() + .unwrap() + .to_string() + .contains("per-field limit")); + } + + #[test] + fn resurrection_tag_cannot_decode_as_player_action() { + let wire = event_to_wire(&resurrect(0, 0, 0, 1)).unwrap(); + assert_eq!(wire.kind_tag, 2); + assert!(wire_to_action(wire.action, "action").is_err()); + } + + #[test] + fn history_across_rollback_survives_roundtrip() { + let replay = ProductionReplay { + identity: identity(Mode::Story, 7), + events: vec![ + transition(0), + SessionEvent::action(0, 1, CommittedAction::Wait), + resurrect(1, 0, 1, 44), + SessionEvent::action(0, 1, CommittedAction::Wait), + ], + expected_final_tick: 1, + expected_final_hash: 99, + }; + let decoded = decode_replay(&encode_replay(&replay).unwrap(), "pack").unwrap(); + assert_eq!(decoded, replay); + assert_eq!(decoded.events.len(), 4); + } + + #[test] + fn story_deep_current_town_checkpoint_exact_dual_state_hash_and_continuation_roundtrip() { + let seed = 17; + let (checkpoint_state, _) = player_state(seed, RunLocation::Town("milthollow".into())); + let checkpoint = StoryCheckpoint { + state: HashedState::from_state(&checkpoint_state).unwrap(), + after_event_count: 0, + }; + let (mut current, _) = player_state(seed, RunLocation::Deep(1)); + current.step(); + let expected_current_hash = current.try_hash_state().unwrap(); + let expected_checkpoint_hash = checkpoint.state.hash; + let bytes = encode_run( + &identity(Mode::Story, seed), + ¤t, + Some(&checkpoint), + &[], + ) + .unwrap(); + let mut decoded = decode_run(&bytes, "pack").unwrap(); + assert_eq!( + decoded.current.try_hash_state().unwrap(), + expected_current_hash + ); + assert_eq!( + decoded.story_checkpoint.as_ref().unwrap().state.hash, + expected_checkpoint_hash + ); + assert!(matches!( + decoded.story_checkpoint.as_ref().unwrap().state.image.run_location, + RunLocation::Town(ref town) if town == "milthollow" + )); + current.step(); + decoded.current.step(); + assert_eq!( + decoded.current.try_hash_state().unwrap(), + current.try_hash_state().unwrap() + ); + } + + #[test] + fn mode_checkpoint_presence_seed_tick_hash_identity_prefix_rejection() { + let seed = 19; + let (town, _) = player_state(seed, RunLocation::Town("milthollow".into())); + let checkpoint = StoryCheckpoint { + state: HashedState::from_state(&town).unwrap(), + after_event_count: 0, + }; + let (deep, _) = player_state(seed, RunLocation::Deep(1)); + assert!(encode_run(&identity(Mode::Story, seed), &deep, None, &[]).is_err()); + assert!(encode_run( + &identity(Mode::Classic, seed), + &deep, + Some(&checkpoint), + &[] + ) + .is_err()); + + let mut bad = checkpoint.clone(); + bad.state.image.seed += 1; + assert!(encode_run(&identity(Mode::Story, seed), &deep, Some(&bad), &[]).is_err()); + let mut bad = checkpoint.clone(); + bad.state.tick += 1; + assert!(encode_run(&identity(Mode::Story, seed), &deep, Some(&bad), &[]).is_err()); + let mut bad = checkpoint.clone(); + bad.state.hash ^= 1; + assert!(encode_run(&identity(Mode::Story, seed), &deep, Some(&bad), &[]).is_err()); + + let mut wrong_name = identity(Mode::Story, seed); + wrong_name.display_name = "Bea".into(); + assert!(encode_run(&wrong_name, &deep, Some(&checkpoint), &[]).is_err()); + + let events = vec![SessionEvent::action( + 0, + 0, + CommittedAction::Move(Direction::North), + )]; + let bad_prefix = StoryCheckpoint { + after_event_count: 1, + ..checkpoint + }; + assert!(encode_run( + &identity(Mode::Story, seed), + &deep, + Some(&bad_prefix), + &events + ) + .is_err()); + } + + #[test] + fn current_dead_player_allowed_only_checkpoint_player_alive() { + let seed = 23; + let (town, _) = player_state(seed, RunLocation::Town("milthollow".into())); + let checkpoint = StoryCheckpoint { + state: HashedState::from_state(&town).unwrap(), + after_event_count: 0, + }; + let (mut deep, player) = player_state(seed, RunLocation::Deep(1)); + let actor = deep + .actors + .iter_mut() + .find(|actor| actor.id == player) + .unwrap(); + actor.alive = false; + actor.hp = 0; + assert!(encode_run(&identity(Mode::Story, seed), &deep, Some(&checkpoint), &[]).is_ok()); + + for mode in [Mode::Classic, Mode::Ironcartographer, Mode::Story] { + for location in [RunLocation::Town("milthollow".into()), RunLocation::Deep(1)] { + for (alive, hp) in [(false, 0), (false, -1), (true, 0)] { + let (mut current, player) = player_state(seed, location.clone()); + let actor = current.actors.iter_mut().find(|a| a.id == player).unwrap(); + actor.alive = alive; + actor.hp = hp; + let saved_checkpoint = (mode == Mode::Story).then_some(&checkpoint); + let bytes = encode_run(&identity(mode, seed), ¤t, saved_checkpoint, &[]) + .expect("current dead or pending-death states are valid in Town and Deep"); + let restored = decode_run(&bytes, "pack").unwrap(); + assert_eq!(restored.current.run_location(), &location); + let actor = restored + .current + .actors + .iter() + .find(|a| a.id == player) + .unwrap(); + assert_eq!((actor.alive, actor.hp), (alive, hp)); + assert_eq!( + restored.current.try_hash_state().unwrap(), + current.try_hash_state().unwrap() + ); + } + } + } + + let mut dead_checkpoint = checkpoint; + let actor = dead_checkpoint + .state + .image + .actors + .iter_mut() + .find(|a| a.id == player) + .unwrap(); + actor.alive = false; + actor.hp = 0; + dead_checkpoint = StoryCheckpoint { + state: HashedState::from_image(dead_checkpoint.state.image).unwrap(), + after_event_count: 0, + }; + assert!(encode_run( + &identity(Mode::Story, seed), + &deep, + Some(&dead_checkpoint), + &[] + ) + .is_err()); + } + + #[test] + fn prefix_digest_stability_and_mutation() { + let base = vec![ + transition(0), + SessionEvent::action(0, 1, CommittedAction::Wait), + resurrect(1, 0, 1, 7), + ]; + let digest = event_prefix_digest(&base, 2).unwrap(); + let mut appended = base.clone(); + appended.push(SessionEvent::action( + 0, + 0, + CommittedAction::Move(Direction::East), + )); + assert_eq!(event_prefix_digest(&appended, 2).unwrap(), digest); + for mutate in 0..6 { + let mut changed = base.clone(); + match mutate { + 0 => changed[0].pre_tick = 1, + 1 => changed[1].post_tick = 2, + 2 => { + changed[1].kind = + SessionEventKind::Action(CommittedAction::Move(Direction::North)) + } + 3 => { + if let SessionEventKind::StoryResurrect { + ref mut checkpoint_hash, + .. + } = changed[2].kind + { + *checkpoint_hash ^= 1 + } + } + 4 => { + if let SessionEventKind::StoryResurrect { + ref mut dead_state_hash, + .. + } = changed[2].kind + { + *dead_state_hash ^= 1 + } + } + _ => { + if let SessionEventKind::StoryResurrect { + ref mut restored_state_hash, + .. + } = changed[2].kind + { + *restored_state_hash ^= 1 + } + } + } + assert_ne!( + event_prefix_digest(&changed, 3).unwrap(), + event_prefix_digest(&base, 3).unwrap() + ); + } + assert!(event_prefix_digest(&base, 4).is_err()); + } + + #[test] + fn pure_costs_include_idle_gaps_wait_inn_rollback_and_checked_overflow() { + let sleep = hexwork_core::services::sleep_ticks(); + let events = vec![ + SessionEvent::action(2, 3, CommittedAction::Wait), + SessionEvent::action( + 5, + 5 + sleep, + CommittedAction::UseService { + service: Service::Inn, + }, + ), + resurrect(5 + sleep, 0, 0, 1), + SessionEvent::action(1, 2, CommittedAction::Wait), + ]; + assert_eq!( + timeline_costs(&events, Mode::Story, 4).unwrap(), + TimelineCosts { + full_steps: 9, + unattended_steps: sleep, + total_elapsed_ticks: 9 + sleep, + } + ); + assert!(timeline_costs( + &[SessionEvent::action( + u64::MAX, + u64::MAX, + CommittedAction::Wait + )], + Mode::Classic, + u64::MAX, + ) + .is_err()); + assert!(timeline_costs( + &[SessionEvent::action( + 0, + 0, + CommittedAction::UseService { + service: Service::Tinker + } + )], + Mode::Classic, + 0, + ) + .is_err()); + } + + #[test] + fn v3_dual_images_share_decode_and_encode_budgets() { + let seed = 29; + let (town, _) = player_state(seed, RunLocation::Town("milthollow".into())); + let checkpoint = StoryCheckpoint { + state: HashedState::from_state(&town).unwrap(), + after_event_count: 0, + }; + let (deep, _) = player_state(seed, RunLocation::Deep(1)); + let mut encode_budget = EncodeBudget { + chunks: MAX_TOTAL_CHUNKS, + ..EncodeBudget::default() + }; + assert!(checked_total( + &mut encode_budget.chunks, + 1, + MAX_TOTAL_CHUNKS, + "total chunks" + ) + .is_err()); + let mut decode_budget = DecodeBudget { + chunks: MAX_TOTAL_CHUNKS, + ..DecodeBudget::default() + }; + assert!(decode_budget.add(BUDGET_CHUNKS, 1).is_err()); + let bytes = + encode_run(&identity(Mode::Story, seed), &deep, Some(&checkpoint), &[]).unwrap(); + assert!(decode_run(&bytes, "pack").is_ok()); + } + + #[test] + fn actual_54_plus_54_images_roundtrip_within_envelope_caps() { + fn populate_54_chunks(state: &mut State) { + for z in -3..3 { + for y in -1..=1 { + for x in -1..=1 { + state.grid.set_world( + x * 48, + y * 48, + z * 48, + Voxel { + material: 4, + ..Voxel::default() + }, + ); + } + } + } + } + + let seed = 31; + let (mut town, _) = player_state(seed, RunLocation::Town("milthollow".into())); + populate_54_chunks(&mut town); + let checkpoint = StoryCheckpoint { + state: HashedState::from_state(&town).unwrap(), + after_event_count: 0, + }; + assert_eq!(checkpoint.state.image.chunks.len(), 54); + let expected_checkpoint_hash = checkpoint.state.hash; + drop(town); + + let (mut deep, _) = player_state(seed, RunLocation::Deep(1)); + populate_54_chunks(&mut deep); + let expected_current_hash = deep.try_hash_state().unwrap(); + assert_eq!(deep.grid.iter_loaded().count(), 54); + + let current_image = deep.to_image().unwrap(); + let mut budget = EncodeBudget::default(); + budget.check_image(¤t_image).unwrap(); + budget.check_image(&checkpoint.state.image).unwrap(); + assert_eq!(budget.chunks, 108); + assert_eq!(budget.nested_rows, 108 * CHUNK_VOL); + assert!(budget.chunks <= MAX_TOTAL_CHUNKS); + assert!(budget.state_rows <= MAX_TOTAL_STATE_ROWS); + assert!(budget.nested_rows <= MAX_TOTAL_NESTED_ROWS); + drop(current_image); + + let bytes = + encode_run(&identity(Mode::Story, seed), &deep, Some(&checkpoint), &[]).unwrap(); + assert!(bytes.len() <= MAX_COMPRESSED_BYTES); + let raw = decompress_bounded(&bytes).unwrap(); + assert!(raw.len() <= MAX_DECOMPRESSED_BYTES); + eprintln!( + "54+54 Story envelope: compressed={} raw={}", + bytes.len(), + raw.len() + ); + drop(raw); + + let decoded = decode_run(&bytes, "pack").unwrap(); + assert_eq!(decoded.current.grid.iter_loaded().count(), 54); + assert_eq!( + decoded.current.try_hash_state().unwrap(), + expected_current_hash + ); + let decoded_checkpoint = decoded.story_checkpoint.as_ref().unwrap(); + assert_eq!(decoded_checkpoint.state.image.chunks.len(), 54); + assert_eq!(decoded_checkpoint.state.hash, expected_checkpoint_hash); + assert_eq!(decoded_checkpoint.after_event_count, 0); + assert!(matches!( + decoded_checkpoint.state.image.run_location, + RunLocation::Town(ref town) if town == "milthollow" + )); + } + + #[test] + fn bounded_prefixes_and_aggregate_helpers_reject_without_large_allocations() { + fn decode_prefix>(length: u64) -> Result { + let bytes = bincode::encode_to_vec(length, bincode::config::standard()).unwrap(); + bincode::decode_from_slice_with_context( + &bytes, + bincode::config::standard(), + DecodeBudget::default(), + ) + .map(|(value, _)| value) + } + assert!(decode_prefix::>(4).is_err()); + assert!(decode_prefix::((MAX_STRING_BYTES + 1) as u64).is_err()); + let mut total = MAX_TOTAL_STATE_ROWS; + assert!(DecodeBudget::add_counter(&mut total, 1, MAX_TOTAL_STATE_ROWS, "rows").is_err()); + let mut total = MAX_TOTAL_STATE_ROWS; + assert!(checked_total(&mut total, 1, MAX_TOTAL_STATE_ROWS, "rows").is_err()); + } + + #[test] + fn encode_rejects_oversized_nested_containers_before_wire_construction() { + let mut image = State::new(1).to_image().unwrap(); + image.items.insert( + 1, + Instance { + id: 1, + template: "item".into(), + material: 0, + identified: false, + cursed: false, + blessed: false, + condition: Condition::Sound, + silvered: false, + illegal: false, + tags: vec!["tag".into(); MAX_ITEM_TAGS + 1], + fuel: 0, + wet_ticks: 0, + }, + ); + assert!( + matches!(image_to_wire(image), Err(PersistenceError::Limit { context, .. }) if context == "item.tags") + ); + } + + #[test] + fn crafted_reputation_wire_rejects_duplicate_and_reordered_arrays() { + let mut state = State::new(37); + let player = state.spawn_actor_with_identity( + [0.0; 3], + ActorIdentity::new("delver", "Ada", ActorRole::Player), + ); + state.reputations.insert(player, Reputation::new()); + let mut wire = image_to_wire(state.to_image().unwrap()).unwrap(); + wire.reputations[0].1.factions.swap(0, 1); + assert!( + matches!(wire_to_image(wire), Err(PersistenceError::Invalid { context }) if context.contains("factions")) + ); + } + + #[test] + fn f32_bits_roundtrip() { + let mut image = State::new(1).to_image().unwrap(); + image.fall_odo.insert(1, -0.0); + image.pressure_nodes.push(PressureNode { + id: 1, + pressure: f32::from_bits(1), + }); + let back = wire_to_image(image_to_wire(image).unwrap()).unwrap(); + assert_eq!(back.fall_odo[&1].to_bits(), (-0.0f32).to_bits()); + assert_eq!(back.pressure_nodes[0].pressure.to_bits(), 1); + } + + #[test] + fn descending_chunks_are_not_sorted() { + let mut image = State::new(1).to_image().unwrap(); + let voxels = vec![Voxel::default(); CHUNK_VOL]; + image.chunks = vec![ + ChunkImage { + coord: (1, 0, 0), + voxels: voxels.clone(), + }, + ChunkImage { + coord: (0, 0, 0), + voxels, + }, + ]; + let back = wire_to_image(image_to_wire(image).unwrap()).unwrap(); + assert_eq!(back.chunks[0].coord, (1, 0, 0)); + assert!(State::from_image(back).is_err()); + } + + #[test] + fn descending_laws_are_not_sorted() { + let mut image = State::new(1).to_image().unwrap(); + image.laws = vec![ + LawImage { + rule: "waste nothing".into(), + first_seen_tick: 1, + }, + LawImage { + rule: "gravity is fair".into(), + first_seen_tick: 2, + }, + ]; + let back = wire_to_image(image_to_wire(image).unwrap()).unwrap(); + assert_eq!(back.laws[0].rule, "waste nothing"); + assert!(State::from_image(back).is_err()); + } + + #[test] + fn run_metadata_content_and_versions_are_gated() { + let (state, _) = player_state(41, RunLocation::Deep(1)); + let id = identity(Mode::Classic, 41); + let bytes = encode_run(&id, &state, None, &[]).unwrap(); + assert!(matches!( + decode_run(&bytes, "other"), + Err(PersistenceError::ContentHashMismatch { .. }) + )); + let mut wire = run_wire(&id, &state, None, &[]); + wire.header.format_version += 1; + assert!(matches!( + decode_run(&raw(wire), "pack"), + Err(PersistenceError::NewerVersion { .. }) + )); + let mut wire = run_wire(&id, &state, None, &[]); + wire.header.canonical_hash_version += 1; + assert!(matches!( + decode_run(&raw(wire), "pack"), + Err(PersistenceError::CanonicalHashVersion { .. }) + )); + } + + #[test] + fn all_actions_roundtrip_and_same_tick_order() { + let actions = vec![ + CommittedAction::Move(Direction::North), + CommittedAction::Wait, + CommittedAction::InspectBoard { + town: "milthollow".into(), + }, + CommittedAction::AcceptContract { id: "rat-1".into() }, + CommittedAction::UseTransition { transition_id: 7 }, + CommittedAction::ResolveAttack { target_actor: 8 }, + CommittedAction::TurnIn { + contract_id: "rat-1".into(), + }, + CommittedAction::UseService { + service: Service::Inn, + }, + ]; + for action in actions { + assert_eq!( + wire_to_action(action_to_wire(&action).unwrap(), "action").unwrap(), + action + ); + } + } + + #[test] + fn corrupt_truncated_trailing_and_bomb_err() { + for bytes in [b"random".as_slice(), b"".as_slice()] { + assert!(decode_run(bytes, "pack").is_err()); + } + let (state, _) = player_state(43, RunLocation::Deep(1)); + let id = identity(Mode::Classic, 43); + let bytes = encode_run(&id, &state, None, &[]).unwrap(); + assert!(decode_run(&bytes[..bytes.len() / 2], "pack").is_err()); + let mut encoded = bincode::encode_to_vec( + run_wire(&id, &state, None, &[]), + bincode::config::standard(), + ) + .unwrap(); + encoded.push(1); + assert!(matches!( + decode_run( + &zstd::stream::encode_all(encoded.as_slice(), 1).unwrap(), + "pack" + ), + Err(PersistenceError::TrailingBytes { .. }) + )); + let zeroes = std::io::repeat(0).take((MAX_DECOMPRESSED_BYTES as u64) + 1); + let bomb = zstd::stream::encode_all(zeroes, 1).unwrap(); + assert!(matches!( + decode_run(&bomb, "pack"), + Err(PersistenceError::DecompressedTooLarge { .. }) + )); + } +} diff --git a/crates/hexwork-save/src/save.rs b/crates/hexwork-save/src/save.rs index 97d85f1..2c837d7 100644 --- a/crates/hexwork-save/src/save.rs +++ b/crates/hexwork-save/src/save.rs @@ -1,4 +1,8 @@ -//! Versioned save snapshots: bincode + zstd (docs/04 §4.7). +//! Versioned persistence protocols: authoritative run saves and replays. +//! +//! The old [`SaveHeader`] plus `(u64, String)` APIs remain below solely for +//! component compatibility. They are not authoritative checkpoints and MUST +//! NOT be used for production resume. //! //! Design traces: docs/04-software-architecture.md §4.7 ("Save: bincode+zstd //! snapshot + seed + input log tail; versioned (`save_version: u32`,