Theme

alex/beta-minecraft-rust

Minecraft Beta 1.7.3 Rewrite

BRANCH main
main
SNAPSHOT Draw terrain GPU-driven from packed per-section buffers with GPU occlusion culling, add a render distance slider (2-128) with a quad/disk shape, cut chunk bookkeeping at high render distances, and fix a light underflow at y=0
DOWNLOAD
Download file

root / crates / server / src / save / mod.rs

//! World saves in the McRegion format of `SaveHandler`, `McRegionChunkLoader` and `WorldInfo`.

pub mod catalog;
mod chunk;
mod conversion;
pub mod maps;
mod region;
mod writer;

use std::fs::File;
use std::io::{self, BufReader, BufWriter, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicI64, Ordering};
use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
use std::time::{SystemTime, UNIX_EPOCH};

use common::nbt::{Compound, NbtError};
use common::world::ChunkPos;
use flate2::Compression;
use flate2::read::GzDecoder;
use flate2::write::GzEncoder;
use rustc_hash::FxHashMap;

pub use chunk::LoadedChunk;
use region::RegionFile;
pub use writer::ChunkStorage;

/// `SaveConverterMcRegion`'s format version.
pub const MCREGION_VERSION: i32 = 19132;
const MAX_OPEN_REGIONS: usize = 256;

#[derive(Debug, thiserror::Error)]
pub enum SaveError {
    #[error("{path}: {source}")]
    Io { path: PathBuf, source: io::Error },
    #[error("{path}: {source}")]
    Nbt { path: PathBuf, source: NbtError },
    #[error("the save is being accessed from another location, aborting")]
    SessionLock,
    #[error("{0}")]
    Invalid(String),
}

fn io_error(path: &Path) -> impl FnOnce(io::Error) -> SaveError + '_ {
    move |source| SaveError::Io {
        path: path.to_owned(),
        source,
    }
}

/// Locks a mutex, recovering from a panic in another thread.
fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
    mutex.lock().unwrap_or_else(PoisonError::into_inner)
}

fn sync(path: &Path) -> Result<(), SaveError> {
    File::open(path)
        .and_then(|file| file.sync_all())
        .map_err(io_error(path))
}

pub fn now_millis() -> i64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map_or(0, |d| d.as_millis() as i64)
}

/// The `level.dat` contents of `WorldInfo`.
#[derive(Clone, Debug, Default)]
pub struct WorldInfo {
    extra: Compound,
    pub seed: i64,
    pub spawn: [i32; 3],
    pub time: i64,
    pub size_on_disk: i64,
    pub name: String,
    pub version: i32,
    pub raining: bool,
    pub rain_time: i32,
    pub thundering: bool,
    pub thunder_time: i32,
    /// The singleplayer player, stored in `level.dat`.
    pub player: Option<Compound>,
}

impl WorldInfo {
    pub fn new(seed: i64, name: &str) -> Self {
        Self {
            seed,
            name: name.to_owned(),
            version: MCREGION_VERSION,
            ..Self::default()
        }
    }

    fn from_nbt(nbt: &Compound) -> Self {
        Self {
            extra: nbt.clone(),
            seed: nbt.long("RandomSeed"),
            spawn: [nbt.int("SpawnX"), nbt.int("SpawnY"), nbt.int("SpawnZ")],
            time: nbt.long("Time"),
            size_on_disk: nbt.long("SizeOnDisk"),
            name: nbt.string("LevelName").to_owned(),
            version: nbt.int("version"),
            raining: nbt.bool("raining"),
            rain_time: nbt.int("rainTime"),
            thundering: nbt.bool("thundering"),
            thunder_time: nbt.int("thunderTime"),
            player: nbt.compound("Player").cloned(),
        }
    }

    fn to_nbt(&self) -> Compound {
        let mut nbt = self.extra.clone();
        nbt.set_long("RandomSeed", self.seed);
        nbt.set_int("SpawnX", self.spawn[0]);
        nbt.set_int("SpawnY", self.spawn[1]);
        nbt.set_int("SpawnZ", self.spawn[2]);
        nbt.set_long("Time", self.time);
        nbt.set_long("SizeOnDisk", self.size_on_disk);
        nbt.set_long("LastPlayed", now_millis());
        nbt.set_string("LevelName", self.name.clone());
        nbt.set_int("version", self.version);
        nbt.set_int("rainTime", self.rain_time);
        nbt.set_bool("raining", self.raining);
        nbt.set_int("thunderTime", self.thunder_time);
        nbt.set_bool("thundering", self.thundering);
        if let Some(player) = &self.player {
            nbt.set_compound("Player", player.clone());
        }
        nbt
    }

