online Minecraft written book viewer

feat(1.12): more work on world slurping

kokirigla.de 357882d3 070328c3

verified
+408 -68
+15
Cargo.lock
··· 1024 1024 "html-escape", 1025 1025 "lru", 1026 1026 "nara_core", 1027 + "nara_slurper_1_12_world", 1027 1028 "serde", 1028 1029 "serde_json", 1029 1030 "serde_with", ··· 1046 1047 "serde_json", 1047 1048 "serde_with", 1048 1049 "sha1", 1050 + "uuid", 1049 1051 ] 1050 1052 1051 1053 [[package]] ··· 1085 1087 "nara_slurper_1_12_core", 1086 1088 "serde", 1087 1089 "serde_with", 1090 + "thiserror", 1091 + "uuid", 1088 1092 ] 1089 1093 1090 1094 [[package]] ··· 1747 1751 version = "0.2.2" 1748 1752 source = "registry+https://github.com/rust-lang/crates.io-index" 1749 1753 checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" 1754 + 1755 + [[package]] 1756 + name = "uuid" 1757 + version = "1.21.0" 1758 + source = "registry+https://github.com/rust-lang/crates.io-index" 1759 + checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" 1760 + dependencies = [ 1761 + "js-sys", 1762 + "serde_core", 1763 + "wasm-bindgen", 1764 + ] 1750 1765 1751 1766 [[package]] 1752 1767 name = "valuable"
+2
Cargo.toml
··· 35 35 tower-http = { version = "=0.6.8", features = ["compression-full"] } 36 36 tracing = "=0.1.44" 37 37 tracing-subscriber = "=0.3.22" 38 + uuid = { version = "=1.21.0", features = ["serde"] } 38 39 39 40 [package] 40 41 name = "nara" ··· 53 54 html-escape.workspace = true 54 55 lru.workspace = true 55 56 nara_core.workspace = true 57 + nara_slurper_1_12_world.workspace = true 56 58 serde.workspace = true 57 59 serde_json.workspace = true 58 60 serde_with.workspace = true
+1
nara_core/Cargo.toml
··· 8 8 serde_json.workspace = true 9 9 serde_with.workspace = true 10 10 sha1.workspace = true 11 + uuid.workspace = true 11 12 12 13 [dev-dependencies] 13 14 insta.workspace = true
+6 -1
nara_core/src/book.rs
··· 1 1 use serde::{Deserialize, Serialize}; 2 2 use sha1::Digest as _; 3 + use uuid::Uuid; 3 4 4 5 use crate::component::Component; 5 6 ··· 33 34 InfinityRealm, 34 35 /// From a player's save data. 35 36 PlayerData { 36 - uuid: String, 37 + uuid: Uuid, 37 38 inventory: PlayerInventoryKind, 39 + slot: u8, 38 40 }, 39 41 /// From a placed block entity, such as a chest. 40 42 BlockEntity { ··· 42 44 x: i32, 43 45 y: i32, 44 46 z: i32, 47 + slot: u8, 45 48 }, 49 + /// Inside an item at `slot` within another container. 50 + ItemBlockEntity { slot: u8, within: Box<BookSource> }, 46 51 } 47 52 48 53 #[derive(Debug, Clone, Copy, Serialize, Deserialize)]
+7
nara_core/src/lib.rs
··· 1 + use ::uuid::Uuid; 2 + 1 3 pub mod book; 2 4 pub mod color; 3 5 pub mod component; 4 6 pub mod profile; 7 + pub mod uuid; 8 + 9 + pub fn i64_pair_to_uuid(most_significant: i64, least_signifcant: i64) -> Uuid { 10 + Uuid::from_u64_pair(most_significant as u64, least_signifcant as u64) 11 + }
+67
nara_core/src/uuid.rs
··· 1 + use serde::{Deserialize, Deserializer, Serialize, Serializer}; 2 + use serde_with::{DeserializeAs, SerializeAs}; 3 + 4 + /// Usage: `#[serde_as(as = "UuidIntArray")]` or 5 + /// `#[serde_as(as = "Option<UuidIntArray>")]`. 6 + pub struct UuidIntArray; 7 + 8 + impl SerializeAs<::uuid::Uuid> for UuidIntArray { 9 + fn serialize_as<S>( 10 + uuid: &::uuid::Uuid, 11 + serializer: S, 12 + ) -> Result<S::Ok, S::Error> 13 + where 14 + S: Serializer, 15 + { 16 + let b = uuid.as_bytes(); 17 + let arr: [i32; 4] = [ 18 + i32::from_be_bytes([b[0], b[1], b[2], b[3]]), 19 + i32::from_be_bytes([b[4], b[5], b[6], b[7]]), 20 + i32::from_be_bytes([b[8], b[9], b[10], b[11]]), 21 + i32::from_be_bytes([b[12], b[13], b[14], b[15]]), 22 + ]; 23 + arr.serialize(serializer) 24 + } 25 + } 26 + 27 + impl<'de> DeserializeAs<'de, ::uuid::Uuid> for UuidIntArray { 28 + fn deserialize_as<D>(deserializer: D) -> Result<::uuid::Uuid, D::Error> 29 + where 30 + D: Deserializer<'de>, 31 + { 32 + let [a, b, c, d] = <[i32; 4]>::deserialize(deserializer)?; 33 + let mut bytes = [0u8; 16]; 34 + bytes[0..4].copy_from_slice(&a.to_be_bytes()); 35 + bytes[4..8].copy_from_slice(&b.to_be_bytes()); 36 + bytes[8..12].copy_from_slice(&c.to_be_bytes()); 37 + bytes[12..16].copy_from_slice(&d.to_be_bytes()); 38 + Ok(::uuid::Uuid::from_bytes(bytes)) 39 + } 40 + } 41 + 42 + /// Usage: `#[serde_as(as = "UuidLongPair")]` or 43 + /// `#[serde_as(as = "Option<UuidLongPair>")]`. 44 + pub struct UuidLongPair; 45 + 46 + impl SerializeAs<::uuid::Uuid> for UuidLongPair { 47 + fn serialize_as<S>( 48 + uuid: &::uuid::Uuid, 49 + serializer: S, 50 + ) -> Result<S::Ok, S::Error> 51 + where 52 + S: Serializer, 53 + { 54 + let (msb, lsb) = uuid.as_u64_pair(); 55 + [msb as i64, lsb as i64].serialize(serializer) 56 + } 57 + } 58 + 59 + impl<'de> DeserializeAs<'de, ::uuid::Uuid> for UuidLongPair { 60 + fn deserialize_as<D>(deserializer: D) -> Result<::uuid::Uuid, D::Error> 61 + where 62 + D: Deserializer<'de>, 63 + { 64 + let [msb, lsb] = <[i64; 2]>::deserialize(deserializer)?; 65 + Ok(::uuid::Uuid::from_u64_pair(msb as u64, lsb as u64)) 66 + } 67 + }
+32 -2
nara_slurper_1_12_core/src/item.rs
··· 1 - use nara_core::component::Component; 1 + use nara_core::{book::BookContent, component::Component}; 2 2 use serde::Deserialize; 3 3 4 4 #[derive(Debug, Clone, Deserialize)] ··· 117 117 "minecraft:hopper" => BlockEntityKind::Hopper, 118 118 "minecraft:dropper" => BlockEntityKind::Dropper, 119 119 "minecraft:dispenser" => BlockEntityKind::Dispenser, 120 - "minecraft:barrel" => BlockEntityKind::Barrel, 121 120 _ => BlockEntityKind::Other, 122 121 } 123 122 } ··· 130 129 pub item: ItemStack, 131 130 pub slot: i8, 132 131 } 132 + 133 + impl From<BookGeneration> for nara_core::book::BookGeneration { 134 + fn from(value: BookGeneration) -> Self { 135 + match value { 136 + BookGeneration::Original => { 137 + nara_core::book::BookGeneration::Original 138 + } 139 + BookGeneration::CopyOfOriginal => { 140 + nara_core::book::BookGeneration::CopyOfOriginal 141 + } 142 + BookGeneration::CopyOfCopy => { 143 + nara_core::book::BookGeneration::CopyOfCopy 144 + } 145 + BookGeneration::Tattered => { 146 + nara_core::book::BookGeneration::Tattered 147 + } 148 + } 149 + } 150 + } 151 + 152 + impl From<WrittenBookStack> for BookContent { 153 + fn from(value: WrittenBookStack) -> Self { 154 + BookContent { 155 + author: value.tag.author, 156 + pages: value.tag.pages, 157 + title: value.tag.title, 158 + generation: value.tag.generation.into(), 159 + resolved: value.tag.resolved, 160 + } 161 + } 162 + }
+2 -6
nara_slurper_1_12_core/src/lib.rs
··· 12 12 books.push(written_book_stack.clone()) 13 13 } 14 14 ItemStack::BlockEntity(block_entity_stack) => { 15 - extract_books_from_inventory( 15 + books.extend(extract_books_from_inventory( 16 16 &block_entity_stack.tag.block_entity.items, 17 - ) 18 - .into_iter() 19 - .for_each(|book| { 20 - books.push(book); 21 - }); 17 + )); 22 18 } 23 19 _ => {} 24 20 });
+2
nara_slurper_1_12_world/Cargo.toml
··· 10 10 nara_core.workspace = true 11 11 serde.workspace = true 12 12 serde_with.workspace = true 13 + thiserror.workspace = true 14 + uuid.workspace = true 13 15 14 16 [dev-dependencies] 15 17 insta.workspace = true
+29 -28
nara_slurper_1_12_world/src/lib.rs
··· 1 - use nara_slurper_1_12_core::{ 2 - extract_books_from_inventory, 3 - item::{InventoryItemStack, WrittenBookStack}, 4 - }; 5 - use serde::Deserialize; 1 + use std::path::Path; 2 + 3 + use nara_core::book::Book; 4 + use nara_io::read_nbt; 5 + 6 + use crate::playerdata::PlayerData; 7 + 8 + pub mod playerdata; 6 9 7 - // the shit we care about from playerdata.dat files :^) 8 - #[derive(Debug, Deserialize)] 9 - #[serde(rename_all = "PascalCase")] 10 - pub struct PlayerData { 11 - #[serde(default, skip_serializing_if = "Vec::is_empty")] 12 - pub inventory: Vec<InventoryItemStack>, 13 - #[serde( 14 - default, 15 - rename = "EnderItems", 16 - skip_serializing_if = "Vec::is_empty" 17 - )] 18 - pub ender_chest: Vec<InventoryItemStack>, 10 + #[derive(Debug, thiserror::Error)] 11 + pub enum Error { 12 + #[error(transparent)] 13 + Io(#[from] std::io::Error), 14 + #[error(transparent)] 15 + NaraIo(#[from] nara_io::Error), 19 16 } 20 17 21 - pub fn extract_books_from_playerdata( 22 - player_data: &PlayerData, 23 - ) -> Vec<WrittenBookStack> { 24 - let mut books: Vec<WrittenBookStack> = Vec::new(); 25 - extract_books_from_inventory(&player_data.inventory) 26 - .into_iter() 27 - .for_each(|book| books.push(book)); 28 - extract_books_from_inventory(&player_data.ender_chest) 29 - .into_iter() 30 - .for_each(|book| books.push(book)); 31 - books 18 + pub type Result<T> = std::result::Result<T, Error>; 19 + 20 + pub fn scan_world(world_directory: &Path) -> Result<Vec<Book>> { 21 + let mut books = Vec::new(); 22 + let playerdata_dir = world_directory.join("playerdata"); 23 + 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()); 30 + } 31 + 32 + Ok(books) 32 33 }
+89
nara_slurper_1_12_world/src/playerdata.rs
··· 1 + use nara_core::{ 2 + book::{Book, BookMetadata, BookSource, PlayerInventoryKind}, 3 + i64_pair_to_uuid, 4 + }; 5 + use nara_slurper_1_12_core::item::{InventoryItemStack, ItemStack}; 6 + use serde::Deserialize; 7 + use uuid::Uuid; 8 + 9 + // the shit we care about from playerdata.dat files :^) 10 + #[derive(Debug, Deserialize)] 11 + #[serde(rename_all = "PascalCase")] 12 + pub struct PlayerData { 13 + #[serde(rename = "UUIDLeast")] 14 + pub uuid_least: i64, 15 + #[serde(rename = "UUIDMost")] 16 + pub uuid_most: i64, 17 + #[serde(default)] 18 + pub inventory: Vec<InventoryItemStack>, 19 + #[serde(default, rename = "EnderItems")] 20 + pub ender_chest: Vec<InventoryItemStack>, 21 + } 22 + 23 + impl PlayerData { 24 + pub fn uuid(&self) -> Uuid { 25 + i64_pair_to_uuid(self.uuid_most, self.uuid_least) 26 + } 27 + 28 + pub fn slurp(&self) -> Vec<Book> { 29 + let mut books = Vec::new(); 30 + 31 + books.extend(extract_books_from_playerdata( 32 + &self.inventory, 33 + PlayerInventoryKind::Inventory, 34 + self.uuid(), 35 + None, 36 + )); 37 + 38 + books.extend(extract_books_from_playerdata( 39 + &self.ender_chest, 40 + PlayerInventoryKind::EnderChest, 41 + self.uuid(), 42 + None, 43 + )); 44 + 45 + books 46 + } 47 + } 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 + }
+3 -5
nara_slurper_1_12_world/tests/data.rs
··· 1 - use nara_slurper_1_12_world::{PlayerData, extract_books_from_playerdata}; 1 + use nara_slurper_1_12_world::playerdata::PlayerData; 2 2 3 3 const PLAYER_DATA_FIXTURE: &[u8] = include_bytes!("fixtures/playerdata.dat"); 4 4 ··· 12 12 } 13 13 14 14 #[test] 15 - fn playerdata_extract() { 16 - insta::assert_debug_snapshot!(extract_books_from_playerdata( 17 - &get_playerdata() 18 - )); 15 + fn playerdata_slurp() { 16 + insta::assert_debug_snapshot!(get_playerdata().slurp()); 19 17 }
nara_slurper_1_12_world/tests/fixtures/playerdata.dat

