online Minecraft written book viewer

feat(slurp): a LOT of work, almost done now

kokirigla.de 4164ccff 357882d3

verified
+804 -123
+19
Cargo.lock
··· 1024 1024 "html-escape", 1025 1025 "lru", 1026 1026 "nara_core", 1027 + "nara_slurper_1_12_infinity", 1027 1028 "nara_slurper_1_12_world", 1028 1029 "serde", 1029 1030 "serde_json", ··· 1042 1043 name = "nara_core" 1043 1044 version = "0.1.0" 1044 1045 dependencies = [ 1046 + "hex", 1045 1047 "insta", 1048 + "nara_io", 1046 1049 "serde", 1047 1050 "serde_json", 1048 1051 "serde_with", ··· 1061 1064 ] 1062 1065 1063 1066 [[package]] 1067 + name = "nara_mcr" 1068 + version = "0.1.0" 1069 + dependencies = [ 1070 + "crab_nbt", 1071 + "flate2", 1072 + "serde", 1073 + "thiserror", 1074 + ] 1075 + 1076 + [[package]] 1064 1077 name = "nara_slurper_1_12_core" 1065 1078 version = "0.1.0" 1066 1079 dependencies = [ 1067 1080 "nara_core", 1081 + "nara_mcr", 1068 1082 "serde", 1069 1083 ] 1070 1084 ··· 1072 1086 name = "nara_slurper_1_12_infinity" 1073 1087 version = "0.1.0" 1074 1088 dependencies = [ 1089 + "nara_core", 1090 + "nara_io", 1075 1091 "nara_slurper_1_12_core", 1076 1092 "serde", 1077 1093 ] ··· 1084 1100 "insta", 1085 1101 "nara_core", 1086 1102 "nara_io", 1103 + "nara_mcr", 1087 1104 "nara_slurper_1_12_core", 1088 1105 "serde", 1089 1106 "serde_with", 1090 1107 "thiserror", 1108 + "tokio", 1109 + "tracing", 1091 1110 "uuid", 1092 1111 ] 1093 1112
+4 -2
Cargo.toml
··· 1 1 [workspace] 2 2 resolver = "3" 3 - members = ["nara_io", "nara_slurper_1_12_core", "nara_slurper_1_12_infinity", "nara_slurper_1_12_world", "nara_core"] 3 + members = ["nara_io", "nara_slurper_1_12_core", "nara_slurper_1_12_infinity", "nara_slurper_1_12_world", "nara_core", "nara_mcr"] 4 4 5 5 [workspace.package] 6 6 version = "0.1.0" ··· 24 24 nara_slurper_1_12_infinity = { path = "nara_slurper_1_12_infinity" } 25 25 nara_slurper_1_12_world = { path = "nara_slurper_1_12_world" } 26 26 nara_core = { path = "nara_core" } 27 + nara_mcr = { path = "nara_mcr" } 27 28 serde = { version = "=1.0.228", features = ["derive"] } 28 29 serde_json = "=1.0.149" 29 - serde_with = "=3.17.0" 30 + serde_with = { version = "=3.17.0", features = ["hex"] } 30 31 sha1 = "=0.11.0-rc.5" 31 32 smol_str = "=0.3.5" 32 33 strsim = "=0.11.1" ··· 54 55 html-escape.workspace = true 55 56 lru.workspace = true 56 57 nara_core.workspace = true 58 + nara_slurper_1_12_infinity.workspace = true 57 59 nara_slurper_1_12_world.workspace = true 58 60 serde.workspace = true 59 61 serde_json.workspace = true
+1 -1
justfile
··· 24 24 @cargo insta review --workspace 25 25 26 26 test: 27 - @cargo test --workspace 27 + @cargo nextest r --workspace 28 28 29 29 ok: lint test 30 30 @cargo fmt --check
+2
nara_core/Cargo.toml
··· 4 4 edition.workspace = true 5 5 6 6 [dependencies] 7 + hex.workspace = true 8 + nara_io.workspace = true 7 9 serde.workspace = true 8 10 serde_json.workspace = true 9 11 serde_with.workspace = true
+21 -1
nara_core/src/book.rs
··· 41 41 /// From a placed block entity, such as a chest. 42 42 BlockEntity { 43 43 dimension: String, 44 + id: String, 44 45 x: i32, 45 46 y: i32, 46 47 z: i32, 47 48 slot: u8, 48 49 }, 50 + Entity { 51 + dimension: String, 52 + id: String, 53 + x: f64, 54 + y: f64, 55 + z: f64, 56 + }, 57 + EntityInventory { 58 + dimension: String, 59 + id: String, 60 + x: f64, 61 + y: f64, 62 + z: f64, 63 + slot: u8, 64 + }, 49 65 /// Inside an item at `slot` within another container. 50 - ItemBlockEntity { slot: u8, within: Box<BookSource> }, 66 + ItemBlockEntity { 67 + id: String, 68 + slot: u8, 69 + within: Box<BookSource>, 70 + }, 51 71 } 52 72 53 73 #[derive(Debug, Clone, Copy, Serialize, Deserialize)]
+34
nara_core/src/lib.rs
··· 1 + use std::{collections::HashMap, path::Path}; 2 + 1 3 use ::uuid::Uuid; 4 + use serde::{Deserialize, Serialize}; 5 + use serde_with::{hex::Hex, serde_as}; 6 + 7 + use crate::book::{Book, BookHash}; 2 8 3 9 pub mod book; 4 10 pub mod color; 5 11 pub mod component; 6 12 pub mod profile; 7 13 pub mod uuid; 14 + 15 + #[serde_as] 16 + #[derive(Debug, Serialize, Deserialize)] 17 + pub struct BookContainer { 18 + #[serde_as(as = "HashMap<Hex, _>")] 19 + books: HashMap<BookHash, Book>, 20 + } 21 + 22 + impl BookContainer { 23 + pub fn read<P>(path: P) -> nara_io::Result<BookContainer> 24 + where 25 + P: AsRef<Path>, 26 + { 27 + let buffer = std::fs::read(path)?; 28 + nara_io::read_nbt(&buffer) 29 + } 30 + 31 + pub fn add(&mut self, book: Book) { 32 + let hash = book.hash(); 33 + if !self.books.contains_key(&hash) { 34 + self.books.insert(hash, book); 35 + } 36 + } 37 + 38 + pub fn remove(&mut self, book: Book) { 39 + self.books.remove(&book.hash()); 40 + } 41 + } 8 42 9 43 pub fn i64_pair_to_uuid(most_significant: i64, least_signifcant: i64) -> Uuid { 10 44 Uuid::from_u64_pair(most_significant as u64, least_signifcant as u64)
+10
nara_mcr/Cargo.toml
··· 1 + [package] 2 + name = "nara_mcr" 3 + version.workspace = true 4 + edition.workspace = true 5 + 6 + [dependencies] 7 + crab_nbt.workspace = true 8 + flate2.workspace = true 9 + serde.workspace = true 10 + thiserror.workspace = true
+132
nara_mcr/src/lib.rs
··· 1 + use std::io::{Cursor, Read}; 2 + 3 + use flate2::read::{GzDecoder, ZlibDecoder}; 4 + use serde::de::DeserializeOwned; 5 + 6 + const SECTOR_BYTES: usize = 4096; 7 + const REGION_SIDE: usize = 32; 8 + const CHUNK_COUNT: usize = REGION_SIDE * REGION_SIDE; 9 + const HEADER_BYTES: usize = 2 * SECTOR_BYTES; 10 + 11 + #[derive(Debug, thiserror::Error)] 12 + pub enum Error { 13 + #[error( 14 + "region data too short: need at least {HEADER_BYTES} bytes, got {0}" 15 + )] 16 + TooShort(usize), 17 + #[error( 18 + "chunk ({0}, {1}) references sector offset {2} which is outside the file" 19 + )] 20 + ChunkOutOfBounds(u8, u8, usize), 21 + #[error("unknown compression type {0} for chunk ({1}, {2})")] 22 + UnknownCompression(u8, u8, u8), 23 + #[error(transparent)] 24 + Io(#[from] std::io::Error), 25 + #[error(transparent)] 26 + Nbt(#[from] crab_nbt::error::Error), 27 + } 28 + 29 + pub type Result<T> = std::result::Result<T, Error>; 30 + 31 + #[derive(Debug)] 32 + pub struct McRegion<C> { 33 + chunks: Vec<Option<C>>, 34 + } 35 + 36 + impl<C: DeserializeOwned> McRegion<C> { 37 + pub fn from_bytes(data: &[u8]) -> Result<Self> { 38 + if data.len() < HEADER_BYTES { 39 + return Err(Error::TooShort(data.len())); 40 + } 41 + 42 + let mut chunks: Vec<Option<C>> = 43 + (0..CHUNK_COUNT).map(|_| None).collect(); 44 + 45 + for i in 0..CHUNK_COUNT { 46 + let x = (i % REGION_SIDE) as u8; 47 + let z = (i / REGION_SIDE) as u8; 48 + 49 + // location table: first 4096 bytes, 4 bytes per entry. 50 + // [offset_hi, offset_mid, offset_lo, sector_count] 51 + // offset is in units of 4096-byte sectors from the file start. 52 + let loc = i * 4; 53 + let offset_sectors = u32::from_be_bytes([ 54 + 0, 55 + data[loc], 56 + data[loc + 1], 57 + data[loc + 2], 58 + ]) as usize; 59 + 60 + if offset_sectors == 0 { 61 + continue; 62 + } 63 + 64 + let byte_offset = offset_sectors * SECTOR_BYTES; 65 + if byte_offset + 5 > data.len() { 66 + return Err(Error::ChunkOutOfBounds(x, z, offset_sectors)); 67 + } 68 + 69 + let length = u32::from_be_bytes([ 70 + data[byte_offset], 71 + data[byte_offset + 1], 72 + data[byte_offset + 2], 73 + data[byte_offset + 3], 74 + ]) as usize; 75 + 76 + if length == 0 || byte_offset + 4 + length > data.len() { 77 + return Err(Error::ChunkOutOfBounds(x, z, offset_sectors)); 78 + } 79 + 80 + let compression = data[byte_offset + 4]; 81 + let compressed = &data[byte_offset + 5..byte_offset + 4 + length]; 82 + 83 + let decompressed = decompress(compressed, compression, x, z)?; 84 + let mut cursor = Cursor::new(decompressed.as_slice()); 85 + chunks[i] = 86 + Some(crab_nbt::serde::de::from_cursor::<C>(&mut cursor)?); 87 + } 88 + 89 + Ok(Self { chunks }) 90 + } 91 + 92 + pub fn chunk(&self, local_x: u8, local_z: u8) -> Option<&C> { 93 + if local_x as usize >= REGION_SIDE || local_z as usize >= REGION_SIDE { 94 + return None; 95 + } 96 + self.chunks[local_z as usize * REGION_SIDE + local_x as usize].as_ref() 97 + } 98 + 99 + pub fn iter(&self) -> impl Iterator<Item = ((u8, u8), &C)> { 100 + self.chunks.iter().enumerate().filter_map(|(i, slot)| { 101 + slot.as_ref().map(|c| { 102 + let x = (i % REGION_SIDE) as u8; 103 + let z = (i / REGION_SIDE) as u8; 104 + ((x, z), c) 105 + }) 106 + }) 107 + } 108 + 109 + pub fn into_iter_chunks(self) -> impl Iterator<Item = ((u8, u8), C)> { 110 + self.chunks.into_iter().enumerate().filter_map(|(i, slot)| { 111 + slot.map(|c| { 112 + let x = (i % REGION_SIDE) as u8; 113 + let z = (i / REGION_SIDE) as u8; 114 + ((x, z), c) 115 + }) 116 + }) 117 + } 118 + } 119 + 120 + fn decompress(data: &[u8], compression: u8, x: u8, z: u8) -> Result<Vec<u8>> { 121 + let mut buf = Vec::new(); 122 + match compression { 123 + 1 => GzDecoder::new(data).read_to_end(&mut buf)?, 124 + 2 => ZlibDecoder::new(data).read_to_end(&mut buf)?, 125 + 3 => { 126 + buf.extend_from_slice(data); 127 + buf.len() 128 + } 129 + other => return Err(Error::UnknownCompression(other, x, z)), 130 + }; 131 + Ok(buf) 132 + }
nara_mcr/tests/fixtures/r.0.0.mca