    /// The dimension the stored player is in.
    pub fn dimension(&self) -> i32 {
        self.player
            .as_ref()
            .map_or(0, |player| player.int("Dimension"))
    }
}

pub fn read_gzip(path: &Path) -> Result<Compound, SaveError> {
    let file = File::open(path).map_err(io_error(path))?;
    Compound::read(&mut GzDecoder::new(BufReader::new(file))).map_err(|source| SaveError::Nbt {
        path: path.to_owned(),
        source,
    })
}

/// Replaces `level.dat` through `level.dat_new`, keeping a readable previous one as `level.dat_old`.
fn replace_level_dat(dir: &Path, root: &Compound) -> Result<(), SaveError> {
    let new = dir.join("level.dat_new");
    let current = dir.join("level.dat");
    write_gzip(&new, root)?;
    if current.try_exists().map_err(io_error(&current))?
        && read_gzip(&current).is_ok_and(|root| root.compound("Data").is_some())
    {
        let backup = dir.join("level.dat_old_new");
        let old = dir.join("level.dat_old");
        std::fs::copy(&current, &backup).map_err(io_error(&backup))?;
        sync(&backup)?;
        std::fs::rename(&backup, &old).map_err(io_error(&old))?;
    }
    std::fs::rename(&new, &current).map_err(io_error(&current))?;
    sync(dir)
}

pub fn write_gzip(path: &Path, nbt: &Compound) -> Result<(), SaveError> {
    let file = File::create(path).map_err(io_error(path))?;
    let mut encoder = GzEncoder::new(BufWriter::new(file), Compression::default());
    nbt.write(&mut encoder).map_err(io_error(path))?;
    encoder
        .finish()
        .and_then(|mut writer| {
            writer.flush()?;
            writer.get_ref().sync_all()
        })
        .map_err(io_error(path))
}

#[derive(Default)]
struct Regions {
    dir: PathBuf,
    open: FxHashMap<(i32, i32), RegionFile>,
    /// Bytes the region files grew by since the last `level.dat` save.
    growth: u64,
}

#[derive(Clone)]
struct SessionLock {
    path: PathBuf,
    stamp: i64,
}

impl SessionLock {
    fn check(&self) -> io::Result<()> {
        if std::fs::read(&self.path)? != self.stamp.to_be_bytes() {
            return Err(io::Error::other(
                "the save is being accessed from another location, aborting",
            ));
        }
        Ok(())
    }

    fn verify(&self) -> Result<(), SaveError> {
        self.check().map_err(|_| SaveError::SessionLock)
    }
}

/// Player names have 1 to 16 ASCII letters, digits or underscores.
pub(crate) fn valid_name(name: &str) -> bool {
    (1..=16).contains(&name.len()) && name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_')
}

impl Regions {
    fn region(&mut self, pos: ChunkPos, create: bool) -> io::Result<Option<&mut RegionFile>> {
        let key = (pos.x >> 5, pos.z >> 5);
        if !self.open.contains_key(&key) {
            if self.open.len() >= MAX_OPEN_REGIONS {
                self.open.clear();
            }
            let path = self.dir.join(format!("r.{}.{}.mcr", key.0, key.1));
            if create {
                std::fs::create_dir_all(&self.dir)?;
            } else if !path.try_exists()? {
                return Ok(None);
            }
            let region = RegionFile::open(&path)?;
            self.growth += region.created;
            self.open.insert(key, region);
        }
        Ok(self.open.get_mut(&key))
    }
}

/// Port of `SaveHandler`: one world directory with its session lock, `level.dat` and dimensions.
pub struct WorldSave {
    dir: PathBuf,
    session: SessionLock,
    pub overworld: Arc<ChunkStorage>,
    nether: Mutex<Option<Arc<ChunkStorage>>>,
}

impl WorldSave {
    /// Opens or creates a world directory and takes its session lock.
    pub fn open(dir: &Path) -> Result<Self, SaveError> {
        Self::open_converting(dir, &|_| {})
    }

