diff --git a/Cargo.lock b/Cargo.lock index 476a17f..bddc2e9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1081,6 +1081,17 @@ dependencies = [ "serde_bytes", ] +[[package]] +name = "fastnbt" +version = "2.5.0" +source = "git+https://github.com/owengage/fastnbt.git#e2a5d8a7001d4f074ae99fd21bb485667934baeb" +dependencies = [ + "byteorder", + "cesu8", + "serde", + "serde_bytes", +] + [[package]] name = "fastrand" version = "2.3.0" @@ -2667,13 +2678,26 @@ version = "0.1.0" dependencies = [ "color-eyre", "eframe", - "fastnbt", + "fastnbt 2.5.0 (registry+https://github.com/rust-lang/crates.io-index)", "mimalloc", "rfd", + "slimeball-lib", "tracing", "tracing-subscriber", ] +[[package]] +name = "slimeball-lib" +version = "0.1.0" +dependencies = [ + "byteorder", + "fastnbt 2.5.0 (git+https://github.com/owengage/fastnbt.git)", + "serde", + "thiserror 2.0.12", + "tracing", + "zstd", +] + [[package]] name = "slotmap" version = "1.0.7" @@ -4162,6 +4186,34 @@ dependencies = [ "syn", ] +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.15+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb81183ddd97d0c74cedf1d50d85c8d08c1b8b68ee863bdee9e706eedba1a237" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "zvariant" version = "5.6.0" diff --git a/Cargo.toml b/Cargo.toml index 39494ae..905210f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,3 @@ [workspace] resolver = "3" -members = ["slimeball-gui"] +members = ["slimeball-gui", "slimeball-lib"] diff --git a/slimeball-gui/Cargo.toml b/slimeball-gui/Cargo.toml index b38af10..133d65e 100644 --- a/slimeball-gui/Cargo.toml +++ b/slimeball-gui/Cargo.toml @@ -4,6 +4,7 @@ version = "0.1.0" edition = "2024" [dependencies] +slimeball-lib = { path = "../slimeball-lib" } color-eyre = "0.6.5" eframe = { version = "0.32.0", default-features = false, features = ["accesskit", "default_fonts", "wayland", "web_screen_reader", "wgpu", "x11"] } fastnbt = "2.5.0" diff --git a/slimeball-gui/src/main.rs b/slimeball-gui/src/main.rs index d43a94d..4f4b9b7 100644 --- a/slimeball-gui/src/main.rs +++ b/slimeball-gui/src/main.rs @@ -1,6 +1,6 @@ -use std::path::PathBuf; +use std::{fs::File, io::BufReader, path::PathBuf}; -use eframe::egui::{self, Color32, Frame}; +use eframe::egui::{self, Frame}; use mimalloc::MiMalloc; use rfd::FileDialog; use tracing::Level; @@ -13,15 +13,17 @@ extern crate tracing; static GLOBAL: MiMalloc = MiMalloc; mod anvil; +mod viewer; fn main() { tracing_subscriber::registry() - .with(tracing_subscriber::fmt::layer()) + .with(tracing_subscriber::fmt::layer().pretty()) .with( EnvFilter::builder() .with_default_directive(Level::INFO.into()) .from_env_lossy(), - ); + ) + .init(); let native_options = eframe::NativeOptions::default(); eframe::run_native( @@ -32,14 +34,20 @@ fn main() { .unwrap(); } +#[derive(Clone)] +enum World { + Slime(PathBuf), + Anvil(PathBuf), +} + #[derive(Default)] struct SlimeballGui { - file: Option, + world: Option, } impl eframe::App for SlimeballGui { fn update(&mut self, ui: &egui::Context, frame: &mut eframe::Frame) { - match self.file { + match self.world { None => self.show_welcome(ui), Some(_) => self.show_main(ui), } @@ -59,8 +67,25 @@ impl SlimeballGui { egui::CentralPanel::default().show(ui, |ui| { ui.vertical_centered(|ui| { ui.label("No world selected"); - if ui.button("Open a world...").clicked() { - self.file = FileDialog::new().pick_folder(); + + if ui.button("Open Anvil world").clicked() { + self.world = FileDialog::new().pick_folder().map(World::Anvil); + } + + if ui.button("Open Slime world").clicked() { + let path = FileDialog::new() + .add_filter("Slime worlds", &["slime"]) + .pick_file(); + self.world = path.clone().map(World::Slime); + + if let Some(path) = path { + let file = File::open(path).unwrap(); + let mut buf = BufReader::new(file); + match slimeball_lib::SlimeWorld::deserialize(&mut buf) { + Ok(v) => debug!("{:#?}", v), + Err(why) => error!("{:?}", why), + } + } } }); }); @@ -68,7 +93,11 @@ impl SlimeballGui { fn show_main(&mut self, ui: &egui::Context) { // asserted Some in match arm for render - let path = self.file.as_ref().unwrap(); + let world = self.world.as_ref().unwrap(); + let world_path = match world { + World::Anvil(p) => p, + World::Slime(p) => p, + }; egui::TopBottomPanel::top("top_panel") .resizable(false) @@ -83,6 +112,6 @@ impl SlimeballGui { }); }); - egui::CentralPanel::default().show(ui, |ui| ui.heading(path.to_string_lossy())); + egui::CentralPanel::default().show(ui, |ui| ui.heading(world_path.to_string_lossy())); } } diff --git a/slimeball-gui/src/viewer.rs b/slimeball-gui/src/viewer.rs new file mode 100644 index 0000000..7233e34 --- /dev/null +++ b/slimeball-gui/src/viewer.rs @@ -0,0 +1,21 @@ +use eframe::egui::Vec2; + +pub struct Viewer { + camera_pos: Vec2, + camera_zoom: f32, +} + +impl Default for Viewer { + fn default() -> Self { + Self { + camera_pos: Default::default(), + camera_zoom: 1.0, + } + } +} + +impl Viewer { + pub fn new() -> Self { + Self::default() + } +} diff --git a/slimeball-lib/Cargo.toml b/slimeball-lib/Cargo.toml new file mode 100644 index 0000000..84ebbd6 --- /dev/null +++ b/slimeball-lib/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "slimeball-lib" +version = "0.1.0" +edition = "2024" + +[dependencies] +byteorder = "1.5.0" +fastnbt = { git = "https://github.com/owengage/fastnbt.git" } +serde = "1.0.219" +thiserror = "2.0.12" +tracing = "0.1.41" +zstd = "0.13.3" diff --git a/slimeball-lib/SPEC.md b/slimeball-lib/SPEC.md new file mode 100644 index 0000000..f345fe1 --- /dev/null +++ b/slimeball-lib/SPEC.md @@ -0,0 +1,96 @@ +# “Slime” file format +according to https://github.com/InfernalSuite/AdvancedSlimePaper/blob/a192a031930f76dda4d68457e8381090b9e022bc/SLIME_FORMAT#L4 + +``` +------------------------------------- + +“Slime” file format (Version 13) +2 bytes - magic = 0xB10B +1 byte (ubyte) - version, current = 0x0D +4 bytes (int) - world version +1 byte (ubyte) - additional world flags (bitmask - see options below) +4 bytes (int) - compressed chunks size +4 bytes (int) - uncompressed chunks size + (size determined from bitmask) + compressed using zstd + +4 bytes (int) - compressed “extra” size +4 bytes (int) - uncompressed “extra” size +[depends] - extra compound tag compressed using zstd (used for PDC, and/or custom data) + +------------------------------------- +Additional world flag index +POI_CHUNKS - Enum ordinal 0 - Bitmask value 1 +FLUID_TICKS - Enum ordinal 1 - Bitmask value 2 +BLOCK_TICKS - Enum ordinal 2 - Bitmask value 4 +------------------------------------- + +Custom chunk format +4 byte (int) - chunk x +4 byte (int) - chunk z +4 bytes (int) section count +[for each section] + 1 byte (ubyte) - section flags (Bitmask value 1 = has block light, 2 = has sky light) + [if has sky light] + 2048 bytes - sky light + [if has block light] + 2048 bytes - block light + 4 bytes (int) - block states byte size + + same format as mc + 4 bytes (int) - biomes byte size + + same format as mc +4 bytes (int) - heightmaps size + + same format as mc, uncompressed +[if poi chunks flag is enabled] + 4 bytes (int) - poi chunk size + + "Sections" tag of the mc poi chunk compound + uncompressed +[if block ticks flag is enabled] + 4 bytes (int) - fluid ticks size + + Same format as mc + inside an nbt list named "block_ticks" + uncompressed +[if fluid ticks flag is enabled] + 4 bytes (int) - heightmaps size + + Same format as mc + inside an nbt list named "fluid_ticks" + uncompressed +[for every flag set that does not exist yet, for backward compatibility of new future flags] + 4 bytes (int) - size + + +4 bytes (int) - tile entities size + + Same format as mc + inside an nbt list named “tileEntities” + uncompressed +4 bytes (int) entities size + + Same format as mc + inside an nbt list named “entities” + uncompressed +[depends] - compound tag uncompressed (used for PDC, and/or custom data + +------------------------------------- + +Version history: + - v1: Initial release. + - v2: Added "extra" nbt tag for per-world custom data. + - v3: Added entities storage. + - v4: Added support for 1.13 worlds and removed HypixelBlocks3. + - v5: Skylight and blocklight might not always be present. + - v6: Added world versioning + - v7: Added world maps + - v8: Variable biomes size + - v9: Fix issue with biomes size, causing old worlds to be corrupted + - v10: Use minecraft version id, remove legacy version artifacts + - v11: Move entities and tile entities into the chunk structure + - v12: Add support for chunk-based PDC + - v13: Add support for additional world data +``` diff --git a/slimeball-lib/src/lib.rs b/slimeball-lib/src/lib.rs new file mode 100644 index 0000000..757fe1c --- /dev/null +++ b/slimeball-lib/src/lib.rs @@ -0,0 +1,277 @@ +use byteorder::{BigEndian, ReadBytesExt}; +use serde::Deserialize; +use std::{ + collections::HashMap, + io::{BufRead, Read}, +}; +use tracing::debug; + +#[derive(Clone, Debug)] +pub struct SlimeWorld { + pub version: u8, + pub world_version: i32, + pub world_flags: WorldFlags, + pub chunks: Vec, +} + +#[derive(Clone, Debug)] +pub struct Chunk { + pub x: i32, + pub z: i32, + pub sections: Vec
, + // FIXME: use strong type + pub heightmaps: fastnbt::Value, + pub poi_chunks: Option, + pub block_ticks: Option, + pub fluid_ticks: Option, + pub tile_entities: fastnbt::Value, + pub entities: fastnbt::Value, + // but leave this as Value + pub extra: Option, +} + +#[derive(Clone, Debug)] +pub struct Section { + pub sky_light: Option>, + pub block_light: Option>, + pub block_states: BlockStates, + // FIXME: use strong type + pub biomes: fastnbt::Value, +} + +#[derive(Default, Debug, Clone, Copy)] +pub struct WorldFlags(u8); + +impl From for WorldFlags { + fn from(value: u8) -> Self { + Self(value) + } +} + +impl WorldFlags { + const fn poi_chunks(&self) -> bool { + self.0 & 1 == 1 + } + + const fn fluid_ticks(&self) -> bool { + self.0 & 2 == 2 + } + + const fn block_ticks(&self) -> bool { + self.0 & 4 == 4 + } + + const fn other_flag_count(&self) -> u32 { + self.0.count_ones() + - (self.poi_chunks() as u32) + - (self.fluid_ticks() as u32) + - (self.block_ticks() as u32) + } +} + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error("IO error")] + Io(#[from] std::io::Error), + #[error("Magic bytes are not present (expected 0xB10B, got {0:#X})")] + MagicBytes(u16), + #[error("World uses slime version {0}, which is unsupported; only version 13 currently works.")] + UnsupportedVersion(u8), + #[error("Got invalid decompression result: expected size {0}, got {1}")] + DecompressSize(usize, usize), + // TODO: better error type for this case + #[error("Failed to convert decompressed data")] + DecompressConvert, + #[error("FastNBT deserialization error")] + Nbt(#[from] fastnbt::error::Error), +} + +type Result = std::result::Result; + +impl SlimeWorld { + pub fn deserialize(buf: &mut impl BufRead) -> Result { + let magic = buf.read_u16::()?; + if magic != 0xB10B { + return Err(Error::MagicBytes(magic)); + } + + let version = buf.read_u8()?; + if version != 0x0D { + return Err(Error::UnsupportedVersion(version)); + } + + let world_version = buf.read_i32::()?; + let world_flags = buf.read_u8()?.into(); + + let chunk_buf = read_compressed(buf)?; + let chunks = read_chunks(&mut &*chunk_buf, world_flags)?; + + Ok(Self { + version, + world_version, + world_flags, + chunks, + }) + } +} + +fn read_compressed(buf: &mut impl BufRead) -> Result> { + let compressed_chunks_size: usize = buf.read_i32::()?.try_into().unwrap(); + let uncompressed_chunks_size: usize = buf.read_i32::()?.try_into().unwrap(); + + let mut compressed = vec![0; compressed_chunks_size]; + buf.read_exact(&mut compressed)?; + let decoded = zstd::decode_all(&*compressed)?; + + if decoded.len() != uncompressed_chunks_size { + return Err(Error::DecompressSize( + uncompressed_chunks_size, + decoded.len(), + )); + } + + Ok(decoded) +} + +fn read_chunks(buf: &mut impl Read, world_flags: WorldFlags) -> Result> { + let chunks_to_read = buf.read_i32::()?; + let mut chunks = Vec::with_capacity(chunks_to_read.try_into().unwrap()); + + for chunk_index in 0..chunks_to_read { + let x = buf.read_i32::()?; + let z = buf.read_i32::()?; + + let section_count = buf.read_i32::()?; + debug!("chunk {x}, {z}: {section_count} sections"); + + let mut sections = Vec::with_capacity(section_count.try_into().unwrap()); + for section in 0..section_count { + let flags = buf.read_u8()?; + debug!("flags for section {section}: {flags}"); + + let sky_light = match flags & 1 { + 1 => { + debug!("reading skylight"); + let mut sky_light = vec![0; 2048]; + buf.read_exact(&mut sky_light)?; + Some(sky_light) + } + _ => None, + }; + + let block_light = match flags & 2 { + 2 => { + debug!("reading blocklight"); + let mut block_light = vec![0; 2048]; + buf.read_exact(&mut block_light)?; + Some(block_light) + } + _ => None, + }; + + debug!("reading block states"); + let block_states: BlockStates = read_sized(buf)?; + debug!("{:?}", block_states); + debug!("reading biomes"); + let biomes: fastnbt::Value = read_sized(buf)?; + debug!("{:?}", biomes); + + sections.push(Section { + sky_light, + block_light, + block_states, + biomes, + }) + } + + debug!("reading heightmaps"); + let heightmaps: fastnbt::Value = read_sized(buf)?; + + let poi_chunks = match world_flags.poi_chunks() { + true => { + debug!("Loading poi chunks"); + Some(read_sized(buf)?) + } + false => None, + }; + + let block_ticks = match world_flags.block_ticks() { + true => { + debug!("Loading block ticks"); + Some(read_sized(buf)?) + } + false => None, + }; + + let fluid_ticks = match world_flags.fluid_ticks() { + true => { + debug!("Loading fluid ticks"); + Some(read_sized(buf)?) + } + false => None, + }; + + debug!("Loading {} extra flags", world_flags.other_flag_count()); + for i in 0..world_flags.other_flag_count() { + let extra_len = buf.read_i32::()?; + debug!("Discarding {extra_len} bytes for extra flag {i}"); + let mut extra_buf = vec![0; extra_len.try_into().unwrap()]; + buf.read_exact(&mut extra_buf)?; + } + + let tile_entities: fastnbt::Value = read_sized(buf)?; + let entities: fastnbt::Value = read_sized(buf)?; + + let extra_size = buf.read_i32::()?; + let extra: Option = match extra_size { + 0 => None, + extra_size => { + debug!("Loading remaining compound tag size {extra_size} bytes"); + let mut bytebuf = vec![0u8; extra_size.try_into().unwrap()]; + buf.read_exact(&mut bytebuf)?; + let extra = fastnbt::from_reader_with_opts(&*bytebuf, fastnbt::DeOpts::default())?; + debug!("extra: {extra:?}"); + Some(extra) + } + }; + + chunks.push(Chunk { + x, + z, + sections, + heightmaps, + poi_chunks, + block_ticks, + fluid_ticks, + tile_entities, + entities, + extra, + }) + } + + Ok(chunks) +} + +fn read_sized(buf: &mut impl Read) -> Result { + let size = buf.read_i32::()?; + debug!("loading nbt, size {size} bytes"); + let mut bytebuf = vec![0u8; size.try_into().unwrap()]; + buf.read_exact(&mut bytebuf)?; + Ok(fastnbt::from_reader_with_opts( + &*bytebuf, + fastnbt::DeOpts::default(), + )?) +} + +#[derive(Deserialize, Debug, Clone)] +pub struct BlockStates { + palette: Vec, + data: Option, +} + +#[derive(Deserialize, Debug, Clone)] +#[serde(rename_all = "PascalCase")] +pub struct BlockState { + name: String, + properties: Option>, +}