This is a binary file and will not be displayed.

+3
nara_slurper_1_12_core/Cargo.toml
··· 6 6 [dependencies] 7 7 nara_core.workspace = true 8 8 serde.workspace = true 9 + 10 + [dev-dependencies] 11 + nara_mcr.workspace = true
+70
nara_slurper_1_12_core/src/chunk.rs
··· 1 + use serde::Deserialize; 2 + 3 + use crate::item::{InventoryItemStack, ItemStack}; 4 + 5 + /// A 1.12.2 Anvil chunk, containing only the fields we care about. 6 + /// Unknown fields are silently ignored by serde. 7 + #[derive(Debug, Deserialize)] 8 + pub struct Chunk { 9 + #[serde(rename = "DataVersion")] 10 + pub data_version: i32, 11 + #[serde(rename = "Level")] 12 + pub level: ChunkLevel, 13 + } 14 + 15 + #[derive(Debug, Deserialize)] 16 + pub struct ChunkLevel { 17 + #[serde(rename = "xPos")] 18 + pub x_pos: i32, 19 + #[serde(rename = "zPos")] 20 + pub z_pos: i32, 21 + #[serde(rename = "TileEntities", default)] 22 + pub tile_entities: Vec<TileEntity>, 23 + #[serde(rename = "Entities", default)] 24 + pub entities: Vec<Entity>, 25 + } 26 + 27 + #[derive(Debug, Deserialize)] 28 + #[serde(untagged)] 29 + pub enum Entity { 30 + ItemHolder(ItemHolderEntity), 31 + Inventoried(InventoriedEntity), 32 + Base(BaseEntity), 33 + } 34 + 35 + #[derive(Debug, Deserialize)] 36 + pub struct BaseEntity { 37 + #[serde(rename = "Pos")] 38 + pub position: [f64; 3], 39 + pub id: String, 40 + } 41 + 42 + #[derive(Debug, Deserialize)] 43 + pub struct ItemHolderEntity { 44 + #[serde(flatten)] 45 + pub base: BaseEntity, 46 + #[serde(rename = "Item")] 47 + pub item: ItemStack, 48 + } 49 + 50 + #[derive(Debug, Deserialize)] 51 + pub struct InventoriedEntity { 52 + #[serde(flatten)] 53 + pub base: BaseEntity, 54 + #[serde(rename = "Items")] 55 + pub items: Vec<InventoryItemStack>, 56 + } 57 + 58 + /// A placed block entity (chest, furnace, shulker box, …). 59 + /// `Items` is present on container block entities and absent on others, 60 + /// so it defaults to an empty vec. 61 + #[derive(Debug, Deserialize)] 62 + pub struct TileEntity { 63 + pub id: String, 64 + pub x: i32, 65 + pub y: i32, 66 + pub z: i32, 67 + #[serde(rename = "Items", default)] 68 + // TODO(kokiriglade): not every tile entity has items, but its probably ok to assume they do 69 + pub items: Vec<InventoryItemStack>, 70 + }
+144 -29
nara_slurper_1_12_core/src/item.rs
··· 1 - use nara_core::{book::BookContent, component::Component}; 2 - use serde::Deserialize; 1 + use nara_core::{ 2 + book::BookContent, 3 + component::{Component, parse_component_from_str}, 4 + }; 5 + use serde::{Deserialize, Deserializer}; 3 6 4 - #[derive(Debug, Clone, Deserialize)] 5 - #[serde(untagged)] 7 + #[derive(Debug, Clone)] 6 8 pub enum ItemStack { 7 9 WrittenBook(WrittenBookStack), 8 10 BlockEntity(BlockEntityStack), 9 11 Base(BaseItemStack), 10 12 } 11 13 12 - #[derive(Debug, Clone, Deserialize)] 13 - #[serde(rename_all = "PascalCase")] 14 + #[derive(Debug, Clone)] 14 15 pub struct BaseItemStack { 15 16 pub count: i8, 16 17 pub damage: i16, 17 - #[serde(rename = "id")] 18 18 pub id: String, 19 19 } 20 20 21 21 #[derive(Debug, Clone, Deserialize)] 22 22 #[serde(rename_all = "PascalCase")] 23 23 pub struct DisplayTag { 24 - pub name: String, 24 + pub name: Option<String>, 25 25 } 26 26 27 - #[derive(Debug, Clone, Deserialize)] 27 + #[derive(Debug, Clone)] 28 28 pub struct BaseTag { 29 - #[serde(rename = "display")] 30 29 pub display: Option<DisplayTag>, 31 30 } 32 31 33 - #[derive(Debug, Clone, Deserialize)] 32 + #[derive(Debug, Clone)] 34 33 pub struct WrittenBookStack { 35 - #[serde(flatten)] 36 - pub base: BaseItemStack, 34 + pub count: i8, 35 + pub damage: i16, 36 + pub id: String, 37 37 pub tag: WrittenBookTag, 38 38 } 39 39 40 - #[derive(Debug, Clone, Deserialize)] 40 + #[derive(Debug, Clone)] 41 41 pub struct WrittenBookTag { 42 - #[serde(flatten)] 43 42 pub base: BaseTag, 44 43 pub author: String, 45 44 pub title: String, 46 - #[serde(default)] 47 45 pub generation: BookGeneration, 48 - #[serde(default)] 49 46 pub resolved: bool, 50 47 pub pages: Vec<Component>, 51 48 } 52 49 53 - #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)] 54 - #[serde(try_from = "i32")] 50 + #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] 55 51 pub enum BookGeneration { 56 52 #[default] 57 53 Original, ··· 74 70 } 75 71 } 76 72 77 - #[derive(Debug, Clone, Deserialize)] 73 + #[derive(Debug, Clone)] 78 74 pub struct BlockEntityStack { 79 - #[serde(flatten)] 80 - pub base: BaseItemStack, 75 + pub count: i8, 76 + pub damage: i16, 77 + pub id: String, 81 78 pub tag: BlockEntityTag, 82 79 } 83 80 84 - #[derive(Debug, Clone, Deserialize)] 81 + #[derive(Debug, Clone)] 85 82 pub struct BlockEntityTag { 86 - #[serde(flatten)] 87 83 pub base: BaseTag, 88 - #[serde(rename = "BlockEntityTag")] 89 84 pub block_entity: BlockEntity, 90 85 } 91 86 ··· 110 105 111 106 impl BlockEntityStack { 112 107 pub fn kind(&self) -> BlockEntityKind { 113 - match self.base.id.as_str() { 108 + match self.id.as_str() { 114 109 id if id.contains("shulker_box") => BlockEntityKind::ShulkerBox, 115 110 "minecraft:chest" => BlockEntityKind::Chest, 116 111 "minecraft:trapped_chest" => BlockEntityKind::TrappedChest, ··· 122 117 } 123 118 } 124 119 125 - #[derive(Debug, Clone, Deserialize)] 126 - #[serde(rename_all = "PascalCase")] 120 + #[derive(Debug, Clone)] 127 121 pub struct InventoryItemStack { 128 - #[serde(flatten)] 129 122 pub item: ItemStack, 130 123 pub slot: i8, 124 + } 125 + 126 + #[derive(Deserialize)] 127 + struct RawItemStack { 128 + #[serde(rename = "id")] 129 + id: String, 130 + #[serde(rename = "Count")] 131 + count: i8, 132 + #[serde(rename = "Damage")] 133 + damage: i16, 134 + #[serde(default)] 135 + tag: Option<RawTag>, 136 + } 137 + 138 + #[derive(Deserialize)] 139 + struct RawInventoryItemStack { 140 + #[serde(rename = "id")] 141 + id: String, 142 + #[serde(rename = "Count")] 143 + count: i8, 144 + #[serde(rename = "Damage")] 145 + damage: i16, 146 + #[serde(rename = "Slot")] 147 + slot: i8, 148 + #[serde(default)] 149 + tag: Option<RawTag>, 150 + } 151 + 152 + #[derive(Deserialize, Default)] 153 + struct RawTag { 154 + #[serde(default)] 155 + author: Option<String>, 156 + #[serde(default)] 157 + title: Option<String>, 158 + #[serde(default)] 159 + pages: Option<Vec<String>>, 160 + #[serde(default)] 161 + generation: Option<i32>, 162 + #[serde(default)] 163 + resolved: Option<i8>, 164 + 165 + #[serde(default, rename = "BlockEntityTag")] 166 + block_entity: Option<BlockEntity>, 167 + 168 + #[serde(default)] 169 + display: Option<DisplayTag>, 170 + } 171 + 172 + fn build_item_stack( 173 + id: String, 174 + count: i8, 175 + damage: i16, 176 + tag: Option<RawTag>, 177 + ) -> ItemStack { 178 + if id == "minecraft:written_book" { 179 + if let Some(ref t) = tag { 180 + if let (Some(author), Some(title), Some(pages)) = 181 + (t.author.clone(), t.title.clone(), t.pages.clone()) 182 + { 183 + let generation = t 184 + .generation 185 + .and_then(|g| BookGeneration::try_from(g).ok()) 186 + .unwrap_or_default(); 187 + let resolved = t.resolved.map(|b| b != 0).unwrap_or(false); 188 + let display = t.display.clone(); 189 + return ItemStack::WrittenBook(WrittenBookStack { 190 + count, 191 + damage, 192 + id, 193 + tag: WrittenBookTag { 194 + base: BaseTag { display }, 195 + author, 196 + title, 197 + generation, 198 + resolved, 199 + pages: pages 200 + .into_iter() 201 + .map(|s| parse_component_from_str(&s)) 202 + .collect(), 203 + }, 204 + }); 205 + } 206 + } 207 + } 208 + 209 + if let Some(ref t) = tag { 210 + if let Some(ref be) = t.block_entity { 211 + let display = t.display.clone(); 212 + return ItemStack::BlockEntity(BlockEntityStack { 213 + count, 214 + damage, 215 + id, 216 + tag: BlockEntityTag { 217 + base: BaseTag { display }, 218 + block_entity: be.clone(), 219 + }, 220 + }); 221 + } 222 + } 223 + 224 + ItemStack::Base(BaseItemStack { count, damage, id }) 225 + } 226 + 227 + impl<'de> Deserialize<'de> for ItemStack { 228 + fn deserialize<D: Deserializer<'de>>( 229 + deserializer: D, 230 + ) -> Result<Self, D::Error> { 231 + let raw = RawItemStack::deserialize(deserializer)?; 232 + Ok(build_item_stack(raw.id, raw.count, raw.damage, raw.tag)) 233 + } 234 + } 235 + 236 + impl<'de> Deserialize<'de> for InventoryItemStack { 237 + fn deserialize<D: Deserializer<'de>>( 238 + deserializer: D, 239 + ) -> Result<Self, D::Error> { 240 + let raw = RawInventoryItemStack::deserialize(deserializer)?; 241 + Ok(InventoryItemStack { 242 + item: build_item_stack(raw.id, raw.count, raw.damage, raw.tag), 243 + slot: raw.slot, 244 + }) 245 + } 131 246 } 132 247 133 248 impl From<BookGeneration> for nara_core::book::BookGeneration {
+42
nara_slurper_1_12_core/src/lib.rs
··· 1 + use nara_core::book::{Book, BookMetadata, BookSource}; 2 + 1 3 use crate::item::{InventoryItemStack, ItemStack, WrittenBookStack}; 2 4 5 + pub mod chunk; 3 6 pub mod item; 4 7 5 8 pub fn extract_books_from_inventory( ··· 21 24 22 25 books 23 26 } 27 + 28 + pub fn extract_books_with_source( 29 + inventory: &[InventoryItemStack], 30 + nested_block_entity_id: Option<String>, 31 + nested_source: Option<BookSource>, 32 + make_root: &impl Fn(u8) -> BookSource, 33 + ) -> Vec<Book> { 34 + let mut books = Vec::new(); 35 + for item in inventory { 36 + let item_source = match &nested_source { 37 + Some(outer) => BookSource::ItemBlockEntity { 38 + id: nested_block_entity_id.clone().unwrap(), 39 + slot: item.slot as u8, 40 + within: Box::new(outer.clone()), 41 + }, 42 + None => make_root(item.slot as u8), 43 + }; 44 + match &item.item { 45 + ItemStack::WrittenBook(written_book) => { 46 + books.push(Book { 47 + metadata: BookMetadata { 48 + source: item_source, 49 + }, 50 + content: written_book.clone().into(), 51 + }); 52 + } 53 + ItemStack::BlockEntity(block_entity) => { 54 + books.extend(extract_books_with_source( 55 + &block_entity.tag.block_entity.items, 56 + Some(block_entity.id.clone()), 57 + Some(item_source), 58 + make_root, 59 + )); 60 + } 61 + _ => {} 62 + } 63 + } 64 + books 65 + }
+2
nara_slurper_1_12_infinity/Cargo.toml
··· 4 4 edition.workspace = true 5 5 6 6 [dependencies] 7 + nara_core.workspace = true 8 + nara_io.workspace = true 7 9 nara_slurper_1_12_core.workspace = true 8 10 serde.workspace = true
+33 -1
nara_slurper_1_12_infinity/src/lib.rs
··· 1 - use nara_slurper_1_12_core::item::ItemStack; 1 + use std::path::Path; 2 + 3 + use nara_core::book::{Book, BookSource}; 4 + use nara_slurper_1_12_core::{ 5 + extract_books_with_source, 6 + item::{InventoryItemStack, ItemStack}, 7 + }; 2 8 use serde::Deserialize; 3 9 4 10 #[derive(Debug, Deserialize)] ··· 6 12 pub realm: Vec<ItemStack>, 7 13 pub realm_version: String, 8 14 } 15 + 16 + impl Realm { 17 + pub fn read<P>(path: P) -> nara_io::Result<Realm> 18 + where 19 + P: AsRef<Path>, 20 + { 21 + let buffer = std::fs::read(path)?; 22 + Ok(nara_io::read_nbt(&buffer)?) 23 + } 24 + 25 + pub fn slurp(&self) -> Vec<Book> { 26 + let fake_inventory: Vec<InventoryItemStack> = self 27 + .realm 28 + .iter() 29 + .enumerate() 30 + .map(|(slot, item)| InventoryItemStack { 31 + item: item.clone(), 32 + slot: slot as i8, 33 + }) 34 + .collect(); 35 + 36 + extract_books_with_source(&fake_inventory, None, None, &|_| { 37 + BookSource::InfinityRealm 38 + }) 39 + } 40 + }
+3
nara_slurper_1_12_world/Cargo.toml
··· 5 5 6 6 [dependencies] 7 7 crab_nbt.workspace = true 8 + nara_mcr.workspace = true 8 9 nara_slurper_1_12_core.workspace = true 9 10 nara_io.workspace = true 10 11 nara_core.workspace = true 11 12 serde.workspace = true 12 13 serde_with.workspace = true 13 14 thiserror.workspace = true 15 + tokio.workspace = true 16 + tracing.workspace = true 14 17 uuid.workspace = true 15 18 16 19 [dev-dependencies]
-8
nara_slurper_1_12_world/README.md
··· 5 5 6 6 Checks: 7 7 8 - - playerdata 9 - - inventory 10 - - ender chest 11 8 - entitydata 12 9 - item frames 13 - - block entity data 14 - - chests 15 - - shulker boxes 16 - - dispensers 17 - - etc.
+196 -10
nara_slurper_1_12_world/src/lib.rs
··· 1 - use std::path::Path; 1 + use std::{ 2 + path::{Path, PathBuf}, 3 + sync::{Arc, Mutex}, 4 + }; 2 5 3 - use nara_core::book::Book; 6 + use nara_core::book::{Book, BookSource}; 4 7 use nara_io::read_nbt; 8 + use nara_mcr::McRegion; 9 + use nara_slurper_1_12_core::{ 10 + chunk::{Chunk, Entity}, 11 + extract_books_with_source, 12 + item::InventoryItemStack, 13 + }; 14 + use tokio::task::JoinSet; 5 15 6 16 use crate::playerdata::PlayerData; 7 17 ··· 13 23 Io(#[from] std::io::Error), 14 24 #[error(transparent)] 15 25 NaraIo(#[from] nara_io::Error), 26 + #[error(transparent)] 27 + McRegion(#[from] nara_mcr::Error), 28 + #[error("worker task panicked: {0}")] 29 + Join(#[from] tokio::task::JoinError), 16 30 } 17 31 18 32 pub type Result<T> = std::result::Result<T, Error>; 19 33 20 - pub fn scan_world(world_directory: &Path) -> Result<Vec<Book>> { 21 - let mut books = Vec::new(); 34 + enum Job { 35 + Playerdata(PathBuf), 36 + Region { path: PathBuf, dimension: String }, 37 + } 38 + 39 + pub async fn slurp_world( 40 + world_directory: &Path, 41 + num_workers: usize, 42 + ) -> Result<Vec<Book>> { 43 + let (tx, rx) = std::sync::mpsc::channel::<Job>(); 44 + let rx = Arc::new(Mutex::new(rx)); 45 + 46 + let mut set: JoinSet<Result<Vec<Book>>> = JoinSet::new(); 47 + for _ in 0..num_workers { 48 + let rx = Arc::clone(&rx); 49 + set.spawn_blocking(move || { 50 + let mut books = Vec::new(); 51 + loop { 52 + let job = rx.lock().unwrap().recv(); 53 + match job { 54 + Ok(Job::Playerdata(path)) => { 55 + books.extend(slurp_playerdata_file(&path)?); 56 + } 57 + Ok(Job::Region { path, dimension }) => { 58 + books.extend(slurp_region_file(&path, &dimension)?); 59 + } 60 + Err(_) => break, // channel closed, no more jobs 61 + } 62 + } 63 + Ok(books) 64 + }); 65 + } 66 + 67 + // Enumerate files and queue jobs. This is fast synchronous directory I/O 68 + // so it stays in the async task. 22 69 let playerdata_dir = world_directory.join("playerdata"); 70 + if playerdata_dir.exists() { 71 + for entry in std::fs::read_dir(&playerdata_dir)? { 72 + let _ = tx.send(Job::Playerdata(entry?.path())); 73 + } 74 + } 23 75 24 - for entry in std::fs::read_dir(&playerdata_dir)? { 25 - let entry = entry?; 26 - let path = entry.path(); 27 - let buffer = std::fs::read(path)?; 28 - let playerdata = read_nbt::<PlayerData>(&buffer)?; 29 - books.extend(playerdata.slurp()); 76 + for (dimension_dir, dimension) in [( 77 + world_directory.to_owned(), 78 + world_directory 79 + .file_name() 80 + .unwrap() 81 + .to_os_string() 82 + .into_string() 83 + .unwrap(), 84 + )] { 85 + let region_dir = dimension_dir.join("region"); 86 + if !region_dir.exists() { 87 + continue; 88 + } 89 + for entry in std::fs::read_dir(&region_dir)? { 90 + let path = entry?.path(); 91 + if path.extension().and_then(|e| e.to_str()) != Some("mca") { 92 + continue; 93 + } 94 + let _ = tx.send(Job::Region { 95 + path, 96 + dimension: dimension.to_owned(), 97 + }); 98 + } 99 + } 100 + 101 + drop(tx); 102 + 103 + let mut books = Vec::new(); 104 + while let Some(result) = set.join_next().await { 105 + books.extend(result??); 30 106 } 107 + Ok(books) 108 + } 31 109 110 + fn slurp_playerdata_file(path: &Path) -> Result<Vec<Book>> { 111 + let buffer = std::fs::read(path)?; 112 + let playerdata = read_nbt::<PlayerData>(&buffer)?; 113 + let books = playerdata.slurp(); 114 + if !books.is_empty() { 115 + tracing::info!( 116 + "Slurped {} book(s) from {}", 117 + books.len(), 118 + playerdata.uuid() 119 + ); 120 + } 121 + Ok(books) 122 + } 123 + 124 + fn slurp_region_file(path: &Path, dimension: &str) -> Result<Vec<Book>> { 125 + tracing::info!("Slurping {path:?}..."); 126 + let data = std::fs::read(path)?; 127 + let region = McRegion::<Chunk>::from_bytes(&data)?; 128 + 129 + let mut books = Vec::new(); 130 + for (_, chunk) in region.iter() { 131 + for e in &chunk.level.entities { 132 + match e { 133 + Entity::ItemHolder(item_holder_entity) => { 134 + let mut fake_inventory = Vec::with_capacity(1); 135 + fake_inventory.push(InventoryItemStack { 136 + slot: 0, 137 + item: item_holder_entity.item.clone(), 138 + }); 139 + let slurped = extract_books_with_source( 140 + &fake_inventory, 141 + None, 142 + None, 143 + &|_| BookSource::Entity { 144 + dimension: dimension.to_string(), 145 + id: item_holder_entity.base.id.clone(), 146 + x: item_holder_entity.base.position[0], 147 + y: item_holder_entity.base.position[1], 148 + z: item_holder_entity.base.position[2], 149 + }, 150 + ); 151 + if !slurped.is_empty() { 152 + tracing::info!( 153 + "Slurped a book from a {} at {:?} in chunk ({}, {}) of {path:?}", 154 + item_holder_entity.base.id, 155 + item_holder_entity.base.position, 156 + chunk.level.x_pos, 157 + chunk.level.z_pos 158 + ); 159 + } 160 + books.extend(slurped); 161 + } 162 + Entity::Inventoried(inventoried_entity) => { 163 + let slurped = extract_books_with_source( 164 + &inventoried_entity.items, 165 + None, 166 + None, 167 + &|slot| BookSource::EntityInventory { 168 + dimension: dimension.to_string(), 169 + id: inventoried_entity.base.id.clone(), 170 + x: inventoried_entity.base.position[0], 171 + y: inventoried_entity.base.position[1], 172 + z: inventoried_entity.base.position[2], 173 + slot, 174 + }, 175 + ); 176 + if !slurped.is_empty() { 177 + tracing::info!( 178 + "Slurped {} book(s) from a {} at {:?} in chunk ({}, {}) of {path:?}", 179 + slurped.len(), 180 + inventoried_entity.base.id, 181 + inventoried_entity.base.position, 182 + chunk.level.x_pos, 183 + chunk.level.z_pos 184 + ); 185 + } 186 + books.extend(slurped); 187 + } 188 + _ => {} 189 + } 190 + } 191 + for te in &chunk.level.tile_entities { 192 + let slurped = 193 + extract_books_with_source(&te.items, None, None, &|slot| { 194 + BookSource::BlockEntity { 195 + dimension: dimension.to_string(), 196 + id: te.id.clone(), 197 + x: te.x, 198 + y: te.y, 199 + z: te.z, 200 + slot, 201 + } 202 + }); 203 + if !slurped.is_empty() { 204 + tracing::info!( 205 + "Slurped {} book(s) from a {} at ({},{},{}) in chunk ({},{}) of {path:?}", 206 + slurped.len(), 207 + te.id, 208 + te.x, 209 + te.y, 210 + te.z, 211 + chunk.level.x_pos, 212 + chunk.level.z_pos, 213 + ); 214 + } 215 + books.extend(slurped); 216 + } 217 + } 32 218 Ok(books) 33 219 }
+19 -50
nara_slurper_1_12_world/src/playerdata.rs
··· 1 1 use nara_core::{ 2 - book::{Book, BookMetadata, BookSource, PlayerInventoryKind}, 2 + book::{Book, BookSource, PlayerInventoryKind}, 3 3 i64_pair_to_uuid, 4 4 }; 5 - use nara_slurper_1_12_core::item::{InventoryItemStack, ItemStack}; 5 + use nara_slurper_1_12_core::{ 6 + extract_books_with_source, item::InventoryItemStack, 7 + }; 6 8 use serde::Deserialize; 7 9 use uuid::Uuid; 8 10 ··· 26 28 } 27 29 28 30 pub fn slurp(&self) -> Vec<Book> { 31 + let uuid = self.uuid(); 29 32 let mut books = Vec::new(); 30 33 31 - books.extend(extract_books_from_playerdata( 34 + books.extend(extract_books_with_source( 32 35 &self.inventory, 33 - PlayerInventoryKind::Inventory, 34 - self.uuid(), 36 + None, 35 37 None, 38 + &|slot| BookSource::PlayerData { 39 + uuid, 40 + inventory: PlayerInventoryKind::Inventory, 41 + slot, 42 + }, 36 43 )); 37 44 38 - books.extend(extract_books_from_playerdata( 45 + books.extend(extract_books_with_source( 39 46 &self.ender_chest, 40 - PlayerInventoryKind::EnderChest, 41 - self.uuid(), 47 + None, 42 48 None, 49 + &|slot| BookSource::PlayerData { 50 + uuid, 51 + inventory: PlayerInventoryKind::EnderChest, 52 + slot, 53 + }, 43 54 )); 44 55 45 56 books 46 57 } 47 58 } 48 - 49 - fn extract_books_from_playerdata( 50 - inventory: &[InventoryItemStack], 51 - inventory_kind: PlayerInventoryKind, 52 - uuid: Uuid, 53 - nested_source: Option<BookSource>, 54 - ) -> Vec<Book> { 55 - let mut books = Vec::new(); 56 - for item in inventory { 57 - let item_source = match &nested_source { 58 - Some(outer) => BookSource::ItemBlockEntity { 59 - slot: item.slot as u8, 60 - within: Box::new(outer.clone()), 61 - }, 62 - None => BookSource::PlayerData { 63 - uuid, 64 - inventory: inventory_kind, 65 - slot: item.slot as u8, 66 - }, 67 - }; 68 - match &item.item { 69 - ItemStack::WrittenBook(written_book) => { 70 - books.push(Book { 71 - metadata: BookMetadata { 72 - source: item_source, 73 - }, 74 - content: written_book.clone().into(), 75 - }); 76 - } 77 - ItemStack::BlockEntity(block_entity) => { 78 - books.extend(extract_books_from_playerdata( 79 - &block_entity.tag.block_entity.items, 80 - inventory_kind, 81 - uuid, 82 - Some(item_source), 83 - )); 84 - } 85 - _ => {} 86 - } 87 - } 88 - books 89 - }
+1
nara_slurper_1_12_world/tests/snapshots/data__playerdata_slurp.snap
··· 135 135 Book { 136 136 metadata: BookMetadata { 137 137 source: ItemBlockEntity { 138 + id: "minecraft:pink_shulker_box", 138 139 slot: 2, 139 140 within: PlayerData { 140 141 uuid: 89c29c53-ef0c-4623-92cb-0f79930a97c2,
+29 -7
src/cli.rs
··· 15 15 pub enum Command { 16 16 /// Build the library and optionally run the webserver. 17 17 Serve(ServeArgs), 18 - /// Scan a world directory. 19 - ScanWorld(ScanWorldArgs), 18 + /// Slurp a world directory. 19 + SlurpWorld(SlurpWorldArgs), 20 + /// Slurp an Infinity Item Editor realm. 21 + SlurpRealm(SlurpRealmArgs), 22 + } 23 + 24 + #[derive(Debug, Clone, Copy, clap::ValueEnum)] 25 + #[clap(rename_all = "snake_case")] 26 + pub enum MinecraftVersion { 27 + V1_12, 20 28 } 21 29 22 30 #[derive(Args, Debug)] 23 - pub struct ScanWorldArgs { 31 + pub struct SlurpRealmArgs { 32 + #[arg(short = 'v', long = "version", default_value = "v1_12")] 33 + pub version: MinecraftVersion, 34 + 35 + /// The path to the `realm.nbt` file created by Infinity Item Editor. 36 + #[arg(short = 'r', long = "realm", default_value = "realm.nbt")] 37 + pub realm_path: PathBuf, 38 + } 39 + 40 + #[derive(Args, Debug)] 41 + pub struct SlurpWorldArgs { 42 + #[arg(short = 'v', long = "version", default_value = "v1_12")] 43 + pub version: MinecraftVersion, 44 + 24 45 /// Path to the world directory. 25 46 #[arg(short = 'w', long = "world")] 26 47 pub world_path: PathBuf, 48 + 49 + /// Number of worker threads used for parallel file slurping. 50 + /// Defaults to the number of logical CPUs. 51 + #[arg(short = 'j', long = "workers")] 52 + pub workers: Option<usize>, 27 53 } 28 54 29 55 #[derive(Args, Debug)] 30 56 pub struct ServeArgs { 31 - /// The path to the `realm.nbt` file created by Infinity Item Editor. 32 - #[arg(short = 'r', long = "realm", default_value = "realm.nbt")] 33 - pub realm_path: PathBuf, 34 - 35 57 /// Whether to warn about duplicate books being found during initial indexing. 36 58 #[arg(short = 'd', long, default_value_t = true, action = ArgAction::Set)] 37 59 pub warn_duplicates: bool,
+39 -14
src/main.rs
··· 1 1 use anyhow::Context; 2 2 use clap::Parser as _; 3 + use nara_slurper_1_12_infinity::Realm; 3 4 4 5 use crate::{ 5 6 cli::{Cli, Command, ServeArgs}, 6 7 library::Library, 7 8 web::start_webserver, 8 9 }; 9 - use nara_slurper_1_12_world::scan_world; 10 + use nara_slurper_1_12_world::slurp_world; 10 11 11 12 pub mod cli; 12 13 pub mod library; ··· 29 30 .context("Running webserver")?; 30 31 } 31 32 } 32 - Command::ScanWorld(args) => { 33 - let books = 34 - scan_world(&args.world_path).context("Scanning world")?; 35 - books.iter().for_each(|book| { 36 - println!( 37 - "{} [{:?}]: {:#?}", 38 - book.content.title, 39 - book.hash(), 40 - book.metadata.source 41 - ) 42 - }); 43 - println!("found {} books", books.len()); 44 - } 33 + Command::SlurpWorld(args) => match args.version { 34 + cli::MinecraftVersion::V1_12 => { 35 + let num_workers = args.workers.unwrap_or_else(|| { 36 + std::thread::available_parallelism() 37 + .map(|n| n.get()) 38 + .unwrap_or(4) 39 + }); 40 + let books = slurp_world(&args.world_path, num_workers) 41 + .await 42 + .context("Slurping world")?; 43 + books.iter().for_each(|book| { 44 + println!( 45 + "{} [{:?}]: {:#?}", 46 + book.content.title, 47 + book.hash(), 48 + book.metadata.source 49 + ) 50 + }); 51 + println!("found {} books", books.len()); 52 + } 53 + }, 54 + Command::SlurpRealm(args) => match args.version { 55 + cli::MinecraftVersion::V1_12 => { 56 + let realm = Realm::read(args.realm_path)?; 57 + let books = realm.slurp(); 58 + 59 + books.iter().for_each(|book| { 60 + println!( 61 + "{} [{:?}]: {:#?}", 62 + book.content.title, 63 + book.hash(), 64 + book.metadata.source 65 + ) 66 + }); 67 + println!("found {} books", books.len()); 68 + } 69 + }, 45 70 } 46 71 47 72 Ok(())