This is a binary file and will not be displayed.

+62 -1
nara_slurper_1_12_world/tests/snapshots/data__playerdata.snap
··· 3 3 expression: get_playerdata() 4 4 --- 5 5 PlayerData { 6 + uuid_least: -7869178909067405374, 7 + uuid_most: -8520075660724779485, 6 8 inventory: [ 7 9 InventoryItemStack { 8 10 item: WrittenBook( ··· 238 240 ), 239 241 slot: 5, 240 242 }, 243 + InventoryItemStack { 244 + item: Base( 245 + BaseItemStack { 246 + count: 1, 247 + damage: 0, 248 + id: "minecraft:ender_chest", 249 + }, 250 + ), 251 + slot: 7, 252 + }, 241 253 ], 242 - ender_chest: [], 254 + ender_chest: [ 255 + InventoryItemStack { 256 + item: WrittenBook( 257 + WrittenBookStack { 258 + base: BaseItemStack { 259 + count: 1, 260 + damage: 0, 261 + id: "minecraft:written_book", 262 + }, 263 + tag: WrittenBookTag { 264 + base: BaseTag { 265 + display: None, 266 + }, 267 + author: "kokiriglade", 268 + title: "ender book :^)", 269 + generation: Original, 270 + resolved: false, 271 + pages: [ 272 + Object( 273 + ComponentObject { 274 + text: Some( 275 + TextComponent { 276 + text: "this ones in my ender chest !! wow !!!!", 277 + }, 278 + ), 279 + translation: None, 280 + score: None, 281 + selector: None, 282 + keybind: None, 283 + nbt: None, 284 + object: None, 285 + formatting: ComponentFormatting { 286 + color: None, 287 + font: None, 288 + bold: None, 289 + italic: None, 290 + underlined: None, 291 + strikethrough: None, 292 + obfuscated: None, 293 + }, 294 + children: [], 295 + }, 296 + ), 297 + ], 298 + }, 299 + }, 300 + ), 301 + slot: 16, 302 + }, 303 + ], 243 304 }
+66 -23
nara_slurper_1_12_world/tests/snapshots/data__playerdata_extract.snap nara_slurper_1_12_world/tests/snapshots/data__playerdata_slurp.snap
··· 1 1 --- 2 2 source: nara_slurper_1_12_world/tests/data.rs 3 - expression: extract_books_from_playerdata(&get_playerdata()) 3 + expression: get_playerdata().slurp() 4 4 --- 5 5 [ 6 - WrittenBookStack { 7 - base: BaseItemStack { 8 - count: 1, 9 - damage: 0, 10 - id: "minecraft:written_book", 11 - }, 12 - tag: WrittenBookTag { 13 - base: BaseTag { 14 - display: None, 6 + Book { 7 + metadata: BookMetadata { 8 + source: PlayerData { 9 + uuid: 89c29c53-ef0c-4623-92cb-0f79930a97c2, 10 + inventory: Inventory, 11 + slot: 0, 15 12 }, 13 + }, 14 + content: BookContent { 16 15 author: "kokiriglade", 17 - title: "just a book", 18 - generation: Original, 19 - resolved: false, 20 16 pages: [ 21 17 Array( 22 18 [ ··· 131 127 ], 132 128 ), 133 129 ], 130 + title: "just a book", 131 + generation: Original, 132 + resolved: false, 134 133 }, 135 134 }, 136 - WrittenBookStack { 137 - base: BaseItemStack { 138 - count: 1, 139 - damage: 0, 140 - id: "minecraft:written_book", 141 - }, 142 - tag: WrittenBookTag { 143 - base: BaseTag { 144 - display: None, 135 + Book { 136 + metadata: BookMetadata { 137 + source: ItemBlockEntity { 138 + slot: 2, 139 + within: PlayerData { 140 + uuid: 89c29c53-ef0c-4623-92cb-0f79930a97c2, 141 + inventory: Inventory, 142 + slot: 1, 143 + }, 145 144 }, 145 + }, 146 + content: BookContent { 146 147 author: "kokiriglade", 148 + pages: [ 149 + Object( 150 + ComponentObject { 151 + text: Some( 152 + TextComponent { 153 + text: "this one is in a shulker box!! wow!!!", 154 + }, 155 + ), 156 + translation: None, 157 + score: None, 158 + selector: None, 159 + keybind: None, 160 + nbt: None, 161 + object: None, 162 + formatting: ComponentFormatting { 163 + color: None, 164 + font: None, 165 + bold: None, 166 + italic: None, 167 + underlined: None, 168 + strikethrough: None, 169 + obfuscated: None, 170 + }, 171 + children: [], 172 + }, 173 + ), 174 + ], 147 175 title: "in a shulker box", 148 176 generation: Original, 149 177 resolved: false, 178 + }, 179 + }, 180 + Book { 181 + metadata: BookMetadata { 182 + source: PlayerData { 183 + uuid: 89c29c53-ef0c-4623-92cb-0f79930a97c2, 184 + inventory: EnderChest, 185 + slot: 16, 186 + }, 187 + }, 188 + content: BookContent { 189 + author: "kokiriglade", 150 190 pages: [ 151 191 Object( 152 192 ComponentObject { 153 193 text: Some( 154 194 TextComponent { 155 - text: "this one is in a shulker box!! wow!!!", 195 + text: "this ones in my ender chest !! wow !!!!", 156 196 }, 157 197 ), 158 198 translation: None, ··· 174 214 }, 175 215 ), 176 216 ], 217 + title: "ender book :^)", 218 + generation: Original, 219 + resolved: false, 177 220 }, 178 221 }, 179 222 ]
+9
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), 20 + } 21 + 22 + #[derive(Args, Debug)] 23 + pub struct ScanWorldArgs { 24 + /// Path to the world directory. 25 + #[arg(short = 'w', long = "world")] 26 + pub world_path: PathBuf, 18 27 } 19 28 20 29 #[derive(Args, Debug)]
+2 -2
src/library.rs
··· 53 53 } 54 54 55 55 impl Library { 56 - /// Inserts a book and updates all indices and caches. 57 - fn add_book( 56 + /// Inserts a book 57 + pub fn add_book( 58 58 &mut self, 59 59 book: Book, 60 60 warn_duplicates: bool,
+14
src/main.rs
··· 6 6 library::Library, 7 7 web::start_webserver, 8 8 }; 9 + use nara_slurper_1_12_world::scan_world; 9 10 10 11 pub mod cli; 11 12 pub mod library; ··· 27 28 .await 28 29 .context("Running webserver")?; 29 30 } 31 + } 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()); 30 44 } 31 45 } 32 46