lessanvil/
lib.rs

1//! See [`execute`] for the entrypoint of this crate.
2
3use fastanvil::Region;
4use rayon::prelude::{IntoParallelIterator, ParallelIterator};
5use rayon::{ThreadPoolBuildError, ThreadPoolBuilder};
6use serde::{Deserialize, Serialize};
7use std::fs::File;
8use std::io::{self, Seek};
9use std::path::{Path, PathBuf};
10use std::sync::atomic::AtomicU64;
11use std::sync::mpsc;
12use std::time::Duration;
13use std::{fs, thread, time};
14
15/// The subfolders in the world folder in which the region files are contained
16const REGION_SUBFOLDERS: [&str; 3] = ["region", "DIM-1/region", "DIM1/region"];
17
18/// The config to be passed to lessanvil.
19#[derive(Default)]
20pub struct Config {
21    /// The folder containing the world.
22    pub world_folder: PathBuf,
23    /// The maximum [Inhabited Time](https://minecraft.fandom.com/wiki/Chunk_format) value for a chunk to get deleted.
24    pub max_inhabited_time: usize,
25    /// The amount of threads lessanvil should use.
26    pub thread_count: usize,
27}
28
29/// A Report that will be handed out ofter the execution finished.
30#[derive(Serialize)]
31pub struct Report {
32    /// The total time the execution took.
33    pub time_taken: Duration,
34    /// The total disk space freed in bytes.
35    pub total_freed_space: u64,
36    /// The total amount of region(-file-)s processed.
37    pub total_regions: u64,
38    /// The total amount of chunks processed.
39    pub total_chunks: u64,
40    /// The total amount of deleted chunks.
41    pub total_deleted_chunks: u64,
42}
43
44/// The error type for errors that occured before the actual processing started.
45#[derive(thiserror::Error, Debug)]
46pub enum Error {
47    /// The world folder could not be accessed. This can be caused by e.g. the world folder not existing or the user not having sufficient privileges.
48    #[error("The specified world folder could not be found")]
49    WorldFolderNotFound,
50    /// An arbitrary IO error.
51    #[error("Unknown IO error")]
52    IOError(#[from] io::Error),
53    /// An error caused when invoking the [`ThreadPoolBuilder`]
54    #[error("Failed to build Rayon threadpool")]
55    RayonError(#[from] ThreadPoolBuildError),
56}
57
58/// An update during lessanvil's execution.
59pub enum ProcessingUpdate {
60    /// Only sent once after the processing started.
61    Starting {
62        /// Total amount of files to be processed.
63        total_files: u64,
64    },
65    /// Sent after a region has been processed.
66    /// Contains the [`Result`] of the processed region.
67    ProcessedRegion(Result<ProcessedRegion, RegionProcessingError>),
68    /// Only sent once after the entire execution finished. This is the last message sent through the Channel.
69    Finished(Report),
70}
71
72/// The entrypoint to this crate.
73///
74/// The [`Result`] contains a [`Receiver`](`mpsc::Receiver`) through which [`ProcessingUpdate`]s will be sent. Dropping this [`Receiver`](`mpsc::Receiver`) will stop the processing as soon as possible.
75pub fn execute(config: Config) -> Result<mpsc::Receiver<ProcessingUpdate>, Error> {
76    if !config.world_folder.try_exists().map_or(false, |r| r) {
77        return Err(Error::WorldFolderNotFound);
78    }
79
80    ThreadPoolBuilder::new()
81        .num_threads(config.thread_count)
82        .build_global()?;
83
84    let (tx, rx) = mpsc::channel();
85
86    let files = collect_region_files(Path::new(&config.world_folder))?;
87
88    let size_before = dir_size(config.world_folder.as_path())?;
89    let start_time = time::Instant::now();
90    let total_regions = files.len() as u64;
91    let total_chunks = AtomicU64::new(0);
92    let total_deleted_chunks = AtomicU64::new(0);
93
94    thread::spawn(move || {
95        let _ = tx.send(ProcessingUpdate::Starting {
96            total_files: files.len() as u64,
97        });
98
99        let result = files
100            .into_par_iter()
101            .try_for_each_with(tx.clone(), |t, path| {
102                let processed_region =
103                    process_region_file(path.as_path(), config.max_inhabited_time * 20);
104
105                if let Ok(ProcessedRegion {
106                    x: _,
107                    y: _,
108                    total_chunks: chunks,
109                    deleted_chunks,
110                }) = processed_region
111                {
112                    total_chunks.fetch_add(chunks as u64, std::sync::atomic::Ordering::Relaxed);
113                    total_deleted_chunks
114                        .fetch_add(deleted_chunks as u64, std::sync::atomic::Ordering::Relaxed);
115                }
116
117                if t.send(ProcessingUpdate::ProcessedRegion(processed_region))
118                    .is_err()
119                {
120                    Err(())
121                } else {
122                    Ok(())
123                }
124            });
125        if result.is_ok() {
126            let freed_space = size_before - dir_size(config.world_folder.as_path()).unwrap_or(0);
127            let time_taken = time::Instant::now() - start_time;
128
129            let _ = tx.send(ProcessingUpdate::Finished(Report {
130                time_taken,
131                total_freed_space: freed_space,
132                total_regions,
133                total_chunks: total_chunks.into_inner(),
134                total_deleted_chunks: total_deleted_chunks.into_inner(),
135            }));
136        }
137    });
138
139    Ok(rx)
140}
141
142fn collect_region_files(base_path: &Path) -> io::Result<Vec<PathBuf>> {
143    let mut files = vec![];
144    for sub_folder in REGION_SUBFOLDERS {
145        let path = base_path.join(Path::new(sub_folder));
146        if !path.try_exists().map_or(false, |b| b) {
147            continue;
148        }
149        let mut contents = path
150            .read_dir()?
151            .map(|entry| entry.unwrap().path())
152            .filter(|path| {
153                if let Some(ext) = path.extension() {
154                    ext == "mca"
155                } else {
156                    false
157                }
158            })
159            .collect();
160        files.append(&mut contents);
161    }
162    Ok(files)
163}
164
165/// The error type for processed regions.
166#[derive(thiserror::Error, Debug)]
167pub enum RegionProcessingError {
168    /// An arbitrary I/0 Error
169    #[error("Unknown I/O error")]
170    IOError(#[from] io::Error),
171    /// An arbitrary error for [Minecraft Anvil](https://minecraft.fandom.com/wiki/Anvil_file_format) operations.
172    #[error("Anvil error")]
173    AnvilError(#[from] fastanvil::Error),
174    /// An arbitrary error for [Minecraft NBT](https://minecraft.fandom.com/wiki/NBT_format) operations.
175    #[error("NBT error")]
176    NBTError(#[from] fastnbt::error::Error),
177}
178
179#[derive(Serialize, Deserialize)]
180#[serde(rename_all = "PascalCase")]
181struct Chunk {
182    inhabited_time: usize,
183}
184
185/// A processed region.
186pub struct ProcessedRegion {
187    /// The x-coordinate.
188    pub x: usize,
189    /// The y-coordinate.
190    pub y: usize,
191    /// The total chunks processed in this region.
192    pub total_chunks: u16,
193    /// The total chunks deleted in this region.
194    pub deleted_chunks: u16,
195}
196
197fn process_region_file(
198    region_file_path: &Path,
199    man_inhabited_time: usize,
200) -> Result<ProcessedRegion, RegionProcessingError> {
201    let mut total_chunks = 0;
202    let mut deleted_chunks = 0;
203
204    let (y, x) = match region_file_path
205        .file_stem()
206        .and_then(|os| os.to_str())
207        .map(|s| s.split('.').skip(1).collect::<Vec<_>>())
208    {
209        Some(mut vec) => (
210            vec.pop().unwrap_or("0").parse::<usize>().unwrap_or(0),
211            vec.pop().unwrap_or("0").parse::<usize>().unwrap_or(0),
212        ),
213        None => (0, 0),
214    };
215
216    let region_file = File::options()
217        .read(true)
218        .write(true)
219        .open(region_file_path)?;
220    let mut region = Region::from_stream(region_file)?;
221
222    for x in 0..32 {
223        for y in 0..32 {
224            let Ok(Some(chunk)) = region.read_chunk(x, y) else { continue; };
225            let chunk: Chunk = fastnbt::from_bytes(&chunk)?;
226            total_chunks += 1;
227            if chunk.inhabited_time <= (man_inhabited_time / 20) {
228                region.remove_chunk(x, y)?;
229                deleted_chunks += 1;
230            }
231        }
232    }
233
234    // truncate region file
235    let mut region_file = region.into_inner()?;
236    let len = region_file.stream_position()?;
237    region_file.set_len(len)?;
238
239    Ok(ProcessedRegion {
240        x,
241        y,
242        total_chunks,
243        deleted_chunks,
244    })
245}
246
247// Thank you stackoverflow lol
248fn dir_size(path: &Path) -> io::Result<u64> {
249    fn dir_size(mut dir: fs::ReadDir) -> io::Result<u64> {
250        dir.try_fold(0, |acc, file| {
251            let file = file?;
252            let size = match file.metadata()? {
253                data if data.is_dir() => dir_size(fs::read_dir(file.path())?)?,
254                data => data.len(),
255            };
256            Ok(acc + size)
257        })
258    }
259
260    dir_size(fs::read_dir(path)?)
261}