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:
commit
91835402b3
94 changed files with 12372 additions and 0 deletions
10
crates/biome-db/Cargo.toml
Executable file
10
crates/biome-db/Cargo.toml
Executable file
|
|
@ -0,0 +1,10 @@
|
|||
[package]
|
||||
name = "terrafier-biome-db"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "Biome database for Terrafier — biome IDs and names for all Minecraft versions"
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json.workspace = true
|
||||
65
crates/biome-db/data/biomes.json
Executable file
65
crates/biome-db/data/biomes.json
Executable file
|
|
@ -0,0 +1,65 @@
|
|||
[
|
||||
{"id":0,"name":"ocean","temperature":0.5,"downfall":0.5,"precipitation":"rain","water_color":4159204,"sky_color":7907327,"grass_color":9551193,"foliage_color":10387789,"fog_color":12638463},
|
||||
{"id":1,"name":"plains","temperature":0.8,"downfall":0.4,"precipitation":"rain","water_color":4159204,"sky_color":7907327,"grass_color":9551193,"foliage_color":10387789,"fog_color":12638463},
|
||||
{"id":2,"name":"desert","temperature":2.0,"downfall":0.0,"precipitation":"none","water_color":4159204,"sky_color":7254527,"grass_color":13095579,"foliage_color":11759899,"fog_color":12638463},
|
||||
{"id":3,"name":"windswept_hills","temperature":0.2,"downfall":0.3,"precipitation":"rain","water_color":4159204,"sky_color":7907327,"grass_color":10927977,"foliage_color":10854697,"fog_color":12638463},
|
||||
{"id":4,"name":"forest","temperature":0.7,"downfall":0.8,"precipitation":"rain","water_color":4159204,"sky_color":7907327,"grass_color":8107825,"foliage_color":7318464,"fog_color":12638463},
|
||||
{"id":5,"name":"taiga","temperature":0.25,"downfall":0.8,"precipitation":"rain","water_color":4159204,"sky_color":7907327,"grass_color":12207467,"foliage_color":11599129,"fog_color":12638463},
|
||||
{"id":6,"name":"swamp","temperature":0.8,"downfall":0.9,"precipitation":"rain","water_color":9478516,"sky_color":7907327,"grass_color":6975545,"foliage_color":6975545,"fog_color":12638463},
|
||||
{"id":7,"name":"river","temperature":0.5,"downfall":0.5,"precipitation":"rain","water_color":4159204,"sky_color":7907327,"grass_color":9551193,"foliage_color":10387789,"fog_color":12638463},
|
||||
{"id":8,"name":"nether_wastes","temperature":2.0,"downfall":0.0,"precipitation":"none","water_color":4159204,"sky_color":7254527,"grass_color":8792071,"foliage_color":8792071,"fog_color":3348480},
|
||||
{"id":9,"name":"the_end","temperature":0.5,"downfall":0.5,"precipitation":"none","water_color":4159204,"sky_color":0,"grass_color":10065867,"foliage_color":10065867,"fog_color":10526880},
|
||||
{"id":10,"name":"frozen_ocean","temperature":0.0,"downfall":0.5,"precipitation":"snow","water_color":3750089,"sky_color":7907327,"grass_color":12255547,"foliage_color":11599129,"fog_color":12638463},
|
||||
{"id":11,"name":"frozen_river","temperature":0.0,"downfall":0.5,"precipitation":"snow","water_color":3750089,"sky_color":7907327,"grass_color":12255547,"foliage_color":11599129,"fog_color":12638463},
|
||||
{"id":12,"name":"snowy_plains","temperature":0.0,"downfall":0.5,"precipitation":"snow","water_color":4020182,"sky_color":7907327,"grass_color":12255547,"foliage_color":11599129,"fog_color":12638463},
|
||||
{"id":13,"name":"old_growth_pine_taiga","temperature":0.3,"downfall":0.8,"precipitation":"rain","water_color":4159204,"sky_color":7907327,"grass_color":10855236,"foliage_color":10214081,"fog_color":12638463},
|
||||
{"id":14,"name":"old_growth_spruce_taiga","temperature":0.25,"downfall":0.8,"precipitation":"rain","water_color":4159204,"sky_color":7907327,"grass_color":12207467,"foliage_color":11599129,"fog_color":12638463},
|
||||
{"id":15,"name":"snowy_taiga","temperature":-0.5,"downfall":0.4,"precipitation":"snow","water_color":4020182,"sky_color":7907327,"grass_color":12255547,"foliage_color":11599129,"fog_color":12638463},
|
||||
{"id":16,"name":"savanna","temperature":2.0,"downfall":0.0,"precipitation":"none","water_color":4159204,"sky_color":7254527,"grass_color":13551763,"foliage_color":12434877,"fog_color":12638463},
|
||||
{"id":17,"name":"savanna_plateau","temperature":2.0,"downfall":0.0,"precipitation":"none","water_color":4159204,"sky_color":7254527,"grass_color":13551763,"foliage_color":12434877,"fog_color":12638463},
|
||||
{"id":18,"name":"badlands","temperature":2.0,"downfall":0.0,"precipitation":"none","water_color":4159204,"sky_color":7254527,"grass_color":10658679,"foliage_color":10658679,"fog_color":12638463},
|
||||
{"id":19,"name":"wooded_badlands","temperature":2.0,"downfall":0.0,"precipitation":"none","water_color":4159204,"sky_color":7254527,"grass_color":10658679,"foliage_color":10658679,"fog_color":12638463},
|
||||
{"id":20,"name":"eroded_badlands","temperature":2.0,"downfall":0.0,"precipitation":"none","water_color":4159204,"sky_color":7254527,"grass_color":10658679,"foliage_color":10658679,"fog_color":12638463},
|
||||
{"id":21,"name":"warm_ocean","temperature":0.5,"downfall":0.5,"precipitation":"rain","water_color":4020182,"sky_color":7907327,"grass_color":9551193,"foliage_color":10387789,"fog_color":12638463},
|
||||
{"id":22,"name":"lukewarm_ocean","temperature":0.5,"downfall":0.5,"precipitation":"rain","water_color":4566514,"sky_color":7907327,"grass_color":9551193,"foliage_color":10387789,"fog_color":12638463},
|
||||
{"id":23,"name":"cold_ocean","temperature":0.5,"downfall":0.5,"precipitation":"rain","water_color":3750089,"sky_color":7907327,"grass_color":9551193,"foliage_color":10387789,"fog_color":12638463},
|
||||
{"id":24,"name":"deep_lukewarm_ocean","temperature":0.5,"downfall":0.5,"precipitation":"rain","water_color":4566514,"sky_color":7907327,"grass_color":9551193,"foliage_color":10387789,"fog_color":12638463},
|
||||
{"id":25,"name":"deep_cold_ocean","temperature":0.5,"downfall":0.5,"precipitation":"rain","water_color":3750089,"sky_color":7907327,"grass_color":9551193,"foliage_color":10387789,"fog_color":12638463},
|
||||
{"id":26,"name":"deep_frozen_ocean","temperature":0.5,"downfall":0.5,"precipitation":"rain","water_color":3750089,"sky_color":7907327,"grass_color":9551193,"foliage_color":10387789,"fog_color":12638463},
|
||||
{"id":27,"name":"dark_forest","temperature":0.7,"downfall":0.8,"precipitation":"rain","water_color":4159204,"sky_color":7907327,"grass_color":7109967,"foliage_color":6776627,"fog_color":12638463},
|
||||
{"id":28,"name":"snowy_slopes","temperature":-0.3,"downfall":0.9,"precipitation":"snow","water_color":4020182,"sky_color":7907327,"grass_color":12255547,"foliage_color":11599129,"fog_color":12638463},
|
||||
{"id":29,"name":"jagged_peaks","temperature":-0.7,"downfall":0.9,"precipitation":"snow","water_color":4020182,"sky_color":7907327,"grass_color":12255547,"foliage_color":11599129,"fog_color":12638463},
|
||||
{"id":30,"name":"frozen_peaks","temperature":-0.7,"downfall":0.9,"precipitation":"snow","water_color":4020182,"sky_color":7907327,"grass_color":12255547,"foliage_color":11599129,"fog_color":12638463},
|
||||
{"id":31,"name":"stony_peaks","temperature":1.0,"downfall":0.3,"precipitation":"rain","water_color":4159204,"sky_color":7907327,"grass_color":10927977,"foliage_color":10854697,"fog_color":12638463},
|
||||
{"id":32,"name":"bamboo_jungle","temperature":0.95,"downfall":0.9,"precipitation":"rain","water_color":4159204,"sky_color":7907327,"grass_color":12304591,"foliage_color":11437115,"fog_color":12638463},
|
||||
{"id":33,"name":"jungle","temperature":0.95,"downfall":0.9,"precipitation":"rain","water_color":4159204,"sky_color":7907327,"grass_color":12304591,"foliage_color":11437115,"fog_color":12638463},
|
||||
{"id":34,"name":"sparse_jungle","temperature":0.95,"downfall":0.8,"precipitation":"rain","water_color":4159204,"sky_color":7907327,"grass_color":12304591,"foliage_color":11437115,"fog_color":12638463},
|
||||
{"id":35,"name":"deep_ocean","temperature":0.5,"downfall":0.5,"precipitation":"rain","water_color":4159204,"sky_color":7907327,"grass_color":9551193,"foliage_color":10387789,"fog_color":12638463},
|
||||
{"id":36,"name":"mushroom_fields","temperature":0.9,"downfall":1.0,"precipitation":"rain","water_color":4159204,"sky_color":7907327,"grass_color":10215985,"foliage_color":10215985,"fog_color":12638463},
|
||||
{"id":37,"name":"dripstone_caves","temperature":0.8,"downfall":0.4,"precipitation":"rain","water_color":4159204,"sky_color":7907327,"grass_color":9551193,"foliage_color":10387789,"fog_color":12638463},
|
||||
{"id":38,"name":"lush_caves","temperature":0.5,"downfall":0.5,"precipitation":"rain","water_color":4159204,"sky_color":7907327,"grass_color":8107825,"foliage_color":7318464,"fog_color":12638463},
|
||||
{"id":39,"name":"deep_dark","temperature":0.8,"downfall":0.4,"precipitation":"rain","water_color":4159204,"sky_color":7907327,"grass_color":9551193,"foliage_color":10387789,"fog_color":12638463},
|
||||
{"id":40,"name":"meadow","temperature":0.5,"downfall":0.8,"precipitation":"rain","water_color":4159204,"sky_color":7907327,"grass_color":9551193,"foliage_color":10387789,"fog_color":12638463},
|
||||
{"id":41,"name":"cherry_grove","temperature":0.5,"downfall":0.8,"precipitation":"rain","water_color":4159204,"sky_color":7907327,"grass_color":9551193,"foliage_color":10387789,"fog_color":12638463},
|
||||
{"id":42,"name":"grove","temperature":-0.2,"downfall":0.8,"precipitation":"snow","water_color":4020182,"sky_color":7907327,"grass_color":12255547,"foliage_color":11599129,"fog_color":12638463},
|
||||
{"id":43,"name":"snowy_beach","temperature":0.05,"downfall":0.3,"precipitation":"snow","water_color":4020182,"sky_color":7907327,"grass_color":12255547,"foliage_color":11599129,"fog_color":12638463},
|
||||
{"id":44,"name":"beach","temperature":0.8,"downfall":0.4,"precipitation":"rain","water_color":4159204,"sky_color":7907327,"grass_color":13095579,"foliage_color":11759899,"fog_color":12638463},
|
||||
{"id":45,"name":"stone_shore","temperature":0.2,"downfall":0.3,"precipitation":"rain","water_color":4159204,"sky_color":7907327,"grass_color":10927977,"foliage_color":10854697,"fog_color":12638463},
|
||||
{"id":46,"name":"sunflower_plains","temperature":0.8,"downfall":0.4,"precipitation":"rain","water_color":4159204,"sky_color":7907327,"grass_color":9551193,"foliage_color":10387789,"fog_color":12638463},
|
||||
{"id":47,"name":"flower_forest","temperature":0.7,"downfall":0.8,"precipitation":"rain","water_color":4159204,"sky_color":7907327,"grass_color":8107825,"foliage_color":7318464,"fog_color":12638463},
|
||||
{"id":48,"name":"ice_spikes","temperature":0.0,"downfall":0.5,"precipitation":"snow","water_color":4020182,"sky_color":7907327,"grass_color":12255547,"foliage_color":11599129,"fog_color":12638463},
|
||||
{"id":49,"name":"old_growth_birch_forest","temperature":0.6,"downfall":0.6,"precipitation":"rain","water_color":4159204,"sky_color":7907327,"grass_color":8107825,"foliage_color":7318464,"fog_color":12638463},
|
||||
{"id":51,"name":"birch_forest","temperature":0.6,"downfall":0.6,"precipitation":"rain","water_color":4159204,"sky_color":7907327,"grass_color":8107825,"foliage_color":7318464,"fog_color":12638463},
|
||||
{"id":54,"name":"windswept_savanna","temperature":2.0,"downfall":0.0,"precipitation":"none","water_color":4159204,"sky_color":7254527,"grass_color":13551763,"foliage_color":12434877,"fog_color":12638463},
|
||||
{"id":55,"name":"windswept_forest","temperature":0.2,"downfall":0.3,"precipitation":"rain","water_color":4159204,"sky_color":7907327,"grass_color":10927977,"foliage_color":10854697,"fog_color":12638463},
|
||||
{"id":56,"name":"windswept_gravelly_hills","temperature":0.2,"downfall":0.3,"precipitation":"rain","water_color":4159204,"sky_color":7907327,"grass_color":10927977,"foliage_color":10854697,"fog_color":12638463},
|
||||
{"id":66,"name":"mangrove_swamp","temperature":0.8,"downfall":0.9,"precipitation":"rain","water_color":9478516,"sky_color":7907327,"grass_color":6975545,"foliage_color":6975545,"fog_color":12638463},
|
||||
{"id":72,"name":"soul_sand_valley","temperature":2.0,"downfall":0.0,"precipitation":"none","water_color":4159204,"sky_color":7254527,"grass_color":5636761,"foliage_color":5636761,"fog_color":3348480},
|
||||
{"id":73,"name":"crimson_forest","temperature":2.0,"downfall":0.0,"precipitation":"none","water_color":4159204,"sky_color":7254527,"grass_color":9115493,"foliage_color":9115493,"fog_color":3348480},
|
||||
{"id":74,"name":"warped_forest","temperature":2.0,"downfall":0.0,"precipitation":"none","water_color":4159204,"sky_color":7254527,"grass_color":7856483,"foliage_color":7856483,"fog_color":3348480},
|
||||
{"id":75,"name":"basalt_deltas","temperature":2.0,"downfall":0.0,"precipitation":"none","water_color":4159204,"sky_color":7254527,"grass_color":6578527,"foliage_color":6578527,"fog_color":3348480},
|
||||
{"id":77,"name":"end_highlands","temperature":0.5,"downfall":0.5,"precipitation":"none","water_color":4159204,"sky_color":0,"grass_color":10065867,"foliage_color":10065867,"fog_color":10526880},
|
||||
{"id":78,"name":"end_midlands","temperature":0.5,"downfall":0.5,"precipitation":"none","water_color":4159204,"sky_color":0,"grass_color":10065867,"foliage_color":10065867,"fog_color":10526880},
|
||||
{"id":79,"name":"end_barrens","temperature":0.5,"downfall":0.5,"precipitation":"none","water_color":4159204,"sky_color":0,"grass_color":10065867,"foliage_color":10065867,"fog_color":10526880},
|
||||
{"id":80,"name":"small_end_islands","temperature":0.5,"downfall":0.5,"precipitation":"none","water_color":4159204,"sky_color":0,"grass_color":10065867,"foliage_color":10065867,"fog_color":10526880}
|
||||
]
|
||||
BIN
crates/biome-db/data/dry_foliage.png
Executable file
BIN
crates/biome-db/data/dry_foliage.png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 8.8 KiB |
BIN
crates/biome-db/data/foliage.png
Executable file
BIN
crates/biome-db/data/foliage.png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
1
crates/biome-db/data/foliage_colormap.json
Executable file
1
crates/biome-db/data/foliage_colormap.json
Executable file
File diff suppressed because one or more lines are too long
BIN
crates/biome-db/data/grass.png
Executable file
BIN
crates/biome-db/data/grass.png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 5.8 KiB |
1
crates/biome-db/data/grass_colormap.json
Executable file
1
crates/biome-db/data/grass_colormap.json
Executable file
File diff suppressed because one or more lines are too long
140
crates/biome-db/src/colour.rs
Executable file
140
crates/biome-db/src/colour.rs
Executable file
|
|
@ -0,0 +1,140 @@
|
|||
//! Biome colour utilities.
|
||||
//!
|
||||
//! Converts packed u32 colours to RGB arrays and applies
|
||||
//! temperature/downfall adjustments for grass and foliage.
|
||||
|
||||
use crate::db::BiomeEntry;
|
||||
|
||||
pub struct BiomeColour;
|
||||
|
||||
fn u32_to_rgb(colour: u32) -> [u8; 3] {
|
||||
let r = ((colour >> 16) & 0xFF) as u8;
|
||||
let g = ((colour >> 8) & 0xFF) as u8;
|
||||
let b = (colour & 0xFF) as u8;
|
||||
[r, g, b]
|
||||
}
|
||||
|
||||
impl BiomeColour {
|
||||
pub fn water_color(biome: &BiomeEntry) -> [u8; 3] {
|
||||
u32_to_rgb(biome.water_color)
|
||||
}
|
||||
|
||||
pub fn sky_color(biome: &BiomeEntry) -> [u8; 3] {
|
||||
u32_to_rgb(biome.sky_color)
|
||||
}
|
||||
|
||||
pub fn fog_color(biome: &BiomeEntry) -> [u8; 3] {
|
||||
u32_to_rgb(biome.fog_color)
|
||||
}
|
||||
|
||||
/// Returns grass colour adjusted for temperature and downfall.
|
||||
///
|
||||
/// When `temperature * downfall` is low (cold/dry), the colour is
|
||||
/// muted toward cooler tones. When high (warm/humid), it stays vibrant.
|
||||
pub fn grass_color(biome: &BiomeEntry, temperature: f64, downfall: f64) -> [u8; 3] {
|
||||
let rgb = u32_to_rgb(biome.grass_color);
|
||||
adjust_colour(rgb, temperature, downfall)
|
||||
}
|
||||
|
||||
/// Returns foliage colour adjusted for temperature and downfall.
|
||||
pub fn foliage_color(biome: &BiomeEntry, temperature: f64, downfall: f64) -> [u8; 3] {
|
||||
let rgb = u32_to_rgb(biome.foliage_color);
|
||||
adjust_colour(rgb, temperature, downfall)
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies Minecraft-style temperature/humidity colour adjustment.
|
||||
///
|
||||
/// Low `temp * downfall` mutes the colour (colder/grayer),
|
||||
/// high values preserve the original vibrancy (warmer/greener).
|
||||
fn adjust_colour(rgb: [u8; 3], temperature: f64, downfall: f64) -> [u8; 3] {
|
||||
let temp = temperature.clamp(0.0, 1.0) as f32;
|
||||
let humidity = downfall.clamp(0.0, 1.0) as f32;
|
||||
let factor = (temp * humidity).clamp(0.0, 1.0);
|
||||
|
||||
let [r, g, b] = rgb;
|
||||
[
|
||||
(r as f32 * (0.7 + 0.3 * factor)) as u8,
|
||||
(g as f32 * (0.6 + 0.4 * factor)) as u8,
|
||||
(b as f32 * (0.5 + 0.5 * factor)) as u8,
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::BiomeDb;
|
||||
|
||||
fn sample_biome(name: &str) -> BiomeEntry {
|
||||
let db = BiomeDb::new();
|
||||
db.get_by_name(name).expect("biome must exist").clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_colours_non_zero() {
|
||||
let db = BiomeDb::new();
|
||||
for biome in db.all_biomes() {
|
||||
let water = BiomeColour::water_color(biome);
|
||||
let sky = BiomeColour::sky_color(biome);
|
||||
let fog = BiomeColour::fog_color(biome);
|
||||
let grass =
|
||||
BiomeColour::grass_color(biome, biome.temperature as f64, biome.downfall as f64);
|
||||
let foliage =
|
||||
BiomeColour::foliage_color(biome, biome.temperature as f64, biome.downfall as f64);
|
||||
assert!(
|
||||
water != [0, 0, 0] || biome.name == "the_end" || biome.name.contains("end_"),
|
||||
"water colour should not be zero for {}: {:?}",
|
||||
biome.name,
|
||||
water
|
||||
);
|
||||
let all = [water, sky, fog, grass, foliage];
|
||||
for (i, c) in all.iter().enumerate() {
|
||||
assert_eq!(
|
||||
c.len(),
|
||||
3,
|
||||
"colour {} for {} should have 3 components",
|
||||
i,
|
||||
biome.name
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_plains_colours() {
|
||||
let biome = sample_biome("plains");
|
||||
let water = BiomeColour::water_color(&biome);
|
||||
assert_eq!(water, [63, 118, 228]);
|
||||
let grass = BiomeColour::grass_color(&biome, 0.8, 0.4);
|
||||
assert_eq!(grass.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_desert_colours() {
|
||||
let biome = sample_biome("desert");
|
||||
let sky = BiomeColour::sky_color(&biome);
|
||||
assert_eq!(sky, [110, 177, 255]);
|
||||
let water = BiomeColour::water_color(&biome);
|
||||
assert_eq!(water, [63, 118, 228]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cold_dry_adjustment() {
|
||||
let biome = sample_biome("plains");
|
||||
let warm = BiomeColour::grass_color(&biome, 1.0, 1.0);
|
||||
let cold = BiomeColour::grass_color(&biome, 0.0, 0.0);
|
||||
assert!(
|
||||
warm[0] >= cold[0] && warm[1] >= cold[1] && warm[2] >= cold[2],
|
||||
"warm climate should produce more vibrant colours: warm={:?} cold={:?}",
|
||||
warm,
|
||||
cold
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_swamp_water() {
|
||||
let biome = sample_biome("swamp");
|
||||
let water = BiomeColour::water_color(&biome);
|
||||
assert_eq!(water, [144, 161, 116]);
|
||||
}
|
||||
}
|
||||
118
crates/biome-db/src/db.rs
Executable file
118
crates/biome-db/src/db.rs
Executable file
|
|
@ -0,0 +1,118 @@
|
|||
//! Biome database with Minecraft 1.21 biome data.
|
||||
//! Loaded from embedded JSON via `include_str!`.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BiomeEntry {
|
||||
pub name: String,
|
||||
pub id: i32,
|
||||
pub temperature: f32,
|
||||
pub downfall: f32,
|
||||
pub precipitation: String,
|
||||
pub water_color: u32,
|
||||
pub sky_color: u32,
|
||||
pub grass_color: u32,
|
||||
pub foliage_color: u32,
|
||||
pub fog_color: u32,
|
||||
}
|
||||
|
||||
pub struct BiomeDb {
|
||||
by_name: HashMap<String, BiomeEntry>,
|
||||
by_id: HashMap<i32, BiomeEntry>,
|
||||
}
|
||||
|
||||
impl BiomeDb {
|
||||
pub fn new() -> Self {
|
||||
let data = include_str!("../data/biomes.json");
|
||||
let entries: Vec<BiomeEntry> =
|
||||
serde_json::from_str(data).expect("biomes.json must be valid JSON");
|
||||
let mut by_name = HashMap::new();
|
||||
let mut by_id = HashMap::new();
|
||||
for entry in entries {
|
||||
by_name.insert(entry.name.clone(), entry.clone());
|
||||
by_id.insert(entry.id, entry);
|
||||
}
|
||||
Self { by_name, by_id }
|
||||
}
|
||||
|
||||
pub fn get_by_name(&self, name: &str) -> Option<&BiomeEntry> {
|
||||
self.by_name.get(name)
|
||||
}
|
||||
|
||||
pub fn get_by_id(&self, id: i32) -> Option<&BiomeEntry> {
|
||||
self.by_id.get(&id)
|
||||
}
|
||||
|
||||
pub fn all_biomes(&self) -> impl Iterator<Item = &BiomeEntry> {
|
||||
let mut ids: Vec<_> = self.by_id.keys().copied().collect();
|
||||
ids.sort();
|
||||
ids.into_iter().map(move |id| &self.by_id[&id])
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.by_id.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.by_id.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for BiomeDb {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_load_all_biomes() {
|
||||
let db = BiomeDb::new();
|
||||
assert!(!db.is_empty(), "biome db should not be empty");
|
||||
assert!(db.len() > 60, "expected 60+ biomes, got {}", db.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lookup_by_name() {
|
||||
let db = BiomeDb::new();
|
||||
let plains = db.get_by_name("plains").expect("plains should exist");
|
||||
assert_eq!(plains.id, 1);
|
||||
assert_eq!(plains.temperature, 0.8);
|
||||
|
||||
let desert = db.get_by_name("desert").expect("desert should exist");
|
||||
assert_eq!(desert.id, 2);
|
||||
assert_eq!(desert.precipitation, "none");
|
||||
|
||||
let ocean = db.get_by_name("ocean").expect("ocean should exist");
|
||||
assert_eq!(ocean.id, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lookup_by_id() {
|
||||
let db = BiomeDb::new();
|
||||
let plains = db.get_by_id(1).expect("id 1 should be plains");
|
||||
assert_eq!(plains.name, "plains");
|
||||
|
||||
let desert = db.get_by_id(2).expect("id 2 should be desert");
|
||||
assert_eq!(desert.name, "desert");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unknown_biome() {
|
||||
let db = BiomeDb::new();
|
||||
assert!(db.get_by_name("nonexistent").is_none());
|
||||
assert!(db.get_by_id(9999).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_all_biomes_iter() {
|
||||
let db = BiomeDb::new();
|
||||
let count = db.all_biomes().count();
|
||||
assert_eq!(count, db.len());
|
||||
}
|
||||
}
|
||||
10
crates/biome-db/src/lib.rs
Executable file
10
crates/biome-db/src/lib.rs
Executable file
|
|
@ -0,0 +1,10 @@
|
|||
//! Biome database for Terrafier.
|
||||
//!
|
||||
//! Maps biome IDs to names, colours, and patterns for all supported
|
||||
//! Minecraft versions (1.0 through 1.21+).
|
||||
|
||||
pub mod colour;
|
||||
pub mod db;
|
||||
|
||||
pub use colour::BiomeColour;
|
||||
pub use db::{BiomeDb, BiomeEntry};
|
||||
12
crates/fastanvil/Cargo.toml
Executable file
12
crates/fastanvil/Cargo.toml
Executable 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
|
||||
95
crates/fastanvil/src/compression.rs
Executable file
95
crates/fastanvil/src/compression.rs
Executable 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
289
crates/fastanvil/src/io/chunk.rs
Executable 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
4
crates/fastanvil/src/io/mod.rs
Executable 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
227
crates/fastanvil/src/io/region.rs
Executable 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 §or_data {
|
||||
output.extend(sector);
|
||||
}
|
||||
|
||||
Ok(output)
|
||||
}
|
||||
}
|
||||
15
crates/fastanvil/src/lib.rs
Executable file
15
crates/fastanvil/src/lib.rs
Executable 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;
|
||||
11
crates/nbt/Cargo.toml
Executable file
11
crates/nbt/Cargo.toml
Executable file
|
|
@ -0,0 +1,11 @@
|
|||
[package]
|
||||
name = "terrafier-nbt"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "Zero-copy NBT (Named Binary Tag) parser and writer for Minecraft data"
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true, optional = true }
|
||||
thiserror.workspace = true
|
||||
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
|
||||
4
crates/nbt/src/io/mod.rs
Executable file
4
crates/nbt/src/io/mod.rs
Executable file
|
|
@ -0,0 +1,4 @@
|
|||
//! NBT I/O — binary reader and writer for Java Edition (Big Endian).
|
||||
|
||||
pub mod reader;
|
||||
pub mod writer;
|
||||
230
crates/nbt/src/io/reader.rs
Executable file
230
crates/nbt/src/io/reader.rs
Executable file
|
|
@ -0,0 +1,230 @@
|
|||
//! NBT binary reader for Java Edition (Big Endian).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io::{Cursor, Read};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::tag::Tag;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum ReadError {
|
||||
#[error("IO error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("Unknown tag type: {0}")]
|
||||
UnknownTagType(u8),
|
||||
#[error("Invalid string length: {0}")]
|
||||
InvalidStringLength(usize),
|
||||
#[error("Invalid array length: {0}")]
|
||||
InvalidArrayLength(usize),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, ReadError>;
|
||||
|
||||
pub fn read_bytes(data: &[u8]) -> Result<Tag> {
|
||||
let cursor = Cursor::new(data);
|
||||
let mut de = NbtReader::new(cursor);
|
||||
de.read_tag_compound_root()
|
||||
}
|
||||
|
||||
pub fn read_gzip(data: &[u8]) -> Result<Tag> {
|
||||
let mut dec = flate2::read::GzDecoder::new(data);
|
||||
let mut buf = Vec::new();
|
||||
dec.read_to_end(&mut buf)?;
|
||||
read_bytes(&buf)
|
||||
}
|
||||
|
||||
struct NbtReader<R: Read> {
|
||||
inner: R,
|
||||
buf: Vec<u8>,
|
||||
}
|
||||
|
||||
impl<R: Read> NbtReader<R> {
|
||||
fn new(inner: R) -> Self {
|
||||
Self {
|
||||
inner,
|
||||
buf: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn read_exact(&mut self, len: usize) -> Result<&[u8]> {
|
||||
self.buf.clear();
|
||||
self.buf.resize(len, 0);
|
||||
self.inner.read_exact(&mut self.buf)?;
|
||||
Ok(&self.buf)
|
||||
}
|
||||
|
||||
fn read_u8(&mut self) -> Result<u8> {
|
||||
let mut byte = [0u8; 1];
|
||||
self.inner.read_exact(&mut byte)?;
|
||||
Ok(byte[0])
|
||||
}
|
||||
|
||||
fn read_i16_be(&mut self) -> Result<i16> {
|
||||
let b = self.read_exact(2)?;
|
||||
Ok(i16::from_be_bytes([b[0], b[1]]))
|
||||
}
|
||||
|
||||
fn read_i32_be(&mut self) -> Result<i32> {
|
||||
let b = self.read_exact(4)?;
|
||||
Ok(i32::from_be_bytes([b[0], b[1], b[2], b[3]]))
|
||||
}
|
||||
|
||||
fn read_i64_be(&mut self) -> Result<i64> {
|
||||
let b = self.read_exact(8)?;
|
||||
Ok(i64::from_be_bytes([
|
||||
b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7],
|
||||
]))
|
||||
}
|
||||
|
||||
fn read_f32_be(&mut self) -> Result<f32> {
|
||||
let b = self.read_exact(4)?;
|
||||
Ok(f32::from_be_bytes([b[0], b[1], b[2], b[3]]))
|
||||
}
|
||||
|
||||
fn read_f64_be(&mut self) -> Result<f64> {
|
||||
let b = self.read_exact(8)?;
|
||||
Ok(f64::from_be_bytes([
|
||||
b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7],
|
||||
]))
|
||||
}
|
||||
|
||||
fn read_string(&mut self) -> Result<String> {
|
||||
let len = self.read_i16_be()? as u16 as usize;
|
||||
let bytes = self.read_exact(len)?.to_vec();
|
||||
String::from_utf8(bytes).map_err(|_| ReadError::InvalidStringLength(len))
|
||||
}
|
||||
|
||||
fn read_tag_payload(&mut self, tag_type: u8) -> Result<Tag> {
|
||||
match tag_type {
|
||||
0 => Ok(Tag::End),
|
||||
1 => Ok(Tag::Byte(self.read_u8()? as i8)),
|
||||
2 => Ok(Tag::Short(self.read_i16_be()?)),
|
||||
3 => Ok(Tag::Int(self.read_i32_be()?)),
|
||||
4 => Ok(Tag::Long(self.read_i64_be()?)),
|
||||
5 => Ok(Tag::Float(self.read_f32_be()?)),
|
||||
6 => Ok(Tag::Double(self.read_f64_be()?)),
|
||||
7 => Ok(Tag::String(self.read_string()?)),
|
||||
8 => {
|
||||
let elem_type = self.read_u8()?;
|
||||
let len = self.read_i32_be()? as usize;
|
||||
let mut items = Vec::with_capacity(len);
|
||||
for _ in 0..len {
|
||||
items.push(self.read_tag_payload(elem_type)?);
|
||||
}
|
||||
Ok(Tag::List(items))
|
||||
}
|
||||
9 => {
|
||||
let mut map = HashMap::new();
|
||||
loop {
|
||||
let t = self.read_u8()?;
|
||||
if t == 0 {
|
||||
break;
|
||||
}
|
||||
let name = self.read_string()?;
|
||||
let val = self.read_tag_payload(t)?;
|
||||
map.insert(name, val);
|
||||
}
|
||||
Ok(Tag::Compound(map))
|
||||
}
|
||||
10 => {
|
||||
let len = self.read_i32_be()? as usize;
|
||||
let bytes = self.read_exact(len)?.to_vec();
|
||||
Ok(Tag::ByteArray(bytes.into_iter().map(|b| b as i8).collect()))
|
||||
}
|
||||
11 => {
|
||||
let len = self.read_i32_be()? as usize;
|
||||
let mut vals = Vec::with_capacity(len);
|
||||
for _ in 0..len {
|
||||
vals.push(self.read_i32_be()?);
|
||||
}
|
||||
Ok(Tag::IntArray(vals))
|
||||
}
|
||||
12 => {
|
||||
let len = self.read_i32_be()? as usize;
|
||||
let mut vals = Vec::with_capacity(len);
|
||||
for _ in 0..len {
|
||||
vals.push(self.read_i64_be()?);
|
||||
}
|
||||
Ok(Tag::LongArray(vals))
|
||||
}
|
||||
_ => Err(ReadError::UnknownTagType(tag_type)),
|
||||
}
|
||||
}
|
||||
|
||||
fn read_tag_compound_root(&mut self) -> Result<Tag> {
|
||||
let t = self.read_u8()?;
|
||||
if t == 0 {
|
||||
return Ok(Tag::Compound(HashMap::new()));
|
||||
}
|
||||
if t != 9 {
|
||||
return Err(ReadError::UnknownTagType(t));
|
||||
}
|
||||
let _name = self.read_string()?;
|
||||
self.read_tag_payload(9)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn root_envelope(data: &[u8]) -> Vec<u8> {
|
||||
let mut buf = vec![0x09, 0x00, 0x00];
|
||||
buf.extend(data);
|
||||
buf.push(0x00);
|
||||
buf
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_byte() {
|
||||
let payload = vec![0x01, 0x00, 0x04, b't', b'e', b's', b't', 0x2a, 0x00];
|
||||
let tag = read_bytes(&root_envelope(&payload)).unwrap();
|
||||
let expected = Tag::Compound(HashMap::from([("test".into(), Tag::Byte(42))]));
|
||||
assert_eq!(tag, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_string() {
|
||||
let payload = vec![
|
||||
0x07, 0x00, 0x04, b'n', b'a', b'm', b'e', 0x00, 0x05, b'H', b'e', b'l', b'l', b'o',
|
||||
];
|
||||
let tag = read_bytes(&root_envelope(&payload)).unwrap();
|
||||
let expected = Tag::Compound(HashMap::from([(
|
||||
"name".into(),
|
||||
Tag::String("Hello".into()),
|
||||
)]));
|
||||
assert_eq!(tag, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_compound_nested() {
|
||||
let inner = vec![0x01, 0x00, 0x03, b'k', b'e', b'y', 0x07, 0x00];
|
||||
let mut payload = vec![0x09, 0x00, 0x05, b'c', b'h', b'i', b'l', b'd'];
|
||||
payload.extend(inner);
|
||||
payload.push(0x00);
|
||||
let tag = read_bytes(&root_envelope(&payload)).unwrap();
|
||||
let inner_map = HashMap::from([("key".into(), Tag::Byte(7))]);
|
||||
let expected = Tag::Compound(HashMap::from([("child".into(), Tag::Compound(inner_map))]));
|
||||
assert_eq!(tag, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_int_array() {
|
||||
let mut payload = vec![0x0b, 0x00, 0x03, b'a', b'r', b'r', 0x00, 0x00, 0x00, 0x02];
|
||||
payload.extend(&[0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x02]);
|
||||
payload.push(0x00);
|
||||
let tag = read_bytes(&root_envelope(&payload)).unwrap();
|
||||
let expected = Tag::Compound(HashMap::from([("arr".into(), Tag::IntArray(vec![1, 2]))]));
|
||||
assert_eq!(tag, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_long_array() {
|
||||
let mut payload = vec![0x0c, 0x00, 0x03, b'l', b'n', b'g', 0x00, 0x00, 0x00, 0x01];
|
||||
payload.extend(&[0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x2a]);
|
||||
payload.push(0x00);
|
||||
let tag = read_bytes(&root_envelope(&payload)).unwrap();
|
||||
let expected = Tag::Compound(HashMap::from([("lng".into(), Tag::LongArray(vec![42]))]));
|
||||
assert_eq!(tag, expected);
|
||||
}
|
||||
}
|
||||
227
crates/nbt/src/io/writer.rs
Executable file
227
crates/nbt/src/io/writer.rs
Executable file
|
|
@ -0,0 +1,227 @@
|
|||
//! NBT binary writer for Java Edition (Big Endian).
|
||||
|
||||
use std::io::Write;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::tag::Tag;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum WriteError {
|
||||
#[error("IO error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("Unsupported tag type in list: {0}")]
|
||||
UnsupportedListType(u8),
|
||||
#[error("Empty list cannot determine element type")]
|
||||
EmptyList,
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, WriteError>;
|
||||
|
||||
/// Serialize a Tag tree to bytes (Big Endian, no compression).
|
||||
pub fn to_bytes(tag: &Tag) -> Result<Vec<u8>> {
|
||||
let mut buf = Vec::new();
|
||||
let mut w = NbtWriter::new(&mut buf);
|
||||
w.write_tag_compound_root(tag)?;
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
/// Serialize a Tag tree to gzip-compressed bytes.
|
||||
pub fn to_gzip_bytes(tag: &Tag) -> Result<Vec<u8>> {
|
||||
let raw = to_bytes(tag)?;
|
||||
let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
encoder.write_all(&raw)?;
|
||||
Ok(encoder.finish()?)
|
||||
}
|
||||
|
||||
struct NbtWriter<W: Write> {
|
||||
inner: W,
|
||||
}
|
||||
|
||||
impl<W: Write> NbtWriter<W> {
|
||||
fn new(inner: W) -> Self {
|
||||
Self { inner }
|
||||
}
|
||||
|
||||
fn write_u8(&mut self, val: u8) -> Result<()> {
|
||||
self.inner.write_all(&[val])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_i16_be(&mut self, val: i16) -> Result<()> {
|
||||
self.inner.write_all(&val.to_be_bytes())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_i32_be(&mut self, val: i32) -> Result<()> {
|
||||
self.inner.write_all(&val.to_be_bytes())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_i64_be(&mut self, val: i64) -> Result<()> {
|
||||
self.inner.write_all(&val.to_be_bytes())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_f32_be(&mut self, val: f32) -> Result<()> {
|
||||
self.inner.write_all(&val.to_be_bytes())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_f64_be(&mut self, val: f64) -> Result<()> {
|
||||
self.inner.write_all(&val.to_be_bytes())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_string(&mut self, s: &str) -> Result<()> {
|
||||
let bytes = s.as_bytes();
|
||||
if bytes.len() > u16::MAX as usize {
|
||||
return Err(WriteError::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"String too long for NBT",
|
||||
)));
|
||||
}
|
||||
self.write_i16_be(bytes.len() as i16)?;
|
||||
self.inner.write_all(bytes)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_tag(&mut self, tag: &Tag, name: Option<&str>) -> Result<()> {
|
||||
self.write_u8(tag.id())?;
|
||||
if let Some(n) = name {
|
||||
self.write_string(n)?;
|
||||
}
|
||||
self.write_tag_payload(tag)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_tag_payload(&mut self, tag: &Tag) -> Result<()> {
|
||||
match tag {
|
||||
Tag::End => {}
|
||||
Tag::Byte(v) => self.write_u8(*v as u8)?,
|
||||
Tag::Short(v) => self.write_i16_be(*v)?,
|
||||
Tag::Int(v) => self.write_i32_be(*v)?,
|
||||
Tag::Long(v) => self.write_i64_be(*v)?,
|
||||
Tag::Float(v) => self.write_f32_be(*v)?,
|
||||
Tag::Double(v) => self.write_f64_be(*v)?,
|
||||
Tag::String(v) => self.write_string(v)?,
|
||||
Tag::List(items) => {
|
||||
if items.is_empty() {
|
||||
self.write_u8(1)?; // TAG_Byte as fallback
|
||||
self.write_i32_be(0)?;
|
||||
} else {
|
||||
let elem_type = items[0].id();
|
||||
self.write_u8(elem_type)?;
|
||||
self.write_i32_be(items.len() as i32)?;
|
||||
for item in items {
|
||||
self.write_tag_payload(item)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Tag::Compound(map) => {
|
||||
let mut keys: Vec<&String> = map.keys().collect();
|
||||
keys.sort();
|
||||
for key in keys {
|
||||
if let Some(val) = map.get(key) {
|
||||
self.write_tag(val, Some(key))?;
|
||||
}
|
||||
}
|
||||
self.write_u8(0)?; // TAG_End
|
||||
}
|
||||
Tag::ByteArray(v) => {
|
||||
self.write_i32_be(v.len() as i32)?;
|
||||
for b in v {
|
||||
self.inner.write_all(&[*b as u8])?;
|
||||
}
|
||||
}
|
||||
Tag::IntArray(v) => {
|
||||
self.write_i32_be(v.len() as i32)?;
|
||||
for n in v {
|
||||
self.write_i32_be(*n)?;
|
||||
}
|
||||
}
|
||||
Tag::LongArray(v) => {
|
||||
self.write_i32_be(v.len() as i32)?;
|
||||
for n in v {
|
||||
self.write_i64_be(*n)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_tag_compound_root(&mut self, tag: &Tag) -> Result<()> {
|
||||
match tag {
|
||||
Tag::Compound(map) => {
|
||||
self.write_u8(9)?; // TAG_Compound
|
||||
self.write_string("")?; // empty root name
|
||||
let mut keys: Vec<&String> = map.keys().collect();
|
||||
keys.sort();
|
||||
for key in keys {
|
||||
if let Some(val) = map.get(key) {
|
||||
self.write_tag(val, Some(key))?;
|
||||
}
|
||||
}
|
||||
self.write_u8(0)?; // TAG_End
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(WriteError::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"Root tag must be Compound",
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::io::reader;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[test]
|
||||
fn test_roundtrip_byte() {
|
||||
let tag = Tag::Compound(HashMap::from([("val".into(), Tag::Byte(42))]));
|
||||
let bytes = to_bytes(&tag).unwrap();
|
||||
let parsed = reader::read_bytes(&bytes).unwrap();
|
||||
assert_eq!(tag, parsed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_roundtrip_nested() {
|
||||
let inner = Tag::Compound(HashMap::from([
|
||||
("x".into(), Tag::Int(100)),
|
||||
("y".into(), Tag::Int(200)),
|
||||
]));
|
||||
let tag = Tag::Compound(HashMap::from([("pos".into(), inner)]));
|
||||
let bytes = to_bytes(&tag).unwrap();
|
||||
let parsed = reader::read_bytes(&bytes).unwrap();
|
||||
assert_eq!(tag, parsed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_roundtrip_all_types() {
|
||||
let mut map = HashMap::new();
|
||||
map.insert("byte".into(), Tag::Byte(1));
|
||||
map.insert("short".into(), Tag::Short(2));
|
||||
map.insert("int".into(), Tag::Int(3));
|
||||
map.insert("long".into(), Tag::Long(4));
|
||||
map.insert("float".into(), Tag::Float(5.0));
|
||||
map.insert("double".into(), Tag::Double(6.0));
|
||||
map.insert("string".into(), Tag::String("hello".into()));
|
||||
map.insert("bytearray".into(), Tag::ByteArray(vec![1, 2, 3]));
|
||||
map.insert("intarray".into(), Tag::IntArray(vec![4, 5, 6]));
|
||||
map.insert("longarray".into(), Tag::LongArray(vec![7, 8, 9]));
|
||||
let tag = Tag::Compound(map);
|
||||
let bytes = to_bytes(&tag).unwrap();
|
||||
let parsed = reader::read_bytes(&bytes).unwrap();
|
||||
assert_eq!(tag, parsed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_gzip_roundtrip() {
|
||||
let tag = Tag::Compound(HashMap::from([("val".into(), Tag::Int(12345))]));
|
||||
let gz = to_gzip_bytes(&tag).unwrap();
|
||||
let parsed = reader::read_gzip(&gz).unwrap();
|
||||
assert_eq!(tag, parsed);
|
||||
}
|
||||
}
|
||||
20
crates/nbt/src/lib.rs
Executable file
20
crates/nbt/src/lib.rs
Executable file
|
|
@ -0,0 +1,20 @@
|
|||
//! Zero-copy NBT (Named Binary Tag) parser and writer.
|
||||
//!
|
||||
//! Implements the Minecraft NBT format with optional serde support.
|
||||
//! Uses zero-copy deserialization where possible to minimize allocations.
|
||||
//!
|
||||
//! ## Format support
|
||||
//!
|
||||
//! - All tag types: Byte, Short, Int, Long, Float, Double, String,
|
||||
//! List, Compound, IntArray, LongArray, ByteArray
|
||||
//! - GZip compressed streams
|
||||
//! - Java edition (Big Endian) and Bedrock edition (Little Endian) variants
|
||||
|
||||
#![deny(unsafe_code)]
|
||||
|
||||
pub mod io;
|
||||
pub mod tag;
|
||||
|
||||
pub use io::reader;
|
||||
pub use io::writer;
|
||||
pub use tag::Tag;
|
||||
58
crates/nbt/src/tag.rs
Executable file
58
crates/nbt/src/tag.rs
Executable file
|
|
@ -0,0 +1,58 @@
|
|||
//! NBT tag types.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Tag {
|
||||
End,
|
||||
Byte(i8),
|
||||
Short(i16),
|
||||
Int(i32),
|
||||
Long(i64),
|
||||
Float(f32),
|
||||
Double(f64),
|
||||
String(String),
|
||||
List(Vec<Tag>),
|
||||
Compound(HashMap<String, Tag>),
|
||||
ByteArray(Vec<i8>),
|
||||
IntArray(Vec<i32>),
|
||||
LongArray(Vec<i64>),
|
||||
}
|
||||
|
||||
impl Tag {
|
||||
pub fn name(&self) -> &'static str {
|
||||
match self {
|
||||
Tag::End => "TAG_End",
|
||||
Tag::Byte(_) => "TAG_Byte",
|
||||
Tag::Short(_) => "TAG_Short",
|
||||
Tag::Int(_) => "TAG_Int",
|
||||
Tag::Long(_) => "TAG_Long",
|
||||
Tag::Float(_) => "TAG_Float",
|
||||
Tag::Double(_) => "TAG_Double",
|
||||
Tag::String(_) => "TAG_String",
|
||||
Tag::List(_) => "TAG_List",
|
||||
Tag::Compound(_) => "TAG_Compound",
|
||||
Tag::ByteArray(_) => "TAG_Byte_Array",
|
||||
Tag::IntArray(_) => "TAG_Int_Array",
|
||||
Tag::LongArray(_) => "TAG_Long_Array",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn id(&self) -> u8 {
|
||||
match self {
|
||||
Tag::End => 0,
|
||||
Tag::Byte(_) => 1,
|
||||
Tag::Short(_) => 2,
|
||||
Tag::Int(_) => 3,
|
||||
Tag::Long(_) => 4,
|
||||
Tag::Float(_) => 5,
|
||||
Tag::Double(_) => 6,
|
||||
Tag::String(_) => 7,
|
||||
Tag::List(_) => 8,
|
||||
Tag::Compound(_) => 9,
|
||||
Tag::ByteArray(_) => 10,
|
||||
Tag::IntArray(_) => 11,
|
||||
Tag::LongArray(_) => 12,
|
||||
}
|
||||
}
|
||||
}
|
||||
9
crates/noise/Cargo.toml
Executable file
9
crates/noise/Cargo.toml
Executable file
|
|
@ -0,0 +1,9 @@
|
|||
[package]
|
||||
name = "terrafier-noise"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "Noise generation for Terrafier — Perlin, Simplex, fractal noise"
|
||||
|
||||
[dependencies]
|
||||
noise = { package = "noise", version = "0.8" }
|
||||
5
crates/noise/src/lib.rs
Executable file
5
crates/noise/src/lib.rs
Executable file
|
|
@ -0,0 +1,5 @@
|
|||
//! Noise generation for Terrafier.
|
||||
//!
|
||||
//! Wraps the `noise` crate with Terrafier-specific types and utilities.
|
||||
|
||||
pub use noise::*;
|
||||
11
crates/palette-compress/Cargo.toml
Executable file
11
crates/palette-compress/Cargo.toml
Executable file
|
|
@ -0,0 +1,11 @@
|
|||
[package]
|
||||
name = "terrafier-palette-compress"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "Block palette compression for Anvil format"
|
||||
|
||||
[dependencies]
|
||||
terrafier-nbt = { path = "../nbt" }
|
||||
serde.workspace = true
|
||||
thiserror.workspace = true
|
||||
112
crates/palette-compress/src/bits.rs
Executable file
112
crates/palette-compress/src/bits.rs
Executable file
|
|
@ -0,0 +1,112 @@
|
|||
/// Compact storage of fixed-size non-negative integers in a `Vec<i64>`.
|
||||
///
|
||||
/// Minecraft Anvil stores block palette indices as packed integers.
|
||||
/// N bits per entry, entries packed consecutively into 64-bit longs.
|
||||
/// Entries never cross long boundaries.
|
||||
pub struct BitArray {
|
||||
pub data: Vec<i64>,
|
||||
pub bits_per_entry: u8,
|
||||
pub size: usize,
|
||||
}
|
||||
|
||||
impl BitArray {
|
||||
/// Create a new `BitArray` with all entries initialized to 0.
|
||||
pub fn new(size: usize, bits_per_entry: u8) -> Self {
|
||||
assert!(
|
||||
bits_per_entry > 0 && bits_per_entry <= 64,
|
||||
"bits_per_entry must be 1..=64"
|
||||
);
|
||||
let entries_per_long = 64 / bits_per_entry as usize;
|
||||
let data_len = size.div_ceil(entries_per_long);
|
||||
BitArray {
|
||||
data: vec![0i64; data_len],
|
||||
bits_per_entry,
|
||||
size,
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrap existing raw data as a `BitArray`.
|
||||
pub fn from_raw(data: Vec<i64>, bits_per_entry: u8, size: usize) -> Self {
|
||||
BitArray {
|
||||
data,
|
||||
bits_per_entry,
|
||||
size,
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the value at `index`.
|
||||
pub fn get(&self, index: usize) -> i64 {
|
||||
assert!(index < self.size, "index out of bounds");
|
||||
let epb = 64 / self.bits_per_entry as usize;
|
||||
let long_idx = index / epb;
|
||||
let offset = (index % epb) * self.bits_per_entry as usize;
|
||||
let mask = (1i64 << self.bits_per_entry) - 1;
|
||||
(self.data[long_idx] >> offset) & mask
|
||||
}
|
||||
|
||||
/// Write `value` at `index`.
|
||||
pub fn set(&mut self, index: usize, value: i64) {
|
||||
assert!(index < self.size, "index out of bounds");
|
||||
let epb = 64 / self.bits_per_entry as usize;
|
||||
let long_idx = index / epb;
|
||||
let offset = (index % epb) * self.bits_per_entry as usize;
|
||||
let mask = (1i64 << self.bits_per_entry) - 1;
|
||||
self.data[long_idx] =
|
||||
(self.data[long_idx] & !(mask << offset)) | ((value & mask) << offset);
|
||||
}
|
||||
|
||||
/// Borrow the raw underlying long array.
|
||||
pub fn to_raw(&self) -> &[i64] {
|
||||
&self.data
|
||||
}
|
||||
|
||||
/// Minimum bits needed to represent `count` distinct values.
|
||||
///
|
||||
/// Minecraft requires at least 1 bit per entry, even for a single block.
|
||||
pub fn bits_needed(count: usize) -> u8 {
|
||||
if count <= 2 {
|
||||
return 1; // 0 and 1 both fit in 1 bit; 2 values → 1 bit
|
||||
}
|
||||
let count = count as u64;
|
||||
(64 - count.saturating_sub(1).leading_zeros()) as u8
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_pack_unpack() {
|
||||
let mut ba = BitArray::new(10, 4);
|
||||
for i in 0..10 {
|
||||
ba.set(i, (i % 15) as i64);
|
||||
}
|
||||
for i in 0..10 {
|
||||
assert_eq!(ba.get(i), (i % 15) as i64);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bits_needed() {
|
||||
assert_eq!(BitArray::bits_needed(0), 1);
|
||||
assert_eq!(BitArray::bits_needed(1), 1);
|
||||
assert_eq!(BitArray::bits_needed(2), 1);
|
||||
assert_eq!(BitArray::bits_needed(3), 2);
|
||||
assert_eq!(BitArray::bits_needed(4), 2);
|
||||
assert_eq!(BitArray::bits_needed(8), 3);
|
||||
assert_eq!(BitArray::bits_needed(16), 4);
|
||||
assert_eq!(BitArray::bits_needed(256), 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_large_array() {
|
||||
let mut ba = BitArray::new(4096, 13);
|
||||
for i in 0..4096 {
|
||||
ba.set(i, (i % 8191) as i64);
|
||||
}
|
||||
for i in 0..4096 {
|
||||
assert_eq!(ba.get(i), (i % 8191) as i64);
|
||||
}
|
||||
}
|
||||
}
|
||||
14
crates/palette-compress/src/lib.rs
Executable file
14
crates/palette-compress/src/lib.rs
Executable file
|
|
@ -0,0 +1,14 @@
|
|||
//! Block palette compression for Minecraft Anvil format.
|
||||
//!
|
||||
//! Minecraft 1.13+ uses a palette-based block storage format where each
|
||||
//! section stores a list of unique block states (palette) and indices
|
||||
//! into that palette (packed into a BitArray). This crate provides
|
||||
//! the building blocks for reading and writing compressed block data.
|
||||
|
||||
pub mod bits;
|
||||
pub mod palette;
|
||||
pub mod section;
|
||||
|
||||
pub use bits::BitArray;
|
||||
pub use palette::{BlockPalette, BlockState};
|
||||
pub use section::SectionData;
|
||||
203
crates/palette-compress/src/palette.rs
Executable file
203
crates/palette-compress/src/palette.rs
Executable file
|
|
@ -0,0 +1,203 @@
|
|||
use std::collections::HashMap;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use terrafier_nbt::Tag;
|
||||
|
||||
/// A Minecraft block state — name plus key-value properties.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct BlockState {
|
||||
pub name: String,
|
||||
pub properties: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl Hash for BlockState {
|
||||
fn hash<H: Hasher>(&self, state: &mut H) {
|
||||
self.name.hash(state);
|
||||
let mut keys: Vec<&String> = self.properties.keys().collect();
|
||||
keys.sort();
|
||||
for k in keys {
|
||||
k.hash(state);
|
||||
self.properties[k].hash(state);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BlockState {
|
||||
pub fn new(name: &str) -> Self {
|
||||
BlockState {
|
||||
name: name.to_string(),
|
||||
properties: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_property(mut self, key: &str, val: &str) -> Self {
|
||||
self.properties.insert(key.to_string(), val.to_string());
|
||||
self
|
||||
}
|
||||
|
||||
/// Parse from `TAG_Compound` with `"Name"` and optional `"Properties"`.
|
||||
pub fn from_nbt(tag: &Tag) -> Option<Self> {
|
||||
let map = match tag {
|
||||
Tag::Compound(m) => m,
|
||||
_ => return None,
|
||||
};
|
||||
let name = match map.get("Name")? {
|
||||
Tag::String(s) => s.clone(),
|
||||
_ => return None,
|
||||
};
|
||||
let properties = match map.get("Properties") {
|
||||
Some(Tag::Compound(props)) => props
|
||||
.iter()
|
||||
.map(|(k, v)| {
|
||||
(
|
||||
k.clone(),
|
||||
match v {
|
||||
Tag::String(s) => s.clone(),
|
||||
_ => String::new(),
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
_ => HashMap::new(),
|
||||
};
|
||||
Some(BlockState { name, properties })
|
||||
}
|
||||
|
||||
/// Serialize to `TAG_Compound`.
|
||||
pub fn to_nbt(&self) -> Tag {
|
||||
let mut map = HashMap::new();
|
||||
map.insert("Name".into(), Tag::String(self.name.clone()));
|
||||
if !self.properties.is_empty() {
|
||||
let props = self
|
||||
.properties
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), Tag::String(v.clone())))
|
||||
.collect();
|
||||
map.insert("Properties".into(), Tag::Compound(props));
|
||||
}
|
||||
Tag::Compound(map)
|
||||
}
|
||||
}
|
||||
|
||||
/// A palette mapping unique `BlockState`s to compact indices.
|
||||
pub struct BlockPalette {
|
||||
pub entries: Vec<BlockState>,
|
||||
index_map: HashMap<BlockState, u32>,
|
||||
}
|
||||
|
||||
impl BlockPalette {
|
||||
pub fn new() -> Self {
|
||||
BlockPalette {
|
||||
entries: Vec::new(),
|
||||
index_map: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a block state, or return its existing index.
|
||||
pub fn add_or_get(&mut self, state: BlockState) -> u32 {
|
||||
if let Some(&idx) = self.index_map.get(&state) {
|
||||
return idx;
|
||||
}
|
||||
let idx = self.entries.len() as u32;
|
||||
self.index_map.insert(state.clone(), idx);
|
||||
self.entries.push(state);
|
||||
idx
|
||||
}
|
||||
|
||||
pub fn get(&self, index: u32) -> Option<&BlockState> {
|
||||
self.entries.get(index as usize)
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.entries.len()
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.entries.is_empty()
|
||||
}
|
||||
|
||||
/// Minimum bits per entry needed for this palette.
|
||||
pub fn bits_per_entry(&self) -> u8 {
|
||||
crate::bits::BitArray::bits_needed(self.entries.len())
|
||||
}
|
||||
|
||||
/// Import from NBT palette list + block data `LongArray`.
|
||||
pub fn from_nbt(palette_list: &[Tag], data: &[i64]) -> (Self, crate::bits::BitArray) {
|
||||
let mut palette = BlockPalette::new();
|
||||
for tag in palette_list {
|
||||
if let Some(state) = BlockState::from_nbt(tag) {
|
||||
palette.add_or_get(state);
|
||||
}
|
||||
}
|
||||
let bpe = palette.bits_per_entry();
|
||||
(
|
||||
palette,
|
||||
crate::bits::BitArray::from_raw(data.to_vec(), bpe, 4096),
|
||||
)
|
||||
}
|
||||
|
||||
/// Export to NBT palette list + block data `LongArray`.
|
||||
pub fn to_nbt(&self, bitarray: &crate::bits::BitArray) -> (Vec<Tag>, Vec<i64>) {
|
||||
let list: Vec<Tag> = self.entries.iter().map(|s| s.to_nbt()).collect();
|
||||
(list, bitarray.data.clone())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_add_get() {
|
||||
let mut p = BlockPalette::new();
|
||||
let air = BlockState::new("minecraft:air");
|
||||
let stone = BlockState::new("minecraft:stone");
|
||||
assert_eq!(p.add_or_get(air.clone()), 0);
|
||||
assert_eq!(p.add_or_get(stone.clone()), 1);
|
||||
assert_eq!(p.add_or_get(air.clone()), 0);
|
||||
assert_eq!(p.get(0), Some(&air));
|
||||
assert_eq!(p.get(1), Some(&stone));
|
||||
assert!(p.get(99).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_nbt() {
|
||||
let s = BlockState::new("minecraft:grass_block").with_property("snowy", "true");
|
||||
assert_eq!(BlockState::from_nbt(&s.to_nbt()).unwrap(), s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_nbt() {
|
||||
let nbt = BlockState::new("minecraft:stone").to_nbt();
|
||||
let map = match nbt {
|
||||
Tag::Compound(ref m) => m,
|
||||
_ => panic!("expected compound"),
|
||||
};
|
||||
let name = match map.get("Name").unwrap() {
|
||||
Tag::String(s) => s.as_str(),
|
||||
_ => panic!("expected string"),
|
||||
};
|
||||
assert_eq!(name, "minecraft:stone");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_roundtrip() {
|
||||
let states = vec![
|
||||
BlockState::new("minecraft:air"),
|
||||
BlockState::new("minecraft:stone"),
|
||||
BlockState::new("minecraft:grass_block").with_property("snowy", "true"),
|
||||
];
|
||||
let mut palette = BlockPalette::new();
|
||||
for s in &states {
|
||||
palette.add_or_get(s.clone());
|
||||
}
|
||||
let mut ba = crate::bits::BitArray::new(4096, palette.bits_per_entry());
|
||||
for i in 0..10 {
|
||||
ba.set(i, (i % 3) as i64);
|
||||
}
|
||||
let (list, data) = palette.to_nbt(&ba);
|
||||
let (palette2, ba2) = BlockPalette::from_nbt(&list, &data);
|
||||
assert_eq!(palette.len(), palette2.len());
|
||||
for i in 0..10 {
|
||||
assert_eq!(ba.get(i), ba2.get(i));
|
||||
}
|
||||
}
|
||||
}
|
||||
163
crates/palette-compress/src/section.rs
Executable file
163
crates/palette-compress/src/section.rs
Executable file
|
|
@ -0,0 +1,163 @@
|
|||
use std::collections::HashMap;
|
||||
use terrafier_nbt::Tag;
|
||||
|
||||
use crate::bits::BitArray;
|
||||
use crate::palette::{BlockPalette, BlockState};
|
||||
|
||||
/// A 16×16×16 chunk section with palette-based block storage.
|
||||
pub struct SectionData {
|
||||
pub palette: BlockPalette,
|
||||
pub storage: BitArray,
|
||||
}
|
||||
|
||||
impl SectionData {
|
||||
/// Create a new section filled with air.
|
||||
pub fn new() -> Self {
|
||||
let mut palette = BlockPalette::new();
|
||||
let air = BlockState::new("minecraft:air");
|
||||
palette.add_or_get(air);
|
||||
let bpe = palette.bits_per_entry();
|
||||
let storage = BitArray::new(4096, bpe);
|
||||
// index 0 = air
|
||||
SectionData { palette, storage }
|
||||
}
|
||||
|
||||
/// Get the block at the given section-local coordinates.
|
||||
///
|
||||
/// Coordinates must be in 0..16.
|
||||
pub fn get_block(&self, x: u8, y: u8, z: u8) -> Option<&BlockState> {
|
||||
let idx = index(x, y, z);
|
||||
let pal_idx = self.storage.get(idx) as u32;
|
||||
self.palette.get(pal_idx)
|
||||
}
|
||||
|
||||
/// Set the block at the given section-local coordinates.
|
||||
///
|
||||
/// Coordinates must be in 0..16.
|
||||
pub fn set_block(&mut self, x: u8, y: u8, z: u8, block: BlockState) {
|
||||
let idx = index(x, y, z);
|
||||
let pal_idx = self.palette.add_or_get(block);
|
||||
// Grow storage if bits_per_entry changed
|
||||
let new_bpe = self.palette.bits_per_entry();
|
||||
if new_bpe != self.storage.bits_per_entry {
|
||||
let mut new_storage = BitArray::new(4096, new_bpe);
|
||||
for i in 0..4096 {
|
||||
let v = self.storage.get(i);
|
||||
new_storage.set(i, v);
|
||||
}
|
||||
self.storage = new_storage;
|
||||
}
|
||||
self.storage.set(idx, pal_idx as i64);
|
||||
}
|
||||
|
||||
/// Fill the entire section with one block type.
|
||||
pub fn fill(&mut self, block: BlockState) {
|
||||
let pal_idx = self.palette.add_or_get(block);
|
||||
let bpe = self.palette.bits_per_entry();
|
||||
self.storage = BitArray::new(4096, bpe);
|
||||
// Set all entries to pal_idx
|
||||
for i in 0..4096 {
|
||||
self.storage.set(i, pal_idx as i64);
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of unique block states in the palette.
|
||||
pub fn palette_size(&self) -> usize {
|
||||
self.palette.len()
|
||||
}
|
||||
|
||||
/// Parse from an NBT `TAG_Compound` representing a section.
|
||||
///
|
||||
/// Expects `"BlockStates"` (LongArray) and `"Palette"` (List of Compound).
|
||||
pub fn from_nbt(tag: &Tag) -> Option<Self> {
|
||||
let compound = match tag {
|
||||
Tag::Compound(map) => map,
|
||||
_ => return None,
|
||||
};
|
||||
let block_states = match compound.get("BlockStates")? {
|
||||
Tag::LongArray(arr) => arr,
|
||||
_ => return None,
|
||||
};
|
||||
let palette_list = match compound.get("Palette")? {
|
||||
Tag::List(list) => list,
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
let (palette, storage) = BlockPalette::from_nbt(palette_list, block_states);
|
||||
Some(SectionData { palette, storage })
|
||||
}
|
||||
|
||||
/// Serialize to an NBT `TAG_Compound`.
|
||||
pub fn to_nbt(&self) -> Tag {
|
||||
let (palette_list, block_states) = self.palette.to_nbt(&self.storage);
|
||||
let mut map = HashMap::new();
|
||||
map.insert("BlockStates".to_string(), Tag::LongArray(block_states));
|
||||
map.insert("Palette".to_string(), Tag::List(palette_list));
|
||||
Tag::Compound(map)
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert section-local (x, y, z) to linear index.
|
||||
///
|
||||
/// Minecraft order: y → z → x
|
||||
fn index(x: u8, y: u8, z: u8) -> usize {
|
||||
(y as usize) * 256 + (z as usize) * 16 + (x as usize)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_new_section() {
|
||||
let sec = SectionData::new();
|
||||
assert_eq!(sec.palette_size(), 1);
|
||||
assert_eq!(
|
||||
sec.get_block(0, 0, 0),
|
||||
Some(&BlockState::new("minecraft:air"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_get_block() {
|
||||
let mut sec = SectionData::new();
|
||||
let stone = BlockState::new("minecraft:stone");
|
||||
sec.set_block(0, 0, 0, stone.clone());
|
||||
assert_eq!(sec.get_block(0, 0, 0), Some(&stone));
|
||||
assert_eq!(sec.palette_size(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fill() {
|
||||
let mut sec = SectionData::new();
|
||||
let stone = BlockState::new("minecraft:stone");
|
||||
sec.fill(stone.clone());
|
||||
for x in 0..16 {
|
||||
for y in 0..16 {
|
||||
for z in 0..16 {
|
||||
assert_eq!(sec.get_block(x, y, z), Some(&stone));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nbt_roundtrip() {
|
||||
let mut sec = SectionData::new();
|
||||
sec.set_block(1, 2, 3, BlockState::new("minecraft:stone"));
|
||||
sec.set_block(
|
||||
4,
|
||||
5,
|
||||
6,
|
||||
BlockState::new("minecraft:grass_block").with_property("snowy", "true"),
|
||||
);
|
||||
|
||||
let nbt = sec.to_nbt();
|
||||
let restored = SectionData::from_nbt(&nbt).unwrap();
|
||||
|
||||
assert_eq!(sec.palette_size(), restored.palette_size());
|
||||
assert_eq!(sec.get_block(1, 2, 3), restored.get_block(1, 2, 3));
|
||||
assert_eq!(sec.get_block(4, 5, 6), restored.get_block(4, 5, 6));
|
||||
assert_eq!(sec.get_block(0, 0, 0), restored.get_block(0, 0, 0));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue