M1a: deterministischer Rust-Kern #3

Merged
steven merged 15 commits from m1a-rust-kern into main 2026-10-01 06:03:36 +00:00
3 changed files with 319 additions and 0 deletions
Showing only changes of commit 40bef858f3 - Show all commits
+1
View File
@@ -13,6 +13,7 @@ pub mod inventory;
pub mod map; pub mod map;
pub mod mapgen; pub mod mapgen;
pub mod player; pub mod player;
pub mod replay;
pub mod rng; pub mod rng;
pub mod snapshot; pub mod snapshot;
pub mod world; pub mod world;
+183
View File
@@ -0,0 +1,183 @@
//! Recording of a session: the starting state and every command, tick by
//! tick. Replaying a recording reproduces the session exactly.
use std::fmt;
use crate::codec::{DecodeError, Reader, Writer};
use crate::command::Command;
use crate::snapshot::SnapshotError;
use crate::world::World;
const MAGIC: [u8; 4] = *b"GRWR";
/// Version of the recording format.
pub const RECORDING_VERSION: u32 = 1;
/// Reason a recording could not be read or replayed.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum RecordingError {
/// The bytes do not start with the recording marker.
NotARecording,
/// The recording was written by an incompatible version of the game.
UnsupportedVersion(u32),
/// The recording is damaged.
Corrupt(DecodeError),
/// The starting state inside the recording could not be restored.
BadStart(SnapshotError),
/// Replaying did not end in the recorded state.
Diverged { expected: u64, actual: u64 },
}
impl fmt::Display for RecordingError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
RecordingError::NotARecording => write!(f, "not a recording"),
RecordingError::UnsupportedVersion(version) => {
write!(f, "unsupported recording version {version}")
}
RecordingError::Corrupt(error) => write!(f, "damaged recording: {error}"),
RecordingError::BadStart(error) => write!(f, "unusable starting state: {error}"),
RecordingError::Diverged { expected, actual } => write!(
f,
"replay ended in state {actual:016x} instead of {expected:016x}"
),
}
}
}
impl std::error::Error for RecordingError {}
impl From<DecodeError> for RecordingError {
fn from(error: DecodeError) -> Self {
RecordingError::Corrupt(error)
}
}
/// The starting state of a session and the commands of every tick after it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Recording {
start: Vec<u8>,
tick_count: u32,
/// Ticks that had commands, as (tick index, commands), in rising order.
busy_ticks: Vec<(u32, Vec<Command>)>,
}
impl Recording {
/// Starts a recording at the current state of `world`.
pub fn start(world: &World) -> Self {
Self {
start: world.to_snapshot(),
tick_count: 0,
busy_ticks: Vec::new(),
}
}
/// Records the commands of one tick. Call it once for every
/// [`World::step`], with the same commands.
pub fn push_tick(&mut self, commands: &[Command]) {
if !commands.is_empty() {
self.busy_ticks.push((self.tick_count, commands.to_vec()));
}
self.tick_count += 1;
}
/// Number of recorded ticks.
pub fn tick_count(&self) -> u32 {
self.tick_count
}
/// Restores the starting state and applies every recorded tick.
pub fn replay(&self) -> Result<World, RecordingError> {
let mut world = World::from_snapshot(&self.start).map_err(RecordingError::BadStart)?;
let mut busy = self.busy_ticks.iter().peekable();
for index in 0..self.tick_count {
match busy.peek() {
Some((tick, commands)) if *tick == index => {
world.step(commands);
busy.next();
}
_ => {
world.step(&[]);
}
}
}
Ok(world)
}
/// Encodes the recording together with the state that replaying it must
/// reach. `end` is the world after the last recorded tick.
pub fn to_bytes(&self, end: &World) -> Vec<u8> {
let mut w = Writer::new();
w.raw(&MAGIC);
w.u32(RECORDING_VERSION);
w.u64(end.state_hash());
w.bytes(&self.start);
w.u32(self.tick_count);
w.u32(u32::try_from(self.busy_ticks.len()).expect("busy tick count fits in u32"));
for (tick, commands) in &self.busy_ticks {
w.u32(*tick);
w.u16(u16::try_from(commands.len()).expect("command count fits in u16"));
for command in commands {
command.encode(&mut w);
}
}
w.into_bytes()
}
/// Decodes a recording and the state hash that replaying it must reach.
pub fn from_bytes(bytes: &[u8]) -> Result<(Self, u64), RecordingError> {
let mut r = Reader::new(bytes);
let magic = r
.raw(MAGIC.len())
.map_err(|_| RecordingError::NotARecording)?;
if magic != MAGIC.as_slice() {
return Err(RecordingError::NotARecording);
}
let version = r.u32()?;
if version != RECORDING_VERSION {
return Err(RecordingError::UnsupportedVersion(version));
}
let expected_hash = r.u64()?;
let start = r.bytes()?.to_vec();
let tick_count = r.u32()?;
let busy_count = r.u32()?;
let mut busy_ticks = Vec::new();
let mut first_free_tick = 0;
for _ in 0..busy_count {
let tick = r.u32()?;
if tick < first_free_tick || tick >= tick_count {
return Err(DecodeError::InvalidValue("tick index").into());
}
first_free_tick = tick + 1;
let command_count = r.u16()?;
if command_count == 0 {
return Err(DecodeError::InvalidValue("command count").into());
}
let mut commands = Vec::new();
for _ in 0..command_count {
commands.push(Command::decode(&mut r)?);
}
busy_ticks.push((tick, commands));
}
r.finish()?;
Ok((
Self {
start,
tick_count,
busy_ticks,
},
expected_hash,
))
}
/// Replays `bytes` and checks that the recorded final state is reached.
pub fn verify(bytes: &[u8]) -> Result<World, RecordingError> {
let (recording, expected) = Self::from_bytes(bytes)?;
let world = recording.replay()?;
let actual = world.state_hash();
if actual == expected {
Ok(world)
} else {
Err(RecordingError::Diverged { expected, actual })
}
}
}
+135
View File
@@ -0,0 +1,135 @@
use grovewright_core::codec::DecodeError;
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::world::World;
const ADA: PlayerId = PlayerId(0);
/// Commands of tick `tick` in a short scripted session.
fn commands_for(world: &World, tick: u32) -> Vec<Command> {
if tick == 0 {
vec![Command::Join {
key: [1; 16],
name: "Ada".to_owned(),
}]
} else if tick.is_multiple_of(7) {
vec![Command::Dig {
player: ADA,
pos: outcrop_origin(world.map()),
}]
} else {
vec![]
}
}
/// Plays 200 ticks from a fresh world and records them.
fn recorded_session() -> (Recording, World) {
let mut world = World::new(11);
let mut recording = Recording::start(&world);
for tick in 0..200 {
let commands = commands_for(&world, tick);
recording.push_tick(&commands);
world.step(&commands);
}
(recording, world)
}
#[test]
fn replaying_reaches_the_same_state() {
let (recording, world) = recorded_session();
assert_eq!(recording.tick_count(), 200);
assert_eq!(world.player(ADA).unwrap().inventory.count(ITEM_STONE), 1);
assert_eq!(recording.replay(), Ok(world));
}
#[test]
fn a_recording_survives_encoding() {
let (recording, world) = recorded_session();
let bytes = recording.to_bytes(&world);
let (decoded, expected_hash) = Recording::from_bytes(&bytes).unwrap();
assert_eq!(decoded, recording);
assert_eq!(expected_hash, world.state_hash());
}
#[test]
fn verify_accepts_a_faithful_recording() {
let (recording, world) = recorded_session();
assert_eq!(Recording::verify(&recording.to_bytes(&world)), Ok(world));
}
#[test]
fn verify_reports_a_replay_that_ends_in_another_state() {
let (recording, mut world) = recorded_session();
// One tick more than was recorded: the claimed end state is unreachable.
world.step(&[]);
let bytes = recording.to_bytes(&world);
match Recording::verify(&bytes) {
Err(RecordingError::Diverged { expected, actual }) => {
assert_eq!(expected, world.state_hash());
assert_ne!(actual, expected);
}
other => panic!("expected a divergence, got {other:?}"),
}
}
#[test]
fn a_recording_can_start_in_the_middle_of_a_session() {
let mut world = World::new(11);
for tick in 0..50 {
let commands = commands_for(&world, tick);
world.step(&commands);
}
let mut recording = Recording::start(&world);
for tick in 50..120 {
let commands = commands_for(&world, tick);
recording.push_tick(&commands);
world.step(&commands);
}
assert_eq!(recording.tick_count(), 70);
assert_eq!(recording.replay(), Ok(world));
}
#[test]
fn an_empty_recording_replays_to_its_starting_state() {
let world = World::new(3);
let recording = Recording::start(&world);
assert_eq!(recording.tick_count(), 0);
assert_eq!(Recording::verify(&recording.to_bytes(&world)), Ok(world));
}
#[test]
fn damaged_recordings_are_rejected() {
let (recording, world) = recorded_session();
let bytes = recording.to_bytes(&world);
assert_eq!(
Recording::from_bytes(b""),
Err(RecordingError::NotARecording)
);
assert_eq!(
Recording::from_bytes(&world.to_snapshot()),
Err(RecordingError::NotARecording)
);
let mut other_version = bytes.clone();
other_version[4..8].copy_from_slice(&(RECORDING_VERSION + 1).to_le_bytes());
assert_eq!(
Recording::from_bytes(&other_version),
Err(RecordingError::UnsupportedVersion(RECORDING_VERSION + 1))
);
assert_eq!(
Recording::from_bytes(&bytes[..bytes.len() - 1]),
Err(RecordingError::Corrupt(DecodeError::UnexpectedEnd))
);
let mut padded = bytes.clone();
padded.push(0);
assert_eq!(
Recording::from_bytes(&padded),
Err(RecordingError::Corrupt(DecodeError::TrailingBytes))
);
}