Initial commit: Terrafier v0.1.0

- Core world model (World, Dimension, Tile, Terrain, Platform)
- NBT reader/writer with full tag support
- Anvil region (.mca) format reader with chunk parsing
- Block palette compression and export
- Biome database with Minecraft 1.21 biomes
- CLI commands: new, import, export, info, render
- GUI application with egui (viewport, tools, undo/redo)
- Noise-based heightmap generation and editing operations

License: GPL-3.0-or-later
This commit is contained in:
loki5512344 2026-06-15 19:14:11 +02:00
commit 91835402b3
94 changed files with 12372 additions and 0 deletions

12
crates/fastanvil/Cargo.toml Executable file
View file

@ -0,0 +1,12 @@
[package]
name = "terrafier-fastanvil"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "Fast Anvil/MCRegion format reader and writer for Minecraft world files"
[dependencies]
terrafier-nbt = { path = "../nbt" }
thiserror.workspace = true
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
rayon.workspace = true

View file

@ -0,0 +1,95 @@
//! Compression utilities for Minecraft region files.
//!
//! Minecraft uses Zlib (deflate) compression for chunk data in .mca files.
//! MCRegion files use GZip compression.
use thiserror::Error;
#[derive(Error, Debug)]
pub enum CompressionError {
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Decompression error: {0}")]
Decompress(String),
#[error("Compression error: {0}")]
Compress(String),
#[error("Unknown compression scheme: {0}")]
UnknownScheme(u8),
}
pub type Result<T> = std::result::Result<T, CompressionError>;
/// Compression type identifiers used in .mca chunk headers.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CompressionType {
/// GZip compression (MCRegion, type=1)
GZip,
/// Zlib deflate compression (Anvil / .mca, type=2)
Zlib,
/// Uncompressed (type=3)
Uncompressed,
}
impl CompressionType {
pub fn from_id(id: u8) -> Option<Self> {
match id {
1 => Some(CompressionType::GZip),
2 => Some(CompressionType::Zlib),
3 => Some(CompressionType::Uncompressed),
_ => None,
}
}
pub fn id(&self) -> u8 {
match self {
CompressionType::GZip => 1,
CompressionType::Zlib => 2,
CompressionType::Uncompressed => 3,
}
}
}
/// Decompress chunk data given the compression type.
pub fn decompress(data: &[u8], scheme: CompressionType) -> Result<Vec<u8>> {
use std::io::Read;
match scheme {
CompressionType::GZip => {
let mut dec = flate2::read::GzDecoder::new(data);
let mut buf = Vec::new();
dec.read_to_end(&mut buf)
.map_err(|e| CompressionError::Decompress(e.to_string()))?;
Ok(buf)
}
CompressionType::Zlib => {
let mut dec = flate2::read::ZlibDecoder::new(data);
let mut buf = Vec::new();
dec.read_to_end(&mut buf)
.map_err(|e| CompressionError::Decompress(e.to_string()))?;
Ok(buf)
}
CompressionType::Uncompressed => Ok(data.to_vec()),
}
}
/// Compress chunk data using the specified compression scheme.
pub fn compress(data: &[u8], scheme: CompressionType) -> Result<Vec<u8>> {
use std::io::Write;
match scheme {
CompressionType::GZip => {
let mut enc = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
enc.write_all(data)
.map_err(|e| CompressionError::Compress(e.to_string()))?;
enc.finish()
.map_err(|e| CompressionError::Compress(e.to_string()))
}
CompressionType::Zlib => {
let mut enc =
flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
enc.write_all(data)
.map_err(|e| CompressionError::Compress(e.to_string()))?;
enc.finish()
.map_err(|e| CompressionError::Compress(e.to_string()))
}
CompressionType::Uncompressed => Ok(data.to_vec()),
}
}

289
crates/fastanvil/src/io/chunk.rs Executable file
View file