    /// Opens the save, converting a legacy one to McRegion with `progress` in percent.
    pub fn open_converting(dir: &Path, progress: &dyn Fn(i32)) -> Result<Self, SaveError> {
        let mut info = load_info(dir)?;
        if let Some(info) = &info
            && !matches!(info.version, 0 | MCREGION_VERSION)
        {
            return Err(SaveError::Invalid(
                "This world must be converted to McRegion before it can be played".into(),
            ));
        }
        std::fs::create_dir_all(dir.join("data")).map_err(io_error(dir))?;
        static LAST_SESSION: AtomicI64 = AtomicI64::new(0);
        LAST_SESSION.fetch_max(now_millis(), Ordering::Relaxed);
        let stamp = LAST_SESSION.fetch_add(1, Ordering::Relaxed);
        let path = dir.join("session.lock");
        std::fs::write(&path, stamp.to_be_bytes()).map_err(io_error(&path))?;
        let session = SessionLock { path, stamp };
        let save = Self {
            dir: dir.to_owned(),
            overworld: Arc::new(ChunkStorage::with_session(dir.to_owned(), session.clone())?),
            session,
            nether: Default::default(),
        };
        if let Some(info) = &mut info
            && info.version == 0
        {
            conversion::convert_chunks(dir, &save.session, progress)?;
            info.version = MCREGION_VERSION;
            save.save_info(info)?;
        }
        Ok(save)
    }

    pub fn save_player(&self, name: &str, nbt: &Compound) -> Result<(), SaveError> {
        if !valid_name(name) {
            return Err(SaveError::Invalid("invalid player name".into()));
        }
        self.check_session_lock()?;
        let directory = self.dir.join("players");
        std::fs::create_dir_all(&directory).map_err(io_error(&directory))?;
        let temporary = directory.join(format!("{name}.dat.tmp"));
        let path = directory.join(format!("{name}.dat"));
        write_gzip(&temporary, nbt)?;
        self.check_session_lock()?;
        std::fs::rename(&temporary, &path).map_err(io_error(&path))?;
        sync(&directory)
    }

    pub fn dir(&self) -> &Path {
        &self.dir
    }

    /// Storage of the Nether, in `DIM-1`.
    pub fn nether(&self) -> Result<Arc<ChunkStorage>, SaveError> {
        let mut nether = lock(&self.nether);
        if let Some(storage) = &*nether {
            return Ok(storage.clone());
        }
        let storage = Arc::new(ChunkStorage::with_session(
            self.dir.join("DIM-1"),
            self.session.clone(),
        )?);
        *nether = Some(storage.clone());
        Ok(storage)
    }

    /// `SaveHandler.checkSessionLock`: fails once another program opened the world.
    pub fn check_session_lock(&self) -> Result<(), SaveError> {
        self.session.verify()
    }

    /// `SaveHandler.loadWorldInfo`, falling back to `level.dat_old`.
    pub fn load_info(&self) -> Result<Option<WorldInfo>, SaveError> {
        load_info(&self.dir)
    }

    /// `SaveHandler.saveWorldInfo`: writes `level.dat_new`, keeps the previous file as `level.dat_old`.
    pub fn save_info(&self, info: &mut WorldInfo) -> Result<(), SaveError> {
        self.check_session_lock()?;
        info.size_on_disk += self.overworld.take_growth() as i64;
        if let Some(nether) = &*lock(&self.nether) {
            info.size_on_disk += nether.take_growth() as i64;
        }
        let mut root = Compound::default();
        root.set_compound("Data", info.to_nbt());
        replace_level_dat(&self.dir, &root)
    }
}

pub fn load_info(dir: &Path) -> Result<Option<WorldInfo>, SaveError> {
    let mut failure = None;
    for name in ["level.dat", "level.dat_old"] {
        let path = dir.join(name);
        if !path.try_exists().map_err(io_error(&path))? {
            continue;
        }
        match read_gzip(&path).map(|root| root.compound("Data").map(WorldInfo::from_nbt)) {
            Ok(Some(info)) => return Ok(Some(info)),
            Ok(None) => {
                let error = format!("{} has no world data", path.display());
                failure = Some(SaveError::Invalid(error));
            }
            Err(error) => failure = Some(error),
        }
    }
    failure.map_or(Ok(None), Err)
}