diff --git a/core/src/lib.rs b/core/src/lib.rs index d168edc..ad890d3 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -15,5 +15,6 @@ pub mod mapgen; pub mod player; pub mod replay; pub mod rng; +pub mod rules; pub mod snapshot; pub mod world; diff --git a/core/src/replay.rs b/core/src/replay.rs index c5b8477..91569bd 100644 --- a/core/src/replay.rs +++ b/core/src/replay.rs @@ -1,17 +1,26 @@ //! Recording of a session: the starting state and every command, tick by //! tick. Replaying a recording reproduces the session exactly. +//! +//! Layout: the marker `GRWR`, the format version, the identity of the rules +//! that made the recording, the hash of the state the session ended in, the +//! starting snapshot, and the commands of every tick that had any. use std::fmt; use crate::codec::{DecodeError, Reader, Writer}; use crate::command::Command; +use crate::rules::rules_id; use crate::snapshot::SnapshotError; -use crate::world::{Event, World}; +use crate::world::{Event, World, TICKS_PER_SECOND}; const MAGIC: [u8; 4] = *b"GRWR"; /// Version of the recording format. Raise it whenever the encoded recording, /// including the snapshot inside it, changes. -pub const RECORDING_VERSION: u32 = 2; +pub const RECORDING_VERSION: u32 = 3; +/// Most ticks a recording may hold when it is read: thirty days of play. +/// Longer recordings are refused, so that a damaged or crafted file cannot +/// keep [`Recording::verify`] busy for hours. +pub const MAX_RECORDING_TICKS: u32 = 30 * 24 * 60 * 60 * TICKS_PER_SECOND; /// Reason a recording could not be read or replayed. #[derive(Clone, Debug, PartialEq, Eq)] @@ -20,6 +29,9 @@ pub enum RecordingError { NotARecording, /// The recording was written by an incompatible version of the game. UnsupportedVersion(u32), + /// The recording was made with other rules than this build has, so + /// replaying it would not show what happened. + OtherRules { recorded: u64, current: u64 }, /// The recording is damaged. Corrupt(DecodeError), /// The starting state inside the recording could not be restored. @@ -35,6 +47,10 @@ impl fmt::Display for RecordingError { RecordingError::UnsupportedVersion(version) => { write!(f, "unsupported recording version {version}") } + RecordingError::OtherRules { recorded, current } => write!( + f, + "recorded with rules {recorded:016x}, this build has rules {current:016x}" + ), RecordingError::Corrupt(error) => write!(f, "damaged recording: {error}"), RecordingError::BadStart(error) => write!(f, "unusable starting state: {error}"), RecordingError::Diverged { expected, actual } => write!( @@ -139,6 +155,7 @@ impl Recording { let mut w = Writer::new(); w.raw(&MAGIC); w.u32(RECORDING_VERSION); + w.u64(rules_id()); w.u64(end.state_hash()); w.bytes(&self.start); w.u32(self.tick_count); @@ -154,6 +171,8 @@ impl Recording { } /// Decodes a recording and the state hash that replaying it must reach. + /// Fails with [`RecordingError::OtherRules`] before anything else is + /// decoded if the recording was made with other rules. pub fn from_bytes(bytes: &[u8]) -> Result<(Self, u64), RecordingError> { let mut r = Reader::new(bytes); let magic = r @@ -166,12 +185,20 @@ impl Recording { if version != RECORDING_VERSION { return Err(RecordingError::UnsupportedVersion(version)); } + let recorded = r.u64()?; + let current = rules_id(); + if recorded != current { + return Err(RecordingError::OtherRules { recorded, current }); + } let expected_hash = r.u64()?; let start = r.bytes()?.to_vec(); let start_tick = World::from_snapshot(&start) .map_err(RecordingError::BadStart)? .tick(); let tick_count = r.u32()?; + if tick_count > MAX_RECORDING_TICKS { + return Err(DecodeError::InvalidValue("tick count").into()); + } let busy_count = r.u32()?; let mut busy_ticks = Vec::new(); let mut first_free_tick = 0; diff --git a/core/src/rules.rs b/core/src/rules.rs new file mode 100644 index 0000000..a11dfcf --- /dev/null +++ b/core/src/rules.rs @@ -0,0 +1,171 @@ +//! Identity of the rules. Machines can share a world only if their rules +//! produce the same results, and a recording can only be replayed by the +//! rules it was made with. +//! +//! The identity is derived from fixed sessions: each runs a frozen script of +//! commands on a fresh world, and the identity combines the state hashes they +//! end in. A change to a rule that one of the scripts touches changes the +//! identity, without anybody having to raise a number by hand. A new rule +//! needs a new script in [`SCRIPTS`], so that the identity covers it too. + +use crate::codec::Writer; +use crate::command::Command; +use crate::hash::fnv1a64; +use crate::map::Pos; +use crate::mapgen::outcrop_origin; +use crate::player::PlayerId; +use crate::world::World; + +/// Seed of the world every frozen script runs on. +pub const RULES_SEED: u64 = 2026; + +/// A frozen script: the commands of every tick of a fixed session. +pub type Script = fn(&World) -> Vec>; + +/// Every frozen script the rules identity covers, in a fixed order. +pub const SCRIPTS: [Script; 2] = [frozen_script, frozen_offline_script]; + +/// The commands of a session of 60 ticks. It is built from literal values +/// and the outcrop origin of the map, not from constants such as +/// `DIG_COOLDOWN_TICKS`, so that changing a rule changes the result and not +/// the script. +/// +/// Never change or extend this script. To cover more, add a new script to +/// [`SCRIPTS`] instead. +pub fn frozen_script(world: &World) -> Vec> { + let origin = outcrop_origin(world.map()); + let right_of_origin = Pos::new(origin.x + 1, origin.y); + // Pairs of (tick, command). Commands of the same tick are applied in the + // order listed here. + let scripted = [ + // Two players join in the same tick. + ( + 0, + Command::Join { + key: [1; 16], + name: "Ada".to_owned(), + }, + ), + ( + 0, + Command::Join { + key: [2; 16], + name: "Grace".to_owned(), + }, + ), + // Player 0 hits the outcrop origin three times, five ticks apart. + // The third hit breaks the rock and gives a stone. + ( + 2, + Command::Dig { + player: PlayerId(0), + pos: origin, + }, + ), + ( + 7, + Command::Dig { + player: PlayerId(0), + pos: origin, + }, + ), + ( + 12, + Command::Dig { + player: PlayerId(0), + pos: origin, + }, + ), + // Player 1 hits the tile to the right of the origin once. + ( + 20, + Command::Dig { + player: PlayerId(1), + pos: right_of_origin, + }, + ), + // Player 1 leaves and returns with a name of 40 bytes, which the + // world shortens to 32. + ( + 30, + Command::Leave { + player: PlayerId(1), + }, + ), + ( + 40, + Command::Join { + key: [2; 16], + name: "ä".repeat(20), + }, + ), + ]; + let mut ticks = vec![Vec::new(); 60]; + for (tick, command) in scripted { + ticks[tick].push(command); + } + ticks +} + +/// The commands of a second session of 30 ticks that ends with one player +/// offline, so that the encoding of offline players is covered as well. +/// +/// Never change or extend this script either. +pub fn frozen_offline_script(world: &World) -> Vec> { + let origin = outcrop_origin(world.map()); + let scripted = [ + ( + 0, + Command::Join { + key: [7; 16], + name: "Linus".to_owned(), + }, + ), + ( + 1, + Command::Join { + key: [8; 16], + name: "Margaret".to_owned(), + }, + ), + ( + 3, + Command::Dig { + player: PlayerId(1), + pos: origin, + }, + ), + ( + 10, + Command::Leave { + player: PlayerId(0), + }, + ), + ]; + let mut ticks = vec![Vec::new(); 30]; + for (tick, command) in scripted { + ticks[tick].push(command); + } + ticks +} + +/// Runs `script` on a fresh world from [`RULES_SEED`] and returns the hash +/// of the state it ends in. +pub fn end_hash(script: Script) -> u64 { + let mut world = World::new(RULES_SEED); + for commands in script(&world) { + world.step(&commands); + } + world.state_hash() +} + +/// The identity of the rules of this build: one hash over the end states of +/// all [`SCRIPTS`]. Computing it runs every script, which takes about a +/// millisecond, so callers that need it often keep the value. +pub fn rules_id() -> u64 { + let mut w = Writer::new(); + for script in SCRIPTS { + w.u64(end_hash(script)); + } + fnv1a64(&w.into_bytes()) +} diff --git a/core/tests/command.rs b/core/tests/command.rs index c86f27d..58aa1fa 100644 --- a/core/tests/command.rs +++ b/core/tests/command.rs @@ -48,6 +48,21 @@ fn an_overlong_name_is_shortened_when_encoded() { ); } +#[test] +fn a_name_longer_than_allowed_is_rejected_when_decoded() { + // A join written by hand: the encoder would shorten the name. The tag of + // a join is 1; the recording format pins the wire format of commands. + let mut w = Writer::new(); + w.u8(1); + w.raw(&[1; 16]); + w.str(&"x".repeat(MAX_NAME_BYTES + 1)); + let bytes = w.into_bytes(); + assert_eq!( + Command::decode(&mut Reader::new(&bytes)), + Err(DecodeError::InvalidValue("player name")) + ); +} + #[test] fn an_unknown_command_is_rejected() { assert_eq!( diff --git a/core/tests/golden.rs b/core/tests/golden.rs index a2a3632..a38fc45 100644 --- a/core/tests/golden.rs +++ b/core/tests/golden.rs @@ -8,145 +8,19 @@ //! value here deliberately. use grovewright_core::codec::Writer; -use grovewright_core::command::Command; use grovewright_core::hash::fnv1a64; use grovewright_core::map::{Map, Pos, Terrain, Tile}; -use grovewright_core::mapgen::outcrop_origin; -use grovewright_core::player::PlayerId; use grovewright_core::replay::Recording; +use grovewright_core::rules::{ + end_hash, frozen_offline_script, frozen_script, rules_id, RULES_SEED, +}; use grovewright_core::world::World; -const SEED: u64 = 2026; - -/// The commands of a session of 60 ticks. It is built from literal values -/// and the outcrop origin of the map, not from constants such as -/// `DIG_COOLDOWN_TICKS`, so that changing a rule changes the result and not -/// the script. -/// -/// Never change or extend this script. To pin more, add a new script and a -/// new pinned value instead. -fn frozen_script(world: &World) -> Vec> { - let origin = outcrop_origin(world.map()); - let right_of_origin = Pos::new(origin.x + 1, origin.y); - // Pairs of (tick, command). Commands of the same tick are applied in the - // order listed here. - let scripted = [ - // Two players join in the same tick. - ( - 0, - Command::Join { - key: [1; 16], - name: "Ada".to_owned(), - }, - ), - ( - 0, - Command::Join { - key: [2; 16], - name: "Grace".to_owned(), - }, - ), - // Player 0 hits the outcrop origin three times, five ticks apart. - // The third hit breaks the rock and gives a stone. - ( - 2, - Command::Dig { - player: PlayerId(0), - pos: origin, - }, - ), - ( - 7, - Command::Dig { - player: PlayerId(0), - pos: origin, - }, - ), - ( - 12, - Command::Dig { - player: PlayerId(0), - pos: origin, - }, - ), - // Player 1 hits the tile to the right of the origin once. - ( - 20, - Command::Dig { - player: PlayerId(1), - pos: right_of_origin, - }, - ), - // Player 1 leaves and returns with a name of 40 bytes, which the - // world shortens to 32. - ( - 30, - Command::Leave { - player: PlayerId(1), - }, - ), - ( - 40, - Command::Join { - key: [2; 16], - name: "ä".repeat(20), - }, - ), - ]; - let mut ticks = vec![Vec::new(); 60]; - for (tick, command) in scripted { - ticks[tick].push(command); - } - ticks -} - -/// The commands of a second session of 30 ticks that ends with one player -/// offline, so that the encoding of offline players is pinned as well. -/// -/// Never change or extend this script either. -fn frozen_offline_script(world: &World) -> Vec> { - let origin = outcrop_origin(world.map()); - let scripted = [ - ( - 0, - Command::Join { - key: [7; 16], - name: "Linus".to_owned(), - }, - ), - ( - 1, - Command::Join { - key: [8; 16], - name: "Margaret".to_owned(), - }, - ), - ( - 3, - Command::Dig { - player: PlayerId(1), - pos: origin, - }, - ), - ( - 10, - Command::Leave { - player: PlayerId(0), - }, - ), - ]; - let mut ticks = vec![Vec::new(); 30]; - for (tick, command) in scripted { - ticks[tick].push(command); - } - ticks -} - /// Covers the map generator, the map encoding and the snapshot header and /// checksum. #[test] fn the_starting_snapshot_is_unchanged() { - let actual = fnv1a64(&World::new(SEED).to_snapshot()); + let actual = fnv1a64(&World::new(RULES_SEED).to_snapshot()); assert_eq!( actual, 0x6812_937b_f2b2_19ae, "the starting world or the snapshot format changed; if saves are \ @@ -159,15 +33,11 @@ fn the_starting_snapshot_is_unchanged() { /// and the encoding of players. #[test] fn the_frozen_session_ends_in_the_same_state() { - let mut world = World::new(SEED); - for commands in frozen_script(&world) { - world.step(&commands); - } assert_eq!( - world.state_hash(), + end_hash(frozen_script), 0xc304_43df_fc74_dcfc, - "the rules or the snapshot format changed; old recordings no longer \ - verify; if saves are affected, raise SNAPSHOT_VERSION in \ + "the rules or the snapshot format changed, and with them the rules \ + id; if saves are affected, raise SNAPSHOT_VERSION in \ core/src/snapshot.rs; then update this value" ); } @@ -175,30 +45,39 @@ fn the_frozen_session_ends_in_the_same_state() { /// Covers the encoding of a player who is offline. #[test] fn the_frozen_offline_session_ends_in_the_same_state() { - let mut world = World::new(SEED); - for commands in frozen_offline_script(&world) { - world.step(&commands); - } assert_eq!( - world.state_hash(), + end_hash(frozen_offline_script), 0x904f_10e1_7c52_8be4, - "the rules or the snapshot format changed; old recordings no longer \ - verify; if saves are affected, raise SNAPSHOT_VERSION in \ + "the rules or the snapshot format changed, and with them the rules \ + id; if saves are affected, raise SNAPSHOT_VERSION in \ core/src/snapshot.rs; then update this value" ); } +/// Covers how the identity of the rules is combined from the end states of +/// the frozen scripts. +#[test] +fn the_rules_id_is_unchanged() { + assert_eq!( + rules_id(), + 0x04f1_1b1e_16c5_feff, + "the rules id changed: builds with other rules can no longer play \ + together, and older recordings report other rules; if the tests \ + above fail as well, deal with those first; then update this value" + ); +} + /// Covers the recording header and layout and the wire format of commands. #[test] fn the_recording_format_is_unchanged() { - let mut world = World::new(SEED); + let mut world = World::new(RULES_SEED); let mut recording = Recording::start(&world); for commands in frozen_script(&world) { recording.step(&mut world, &commands); } assert_eq!( fnv1a64(&recording.to_bytes(&world)), - 0x1c24_8cc7_6bc8_846a, + 0xf203_b229_8dad_7c53, "the recording or command format changed; if other tests in this \ file fail as well, deal with those first, because a recording \ contains a snapshot and an end state; otherwise raise \ diff --git a/core/tests/replay.rs b/core/tests/replay.rs index 53f4e1e..c1cfb34 100644 --- a/core/tests/replay.rs +++ b/core/tests/replay.rs @@ -3,11 +3,15 @@ use grovewright_core::command::Command; use grovewright_core::inventory::ITEM_STONE; use grovewright_core::mapgen::outcrop_origin; use grovewright_core::player::PlayerId; -use grovewright_core::replay::{Recording, RecordingError, RECORDING_VERSION}; +use grovewright_core::replay::{Recording, RecordingError, MAX_RECORDING_TICKS, RECORDING_VERSION}; +use grovewright_core::rules::rules_id; use grovewright_core::snapshot::SnapshotError; use grovewright_core::world::World; const ADA: PlayerId = PlayerId(0); +/// Offset of the starting snapshot in an encoded recording: marker, version, +/// rules identity, expected hash and the snapshot's length come first. +const START: usize = 4 + 4 + 8 + 8 + 4; /// Commands of tick `tick` in a short scripted session. fn commands_for(world: &World, tick: u32) -> Vec { @@ -81,8 +85,9 @@ fn verify_accepts_a_faithful_recording() { fn verify_reports_a_replay_that_ends_in_another_state() { let (recording, world) = recorded_session(); let mut bytes = recording.to_bytes(&world); - // The expected state hash follows the marker and the version. - bytes[8] ^= 0x01; + // The expected state hash follows the marker, the version and the rules + // identity. + bytes[16] ^= 0x01; match Recording::verify(&bytes) { Err(RecordingError::Diverged { expected, actual }) => { assert_eq!(actual, world.state_hash()); @@ -179,11 +184,55 @@ fn damaged_recordings_are_rejected() { fn a_recording_with_a_damaged_start_is_rejected() { let (recording, world) = recorded_session(); let mut bytes = recording.to_bytes(&world); - // The snapshot starts after marker, version, expected hash and its own - // length: 4 + 4 + 8 + 4 bytes. - bytes[20 + 10] ^= 0x01; + // The snapshot starts after marker, version, rules identity, expected + // hash and its own length: 4 + 4 + 8 + 8 + 4 bytes. + bytes[START + 10] ^= 0x01; assert_eq!( Recording::from_bytes(&bytes), Err(RecordingError::BadStart(SnapshotError::ChecksumMismatch)) ); } + +#[test] +fn a_recording_of_other_rules_is_refused_before_replaying() { + let (recording, world) = recorded_session(); + let mut bytes = recording.to_bytes(&world); + // The rules identity follows the marker and the version. + bytes[8] ^= 0x01; + assert_eq!( + Recording::from_bytes(&bytes), + Err(RecordingError::OtherRules { + recorded: rules_id() ^ 0x01, + current: rules_id() + }) + ); +} + +/// The encoding of `recording` with its tick count replaced by `ticks`. The +/// tick count follows the starting snapshot. +fn with_tick_count(recording: &Recording, world: &World, ticks: u32) -> Vec { + let mut bytes = recording.to_bytes(world); + let length = u32::from_le_bytes(bytes[START - 4..START].try_into().unwrap()) as usize; + let at = START + length; + bytes[at..at + 4].copy_from_slice(&ticks.to_le_bytes()); + bytes +} + +#[test] +fn a_recording_longer_than_thirty_days_is_refused() { + let world = World::new(3); + let recording = Recording::start(&world); + let (longest, _) = + Recording::from_bytes(&with_tick_count(&recording, &world, MAX_RECORDING_TICKS)).unwrap(); + assert_eq!(longest.tick_count(), MAX_RECORDING_TICKS); + assert_eq!( + Recording::from_bytes(&with_tick_count( + &recording, + &world, + MAX_RECORDING_TICKS + 1 + )), + Err(RecordingError::Corrupt(DecodeError::InvalidValue( + "tick count" + ))) + ); +}