@ -0,0 +1,289 @@
//! Chunk data structures for Minecraft Anvil format.
use std::collections::HashMap;
/// A parsed chunk from an Anvil (.mca) region file.
#[derive(Debug, Clone)]
pub struct Chunk {
pub x: i32,
pub z: i32,
pub data_version: i32,
pub sections: Vec<ChunkSection>,
pub block_entities: HashMap<String, HashMap<String, terrafier_nbt::Tag>>,
pub heightmaps: HashMap<String, terrafier_nbt::Tag>,
pub status: Option<String>,
pub biomes: Vec<i32>,
pub raw: HashMap<String, terrafier_nbt::Tag>,
}
/// A single vertical section (16x16x16 blocks) within a chunk.
#[derive(Debug, Clone)]
pub struct ChunkSection {
pub section_y: i8,
pub palette: Vec<HashMap<String, terrafier_nbt::Tag>>,
pub block_data: Vec<i64>,
pub biome_palette: Vec<HashMap<String, terrafier_nbt::Tag>>,
pub biome_data: Vec<i64>,
pub block_light: Option<Vec<i8>>,
pub sky_light: Option<Vec<i8>>,
}
impl Chunk {
/// Parse a chunk from an NBT Compound tag.
pub fn from_nbt(tag: &terrafier_nbt::Tag) -> Option<Self> {
let compound = match tag {
terrafier_nbt::Tag::Compound(m) => m,
_ => return None,
};
let x = get_int(compound, "xPos")?;
let z = get_int(compound, "zPos")?;
let data_version = get_int(compound, "DataVersion").unwrap_or(0);
let mut sections = Vec::new();
if let Some(terrafier_nbt::Tag::List(section_list)) = compound.get("sections") {
for section_tag in section_list {
if let Some(section) = ChunkSection::from_nbt(section_tag) {
sections.push(section);
}
}
}
let mut block_entities = HashMap::new();
if let Some(terrafier_nbt::Tag::List(entity_list)) = compound.get("block_entities") {
for entity in entity_list {
if let terrafier_nbt::Tag::Compound(m) = entity {
let key = format!("{:?}", m.get("id"));
block_entities.insert(key, m.clone());
}
}
}
let mut heightmaps = HashMap::new();
if let Some(terrafier_nbt::Tag::Compound(hm)) = compound.get("Heightmaps") {
for (k, v) in hm {
heightmaps.insert(k.clone(), v.clone());
}
}
let status = compound.get("Status").and_then(|t| match t {
terrafier_nbt::Tag::String(s) => Some(s.clone()),
_ => None,
});
let biomes = Vec::new();
let raw = compound.clone();
Some(Self {
x,
z,
data_version,
sections,
block_entities,
heightmaps,
status,
biomes,
raw,
})
}
/// Serialize this chunk back to an NBT Compound tag.
pub fn to_nbt(&self) -> terrafier_nbt::Tag {
let mut compound = self.raw.clone();
compound.insert("xPos".into(), terrafier_nbt::Tag::Int(self.x));
compound.insert("zPos".into(), terrafier_nbt::Tag::Int(self.z));
compound.insert(
"DataVersion".into(),
terrafier_nbt::Tag::Int(self.data_version),
);
let sections_list: Vec<terrafier_nbt::Tag> =
self.sections.iter().map(|s| s.to_nbt()).collect();
compound.insert("sections".into(), terrafier_nbt::Tag::List(sections_list));
if let Some(status) = &self.status {
compound.insert("Status".into(), terrafier_nbt::Tag::String(status.clone()));
}
if !self.heightmaps.is_empty() {
compound.insert(
"Heightmaps".into(),
terrafier_nbt::Tag::Compound(self.heightmaps.clone()),
);
}
terrafier_nbt::Tag::Compound(compound)
}
}
impl ChunkSection {
/// Parse a section from an NBT Compound tag.
pub fn from_nbt(tag: &terrafier_nbt::Tag) -> Option<Self> {
let compound = match tag {
terrafier_nbt::Tag::Compound(m) => m,
_ => return None,
};
let section_y = get_byte(compound, "Y")?;
let palette = if let Some(terrafier_nbt::Tag::List(list)) =
compound.get("block_states").and_then(|t| match t {
terrafier_nbt::Tag::Compound(m) => m.get("palette"),
_ => None,
}) {
list.iter()
.filter_map(|t| match t {
terrafier_nbt::Tag::Compound(m) => Some(m.clone()),
_ => None,
})
.collect()
} else {
Vec::new()
};
let block_data =
if let Some(terrafier_nbt::Tag::Compound(bs)) = compound.get("block_states") {
if let Some(terrafier_nbt::Tag::LongArray(data)) = bs.get("data") {
data.clone()
} else {
Vec::new()
}
} else {
Vec::new()
};
let biome_palette = compound
.get("biomes")
.and_then(|t| match t {
terrafier_nbt::Tag::Compound(m) => m.get("palette"),
_ => None,
})
.and_then(|t| {
if let terrafier_nbt::Tag::List(list) = t {
Some(
list.iter()
.filter_map(|t| match t {
terrafier_nbt::Tag::Compound(m) => Some(m.clone()),
_ => None,
})
.collect(),
)
} else {
None
}
})
.unwrap_or_default();
let biome_data = compound
.get("biomes")
.and_then(|t| match t {
terrafier_nbt::Tag::Compound(m) => m.get("data"),
_ => None,
})
.and_then(|t| {
if let terrafier_nbt::Tag::LongArray(data) = t {
Some(data.clone())
} else {
None
}
})
.unwrap_or_default();
let block_light = compound.get("BlockLight").and_then(|t| {
if let terrafier_nbt::Tag::ByteArray(data) = t {
Some(data.clone())
} else {
None
}
});
let sky_light = compound.get("SkyLight").and_then(|t| {
if let terrafier_nbt::Tag::ByteArray(data) = t {
Some(data.clone())
} else {
None
}
});
Some(Self {
section_y,
palette,
block_data,
biome_palette,
biome_data,
block_light,
sky_light,
})
}
/// Serialize section back to NBT Compound.
pub fn to_nbt(&self) -> terrafier_nbt::Tag {
let mut compound = HashMap::new();
compound.insert("Y".into(), terrafier_nbt::Tag::Byte(self.section_y));
// Block states
let palette_list: Vec<terrafier_nbt::Tag> = self
.palette
.iter()
.map(|p| terrafier_nbt::Tag::Compound(p.clone()))
.collect();
let mut block_states = HashMap::new();
block_states.insert("palette".into(), terrafier_nbt::Tag::List(palette_list));
if !self.block_data.is_empty() {
block_states.insert(
"data".into(),
terrafier_nbt::Tag::LongArray(self.block_data.clone()),
);
}
compound.insert(
"block_states".into(),
terrafier_nbt::Tag::Compound(block_states),
);
// Biomes
let biome_palette_list: Vec<terrafier_nbt::Tag> = self
.biome_palette
.iter()
.map(|p| terrafier_nbt::Tag::Compound(p.clone()))
.collect();
let mut biomes = HashMap::new();
biomes.insert(
"palette".into(),
terrafier_nbt::Tag::List(biome_palette_list),
);
if !self.biome_data.is_empty() {
biomes.insert(
"data".into(),
terrafier_nbt::Tag::LongArray(self.biome_data.clone()),
);
}
compound.insert("biomes".into(), terrafier_nbt::Tag::Compound(biomes));
if let Some(bl) = &self.block_light {
compound.insert(
"BlockLight".into(),
terrafier_nbt::Tag::ByteArray(bl.clone()),
);
}
if let Some(sl) = &self.sky_light {
compound.insert("SkyLight".into(), terrafier_nbt::Tag::ByteArray(sl.clone()));
}
terrafier_nbt::Tag::Compound(compound)
}
}
fn get_int(map: &HashMap<String, terrafier_nbt::Tag>, key: &str) -> Option<i32> {
map.get(key).and_then(|t| match t {
terrafier_nbt::Tag::Int(v) => Some(*v),
_ => None,
})
}
fn get_byte(map: &HashMap<String, terrafier_nbt::Tag>, key: &str) -> Option<i8> {
map.get(key).and_then(|t| match t {
terrafier_nbt::Tag::Byte(v) => Some(*v),
_ => None,
})
}

