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 / client / src / stats / storage.rs

use super::{Shared, Stats};
use md5::{Digest, Md5};
use rustc_hash::FxHashMap;
use serde_json::{Value, json};
use std::fs::File;
use std::io::{self, Write};
use std::path::Path;
use std::sync::{Arc, Condvar, Mutex};
use std::thread::JoinHandle;

type Snapshot = Vec<(i32, i32, &'static str)>;
#[derive(Default)]
struct Pending {
    snapshot: Option<Snapshot>,
    stopping: bool,
    failed: bool,
}
pub struct Store {
    pub stats: Shared,
    pending: Arc<(Mutex<Pending>, Condvar)>,
    thread: Option<JoinHandle<()>>,
    scheduled: u64,
}
impl Store {
    pub fn open(game_dir: &Path, assets: &Path, username: &str) -> io::Result<Self> {
        let directory = game_dir.join("stats");
        std::fs::create_dir_all(&directory)?;
        // `StatFileWriter`: statistics once kept in the game directory move into stats/
        for entry in std::fs::read_dir(game_dir).into_iter().flatten().flatten() {
            let name = entry.file_name();
            let name = name.to_string_lossy();
            let target = directory.join(&*name);
            if name.starts_with("stats_") && name.ends_with(".dat") && !target.exists() {
                tracing::info!("Relocating {name}");
                if let Err(error) = std::fs::rename(entry.path(), target) {
                    tracing::warn!("cannot relocate {name}: {error}");
                }
            }
        }
        let sanitize = |name: &str| {
            name.replace(
                |ch: char| !(ch.is_ascii_alphanumeric() || ch == '_' || ch == '-'),
                "_",
            )
        };
        let safe_name = sanitize(&username.to_lowercase());
        // `StatsSyncher`: files named after the username's original case are renamed
        let cased = sanitize(username);
        if cased != safe_name {
            for suffix in [
                "_unsent.dat",
                ".dat",
                "_unsent.old",
                ".old",
                "_unsent.tmp",
                ".tmp",
            ] {
                let source = directory.join(format!("stats_{cased}{suffix}"));
                let target = directory.join(format!("stats_{safe_name}{suffix}"));
                if source.is_file()
                    && !target.exists()
                    && let Err(error) = std::fs::rename(&source, target)
                {
                    tracing::warn!("cannot rename {}: {error}", source.display());
                }
            }
        }
        let mut stats = Stats::load(assets)?;
        for unsent in [false, true] {
            let name = format!("stats_{safe_name}{}", if unsent { "_unsent" } else { "" });
            let files = ["dat", "old", "tmp"]
                .map(|extension| directory.join(format!("{name}.{extension}")));
            let loaded = files
                .into_iter()
                .filter(|file| file.exists())
                .find_map(|file| {
                    let values = std::fs::read_to_string(&file)
                        .and_then(|data| deserialize(&data, &stats.guids));
                    values
                        .inspect_err(|error| {
                            tracing::warn!("cannot read statistics {}: {error}", file.display())
                        })
                        .ok()
                });
            if let Some(values) = loaded {
                for (&id, &amount) in &values {
                    let total = stats.values.entry(id).or_default();
                    *total = total.saturating_add(amount);
                }
                if unsent {
                    stats.unsent = values;
                }
            }
        }
        let pending = Arc::new((Mutex::new(Pending::default()), Condvar::new()));
        let path = directory.join(format!("stats_{safe_name}_unsent.dat"));
        let thread = std::thread::Builder::new()
            .name("save-statistics".into())
            .spawn({
                let pending = pending.clone();
                let username = username.to_owned();
                move || loop {
                    let (lock, wake) = &*pending;
                    let mut state = lock.lock().unwrap();
                    while state.snapshot.is_none() && !state.stopping {
                        state = wake.wait(state).unwrap();
                    }
                    let Some(snapshot) = state.snapshot.take() else {
                        break;
                    };
                    drop(state);
                    if let Err(error) = save(&path, &serialize(&username, &snapshot)) {
                        tracing::error!("cannot save statistics {}: {error}", path.display());
                        pending.0.lock().unwrap().failed = true;
                    }
                }
            })?;
        Ok(Self {
            stats: Arc::new(Mutex::new(stats)),
            pending,
            thread: Some(thread),
            scheduled: u64::MAX,
        })
    }
    pub fn save(&mut self) {
        let stats = self.stats.lock().unwrap();
        let mut pending = self.pending.0.lock().unwrap();
        if self.scheduled == stats.revision && !pending.failed {
            return;
        }
        let snapshot = stats
            .unsent
            .iter()
            .filter_map(|(&id, &amount)| stats.guids.get(&id).map(|&guid| (id, amount, guid)))
            .collect();
        self.scheduled = stats.revision;
        pending.failed = false;
        pending.snapshot = Some(snapshot);
        self.pending.1.notify_one();
    }
}
impl Drop for Store {
    fn drop(&mut self) {
        self.save();
        self.pending.0.lock().unwrap().stopping = true;
        self.pending.1.notify_one();
        if let Some(thread) = self.thread.take() {
            let _ = thread.join();
        }
    }
}

fn checksum(input: &str) -> String {
    let digest = Md5::digest(format!("local{input}").as_bytes());
    let padded: String = digest.iter().map(|byte| format!("{byte:02x}")).collect();
    let unpadded = padded.trim_start_matches('0');
    if unpadded.is_empty() { "0" } else { unpadded }.into()
}
fn serialize(username: &str, snapshot: &Snapshot) -> String {
    let mut entries = snapshot.clone();
    entries.sort_unstable_by_key(|&(id, _, _)| id);
    let mut input = String::new();
    let changes: Vec<_> = entries
        .into_iter()
        .map(|(id, amount, guid)| {
            input.push_str(&format!("{guid},{amount},"));
            json!({ id.to_string(): amount })
        })
        .collect();
    serde_json::to_string_pretty(&json!({"user":{"name":username,"sessionid":"local"},"stats-change":changes,"checksum":checksum(&input)})).expect("serializable statistics")
}
fn deserialize(text: &str, guids: &FxHashMap<i32, &str>) -> io::Result<FxHashMap<i32, i32>> {
    let root: Value = serde_json::from_str(text)?;
    let changes = root["stats-change"]
        .as_array()
        .ok_or_else(|| invalid("statistics array is missing"))?;
    let mut values = FxHashMap::default();
    let mut input = String::new();
    for object in changes {
        for (key, value) in object
            .as_object()
            .ok_or_else(|| invalid("invalid statistics entry"))?
        {
            let id = key.parse::<i32>().map_err(invalid)?;
            let Some(&guid) = guids.get(&id) else {
                continue;
            };
            let amount = value
                .as_i64()
                .and_then(|v| i32::try_from(v).ok())
                .ok_or_else(|| invalid("invalid statistics count"))?;
            input.push_str(&format!("{guid},{amount},"));
            values.insert(id, amount);
        }
    }
    if root["checksum"].as_str() != Some(checksum(&input).as_str()) {
        return Err(invalid("statistics checksum mismatch"));
    }
    Ok(values)
}
fn invalid(error: impl Into<Box<dyn std::error::Error + Send + Sync>>) -> io::Error {
    io::Error::new(io::ErrorKind::InvalidData, error)
}
fn save(path: &Path, text: &str) -> io::Result<()> {
    let temporary = path.with_extension("tmp");
    let old = path.with_extension("old");
    let mut file = File::create(&temporary)?;
    file.write_all(text.as_bytes())?;
    file.sync_all()?;
    if path.exists() {
        std::fs::rename(path, &old)?;
    }
    std::fs::rename(&temporary, path)?;
    if let Some(directory) = path.parent() {
        File::open(directory)?.sync_all()?;
    }
    Ok(())
}