alex/beta-minecraft-rust
Minecraft Beta 1.7.3 Rewrite
BRANCH 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
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(())
}