M1c: Gleichschritt im Netz #9

Merged
steven merged 20 commits from m1c-gleichschritt-im-netz into main 2026-10-02 06:41:02 +00:00
6 changed files with 296 additions and 154 deletions
Showing only changes of commit f2e4c91082 - Show all commits
+1
View File
@@ -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;
+29 -2
View File
@@ -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;
+171
View File
@@ -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<Vec<Command>>;
/// 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<Vec<Command>> {
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<Vec<Command>> {
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())
}
+15
View File
@@ -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!(
+25 -146
View File
@@ -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<Vec<Command>> {
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<Vec<Command>> {
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 \
+55 -6
View File
@@ -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<Command> {
@@ -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<u8> {
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"
)))
);
}