4
crates/fastanvil/src/io/mod.rs Executable file
View file

@ -0,0 +1,4 @@
//! Anvil format I/O — region file and chunk data structures.
pub mod chunk;
pub mod region;

227
crates/fastanvil/src/io/region.rs Executable file
View file

@ -0,0 +1,227 @@
//! Region file format (.mca / .mcr).
//!
//! A region file contains 32x32 chunks stored in a 8KB header
//! (2KB location table + 2KB timestamp table + 4KB padding)
//! followed by chunk data sectors of 4KB each.
use std::collections::HashMap;
use std::io::{Cursor, Read, Seek, SeekFrom};
use thiserror::Error;
use crate::compression::{self, CompressionType};
#[derive(Error, Debug)]
pub enum RegionError {
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Compression error: {0}")]
Compression(#[from] compression::CompressionError),
#[error("Invalid region header at offset {0}")]
InvalidHeader(u32),
#[error("Chunk ({0}, {1}) not found in region")]
ChunkNotFound(i32, i32),
}
pub type Result<T> = std::result::Result<T, RegionError>;
/// A region file containing up to 32x32 chunks.
pub struct Region {
pub x: i32,
pub z: i32,
chunks: HashMap<(u8, u8), ChunkEntry>,
}
pub struct ChunkEntry {
pub offset: u32,
pub size: u32,
pub timestamp: u32,
pub data: Option<Vec<u8>>,
}
impl Region {
/// Open a region file from raw bytes.
pub fn from_bytes(x: i32, z: i32, data: &[u8]) -> Result<Self> {
let mut reader = Cursor::new(data);
let mut locations = [0u32; 1024];
let mut timestamps = [0u32; 1024];
// Read location table (first 4096 bytes: 1024 entries x 4 bytes)
for i in 0..1024 {
let mut buf = [0u8; 4];
reader.read_exact(&mut buf)?;
locations[i] = u32::from_be_bytes(buf);
}
// Read timestamp table (second 4096 bytes: 1024 entries x 4 bytes)
for i in 0..1024 {
let mut buf = [0u8; 4];
reader.read_exact(&mut buf)?;
timestamps[i] = u32::from_be_bytes(buf);
}
let mut chunks = HashMap::new();
for i in 0..1024 {
let loc = locations[i];
if loc == 0 {
continue;
}
let sector_offset = loc >> 8;
let sector_count = loc & 0xFF;
let timestamp = timestamps[i];
if sector_offset == 0 {
continue;
}
// Read chunk header: 4 bytes length (including 1 byte compression type)
let byte_offset = (sector_offset as u64) * 4096;
reader.seek(SeekFrom::Start(byte_offset))?;
let mut len_buf = [0u8; 4];
reader.read_exact(&mut len_buf)?;
let chunk_data_len = u32::from_be_bytes(len_buf);
// Compression type byte follows the length
let mut comp_type_buf = [0u8; 1];
reader.read_exact(&mut comp_type_buf)?;
let compression_scheme = comp_type_buf[0];
// Read compressed chunk payload
let payload_len = if chunk_data_len > 0 {
chunk_data_len as usize - 1
} else {
0
};
let mut compressed = vec![0u8; payload_len];
reader.read_exact(&mut compressed)?;
// Determine compression type
let scheme = match CompressionType::from_id(compression_scheme) {
Some(s) => s,
None => continue,
};
// Decompress
let decompressed = compression::decompress(&compressed, scheme)?;
let local_x = (i % 32) as u8;
let local_z = (i / 32) as u8;
chunks.insert(
(local_x, local_z),
ChunkEntry {
offset: sector_offset,
size: sector_count as u32,
timestamp,
data: Some(decompressed),
},
);
}
Ok(Self { x, z, chunks })
}
/// Get the decompressed NBT data for a chunk at local coordinates (0..32).
pub fn get_chunk_data(&self, local_x: u8, local_z: u8) -> Option<&[u8]> {
self.chunks
.get(&(local_x, local_z))
.and_then(|e| e.data.as_deref())
}
/// Create a new empty region.
pub fn new(x: i32, z: i32) -> Self {
Self {
x,
z,
chunks: HashMap::new(),
}
}
/// Set chunk data at local coordinates (0..32, 0..32).
/// `data` should be decompressed NBT bytes.
pub fn set_chunk_data(&mut self, local_x: u8, local_z: u8, data: Vec<u8>) {
self.chunks.insert((local_x, local_z), ChunkEntry {
offset: 0,
size: 0,
timestamp: 0,
data: Some(data),
});
}
/// List all chunk coordinates present in this region.
pub fn chunk_coords(&self) -> Vec<(u8, u8)> {
let mut coords: Vec<_> = self.chunks.keys().copied().collect();
coords.sort();
coords
}
/// Number of chunks in this region.
pub fn chunk_count(&self) -> usize {
self.chunks.len()
}
/// Serialize region back to .mca bytes.
pub fn to_bytes(&self) -> Result<Vec<u8>> {
let sector_size: u64 = 4096;
let mut locations = [0u32; 1024];
let mut timestamps = [0u32; 1024];
let mut sector_data: Vec<Vec<u8>> = Vec::new();
for i in 0..1024 {
let local_x = (i % 32) as u8;
let local_z = (i / 32) as u8;
if let Some(entry) = self.chunks.get(&(local_x, local_z)) {
timestamps[i] = entry.timestamp;
// Serialize NBT and compress with Zlib
let compressed = compression::compress(
entry.data.as_deref().unwrap_or_default(),
CompressionType::Zlib,
)?;
// Prepend: length (4 bytes BE) + compression type (1 byte)
let total_len = 1 + compressed.len();
let mut sector = Vec::with_capacity(4 + total_len);
sector.extend(&(total_len as u32).to_be_bytes());
sector.push(CompressionType::Zlib.id());
sector.extend(&compressed);
// Pad to sector boundary
while sector.len() % sector_size as usize != 0 {
sector.push(0);
}
let offset = 2 + sector_data.len() as u32;
locations[i] =
(offset << 8) | ((sector.len() / sector_size as usize) as u32 & 0xFF);
sector_data.push(sector);
}
}
// Build output: header + sector data
let mut output =
Vec::with_capacity(2 * 4096 + sector_data.iter().map(|s| s.len()).sum::<usize>());
// Location table
for loc in locations.iter() {
output.extend(&loc.to_be_bytes());
}
// Timestamp table
for ts in timestamps.iter() {
output.extend(&ts.to_be_bytes());
}
// Pad header to exactly 2 sectors
while output.len() < 2 * sector_size as usize {
output.push(0);
}
// Sector data
for sector in &sector_data {
output.extend(sector);
}
Ok(output)
}
}

15
crates/fastanvil/src/lib.rs Executable file
View file

@ -0,0 +1,15 @@
//! # Terrafier FastAnvil
//!
//! Fast reading and writing of Minecraft Anvil (.mca) and MCRegion (.mcr) files.
//!
//! Supports:
//! - Reading existing regions
//! - Writing new regions
//! - Chunk-level access (compressed NBT data)
//! - Parallel chunk processing
pub mod compression;
pub mod io;
pub use io::chunk;
pub use io::region;