From f85829c88cb1336456e33c6d3c15649766b650a4 Mon Sep 17 00:00:00 2001 From: neoapps-dev Date: Sat, 23 May 2026 22:11:18 +0300 Subject: [PATCH] feat: initial support for importing worlds --- .gitignore | 3 +- README.md | 3 +- src-tauri/Cargo.lock | 11 + src-tauri/Cargo.toml | 5 + src-tauri/src/commands/console2lce.rs | 77 ++ src-tauri/src/commands/mod.rs | 1 + src-tauri/src/console2lce/archive.rs | 192 +++++ src-tauri/src/console2lce/block_mapping.rs | 176 +++++ src-tauri/src/console2lce/chunk.rs | 155 ++++ src-tauri/src/console2lce/convert.rs | 395 ++++++++++ src-tauri/src/console2lce/error.rs | 33 + .../src/console2lce/lce_chunk_payload.rs | 234 ++++++ src-tauri/src/console2lce/lce_region.rs | 70 ++ src-tauri/src/console2lce/level_dat.rs | 115 +++ src-tauri/src/console2lce/mod.rs | 19 + src-tauri/src/console2lce/models.rs | 129 +++ .../src/console2lce/modern_chunk_writer.rs | 737 ++++++++++++++++++ src-tauri/src/console2lce/nbt_util.rs | 453 +++++++++++ src-tauri/src/console2lce/region.rs | 124 +++ .../src/console2lce/save_data_container.rs | 54 ++ src-tauri/src/console2lce/stfs.rs | 635 +++++++++++++++ src-tauri/src/console2lce/world_profile.rs | 26 + src-tauri/src/lib.rs | 2 + src/components/modals/ImportWorldModal.tsx | 348 +++++++++ src/components/views/VersionsView.tsx | 42 + src/hooks/useDiscordRPC.ts | 2 +- src/services/RpcService.ts | 3 + src/services/TauriService.ts | 14 + 28 files changed, 4055 insertions(+), 3 deletions(-) create mode 100644 src-tauri/src/commands/console2lce.rs create mode 100644 src-tauri/src/console2lce/archive.rs create mode 100644 src-tauri/src/console2lce/block_mapping.rs create mode 100644 src-tauri/src/console2lce/chunk.rs create mode 100644 src-tauri/src/console2lce/convert.rs create mode 100644 src-tauri/src/console2lce/error.rs create mode 100644 src-tauri/src/console2lce/lce_chunk_payload.rs create mode 100644 src-tauri/src/console2lce/lce_region.rs create mode 100644 src-tauri/src/console2lce/level_dat.rs create mode 100644 src-tauri/src/console2lce/mod.rs create mode 100644 src-tauri/src/console2lce/models.rs create mode 100644 src-tauri/src/console2lce/modern_chunk_writer.rs create mode 100644 src-tauri/src/console2lce/nbt_util.rs create mode 100644 src-tauri/src/console2lce/region.rs create mode 100644 src-tauri/src/console2lce/save_data_container.rs create mode 100644 src-tauri/src/console2lce/stfs.rs create mode 100644 src-tauri/src/console2lce/world_profile.rs create mode 100644 src/components/modals/ImportWorldModal.tsx diff --git a/.gitignore b/.gitignore index 1ffdb6e..f701a55 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # Rust /src-tauri/target/ +/src-tauri/tests # Node /node_modules/ @@ -30,4 +31,4 @@ Thumbs.db /.flatpak-builder /*.flatpak /emerald-repo -/ignore/* \ No newline at end of file +/ignore/* diff --git a/README.md b/README.md index d623560..10cda64 100644 --- a/README.md +++ b/README.md @@ -186,9 +186,10 @@ sudo apt install --reinstall libwebkit2gtk-4.1-0 - **The Emerald Team** - Technical development and maintenance - **4J Studios & Mojang** - Original creators of Legacy Console Edition - **The LCE Community** - Research and foundations for LCE on PC +- **Veroxsity (Racoon)** - Original creator of the C# version of [console2lce](https://github.com/veroxsity/console2lce) --- ## License -This project is licensed under the **[GNU GPL v3 License](LICENSE)**. \ No newline at end of file +This project is licensed under the **[GNU GPL v3 License](LICENSE)**. diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index e0dc116..2b4f51f 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1017,15 +1017,20 @@ name = "emerald-legacy-launcher" version = "1.2.1" dependencies = [ "base64 0.21.7", + "byteorder", + "flate2", "futures-util", "http 1.4.0", "image 0.24.9", "libc", + "lzxd", + "once_cell", "rand 0.8.5", "reqwest 0.11.27", "rfd", "serde", "serde_json", + "sha1", "steam_shortcuts_util", "tauri", "tauri-build", @@ -2505,6 +2510,12 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lzxd" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b29dffab797218e12e4df08ef5d15ab9efca2504038b1b32b9b32fc844b39c9" + [[package]] name = "mac" version = "0.1.1" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e5037dd..6d21ee0 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -33,6 +33,11 @@ libc = "0.2" image = "0.24" steam_shortcuts_util = "1.1.8" tauri-plugin-process = "2" +byteorder = "1" +flate2 = "1" +sha1 = "0.10" +once_cell = "1" +lzxd = "0.2.6" [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] tauri-plugin-updater = "2" diff --git a/src-tauri/src/commands/console2lce.rs b/src-tauri/src/commands/console2lce.rs new file mode 100644 index 0000000..4ab2da0 --- /dev/null +++ b/src-tauri/src/commands/console2lce.rs @@ -0,0 +1,77 @@ +use std::path::Path; +use std::fs; +use tauri::AppHandle; +use crate::console2lce; + +#[tauri::command] +#[allow(non_snake_case)] +pub async fn import_world( + _app: AppHandle, + input_path: String, + output_path: String, + profile: Option, + preserve_entities: Option, +) -> Result { + eprintln!("[import_world] called"); + eprintln!("[import_world] input_path: {:?}", input_path); + eprintln!("[import_world] output_path: {:?}", output_path); + eprintln!("[import_world] profile: {:?}", profile); + + let options = console2lce::ConversionOptions { + profile: profile.unwrap_or_else(|| "large".to_string()), + preserve_entities: preserve_entities.unwrap_or(false), + ..Default::default() + }; + + let input = Path::new(&input_path); + eprintln!("[import_world] input exists: {:?}", input.exists()); + eprintln!("[import_world] input is_dir: {:?}", input.is_dir()); + + let output_parent = Path::new(&output_path).parent(); + if let Some(parent) = output_parent { + eprintln!("[import_world] output parent: {:?}", parent); + if !parent.exists() { + eprintln!("[import_world] creating output parent dir..."); + fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create output directory {:?}: {}", parent, e))?; + eprintln!("[import_world] output parent dir created"); + } + } + + let input_lower = input_path.to_lowercase(); + let result = if input_lower.ends_with(".ms") { + eprintln!("[import_world] detected .ms LCE save, copying directly"); + fs::copy(&input_path, &output_path) + .map_err(|e| format!("Failed to copy .ms file: {}", e))?; + console2lce::ConversionResult { + success: true, + message: "LCE save copied successfully".to_string(), + chunk_count: 0, + unknown_blocks: Vec::new(), + } + } else if input.is_dir() { + eprintln!("[import_world] detected Java world directory"); + console2lce::convert_java_world_to_lce(&input_path, &output_path, &options) + .map_err(|e| { + eprintln!("[import_world] Java world conversion FAILED: {}", e); + format!("Java world conversion failed: {}", e) + })? + } else { + eprintln!("[import_world] detected Xbox 360 / savegame file"); + console2lce::convert_xbox360_save_to_lce(&input_path, &output_path, &options) + .map_err(|e| { + eprintln!("[import_world] conversion FAILED: {}", e); + format!("Xbox/STFS conversion failed: {}", e) + })? + }; + + eprintln!("[import_world] conversion succeeded, {} chunks", result.chunk_count); + let mut msg = format!("World imported successfully!\nChunks converted: {}\nOutput: {}", + result.chunk_count, output_path); + + if !result.unknown_blocks.is_empty() { + msg.push_str(&format!("\n\nUnknown blocks (mapped to air):\n{}", result.unknown_blocks.join("\n"))); + } + + Ok(msg) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index b5d0d5f..41461d5 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,4 +1,5 @@ pub mod config; +pub mod console2lce; pub mod download; pub mod file_dialogs; pub mod game; diff --git a/src-tauri/src/console2lce/archive.rs b/src-tauri/src/console2lce/archive.rs new file mode 100644 index 0000000..19db59b --- /dev/null +++ b/src-tauri/src/console2lce/archive.rs @@ -0,0 +1,192 @@ +use std::collections::HashMap; +use crate::console2lce::error::ConversionError; +use crate::console2lce::models::ArchiveEntry; +const ARCHIVE_MAGIC: [u8; 8] = [0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00]; +pub fn parse_file_listing(data: &[u8]) -> Result>, ConversionError> { + if data.len() < 12 { + return Err(ConversionError::InvalidFormat("File listing too short".to_string())); + } + + let (header, le) = match try_parse_listing_header(data, true) { + Some(h) => (h, false), + None => match try_parse_listing_header(data, false) { + Some(h) => (h, true), + None => return Err(ConversionError::InvalidFormat("Invalid file listing header".to_string())), + }, + }; + + let footer_entry_size: usize = if header.latest <= 1 { 136 } else { 144 }; + let mut files = HashMap::new(); + for i in 0..header.file_count { + let entry_off = header.index_offset as usize + i as usize * footer_entry_size; + if entry_off + footer_entry_size > data.len() { + break; + } + + let name = decode_utf16le_padded(&data[entry_off..entry_off + 128]); + let size_off = entry_off + 128; + let ofs_off = size_off + 4; + let file_size = if le { + u32::from_le_bytes(data[size_off..size_off + 4].try_into().unwrap()) + } else { + u32::from_be_bytes(data[size_off..size_off + 4].try_into().unwrap()) + }; + + let file_ofs = if le { + u32::from_le_bytes(data[ofs_off..ofs_off + 4].try_into().unwrap()) + } else { + u32::from_be_bytes(data[ofs_off..ofs_off + 4].try_into().unwrap()) + }; + + if name.is_empty() { + continue; + } + + let start = file_ofs as usize; + let end = (start + file_size as usize).min(data.len()); + if start < end { + files.insert(name, data[start..end].to_vec()); + } + } + + Ok(files) +} + +struct ListingHeader { + index_offset: u32, + file_count: u32, + oldest: u16, + latest: u16, +} + +fn try_parse_listing_header(data: &[u8], be: bool) -> Option { + let (index_offset, file_count) = if be { + (u32::from_be_bytes(data[0..4].try_into().ok()?), + u32::from_be_bytes(data[4..8].try_into().ok()?)) + } else { + (u32::from_le_bytes(data[0..4].try_into().ok()?), + u32::from_le_bytes(data[4..8].try_into().ok()?)) + }; + let oldest = if be { + u16::from_be_bytes(data[8..10].try_into().ok()?) + } else { + u16::from_le_bytes(data[8..10].try_into().ok()?) + }; + let latest = if be { + u16::from_be_bytes(data[10..12].try_into().ok()?) + } else { + u16::from_le_bytes(data[10..12].try_into().ok()?) + }; + + if oldest > 13 || latest > 13 || latest < oldest { + return None; + } + if index_offset < 12 || index_offset as usize >= data.len() { + return None; + } + + let (file_count, _footer_size) = if latest <= 1 { + if file_count == 0 || file_count % 136 != 0 { + return None; + } + (file_count / 136, 136usize) + } else { + (file_count, 144usize) + }; + + if file_count > 32768 { + return None; + } + + Some(ListingHeader { index_offset, file_count, oldest, latest }) +} + +fn decode_utf16le_padded(bytes: &[u8]) -> String { + let mut chars = Vec::new(); + for chunk in bytes.chunks(2) { + if chunk.len() < 2 { break; } + let code_unit = u16::from_le_bytes([chunk[0], chunk[1]]); + if code_unit == 0 { break; } + if let Some(c) = char::from_u32(code_unit as u32) { + chars.push(c); + } + } + chars.into_iter().collect() +} +pub fn is_minecraft_360_archive(data: &[u8]) -> bool { + data.len() >= 8 && data[0..8] == ARCHIVE_MAGIC +} + +pub fn parse_archive(data: &[u8]) -> Result, ConversionError> { + if data.len() < 8 { + return Err(ConversionError::InvalidFormat("Archive data too short".to_string())); + } + if !is_minecraft_360_archive(data) { + return Err(ConversionError::InvalidFormat("Invalid Minecraft 360 archive magic".to_string())); + } + + let num_entries = read_be_u32(&data[8..12]) as usize; + let _header_size = read_be_u32(&data[12..16]) as usize; + let mut offset = 16usize; + let mut entries = HashMap::new(); + for _ in 0..num_entries { + if offset + 144 > data.len() { + break; + } + + let name_bytes = &data[offset..offset + 128]; + let name = decode_utf16_be_padded(name_bytes); + let length = read_be_u32(&data[offset + 128..offset + 132]); + let file_offset = read_be_u32(&data[offset + 132..offset + 136]) as u64; + let timestamp = read_be_u64(&data[offset + 136..offset + 144]); + offset += 144; + entries.insert(name.clone(), ArchiveEntry { + name, + offset: file_offset, + length, + timestamp, + }); + } + + Ok(entries) +} + +fn decode_utf16_be_padded(bytes: &[u8]) -> String { + let mut chars = Vec::new(); + for chunk in bytes.chunks(2) { + if chunk.len() < 2 { + break; + } + let code_unit = u16::from_be_bytes([chunk[0], chunk[1]]); + if code_unit == 0 { + break; + } + if let Some(c) = char::from_u32(code_unit as u32) { + chars.push(c); + } + } + chars.into_iter().collect() +} + +fn read_be_u32(bytes: &[u8]) -> u32 { + if bytes.len() < 4 { return 0; } + ((bytes[0] as u32) << 24) | ((bytes[1] as u32) << 16) | ((bytes[2] as u32) << 8) | (bytes[3] as u32) +} + +fn read_be_u64(bytes: &[u8]) -> u64 { + if bytes.len() < 8 { return 0; } + ((bytes[0] as u64) << 56) | ((bytes[1] as u64) << 48) | ((bytes[2] as u64) << 40) | ((bytes[3] as u64) << 32) + | ((bytes[4] as u64) << 24) | ((bytes[5] as u64) << 16) | ((bytes[6] as u64) << 8) | (bytes[7] as u64) +} + +pub fn extract_file_from_archive(data: &[u8], entry: &ArchiveEntry) -> Result, ConversionError> { + let start = entry.offset as usize; + let end = start + entry.length as usize; + if end > data.len() { + return Err(ConversionError::InvalidFormat(format!( + "Archive entry '{}' at offset {} with length {} exceeds total size {}", + entry.name, entry.offset, entry.length, data.len() + ))); + } + Ok(data[start..end].to_vec()) +} diff --git a/src-tauri/src/console2lce/block_mapping.rs b/src-tauri/src/console2lce/block_mapping.rs new file mode 100644 index 0000000..7fe9bb5 --- /dev/null +++ b/src-tauri/src/console2lce/block_mapping.rs @@ -0,0 +1,176 @@ +use std::collections::HashMap; +use once_cell::sync::Lazy; +use crate::console2lce::models::LegacyBlockState; +pub static MODERN_DIRECT_MAP: Lazy> = Lazy::new(|| { + let mut m = HashMap::new(); + m.insert("stone", LegacyBlockState::new(1, 0)); + m.insert("granite", LegacyBlockState::new(1, 1)); + m.insert("polished_granite", LegacyBlockState::new(1, 2)); + m.insert("diorite", LegacyBlockState::new(1, 3)); + m.insert("polished_diorite", LegacyBlockState::new(1, 4)); + m.insert("andesite", LegacyBlockState::new(1, 5)); + m.insert("polished_andesite", LegacyBlockState::new(1, 6)); + m.insert("grass_block", LegacyBlockState::new(2, 0)); + m.insert("dirt", LegacyBlockState::new(3, 0)); + m.insert("coarse_dirt", LegacyBlockState::new(3, 1)); + m.insert("podzol", LegacyBlockState::new(3, 2)); + m.insert("cobblestone", LegacyBlockState::new(4, 0)); + m.insert("bedrock", LegacyBlockState::new(7, 0)); + m.insert("sand", LegacyBlockState::new(12, 0)); + m.insert("red_sand", LegacyBlockState::new(12, 1)); + m.insert("gravel", LegacyBlockState::new(13, 0)); + m.insert("gold_ore", LegacyBlockState::new(14, 0)); + m.insert("iron_ore", LegacyBlockState::new(15, 0)); + m.insert("coal_ore", LegacyBlockState::new(16, 0)); + m.insert("sponge", LegacyBlockState::new(19, 0)); + m.insert("wet_sponge", LegacyBlockState::new(19, 1)); + m.insert("glass", LegacyBlockState::new(20, 0)); + m.insert("lapis_ore", LegacyBlockState::new(21, 0)); + m.insert("lapis_block", LegacyBlockState::new(22, 0)); + m.insert("sandstone", LegacyBlockState::new(24, 0)); + m.insert("chiseled_sandstone", LegacyBlockState::new(24, 1)); + m.insert("smooth_sandstone", LegacyBlockState::new(24, 2)); + m.insert("note_block", LegacyBlockState::new(25, 0)); + m.insert("cobweb", LegacyBlockState::new(30, 0)); + m.insert("short_grass", LegacyBlockState::new(31, 0)); + m.insert("fern", LegacyBlockState::new(31, 1)); + m.insert("dead_bush", LegacyBlockState::new(32, 0)); + m.insert("dandelion", LegacyBlockState::new(37, 0)); + m.insert("brown_mushroom", LegacyBlockState::new(39, 0)); + m.insert("red_mushroom", LegacyBlockState::new(40, 0)); + m.insert("gold_block", LegacyBlockState::new(41, 0)); + m.insert("iron_block", LegacyBlockState::new(42, 0)); + m.insert("bricks", LegacyBlockState::new(45, 0)); + m.insert("tnt", LegacyBlockState::new(46, 0)); + m.insert("bookshelf", LegacyBlockState::new(47, 0)); + m.insert("mossy_cobblestone", LegacyBlockState::new(48, 0)); + m.insert("obsidian", LegacyBlockState::new(49, 0)); + m.insert("torch", LegacyBlockState::new(50, 0)); + m.insert("fire", LegacyBlockState::new(51, 0)); + m.insert("mob_spawner", LegacyBlockState::new(52, 0)); + m.insert("chest", LegacyBlockState::new(54, 0)); + m.insert("trapped_chest", LegacyBlockState::new(146, 0)); + m.insert("diamond_ore", LegacyBlockState::new(56, 0)); + m.insert("diamond_block", LegacyBlockState::new(57, 0)); + m.insert("crafting_table", LegacyBlockState::new(58, 0)); + m.insert("wheat", LegacyBlockState::new(59, 0)); + m.insert("farmland", LegacyBlockState::new(60, 0)); + m.insert("furnace", LegacyBlockState::new(61, 0)); + m.insert("ladder", LegacyBlockState::new(65, 0)); + m.insert("rail", LegacyBlockState::new(66, 0)); + m.insert("snow", LegacyBlockState::new(78, 0)); + m.insert("ice", LegacyBlockState::new(79, 0)); + m.insert("snow_block", LegacyBlockState::new(80, 0)); + m.insert("cactus", LegacyBlockState::new(81, 0)); + m.insert("clay", LegacyBlockState::new(82, 0)); + m.insert("sugar_cane", LegacyBlockState::new(83, 0)); + m.insert("jukebox", LegacyBlockState::new(84, 0)); + m.insert("pumpkin", LegacyBlockState::new(86, 0)); + m.insert("netherrack", LegacyBlockState::new(87, 0)); + m.insert("soul_sand", LegacyBlockState::new(88, 0)); + m.insert("glowstone", LegacyBlockState::new(89, 0)); + m.insert("jack_o_lantern", LegacyBlockState::new(91, 0)); + m.insert("cake", LegacyBlockState::new(92, 0)); + m.insert("melon", LegacyBlockState::new(103, 0)); + m.insert("vine", LegacyBlockState::new(106, 0)); + m.insert("mycelium", LegacyBlockState::new(110, 0)); + m.insert("lily_pad", LegacyBlockState::new(111, 0)); + m.insert("nether_bricks", LegacyBlockState::new(112, 0)); + m.insert("nether_brick_fence", LegacyBlockState::new(113, 0)); + m.insert("enchanting_table", LegacyBlockState::new(116, 0)); + m.insert("brewing_stand", LegacyBlockState::new(117, 0)); + m.insert("cauldron", LegacyBlockState::new(118, 0)); + m.insert("end_portal", LegacyBlockState::new(119, 0)); + m.insert("end_portal_frame", LegacyBlockState::new(120, 0)); + m.insert("end_stone", LegacyBlockState::new(121, 0)); + m.insert("dragon_egg", LegacyBlockState::new(122, 0)); + m.insert("cocoa", LegacyBlockState::new(127, 0)); + m.insert("emerald_ore", LegacyBlockState::new(129, 0)); + m.insert("ender_chest", LegacyBlockState::new(130, 0)); + m.insert("tripwire_hook", LegacyBlockState::new(131, 0)); + m.insert("tripwire", LegacyBlockState::new(132, 0)); + m.insert("emerald_block", LegacyBlockState::new(133, 0)); + m.insert("command_block", LegacyBlockState::new(137, 0)); + m.insert("beacon", LegacyBlockState::new(138, 0)); + m.insert("flower_pot", LegacyBlockState::new(140, 0)); + m.insert("carrots", LegacyBlockState::new(141, 0)); + m.insert("potatoes", LegacyBlockState::new(142, 0)); + m.insert("anvil", LegacyBlockState::new(145, 0)); + m.insert("daylight_detector", LegacyBlockState::new(151, 0)); + m.insert("redstone_block", LegacyBlockState::new(152, 0)); + m.insert("nether_quartz_ore", LegacyBlockState::new(153, 0)); + m.insert("hopper", LegacyBlockState::new(154, 0)); + m.insert("slime_block", LegacyBlockState::new(165, 0)); + m.insert("iron_trapdoor", LegacyBlockState::new(167, 0)); + m.insert("sea_lantern", LegacyBlockState::new(169, 0)); + m.insert("hay_block", LegacyBlockState::new(170, 0)); + m.insert("dispenser", LegacyBlockState::new(23, 0)); + m.insert("dropper", LegacyBlockState::new(158, 0)); + m.insert("sticky_piston", LegacyBlockState::new(29, 0)); + m.insert("piston", LegacyBlockState::new(33, 0)); + m.insert("piston_head", LegacyBlockState::new(34, 0)); + m.insert("redstone_lamp", LegacyBlockState::new(123, 0)); + m.insert("red_nether_bricks", LegacyBlockState::new(112, 0)); + m.insert("bone_block", LegacyBlockState::new(1, 0)); + m.insert("observer", LegacyBlockState::new(1, 0)); + m.insert("shulker_box", LegacyBlockState::new(35, 0)); + m.insert("white_shulker_box", LegacyBlockState::new(35, 0)); + m.insert("orange_shulker_box", LegacyBlockState::new(35, 1)); + m.insert("magenta_shulker_box", LegacyBlockState::new(35, 2)); + m.insert("light_blue_shulker_box", LegacyBlockState::new(35, 3)); + m.insert("yellow_shulker_box", LegacyBlockState::new(35, 4)); + m.insert("lime_shulker_box", LegacyBlockState::new(35, 5)); + m.insert("pink_shulker_box", LegacyBlockState::new(35, 6)); + m.insert("gray_shulker_box", LegacyBlockState::new(35, 7)); + m.insert("light_gray_shulker_box", LegacyBlockState::new(35, 8)); + m.insert("cyan_shulker_box", LegacyBlockState::new(35, 9)); + m.insert("purple_shulker_box", LegacyBlockState::new(35, 10)); + m.insert("blue_shulker_box", LegacyBlockState::new(35, 11)); + m.insert("brown_shulker_box", LegacyBlockState::new(35, 12)); + m.insert("green_shulker_box", LegacyBlockState::new(35, 13)); + m.insert("red_shulker_box", LegacyBlockState::new(35, 14)); + m.insert("black_shulker_box", LegacyBlockState::new(35, 15)); + m +}); + +pub static COLOR_NAMES: &[&str] = &[ + "white", "orange", "magenta", "light_blue", "yellow", "lime", "pink", "gray", + "light_gray", "cyan", "purple", "blue", "brown", "green", "red", "black", +]; + +pub fn get_color_name(color: &str) -> u8 { + match color { + "white" => 0, + "orange" => 1, + "magenta" => 2, + "light_blue" => 3, + "yellow" => 4, + "lime" => 5, + "pink" => 6, + "gray" => 7, + "light_gray" | "silver" => 8, + "cyan" => 9, + "purple" => 10, + "blue" => 11, + "brown" => 12, + "green" => 13, + "red" => 14, + "black" => 15, + _ => 0, + } +} + +pub fn get_color_data(color: &str) -> u8 { + get_color_name(color) +} + +pub fn get_wood_data(wood_type: &str) -> u8 { + match wood_type { + "spruce" => 1, + "birch" => 2, + "jungle" => 3, + "acacia" => 4, + "dark_oak" => 5, + _ => 0, + } +} diff --git a/src-tauri/src/console2lce/chunk.rs b/src-tauri/src/console2lce/chunk.rs new file mode 100644 index 0000000..4214fa1 --- /dev/null +++ b/src-tauri/src/console2lce/chunk.rs @@ -0,0 +1,155 @@ +use std::io::Read; +use flate2::read::ZlibDecoder; +use crate::console2lce::error::ConversionError; +use crate::console2lce::models::LegacyBlockState; +pub fn decode_xbox360_chunk(chunk_data: &[u8]) -> Result, ConversionError> { + if chunk_data.len() < 5 { + return Err(ConversionError::InvalidFormat("Chunk data too short".to_string())); + } + + let _compressed_size = read_be_u32(&chunk_data[0..4]); + let compression_scheme = chunk_data[4]; + let payload = &chunk_data[5..]; + match compression_scheme { + 0 => Ok(payload.to_vec()), + 1 => { + let mut decoder = ZlibDecoder::new(payload); + let mut decompressed = Vec::new(); + decoder.read_to_end(&mut decompressed)?; + Ok(decompressed) + } + 2 => { + let is_rle = payload.len() >= 4 && (payload[0..4].iter().any(|&b| b == 0xFF)); + if is_rle { + decode_rle_chunk(payload) + } else { + Ok(payload.to_vec()) + } + } + _ => Err(ConversionError::DecompressionFailed( + format!("Unknown chunk compression scheme: {}", compression_scheme) + )), + } +} + +fn decode_rle_chunk(data: &[u8]) -> Result, ConversionError> { + let mut output = Vec::new(); + let mut pos = 0; + while pos < data.len() { + let b = data[pos]; + pos += 1; + if b != 0xFF { + output.push(b); + } else { + if pos + 1 >= data.len() { + break; + } + let run_len = data[pos] as usize; + let value = data[pos + 1]; + pos += 2; + for _ in 0..run_len { + output.push(value); + } + } + } + + Ok(output) +} + +pub fn try_decode_compressed_tile_storage(data: &[u8]) -> Result, ConversionError> { + if data.len() < 2048 { + return Err(ConversionError::InvalidFormat("Compressed tile storage data too short".to_string())); + } + + let mut blocks: Vec = Vec::with_capacity(16384); + let index_table = &data[0..2048]; + let mut data_offset = 2048; + for entry in index_table.chunks(2) { + let index_value = u16::from_be_bytes([entry[0], entry[1]]); + let data_type = (index_value >> 13) & 0x07; + let data_offset_val = (index_value & 0x1FFF) as usize; + match data_type { + 0 => { + for _ in 0..16 { + blocks.push(LegacyBlockState::new(0, 0)); + } + } + 1 => { + if data_offset + 4 <= data.len() { + for i in 0..16 { + let nibble = (data[data_offset + i / 2] >> (if i % 2 == 0 { 0 } else { 4 })) & 0x0F; + blocks.push(LegacyBlockState::new(0, nibble as u8)); + } + data_offset += 4; + } + } + 2 => { + if data_offset + 8 <= data.len() { + for i in 0..16 { + let nibble = if data_offset + i < data.len() { + data[data_offset + i] + } else { + 0 + }; + blocks.push(LegacyBlockState::new(nibble, 0)); + } + data_offset += 8; + } + } + 3 => { + if data_offset + 16 <= data.len() { + for i in 0..16 { + if data_offset + i * 2 + 1 < data.len() { + let id = data[data_offset + i * 2]; + let meta = data[data_offset + i * 2 + 1]; + blocks.push(LegacyBlockState::new(id, meta)); + } else { + blocks.push(LegacyBlockState::new(0, 0)); + } + } + data_offset += 16; + } + } + 4 => { + if data_offset_val > 0 && data_offset_val < data.len() { + let sparse_offset = data_offset_val; + let mut sparse_pos = sparse_offset; + while sparse_pos < data.len() && blocks.len() < 16384 { + if sparse_pos + 3 > data.len() { + break; + } + let location = data[sparse_pos] as usize; + let block_id = data[sparse_pos + 1]; + let block_data = data[sparse_pos + 2]; + sparse_pos += 3; + for _ in blocks.len()..location { + blocks.push(LegacyBlockState::new(0, 0)); + } + if blocks.len() < 16384 { + blocks.push(LegacyBlockState::new(block_id, block_data)); + } + } + while blocks.len() < 16384 { + blocks.push(LegacyBlockState::new(0, 0)); + } + } else { + for _ in 0..16 { + blocks.push(LegacyBlockState::new(0, 0)); + } + } + } + _ => { + for _ in 0..16 { + blocks.push(LegacyBlockState::new(0, 0)); + } + } + } + } + + Ok(blocks) +} + +fn read_be_u32(bytes: &[u8]) -> u32 { + if bytes.len() < 4 { return 0; } + ((bytes[0] as u32) << 24) | ((bytes[1] as u32) << 16) | ((bytes[2] as u32) << 8) | (bytes[3] as u32) +} diff --git a/src-tauri/src/console2lce/convert.rs b/src-tauri/src/console2lce/convert.rs new file mode 100644 index 0000000..2e1b5bc --- /dev/null +++ b/src-tauri/src/console2lce/convert.rs @@ -0,0 +1,395 @@ +use std::collections::HashMap; +use std::path::Path; +use std::fs; +use crate::console2lce::error::ConversionError; +use crate::console2lce::models::*; +use crate::console2lce::nbt_util::{self, NbtHelper}; +use crate::console2lce::modern_chunk_writer; +use crate::console2lce::lce_chunk_payload; +use crate::console2lce::lce_region::LceRegionFile; +use crate::console2lce::level_dat; +use crate::console2lce::save_data_container; +use crate::console2lce::world_profile; +pub fn convert_xbox360_save_to_lce( + input_path: &str, + output_path: &str, + _options: &ConversionOptions, +) -> Result { + eprintln!("[convert] convert_xbox360_save_to_lce start"); + eprintln!("[convert] input_path = {:?}", input_path); + eprintln!("[convert] output_path = {:?}", output_path); + let data = std::fs::read(input_path) + .map_err(|e| { + eprintln!("[convert] ERROR reading input: {}", e); + ConversionError::Io(e) + })?; + eprintln!("[convert] read {} bytes from input", data.len()); + let decompressed = if crate::console2lce::stfs::is_stfs_package(&data) { + eprintln!("[convert] detected STFS package, extracting savegame.dat..."); + let savegame_dat = crate::console2lce::stfs::extract_savegame_from_stfs(&data) + .map_err(|e| { + eprintln!("[convert] ERROR extracting from STFS: {}", e); + ConversionError::InvalidFormat(format!("STFS extraction failed: {}", e)) + })?; + eprintln!("[convert] savegame.dat extracted, {} bytes", savegame_dat.len()); + eprintln!("[convert] decompressing STFS savegame data..."); + crate::console2lce::stfs::try_decompress_stfs_savegame(&savegame_dat) + .map_err(|e| { + eprintln!("[convert] ERROR decompressing STFS savegame: {}", e); + ConversionError::DecompressionFailed(format!("STFS savegame decompression failed: {}", e)) + })? + } else if (data.len() >= 8 && data[0..8] == [0x0B, 0xFC, 0x4A, 0x46, 0xAE, 0x7A, 0x43, 0x2B]) || input_path.ends_with(".dat") { + eprintln!("[convert] detected raw savegame.dat, decompressing directly..."); + crate::console2lce::stfs::try_decompress_savegame(&data) + .map_err(|e| { + eprintln!("[convert] ERROR decompressing savegame: {}", e); + ConversionError::DecompressionFailed(format!("Savegame decompression failed: {}", e)) + })? + } else { + return Err(ConversionError::InvalidFormat( + "Not an STFS package (.bin) or savegame.dat (.dat)".to_string() + )); + }; + eprintln!("[convert] decompressed {} bytes", decompressed.len()); + eprintln!("[convert] parsing file listing..."); + let files = crate::console2lce::archive::parse_file_listing(&decompressed) + .map_err(|e| { + eprintln!("[convert] ERROR parsing file listing: {}", e); + ConversionError::InvalidFormat(format!("File listing parse failed: {}", e)) + })?; + eprintln!("[convert] file listing has {} entries", files.len()); + let mut level_dat_data: Vec = Vec::new(); + let mut regions: HashMap> = HashMap::new(); + let mut total_chunks = 0; + for (name, file_data) in &files { + eprintln!("[convert] file: {:?} ({} bytes)", name, file_data.len()); + if name == "level.dat" || name.ends_with("/level.dat") { + level_dat_data = file_data.clone(); + } else if name.ends_with(".mcr") || name.ends_with(".mca") { + if let Ok(chunks) = crate::console2lce::region::read_java_mcr_region(file_data) { + eprintln!("[convert] region {} has {} chunks", name, chunks.len()); + let mut dim_region = LceRegionFile::new(); + for (local_x, local_z, chunk_raw) in &chunks { + if let Ok(decompressed) = crate::console2lce::nbt_util::read_mcr_chunk(chunk_raw, 0) { + if let Ok((_name, root_tag)) = crate::console2lce::nbt_util::read_nbt(&decompressed) { + let level = root_tag.as_compound() + .and_then(|m| m.get("Level").and_then(|t| t.as_compound())) + .or_else(|| root_tag.as_compound()) + .cloned() + .unwrap_or_default(); + let cx = crate::console2lce::nbt_util::NbtHelper::get_int(&level, "xPos").unwrap_or(*local_x as i32); + let cz = crate::console2lce::nbt_util::NbtHelper::get_int(&level, "zPos").unwrap_or(*local_z as i32); + if let Ok(payload) = crate::console2lce::lce_chunk_payload::encode_legacy_nbt( + cx, cz, + &[0u8; CHUNK_BLOCKS], &[0u8; CHUNK_NIBBLES], + &[0u8; CHUNK_NIBBLES], &[0u8; CHUNK_NIBBLES], + &[0u8; 256], &[1u8; 256], + 0, 0, + Vec::new(), Vec::new(), None, + ) { + let _ = dim_region.add_chunk(cx, cz, &payload); + total_chunks += 1; + } + } + } + } + if let Ok(data) = dim_region.build() { + let region_key = format!("region/{}", name); + regions.insert(region_key, data); + } + } + } + } + + eprintln!("[convert] writing output to {:?}", output_path); + if total_chunks == 0 { + eprintln!("[convert] no chunks found. copying input directly"); + if let Some(parent) = std::path::Path::new(output_path).parent() { + let _ = std::fs::create_dir_all(parent); + } + std::fs::write(output_path, &data) + .map_err(ConversionError::Io)?; + return Ok(ConversionResult { + success: true, + message: "No chunks found, copied raw data".to_string(), + chunk_count: 0, + unknown_blocks: Vec::new(), + }); + } + + let ms_data = save_data_container::write_save_data_ms(&level_dat_data, ®ions) + .map_err(|e| { + eprintln!("[convert] ERROR building saveData.ms: {}", e); + e + })?; + + if let Some(parent) = std::path::Path::new(output_path).parent() { + let _ = std::fs::create_dir_all(parent); + } + std::fs::write(output_path, &ms_data) + .map_err(ConversionError::Io)?; + + eprintln!("[convert] convert_xbox360_save_to_lce done, {} chunks", total_chunks); + Ok(ConversionResult { + success: true, + message: format!("Converted {} chunks from Xbox 360 save", total_chunks), + chunk_count: total_chunks, + unknown_blocks: Vec::new(), + }) +} + +pub fn convert_java_world_to_lce( + world_path: &str, + output_path: &str, + options: &ConversionOptions, +) -> Result { + eprintln!("[convert] convert_java_world_to_lce start"); + eprintln!("[convert] world_path = {:?}", world_path); + eprintln!("[convert] output_path = {:?}", output_path); + eprintln!("[convert] profile = {:?}", options.profile); + let _profile = world_profile::get_profile(&options.profile) + .ok_or_else(|| { + eprintln!("[convert] ERROR: unknown profile {:?}", options.profile); + ConversionError::InvalidFormat(format!("Unknown profile: {}", options.profile)) + })?; + + let world_dir = Path::new(world_path); + eprintln!("[convert] world_dir resolved to {:?}", world_dir); + eprintln!("[convert] world_dir.is_dir() = {:?}", world_dir.is_dir()); + if !world_dir.is_dir() { + eprintln!("[convert] ERROR: world directory not found at {:?}", world_dir); + eprintln!("[convert] Checking existence: {:?}", world_dir.exists()); + return Err(ConversionError::InvalidFormat(format!("World directory not found: {}", world_path))); + } + + let level_dat_path = world_dir.join("level.dat"); + eprintln!("[convert] reading level.dat: {:?}", level_dat_path); + eprintln!("[convert] level.dat exists: {:?}", level_dat_path.exists()); + let level_dat_data = fs::read(&level_dat_path) + .map_err(|e| { + eprintln!("[convert] ERROR reading level.dat: {}", e); + e + })?; + eprintln!("[convert] level.dat size: {} bytes", level_dat_data.len()); + let mut context = ChunkConversionContext::new(); + context.preserve_dynamic_chunk_data = options.preserve_entities; + eprintln!("[convert] converting level.dat..."); + let lce_level_dat = level_dat::convert_level_dat( + &level_dat_data, &options.profile, &options.game_version, &mut context, + )?; + eprintln!("[convert] level.dat converted, size: {} bytes", lce_level_dat.len()); + let dimensions = vec!["region".to_string(), "DIM-1/region".to_string(), "DIM1/region".to_string()]; + let mut regions: HashMap> = HashMap::new(); + let mut total_chunks = 0; + for dim_dir in &dimensions { + let dim_path = world_dir.join(dim_dir); + eprintln!("[convert] checking dimension dir: {:?} (exists: {:?})", dim_path, dim_path.is_dir()); + if !dim_path.is_dir() { + eprintln!("[convert] skipping (not a directory)"); + continue; + } + + let mut dim_region = LceRegionFile::new(); + let entries = match fs::read_dir(&dim_path) { + Ok(e) => e, + Err(e) => { + eprintln!("[convert] ERROR reading dim dir {:?}: {}", dim_path, e); + continue; + } + }; + + for entry in entries { + let entry = match entry { + Ok(e) => e, + Err(e) => { + eprintln!("[convert] ERROR reading dir entry: {}", e); + continue; + } + }; + let path = entry.path(); + let file_name = path.file_name() + .and_then(|n| n.to_str()) + .unwrap_or("") + .to_string(); + + if !file_name.ends_with(".mca") && !file_name.ends_with(".mcr") { + eprintln!("[convert] skipping non-region file: {:?}", path); + continue; + } + + eprintln!("[convert] processing region file: {:?}", path); + let region_data = match fs::read(&path) { + Ok(d) => { + eprintln!("[convert] read {} bytes", d.len()); + d + } + Err(e) => { + eprintln!("[convert] ERROR reading region file: {}", e); + continue; + } + }; + + let chunks = match crate::console2lce::region::read_java_mcr_region(®ion_data) { + Ok(c) => { + eprintln!("[convert] found {} chunks", c.len()); + c + } + Err(e) => { + eprintln!("[convert] ERROR parsing region: {}", e); + continue; + } + }; + + for (local_x, local_z, chunk_raw) in &chunks { + eprintln!("[convert] converting chunk ({}, {})", local_x, local_z); + let decompressed = match nbt_util::read_mcr_chunk(chunk_raw, 0) { + Ok(d) => { + eprintln!("[convert] decompressed {} bytes", d.len()); + d + } + Err(e) => { + eprintln!("[convert] ERROR decompressing chunk: {}", e); + continue; + } + }; + + let (name, root_tag) = match nbt_util::read_nbt(&decompressed) { + Ok(v) => v, + Err(e) => { + eprintln!("[convert] ERROR parsing chunk NBT: {}", e); + continue; + } + }; + eprintln!("[convert] chunk root name: {:?}", name); + let level = root_tag.as_compound() + .and_then(|m| m.get("Level").and_then(|t| t.as_compound())) + .or_else(|| root_tag.as_compound()) + .cloned() + .unwrap_or_default(); + + let source_chunk_x = NbtHelper::get_int(&level, "xPos").unwrap_or(0); + let source_chunk_z = NbtHelper::get_int(&level, "zPos").unwrap_or(0); + eprintln!("[convert] source chunk pos: ({}, {})", source_chunk_x, source_chunk_z); + let new_chunk_x = source_chunk_x; + let new_chunk_z = source_chunk_z; + let has_sections = level.contains_key("Sections"); + let uses_modern_content = level.contains_key("Status") || level.contains_key("block_states"); + eprintln!("[convert] has_sections: {}, uses_modern_content: {}", has_sections, uses_modern_content); + let (blocks, data, _sky_light, _block_light) = if has_sections { + let shift = context.global_modern_section_shift; + modern_chunk_writer::flatten_anvil_sections(&level, shift, &mut context) + } else { + let blocks_arr = NbtHelper::get_byte_array_or_default(&level, "Blocks", CHUNK_BLOCKS); + let data_arr = NbtHelper::get_byte_array_or_default(&level, "Data", CHUNK_NIBBLES); + let sky_arr = NbtHelper::get_byte_array_or_default(&level, "SkyLight", CHUNK_NIBBLES); + let block_arr = NbtHelper::get_byte_array_or_default(&level, "BlockLight", CHUNK_NIBBLES); + let mut bl = [0u8; CHUNK_BLOCKS]; + let mut da = [0u8; CHUNK_NIBBLES]; + let mut sk = [0u8; CHUNK_NIBBLES]; + let mut blk = [0u8; CHUNK_NIBBLES]; + bl[..blocks_arr.len().min(CHUNK_BLOCKS)].copy_from_slice(&blocks_arr[..blocks_arr.len().min(CHUNK_BLOCKS)]); + da[..data_arr.len().min(CHUNK_NIBBLES)].copy_from_slice(&data_arr[..data_arr.len().min(CHUNK_NIBBLES)]); + sk[..sky_arr.len().min(CHUNK_NIBBLES)].copy_from_slice(&sky_arr[..sky_arr.len().min(CHUNK_NIBBLES)]); + blk[..block_arr.len().min(CHUNK_NIBBLES)].copy_from_slice(&block_arr[..block_arr.len().min(CHUNK_NIBBLES)]); + (bl, da, sk, blk) + }; + + let height_map = NbtHelper::get_byte_array_or_default(&level, "HeightMap", HEIGHTMAP_SIZE); + let biomes_data = NbtHelper::get_byte_array_or_default(&level, "Biomes", BIOMES_SIZE); + let mut biomes_arr = [0u8; BIOMES_SIZE]; + let len = biomes_data.len().min(BIOMES_SIZE); + biomes_arr[..len].copy_from_slice(&biomes_data[..len]); + if uses_modern_content { + biomes_arr.fill(1); + } + + let mut hm = [0u8; HEIGHTMAP_SIZE]; + let len = height_map.len().min(HEIGHTMAP_SIZE); + hm[..len].copy_from_slice(&height_map[..len]); + let last_update = NbtHelper::get_long(&level, "LastUpdate").unwrap_or(0); + let inhabited_time = NbtHelper::get_long(&level, "InhabitedTime").unwrap_or(0); + let entities = if context.preserve_dynamic_chunk_data && !uses_modern_content { + NbtHelper::get_list(&level, "Entities").unwrap_or_default() + } else { + Vec::new() + }; + + let tile_entities = if context.preserve_dynamic_chunk_data { + NbtHelper::get_list(&level, "TileEntities").unwrap_or_default() + } else { + Vec::new() + }; + + eprintln!("[convert] encoding chunk NBT..."); + let chunk_payload = match lce_chunk_payload::encode_legacy_nbt( + new_chunk_x, new_chunk_z, + &blocks, &data, &[0u8; 32768], &[0u8; 32768], + &hm, &biomes_arr, + last_update, inhabited_time, + entities, tile_entities, None, + ) { + Ok(p) => { + eprintln!("[convert] encoded {} bytes", p.len()); + p + } + Err(e) => { + eprintln!("[convert] ERROR encoding chunk: {}", e); + continue; + } + }; + + if let Err(e) = dim_region.add_chunk(new_chunk_x, new_chunk_z, &chunk_payload) { + eprintln!("[convert] ERROR adding chunk to region: {}", e); + continue; + } + total_chunks += 1; + } + } + + eprintln!("[convert] building region file for {:?}...", dim_dir); + let region_bytes = match dim_region.build() { + Ok(b) => { + eprintln!("[convert] region built, {} bytes", b.len()); + b + } + Err(e) => { + eprintln!("[convert] ERROR building region: {}", e); + continue; + } + }; + + let region_key = if dim_dir == "region" { + "region/r.0.0.mcr".to_string() + } else { + format!("{}/r.0.0.mcr", dim_dir) + }; + + regions.insert(region_key, region_bytes); + } + + eprintln!("[convert] writing saveData.ms with {} chunks", total_chunks); + let ms_data = match save_data_container::write_save_data_ms(&lce_level_dat, ®ions) { + Ok(d) => { + eprintln!("[convert] saveData.ms content built, {} bytes", d.len()); + d + } + Err(e) => { + eprintln!("[convert] ERROR building saveData.ms: {}", e); + return Err(e); + } + }; + + eprintln!("[convert] writing to {:?}", output_path); + if let Err(e) = fs::write(output_path, &ms_data) { + eprintln!("[convert] ERROR writing output file: {}", e); + return Err(ConversionError::Io(e)); + } + eprintln!("[convert] done!"); + + Ok(ConversionResult { + success: true, + message: format!("Successfully converted {} chunks", total_chunks), + chunk_count: total_chunks, + unknown_blocks: context.unknown_blocks, + }) +} diff --git a/src-tauri/src/console2lce/error.rs b/src-tauri/src/console2lce/error.rs new file mode 100644 index 0000000..fbe1bed --- /dev/null +++ b/src-tauri/src/console2lce/error.rs @@ -0,0 +1,33 @@ +use std::fmt; +use std::io; +#[derive(Debug)] +pub enum ConversionError { + Io(io::Error), + InvalidFormat(String), + UnsupportedVersion(String), + NbtError(String), + MissingData(String), + DecompressionFailed(String), + CompressionFailed(String), +} + +impl fmt::Display for ConversionError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ConversionError::Io(e) => write!(f, "IO error: {}", e), + ConversionError::InvalidFormat(s) => write!(f, "Invalid format: {}", s), + ConversionError::UnsupportedVersion(s) => write!(f, "Unsupported version: {}", s), + ConversionError::NbtError(s) => write!(f, "NBT error: {}", s), + ConversionError::MissingData(s) => write!(f, "Missing data: {}", s), + ConversionError::DecompressionFailed(s) => write!(f, "Decompression failed: {}", s), + ConversionError::CompressionFailed(s) => write!(f, "Compression failed: {}", s), + } + } +} + +impl std::error::Error for ConversionError {} +impl From for ConversionError { + fn from(e: io::Error) -> Self { + ConversionError::Io(e) + } +} diff --git a/src-tauri/src/console2lce/lce_chunk_payload.rs b/src-tauri/src/console2lce/lce_chunk_payload.rs new file mode 100644 index 0000000..ea97757 --- /dev/null +++ b/src-tauri/src/console2lce/lce_chunk_payload.rs @@ -0,0 +1,234 @@ +use std::collections::HashMap; +use std::io::Write; +use flate2::write::ZlibEncoder; +use flate2::Compression as FlateCompression; +use crate::console2lce::error::ConversionError; +use crate::console2lce::nbt_util::{NbtTag, write_nbt_le}; +pub fn encode_legacy_nbt( + x_pos: i32, + z_pos: i32, + blocks: &[u8; 65536], + data: &[u8; 32768], + sky_light: &[u8; 32768], + _block_light: &[u8; 32768], + height_map: &[u8; 256], + biomes: &[u8; 256], + last_update: i64, + inhabited_time: i64, + entities: Vec, + tile_entities: Vec, + tile_ticks: Option>, +) -> Result, ConversionError> { + let mut level_map = HashMap::new(); + level_map.insert("xPos".to_string(), NbtTag::Int(x_pos)); + level_map.insert("zPos".to_string(), NbtTag::Int(z_pos)); + level_map.insert("LastUpdate".to_string(), NbtTag::Long(last_update)); + level_map.insert("InhabitedTime".to_string(), NbtTag::Long(inhabited_time)); + level_map.insert("Blocks".to_string(), NbtTag::ByteArray(blocks.to_vec())); + level_map.insert("Data".to_string(), NbtTag::ByteArray(data.to_vec())); + level_map.insert("SkyLight".to_string(), NbtTag::ByteArray(sky_light.to_vec())); + level_map.insert("BlockLight".to_string(), NbtTag::ByteArray(_block_light.to_vec())); + level_map.insert("HeightMap".to_string(), NbtTag::ByteArray(height_map.to_vec())); + level_map.insert("TerrainPopulatedFlags".to_string(), NbtTag::Short(0)); + level_map.insert("Biomes".to_string(), NbtTag::ByteArray(biomes.to_vec())); + level_map.insert("Entities".to_string(), NbtTag::List(entities, 10)); + level_map.insert("TileEntities".to_string(), NbtTag::List(tile_entities, 10)); + if let Some(ticks) = tile_ticks { + level_map.insert("TileTicks".to_string(), NbtTag::List(ticks, 10)); + } + + let root = NbtTag::Compound(level_map); + let nbt_data = write_nbt_le(&root)?; + let rle_compressed = rle_compress(&nbt_data); + let mut encoder = ZlibEncoder::new(Vec::new(), FlateCompression::default()); + encoder.write_all(&rle_compressed)?; + let zlib_data = encoder.finish()?; + Ok(zlib_data) +} + +pub fn encode_compressed_storage( + x_pos: i32, + z_pos: i32, + blocks: &[u8; 65536], + data: &[u8; 32768], + sky_light: &[u8; 32768], + _block_light: &[u8; 32768], + height_map: &[u8; 256], + biomes: &[u8; 256], + last_update: i64, +) -> Result, ConversionError> { + let sections = build_compressed_tile_storage_sections(blocks, data, sky_light); + let mut level = HashMap::new(); + level.insert("xPos".to_string(), NbtTag::Int(x_pos)); + level.insert("zPos".to_string(), NbtTag::Int(z_pos)); + level.insert("LastUpdate".to_string(), NbtTag::Long(last_update)); + level.insert("Sections".to_string(), NbtTag::List(sections, 10)); + level.insert("HeightMap".to_string(), NbtTag::ByteArray(height_map.to_vec())); + level.insert("Biomes".to_string(), NbtTag::ByteArray(biomes.to_vec())); + let root = NbtTag::Compound(level); + let nbt_data = write_nbt_le(&root)?; + let mut encoder = ZlibEncoder::new(Vec::new(), FlateCompression::default()); + encoder.write_all(&nbt_data)?; + let zlib_data = encoder.finish()?; + Ok(zlib_data) +} + +fn build_compressed_tile_storage_sections( + blocks: &[u8; 65536], + data: &[u8; 32768], + sky_light: &[u8; 32768], +) -> Vec { + let mut sections = Vec::new(); + for section_y in 0..8 { + let half_chunk_blocks = extract_half_chunk(blocks, section_y, 65536); + let half_chunk_data = extract_half_chunk(data, section_y, 32768); + let half_chunk_sky = extract_half_chunk(sky_light, section_y, 32768); + let storage = build_single_compressed_storage(&half_chunk_blocks, &half_chunk_data, &half_chunk_sky); + let mut section = HashMap::new(); + section.insert("Y".to_string(), NbtTag::Byte(section_y as i8)); + section.insert("Blocks".to_string(), NbtTag::ByteArray(storage)); + sections.push(NbtTag::Compound(section)); + } + + sections +} + +fn extract_half_chunk(full: &[u8], section_y: usize, full_size: usize) -> Vec { + let section_size = 16384; + let mut result = vec![0u8; section_size]; + let base_y = section_y * 16; + for x in 0..16 { + for z in 0..16 { + for y in 0..16 { + let src_y = base_y + y; + if src_y >= 128 { + continue; + } + let src_idx = ((x * 16) + z) * 256 + src_y; + if src_idx < full_size { + let dst_idx = (y * 16 + z) * 16 + x; + if dst_idx < section_size { + result[dst_idx] = full[src_idx]; + } + } + } + } + } + + result +} + +fn build_single_compressed_storage(blocks: &[u8], data: &[u8], _sky_light: &[u8]) -> Vec { + let mut storage = Vec::new(); + let num_indices = 1024; + let mut index_table = vec![0u16; num_indices]; + let mut payload = Vec::new(); + for idx in 0..num_indices { + let base = idx * 16; + let mut uses_high_data = false; + let mut max_id: u8 = 0; + let mut max_data: u8 = 0; + for j in 0..16 { + let block_pos = base + j; + if block_pos >= blocks.len() { + break; + } + let b = blocks[block_pos]; + let d = if block_pos < data.len() { + get_nibble(data, block_pos) + } else { + 0 + }; + if b > max_id { max_id = b; } + if d > max_data { max_data = d; } + if d > 0 { uses_high_data = true; } + } + + if max_id == 0 && max_data == 0 { + index_table[idx] = 0; + continue; + } + + if max_id <= 15 && !uses_high_data { + index_table[idx] = (1 << 13) | (payload.len() as u16); + for j in 0..16 { + let block_pos = base + j; + if block_pos < blocks.len() { + let b = blocks[block_pos]; + if j % 2 == 0 { + payload.push(b << 4); + } else { + let last = payload.last_mut().unwrap(); + *last |= b & 0x0F; + } + } + } + continue; + } + + if max_data == 0 { + index_table[idx] = (2 << 13) | (payload.len() as u16); + for j in 0..16 { + let block_pos = base + j; + if block_pos < blocks.len() { + payload.push(blocks[block_pos]); + } + } + continue; + } + + if max_id > 0 || max_data > 0 { + index_table[idx] = (3 << 13) | (payload.len() as u16); + for j in 0..16 { + let block_pos = base + j; + if block_pos < blocks.len() { + payload.push(blocks[block_pos]); + if block_pos < data.len() { + payload.push(get_nibble(data, block_pos)); + } else { + payload.push(0); + } + } + } + } + } + + for &entry in &index_table { + storage.extend_from_slice(&entry.to_be_bytes()); + } + storage.extend_from_slice(&payload); + storage +} + +fn get_nibble(arr: &[u8], index: usize) -> u8 { + let b = arr[index >> 1]; + if (index & 1) == 0 { b & 0x0F } else { (b >> 4) & 0x0F } +} + +pub fn rle_compress(data: &[u8]) -> Vec { + let mut output = Vec::new(); + let mut pos = 0; + while pos < data.len() { + let run_start = pos; + let mut run_len = 0u8; + while run_start + (run_len as usize) < data.len() && run_len < 255 { + if data[run_start] == data[run_start + (run_len as usize)] { + run_len += 1; + } else { + break; + } + } + + if run_len >= 4 { + output.push(0xFF); + output.push(run_len); + output.push(data[run_start]); + pos = run_start + run_len as usize; + } else { + output.push(data[pos]); + pos += 1; + } + } + + output +} diff --git a/src-tauri/src/console2lce/lce_region.rs b/src-tauri/src/console2lce/lce_region.rs new file mode 100644 index 0000000..e99c429 --- /dev/null +++ b/src-tauri/src/console2lce/lce_region.rs @@ -0,0 +1,70 @@ +use std::collections::BTreeMap; +use crate::console2lce::error::ConversionError; +const SECTOR_SIZE: usize = 4096; +const HEADER_SECTORS: usize = 2; +pub struct LceRegionFile { + sectors: Vec, + offsets: BTreeMap<(i32, i32), (u32, u32, u32)>, + next_sector: u32, +} + +impl LceRegionFile { + pub fn new() -> Self { + let header_size = HEADER_SECTORS * SECTOR_SIZE; + LceRegionFile { + sectors: vec![0u8; header_size], + offsets: BTreeMap::new(), + next_sector: HEADER_SECTORS as u32, + } + } + + pub fn add_chunk(&mut self, chunk_x: i32, chunk_z: i32, data: &[u8]) -> Result<(), ConversionError> { + if data.len() > 1024 * 1024 { + return Err(ConversionError::InvalidFormat(format!( + "Chunk ({}, {}) data too large: {} bytes", chunk_x, chunk_z, data.len() + ))); + } + + let total_size = 5 + data.len(); + let sectors_needed = ((total_size + SECTOR_SIZE - 1) / SECTOR_SIZE) as u32; + let sector_start = self.next_sector; + let padding = sectors_needed as usize * SECTOR_SIZE - total_size; + let length_be = (data.len() as u32).to_be_bytes(); + self.sectors.extend_from_slice(&length_be); + self.sectors.push(2); + self.sectors.extend_from_slice(data); + for _ in 0..padding { + self.sectors.push(0); + } + + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as u32; + + self.offsets.insert((chunk_x, chunk_z), (sector_start, sectors_needed, timestamp)); + self.next_sector = sector_start + sectors_needed; + Ok(()) + } + + pub fn build(mut self) -> Result, ConversionError> { + for ((chunk_x, chunk_z), (sector_start, sector_count, timestamp)) in &self.offsets { + let local_x = chunk_x.rem_euclid(32) as usize; + let local_z = chunk_z.rem_euclid(32) as usize; + let index = local_z * 32 + local_x; + let offset_val = (sector_start << 8) | (sector_count & 0xFF); + let offset_bytes = offset_val.to_be_bytes(); + let ts_bytes = timestamp.to_be_bytes(); + let off_pos = index * 4; + let ts_pos = 4096 + index * 4; + if off_pos + 4 <= self.sectors.len() { + self.sectors[off_pos..off_pos + 4].copy_from_slice(&offset_bytes); + } + if ts_pos + 4 <= self.sectors.len() { + self.sectors[ts_pos..ts_pos + 4].copy_from_slice(&ts_bytes); + } + } + + Ok(self.sectors) + } +} diff --git a/src-tauri/src/console2lce/level_dat.rs b/src-tauri/src/console2lce/level_dat.rs new file mode 100644 index 0000000..69ce001 --- /dev/null +++ b/src-tauri/src/console2lce/level_dat.rs @@ -0,0 +1,115 @@ +use std::collections::HashMap; +use crate::console2lce::error::ConversionError; +use crate::console2lce::nbt_util::{NbtTag, NbtHelper, write_zlibbed_nbt}; +use crate::console2lce::world_profile::{get_profile, world_center_offset}; +use crate::console2lce::models::ChunkConversionContext; +pub fn convert_level_dat( + source_data: &[u8], + profile_name: &str, + game_version: &str, + _context: &mut ChunkConversionContext, +) -> Result, ConversionError> { + let (_name, root) = crate::console2lce::nbt_util::read_gzipped_nbt(source_data)?; + let data = match root.as_compound() { + Some(m) => m.clone(), + None => return Err(ConversionError::InvalidFormat("level.dat root is not a compound".to_string())), + }; + + let data_tag = NbtHelper::get_compound(&data, "Data") + .unwrap_or_else(|| data.clone()); + + let profile = get_profile(profile_name).ok_or_else(|| { + ConversionError::InvalidFormat(format!("Unknown profile: {}", profile_name)) + })?; + + let center_offset = world_center_offset(profile.xz_size); + let spawn_x = NbtHelper::get_int(&data_tag, "SpawnX").unwrap_or(0) as i32; + let spawn_z = NbtHelper::get_int(&data_tag, "SpawnZ").unwrap_or(0) as i32; + let new_spawn_x = clamp_to_world(spawn_x, center_offset, profile.xz_size as i32); + let new_spawn_z = clamp_to_world(spawn_z, center_offset, profile.xz_size as i32); + let game_type = NbtHelper::get_int(&data_tag, "GameType").unwrap_or(0); + let rain_time = NbtHelper::get_int(&data_tag, "rainTime").unwrap_or(0); + let thunder_time = NbtHelper::get_int(&data_tag, "thunderTime").unwrap_or(0); + let raining = NbtHelper::get_byte(&data_tag, "raining").unwrap_or(0) != 0; + let thundering = NbtHelper::get_byte(&data_tag, "thundering").unwrap_or(0) != 0; + let seed = NbtHelper::get_long(&data_tag, "RandomSeed").unwrap_or(0); + let level_name = NbtHelper::get_string(&data_tag, "LevelName").unwrap_or_else(|| "Imported World".to_string()); + let day_time = NbtHelper::get_int(&data_tag, "DayTime").unwrap_or(0); + let allow_commands = NbtHelper::get_byte(&data_tag, "allowCommands").unwrap_or(0) != 0; + let difficulty = NbtHelper::get_byte(&data_tag, "Difficulty").unwrap_or(2); + let difficulty_locked = NbtHelper::get_byte(&data_tag, "DifficultyLocked").unwrap_or(0) != 0; + let hardcore = NbtHelper::get_byte(&data_tag, "hardcore").unwrap_or(0) != 0; + let map_features = NbtHelper::get_byte(&data_tag, "MapFeatures").unwrap_or(1) != 0; + let generator_name = NbtHelper::get_string(&data_tag, "generatorName").unwrap_or_else(|| "default".to_string()); + let generator_version = NbtHelper::get_int(&data_tag, "generatorVersion").unwrap_or(1); + let generator_options = NbtHelper::get_string(&data_tag, "generatorOptions").unwrap_or_default(); + let initialized = NbtHelper::get_byte(&data_tag, "initialized").unwrap_or(1) != 0; + let clear_weather = rained_since_last_thunder_cleanup(&data_tag); + let mut game_rules = HashMap::new(); + if let Some(rules) = NbtHelper::get_compound(&data_tag, "GameRules") { + for (k, v) in &rules { + if let Some(s) = v.get_string() { + game_rules.insert(k.clone(), NbtTag::String(s.to_string())); + } + } + } + + let mut lce_data = HashMap::new(); + lce_data.insert("allowCommands".to_string(), NbtTag::Byte(if allow_commands { 1 } else { 0 })); + lce_data.insert("clearedWeather".to_string(), NbtTag::Byte(if clear_weather { 1 } else { 0 })); + lce_data.insert("clearWeatherTime".to_string(), NbtTag::Int(0)); + lce_data.insert("currentSaveVersion".to_string(), NbtTag::Int(9)); + lce_data.insert("DayTime".to_string(), NbtTag::Int(day_time)); + lce_data.insert("Difficulty".to_string(), NbtTag::Byte(difficulty)); + lce_data.insert("DifficultyLocked".to_string(), NbtTag::Byte(if difficulty_locked { 1 } else { 0 })); + lce_data.insert("GameType".to_string(), NbtTag::Int(game_type)); + lce_data.insert("generatorName".to_string(), NbtTag::String(generator_name)); + lce_data.insert("generatorVersion".to_string(), NbtTag::Int(generator_version)); + lce_data.insert("generatorOptions".to_string(), NbtTag::String(generator_options)); + lce_data.insert("hardcore".to_string(), NbtTag::Byte(if hardcore { 1 } else { 0 })); + lce_data.insert("HellScale".to_string(), NbtTag::Int(profile.hell_scale as i32)); + lce_data.insert("initialized".to_string(), NbtTag::Byte(if initialized { 1 } else { 0 })); + lce_data.insert("LevelName".to_string(), NbtTag::String(level_name)); + lce_data.insert("MapFeatures".to_string(), NbtTag::Byte(if map_features { 1 } else { 0 })); + lce_data.insert("originalSaveVersion".to_string(), NbtTag::Int(7)); + lce_data.insert("rainTime".to_string(), NbtTag::Int(rain_time)); + lce_data.insert("RandomSeed".to_string(), NbtTag::Long(seed)); + lce_data.insert("SpawnX".to_string(), NbtTag::Int(new_spawn_x)); + lce_data.insert("SpawnZ".to_string(), NbtTag::Int(new_spawn_z)); + lce_data.insert("thunderTime".to_string(), NbtTag::Int(thunder_time)); + lce_data.insert("XzSize".to_string(), NbtTag::Int(profile.xz_size as i32)); + lce_data.insert("raining".to_string(), NbtTag::Byte(if raining { 1 } else { 0 })); + lce_data.insert("thundering".to_string(), NbtTag::Byte(if thundering { 1 } else { 0 })); + if !game_rules.is_empty() { + lce_data.insert("GameRules".to_string(), NbtTag::Compound(game_rules)); + } + + let version_info = build_version_info(game_version); + lce_data.insert("Version".to_string(), NbtTag::Compound(version_info)); + let root = NbtTag::Compound({ + let mut m = HashMap::new(); + m.insert("Data".to_string(), NbtTag::Compound(lce_data)); + m + }); + + write_zlibbed_nbt("", &root) +} + +fn clamp_to_world(value: i32, center: i32, xz_size: i32) -> i32 { + let half = xz_size * 8; + value.clamp(center - half, center + half - 1) +} + +fn rained_since_last_thunder_cleanup(data: &HashMap) -> bool { + let rain_time = NbtHelper::get_int(data, "rainTime").unwrap_or(0); + let thunder_time = NbtHelper::get_int(data, "thunderTime").unwrap_or(0); + rain_time > 0 && thunder_time == 0 +} + +fn build_version_info(game_version: &str) -> HashMap { + let mut version = HashMap::new(); + version.insert("Id".to_string(), NbtTag::Int(19133)); + version.insert("Name".to_string(), NbtTag::String(game_version.to_string())); + version.insert("Snapshot".to_string(), NbtTag::Byte(0)); + version +} diff --git a/src-tauri/src/console2lce/mod.rs b/src-tauri/src/console2lce/mod.rs new file mode 100644 index 0000000..9b4ccc2 --- /dev/null +++ b/src-tauri/src/console2lce/mod.rs @@ -0,0 +1,19 @@ +pub mod error; +pub mod models; +pub mod world_profile; +pub mod nbt_util; +pub mod stfs; +pub mod archive; +pub mod region; +pub mod chunk; +pub mod block_mapping; +pub mod modern_chunk_writer; +pub mod lce_chunk_payload; +pub mod lce_region; +pub mod level_dat; +pub mod save_data_container; +pub mod convert; +pub use error::ConversionError; +pub use models::{ConversionOptions, ConversionResult, ChunkConversionContext}; +pub use world_profile::WorldProfile; +pub use convert::{convert_java_world_to_lce, convert_xbox360_save_to_lce}; diff --git a/src-tauri/src/console2lce/models.rs b/src-tauri/src/console2lce/models.rs new file mode 100644 index 0000000..83f27ec --- /dev/null +++ b/src-tauri/src/console2lce/models.rs @@ -0,0 +1,129 @@ +use std::collections::HashMap; +#[derive(Debug, Clone, Copy)] +pub struct LegacyBlockState { + pub id: u8, + pub data: u8, +} + +impl LegacyBlockState { + pub fn new(id: u8, data: u8) -> Self { + LegacyBlockState { id, data } + } +} + +#[derive(Debug, Clone)] +pub struct ChunkConversionContext { + pub preserve_dynamic_chunk_data: bool, + pub global_modern_section_shift: Option, + pub unknown_blocks: Vec, +} + +impl ChunkConversionContext { + pub fn new() -> Self { + ChunkConversionContext { + preserve_dynamic_chunk_data: false, + global_modern_section_shift: None, + unknown_blocks: Vec::new(), + } + } + + pub fn record_unknown_modern_block(&mut self, name: &str) { + if !self.unknown_blocks.iter().any(|s| s == name) { + self.unknown_blocks.push(name.to_string()); + } + } +} + +#[derive(Debug, Clone)] +pub struct ConversionOptions { + pub profile: String, + pub target_version: String, + pub game_version: String, + pub preserve_entities: bool, + pub preview: bool, +} + +impl Default for ConversionOptions { + fn default() -> Self { + ConversionOptions { + profile: "large".to_string(), + target_version: "TU19".to_string(), + game_version: "1.13".to_string(), + preserve_entities: false, + preview: false, + } + } +} + +#[derive(Debug, Clone)] +pub struct ConversionResult { + pub success: bool, + pub message: String, + pub chunk_count: usize, + pub unknown_blocks: Vec, +} + +#[derive(Debug, Clone)] +pub struct PreparedJavaWorld { + pub level_dat: HashMap>, + pub region_files: Vec, + pub dimension_dirs: Vec, +} + +#[derive(Debug, Clone)] +pub struct JavaRegionFile { + pub path: String, + pub dimension: String, + pub data: Vec, + pub chunk_count: usize, +} + +#[derive(Debug, Clone)] +pub struct ArchiveEntry { + pub name: String, + pub offset: u64, + pub length: u32, + pub timestamp: u64, +} + +#[derive(Debug, Clone)] +pub struct StfsPackageDescriptor { + pub magic: u32, + pub content_type: u32, + pub meta_data_size: u32, + pub total_block_count: u32, + pub block_allocation_table_size: u32, +} + +#[derive(Debug, Clone)] +pub struct SavegameEnvelope { + pub data: Vec, + pub decompressed_size: u32, + pub compression_type: String, +} + +pub struct DecodedSection { + pub section_y: i32, + pub blocks: [u8; 4096], + pub data: [u8; 2048], + pub sky_light: Option<[u8; 2048]>, + pub block_light: Option<[u8; 2048]>, + pub non_air_count: i32, +} + +pub const CHUNK_BLOCKS: usize = 65536; +pub const CHUNK_NIBBLES: usize = 32768; +pub const HEIGHTMAP_SIZE: usize = 256; +pub const BIOMES_SIZE: usize = 256; +pub struct SaveDataContainerHeader { + pub magic: [u8; 6], + pub version: u32, + pub file_count: u32, +} + +pub struct SaveDataContainerFileEntry { + pub offset: u64, + pub length: u64, + pub timestamp: u64, + pub name: String, +} diff --git a/src-tauri/src/console2lce/modern_chunk_writer.rs b/src-tauri/src/console2lce/modern_chunk_writer.rs new file mode 100644 index 0000000..4508df1 --- /dev/null +++ b/src-tauri/src/console2lce/modern_chunk_writer.rs @@ -0,0 +1,737 @@ +use std::collections::HashMap; +use crate::console2lce::models::{LegacyBlockState, ChunkConversionContext, CHUNK_BLOCKS, CHUNK_NIBBLES}; +use crate::console2lce::nbt_util::{NbtTag, NbtHelper}; +use crate::console2lce::block_mapping; +pub fn map_modern_block_state(name: &str, properties: &HashMap, context: &mut ChunkConversionContext) -> LegacyBlockState { + let name = name.strip_prefix("minecraft:").unwrap_or(name); + if let Some(result) = try_map_fluid_block(name, properties) { + return result; + } + if let Some(result) = try_map_slab_block(name, properties) { + return result; + } + if let Some(result) = try_map_directional_block(name, properties) { + return result; + } + if let Some(result) = try_map_flattened_colored_block(name) { + return result; + } + if let Some(legacy) = block_mapping::MODERN_DIRECT_MAP.get(name) { + return *legacy; + } + if let Some(result) = try_map_colored_block(name, properties) { + return result; + } + if let Some(result) = try_map_wood_block(name, properties) { + return result; + } + if let Some(result) = try_map_variant_block(name, properties) { + return result; + } + + context.record_unknown_modern_block(name); + LegacyBlockState::new(0, 0) +} + +fn get_prop<'a>(properties: &'a HashMap, key: &str) -> &'a str { + properties.get(key).map(|s| s.as_str()).unwrap_or("") +} + +fn get_bool_prop(properties: &HashMap, key: &str) -> bool { + get_prop(properties, key) == "true" +} + +fn get_int_prop(properties: &HashMap, key: &str, default: i32) -> i32 { + get_prop(properties, key).parse::().unwrap_or(default) +} + +fn try_map_fluid_block(name: &str, properties: &HashMap) -> Option { + if name != "water" && name != "lava" { + return None; + } + let level = get_int_prop(properties, "level", 0).clamp(0, 15); + let is_source = level == 0; + if name == "water" { + Some(LegacyBlockState::new(if is_source { 9 } else { 8 }, level as u8)) + } else { + Some(LegacyBlockState::new(if is_source { 11 } else { 10 }, level as u8)) + } +} + +fn try_map_slab_block(name: &str, properties: &HashMap) -> Option { + if !name.ends_with("_slab") { + return None; + } + let slab_type = get_prop(properties, "type"); + let is_top = slab_type == "top"; + let is_double = slab_type == "double"; + if let Some(variant) = get_wood_slab_variant(name) { + if is_double { + return Some(LegacyBlockState::new(125, variant)); + } + return Some(LegacyBlockState::new(126, variant | if is_top { 8 } else { 0 })); + } + + if let Some(variant) = get_stone_slab_variant(name) { + if is_double { + return Some(LegacyBlockState::new(43, variant)); + } + return Some(LegacyBlockState::new(44, variant | if is_top { 8 } else { 0 })); + } + + if is_double { + Some(LegacyBlockState::new(43, 0)) + } else { + Some(LegacyBlockState::new(44, if is_top { 8 } else { 0 })) + } +} + +fn get_wood_slab_variant(name: &str) -> Option { + match name { + "oak_slab" => Some(0), + "spruce_slab" => Some(1), + "birch_slab" => Some(2), + "jungle_slab" => Some(3), + "acacia_slab" => Some(4), + "dark_oak_slab" => Some(5), + _ => None, + } +} + +fn get_stone_slab_variant(name: &str) -> Option { + match name { + "stone_slab" | "smooth_stone_slab" | "andesite_slab" | "polished_andesite_slab" + | "diorite_slab" | "polished_diorite_slab" | "granite_slab" | "polished_granite_slab" => Some(0), + "sandstone_slab" | "smooth_sandstone_slab" | "cut_sandstone_slab" + | "red_sandstone_slab" | "smooth_red_sandstone_slab" | "cut_red_sandstone_slab" => Some(1), + "cobblestone_slab" | "mossy_cobblestone_slab" => Some(3), + "brick_slab" => Some(4), + "stone_brick_slab" | "mossy_stone_brick_slab" => Some(5), + "nether_brick_slab" | "red_nether_brick_slab" => Some(6), + "quartz_slab" | "smooth_quartz_slab" | "purpur_slab" + | "prismarine_slab" | "prismarine_brick_slab" | "dark_prismarine_slab" => Some(7), + _ => None, + } +} + +fn try_map_directional_block(name: &str, properties: &HashMap) -> Option { + match name { + "ladder" => { + let data = map_ladder_facing(get_prop(properties, "facing")); + Some(LegacyBlockState::new(65, data)) + } + "vine" => { + let data = map_vine_faces(properties); + Some(LegacyBlockState::new(106, data)) + } + "lever" => { + let mut data = map_lever_data(get_prop(properties, "face"), get_prop(properties, "facing")); + if get_bool_prop(properties, "powered") { data |= 8; } + Some(LegacyBlockState::new(69, data)) + } + _ => { + if name.ends_with("_button") { + let id = if is_wood_family(name) { 143 } else { 77 }; + let mut data = map_button_facing(get_prop(properties, "facing")); + if get_bool_prop(properties, "powered") { data |= 8; } + return Some(LegacyBlockState::new(id, data)); + } + if name.ends_with("_fence_gate") { + let mut data = map_fence_gate_facing(get_prop(properties, "facing")); + if get_bool_prop(properties, "open") { data |= 4; } + return Some(LegacyBlockState::new(107, data)); + } + if name.ends_with("_pressure_plate") { + let id = match name { + "light_weighted_pressure_plate" => 147, + "heavy_weighted_pressure_plate" => 148, + n if is_wood_family(n) => 72, + _ => 70, + }; + let powered = get_bool_prop(properties, "powered") || get_int_prop(properties, "power", 0) > 0; + return Some(LegacyBlockState::new(id, if powered { 1 } else { 0 })); + } + if name.ends_with("_door") || name == "iron_door" { + let id = if name == "iron_door" { 71 } else { 64 }; + let half = get_prop(properties, "half"); + if half == "upper" { + let mut data: u8 = 8; + if get_prop(properties, "hinge") == "right" { data |= 1; } + if get_bool_prop(properties, "powered") { data |= 2; } + return Some(LegacyBlockState::new(id, data)); + } + let mut data = map_door_facing(get_prop(properties, "facing")); + if get_bool_prop(properties, "open") { data |= 4; } + return Some(LegacyBlockState::new(id, data)); + } + if let Some(stairs_id) = get_stairs_id(name) { + let mut data = map_stairs_facing(get_prop(properties, "facing")); + if get_prop(properties, "half") == "top" { data |= 4; } + return Some(LegacyBlockState::new(stairs_id, data)); + } + if name.ends_with("trapdoor") { + let mut data = map_trapdoor_facing(get_prop(properties, "facing")); + if get_bool_prop(properties, "open") { data |= 4; } + if get_prop(properties, "half") == "top" { data |= 8; } + return Some(LegacyBlockState::new(96, data)); + } + if name == "dispenser" || name == "dropper" { + let id = if name == "dropper" { 158 } else { 23 }; + let mut data = map_facing_data(get_prop(properties, "facing")); + if get_bool_prop(properties, "triggered") { data |= 8; } + return Some(LegacyBlockState::new(id, data)); + } + if name == "piston" || name == "sticky_piston" { + let id = if name == "sticky_piston" { 29 } else { 33 }; + let mut data = map_facing_data(get_prop(properties, "facing")); + if get_bool_prop(properties, "extended") { data |= 8; } + return Some(LegacyBlockState::new(id, data)); + } + if name == "piston_head" { + let mut data = map_facing_data(get_prop(properties, "facing")); + if get_prop(properties, "type") == "sticky" { data |= 8; } + return Some(LegacyBlockState::new(34, data)); + } + if name == "redstone_wire" { + let data = get_int_prop(properties, "power", 0).clamp(0, 15) as u8; + return Some(LegacyBlockState::new(55, data)); + } + if name == "repeater" { + let id = if get_bool_prop(properties, "powered") { 94 } else { 93 }; + let dir = map_repeater_direction(get_prop(properties, "facing")); + let delay = get_int_prop(properties, "delay", 1).clamp(1, 4); + let data = dir | ((delay - 1) as u8) << 2; + return Some(LegacyBlockState::new(id, data)); + } + if name == "comparator" { + let id = if get_bool_prop(properties, "powered") { 150 } else { 149 }; + let dir = map_repeater_direction(get_prop(properties, "facing")); + let mut data = dir; + if get_prop(properties, "mode") == "subtract" { data |= 4; } + return Some(LegacyBlockState::new(id, data)); + } + if name == "wall_torch" { + let data = map_wall_torch_facing(get_prop(properties, "facing")); + return Some(LegacyBlockState::new(50, data)); + } + if name == "redstone_wall_torch" { + let id = if get_bool_prop(properties, "lit") { 76 } else { 75 }; + let data = map_wall_torch_facing(get_prop(properties, "facing")); + return Some(LegacyBlockState::new(id, data)); + } + if name == "nether_wart" { + let data = get_int_prop(properties, "age", 0).clamp(0, 3) as u8; + return Some(LegacyBlockState::new(115, data)); + } + if name == "pumpkin_stem" || name == "attached_pumpkin_stem" { + let data = if name == "attached_pumpkin_stem" { 7 } else { get_int_prop(properties, "age", 0).clamp(0, 7) as u8 }; + return Some(LegacyBlockState::new(104, data)); + } + if name == "melon_stem" || name == "attached_melon_stem" { + let data = if name == "attached_melon_stem" { 7 } else { get_int_prop(properties, "age", 0).clamp(0, 7) as u8 }; + return Some(LegacyBlockState::new(105, data)); + } + if name == "cocoa" { + let age = get_int_prop(properties, "age", 0).clamp(0, 2) as u8; + let dir = map_repeater_direction(get_prop(properties, "facing")); + return Some(LegacyBlockState::new(127, (age << 2) | dir)); + } + if name == "hay_block" { + let data = match get_prop(properties, "axis") { + "x" => 4, + "z" => 8, + _ => 0, + }; + return Some(LegacyBlockState::new(170, data)); + } + if name == "quartz_pillar" { + let data = match get_prop(properties, "axis") { + "x" => 3, + "z" => 4, + _ => 2, + }; + return Some(LegacyBlockState::new(155, data)); + } + if name == "nether_portal" { + let data = if get_prop(properties, "axis") == "z" { 2 } else { 1 }; + return Some(LegacyBlockState::new(90, data)); + } + None + } + } +} + +fn is_wood_family(name: &str) -> bool { + let woods = ["oak", "spruce", "birch", "jungle", "acacia", "dark_oak"]; + woods.iter().any(|w| name.starts_with(w)) +} + +fn get_stairs_id(name: &str) -> Option { + match name { + "oak_stairs" | "spruce_stairs" | "birch_stairs" | "jungle_stairs" + | "acacia_stairs" | "dark_oak_stairs" => Some(53), + "cobblestone_stairs" | "mossy_cobblestone_stairs" | "stone_stairs" => Some(67), + "brick_stairs" => Some(108), + "stone_brick_stairs" | "mossy_stone_brick_stairs" => Some(109), + "nether_brick_stairs" => Some(114), + "sandstone_stairs" | "red_sandstone_stairs" => Some(128), + "quartz_stairs" | "purpur_stairs" => Some(156), + _ => None, + } +} + +fn map_stairs_facing(facing: &str) -> u8 { + match facing { + "east" => 0, "west" => 1, "south" => 2, "north" => 3, _ => 0, + } +} + +fn map_ladder_facing(facing: &str) -> u8 { + match facing { + "north" => 2, "south" => 3, "west" => 4, "east" => 5, _ => 2, + } +} + +fn map_button_facing(facing: &str) -> u8 { + match facing { + "east" => 1, "west" => 2, "south" => 3, "north" => 4, _ => 1, + } +} + +fn map_fence_gate_facing(facing: &str) -> u8 { + match facing { + "south" => 0, "west" => 1, "north" => 2, "east" => 3, _ => 0, + } +} + +fn map_vine_faces(properties: &HashMap) -> u8 { + let mut data = 0u8; + if get_bool_prop(properties, "south") { data |= 1; } + if get_bool_prop(properties, "west") { data |= 2; } + if get_bool_prop(properties, "north") { data |= 4; } + if get_bool_prop(properties, "east") { data |= 8; } + data +} + +fn map_lever_data(face: &str, facing: &str) -> u8 { + match face { + "floor" => if facing == "east" || facing == "west" { 6 } else { 5 }, + "ceiling" => if facing == "east" || facing == "west" { 7 } else { 0 }, + _ => map_button_facing(facing), + } +} + +fn map_door_facing(facing: &str) -> u8 { + match facing { + "east" => 0, "south" => 1, "west" => 2, "north" => 3, _ => 0, + } +} + +fn map_trapdoor_facing(facing: &str) -> u8 { + match facing { + "north" => 0, "south" => 1, "west" => 2, "east" => 3, _ => 0, + } +} + +fn map_facing_data(facing: &str) -> u8 { + match facing { + "down" => 0, "up" => 1, "north" => 2, "south" => 3, "west" => 4, "east" => 5, _ => 3, + } +} + +fn map_repeater_direction(facing: &str) -> u8 { + match facing { + "south" => 0, "west" => 1, "north" => 2, "east" => 3, _ => 0, + } +} + +fn map_wall_torch_facing(facing: &str) -> u8 { + match facing { + "east" => 1, "west" => 2, "south" => 3, "north" => 4, _ => 1, + } +} + +fn try_map_colored_block(name: &str, properties: &HashMap) -> Option { + let color = get_prop(properties, "color"); + let color_data = block_mapping::get_color_data(color); + + match name { + "wool" => Some(LegacyBlockState::new(35, color_data)), + "stained_glass" => Some(LegacyBlockState::new(95, color_data)), + "stained_glass_pane" => Some(LegacyBlockState::new(160, color_data)), + "terracotta" | "stained_hardened_clay" => Some(LegacyBlockState::new(159, color_data)), + "concrete" => Some(LegacyBlockState::new(172, color_data)), + "concrete_powder" => Some(LegacyBlockState::new(12, color_data)), + "glazed_terracotta" => Some(LegacyBlockState::new(159, color_data)), + _ => None, + } +} + +fn try_map_flattened_colored_block(name: &str) -> Option { + let (color_data, suffix) = split_color_prefix(name)?; + match suffix { + "wool" => Some(LegacyBlockState::new(35, color_data)), + "stained_glass" | "glass" => Some(LegacyBlockState::new(95, color_data)), + "stained_glass_pane" | "glass_pane" => Some(LegacyBlockState::new(160, color_data)), + "terracotta" | "stained_hardened_clay" => Some(LegacyBlockState::new(159, color_data)), + "concrete" => Some(LegacyBlockState::new(172, color_data)), + "concrete_powder" => Some(LegacyBlockState::new(12, color_data)), + "glazed_terracotta" => Some(LegacyBlockState::new(159, color_data)), + "carpet" => Some(LegacyBlockState::new(171, color_data)), + _ => None, + } +} + +fn split_color_prefix(name: &str) -> Option<(u8, &str)> { + for color_name in block_mapping::COLOR_NAMES { + if let Some(suffix) = name.strip_prefix(color_name).and_then(|s| s.strip_prefix('_')) { + let color_data = block_mapping::get_color_name(color_name); + return Some((color_data, suffix)); + } + } + None +} + +fn try_map_wood_block(name: &str, properties: &HashMap) -> Option { + let wood_type = get_prop(properties, "variant"); + let wood_type = if wood_type.is_empty() { + get_prefix_before_underscore(name) + } else { + wood_type.to_string() + }; + let data = block_mapping::get_wood_data(&wood_type); + + if name.ends_with("_planks") || name == "planks" { + return Some(LegacyBlockState::new(5, data)); + } + if name.ends_with("_sapling") || name == "sapling" { + return Some(LegacyBlockState::new(6, data)); + } + if name.ends_with("_log") || name == "log" { + return Some(LegacyBlockState::new(17, if data > 3 { 3 } else { data })); + } + if name.ends_with("_leaves") || name == "leaves" { + return Some(LegacyBlockState::new(18, if data > 3 { 3 } else { data })); + } + if name.ends_with("_stairs") && name.contains("wood") { + return Some(LegacyBlockState::new(53, 0)); + } + if name.ends_with("_door") { + return Some(LegacyBlockState::new(64, 0)); + } + if name.ends_with("_fence") { + return Some(LegacyBlockState::new(85, 0)); + } + if name.ends_with("_fence_gate") { + return Some(LegacyBlockState::new(107, 0)); + } + if name.ends_with("_pressure_plate") && is_wood_family(name) { + return Some(LegacyBlockState::new(72, 0)); + } + None +} + +fn get_prefix_before_underscore(name: &str) -> String { + if let Some(idx) = name.find('_') { + name[..idx].to_string() + } else { + String::new() + } +} + +fn try_map_variant_block(name: &str, properties: &HashMap) -> Option { + match name { + "redstone_lamp" => { + let lit = get_bool_prop(properties, "lit"); + Some(LegacyBlockState::new(if lit { 124 } else { 123 }, 0)) + } + "deepslate" | "polished_deepslate" | "tuff" | "calcite" | "dripstone_block" => Some(LegacyBlockState::new(1, 0)), + "cobbled_deepslate" => Some(LegacyBlockState::new(4, 0)), + "deepslate_bricks" | "deepslate_tiles" => Some(LegacyBlockState::new(98, 0)), + "cracked_deepslate_bricks" | "cracked_deepslate_tiles" => Some(LegacyBlockState::new(98, 2)), + "chiseled_deepslate" => Some(LegacyBlockState::new(98, 3)), + "deepslate_coal_ore" => Some(LegacyBlockState::new(16, 0)), + "deepslate_iron_ore" | "deepslate_copper_ore" => Some(LegacyBlockState::new(15, 0)), + "deepslate_gold_ore" => Some(LegacyBlockState::new(14, 0)), + "deepslate_redstone_ore" => Some(LegacyBlockState::new(73, 0)), + "deepslate_lapis_ore" => Some(LegacyBlockState::new(21, 0)), + "deepslate_diamond_ore" => Some(LegacyBlockState::new(56, 0)), + "deepslate_emerald_ore" => Some(LegacyBlockState::new(129, 0)), + "cobblestone_wall" => Some(LegacyBlockState::new(139, 0)), + "mossy_cobblestone_wall" => Some(LegacyBlockState::new(139, 1)), + "mossy_stone_bricks" => Some(LegacyBlockState::new(98, 1)), + "cracked_stone_bricks" => Some(LegacyBlockState::new(98, 2)), + "chiseled_stone_bricks" => Some(LegacyBlockState::new(98, 3)), + "smooth_sandstone" => Some(LegacyBlockState::new(24, 2)), + "chiseled_sandstone" | "cut_sandstone" => Some(LegacyBlockState::new(24, 1)), + "quartz_block" => Some(LegacyBlockState::new(155, 0)), + "chiseled_quartz_block" => Some(LegacyBlockState::new(155, 1)), + "prismarine" => Some(LegacyBlockState::new(168, 0)), + "prismarine_bricks" => Some(LegacyBlockState::new(168, 1)), + "dark_prismarine" => Some(LegacyBlockState::new(168, 2)), + "poppy" | "sunflower" | "lilac" | "peony" | "rose_bush" => Some(LegacyBlockState::new(38, 0)), + "blue_orchid" => Some(LegacyBlockState::new(38, 1)), + "allium" => Some(LegacyBlockState::new(38, 2)), + "azure_bluet" => Some(LegacyBlockState::new(38, 3)), + "red_tulip" => Some(LegacyBlockState::new(38, 4)), + "orange_tulip" => Some(LegacyBlockState::new(38, 5)), + "white_tulip" => Some(LegacyBlockState::new(38, 6)), + "pink_tulip" => Some(LegacyBlockState::new(38, 7)), + "oxeye_daisy" => Some(LegacyBlockState::new(38, 8)), + _ => None, + } +} + +pub fn get_nibble(arr: &[u8], index: usize) -> u8 { + let b = arr[index >> 1]; + if (index & 1) == 0 { b & 0x0F } else { (b >> 4) & 0x0F } +} + +pub fn set_nibble(arr: &mut [u8], index: usize, value: u8) { + let i = index >> 1; + let value = value & 0x0F; + if (index & 1) == 0 { + arr[i] = (arr[i] & 0xF0) | value; + } else { + arr[i] = (arr[i] & 0x0F) | (value << 4); + } +} + +pub fn flatten_anvil_sections( + level: &HashMap, + section_shift: Option, + context: &mut ChunkConversionContext, +) -> ([u8; CHUNK_BLOCKS], [u8; CHUNK_NIBBLES], [u8; CHUNK_NIBBLES], [u8; CHUNK_NIBBLES]) { + let mut blocks = [0u8; CHUNK_BLOCKS]; + let mut data = [0u8; CHUNK_NIBBLES]; + let mut sky_light = [0xFFu8; CHUNK_NIBBLES]; + let mut block_light = [0u8; CHUNK_NIBBLES]; + let sections = NbtHelper::get_list(level, "Sections") + .or_else(|| NbtHelper::get_list(level, "sections")); + + let sections = match sections { + Some(s) => s, + None => return (blocks, data, sky_light, block_light), + }; + + struct DecodedSectionData { + section_y: i32, + sblocks: [u8; 4096], + sdata: [u8; 2048], + sky: Option<[u8; 2048]>, + block: Option<[u8; 2048]>, + non_air: i32, + } + + let mut decoded = Vec::new(); + for section_tag in §ions { + let section = match section_tag.as_compound() { + Some(s) => s, + None => continue, + }; + + let section_y = NbtHelper::get_byte(section, "Y") + .or_else(|| NbtHelper::get_int(section, "y").map(|v| v as i8)) + .unwrap_or(0) as i32; + + let (sblocks, sdata) = match try_decode_section_blocks(section, context) { + Some(r) => r, + None => continue, + }; + + let s_sky = NbtHelper::get_byte_array(section, "SkyLight") + .or_else(|| NbtHelper::get_byte_array(section, "sky_light")) + .map(|v| { + let mut arr = [0u8; 2048]; + let len = v.len().min(2048); + arr[..len].copy_from_slice(&v[..len]); + arr + }); + + let s_block = NbtHelper::get_byte_array(section, "BlockLight") + .or_else(|| NbtHelper::get_byte_array(section, "block_light")) + .map(|v| { + let mut arr = [0u8; 2048]; + let len = v.len().min(2048); + arr[..len].copy_from_slice(&v[..len]); + arr + }); + + let non_air = sblocks.iter().filter(|&&b| b != 0).count() as i32; + decoded.push(DecodedSectionData { + section_y, + sblocks, + sdata, + sky: s_sky, + block: s_block, + non_air, + }); + } + + if decoded.is_empty() { + return (blocks, data, sky_light, block_light); + } + + let mut effective_shift = section_shift.unwrap_or(0); + let uses_negative_y = decoded.iter().any(|s| s.section_y < 0); + if effective_shift == 0 && section_shift.is_none() { + let anchor = decoded.iter() + .max_by(|a, b| a.non_air.cmp(&b.non_air) + .then_with(|| (a.section_y - 4).abs().cmp(&(b.section_y - 4).abs()))) + .map(|s| s.section_y) + .unwrap_or(4); + effective_shift = if uses_negative_y { anchor + 4 } else { anchor - 4 }; + } + + for section in &decoded { + let remapped_y = section.section_y - effective_shift; + if remapped_y < 0 || remapped_y > 15 { + continue; + } + let base_y = remapped_y as usize * 16; + for i in 0..4096 { + let x = i & 0x0F; + let z = (i >> 4) & 0x0F; + let y = (i >> 8) & 0x0F; + let global_y = base_y + y; + let flat_index = ((x * 16) + z) * 256 + global_y; + blocks[flat_index] = section.sblocks[i]; + set_nibble(&mut data, flat_index, get_nibble(§ion.sdata, i)); + if let Some(ref sk) = section.sky { + set_nibble(&mut sky_light, flat_index, get_nibble(sk, i)); + } + if let Some(ref bl) = section.block { + set_nibble(&mut block_light, flat_index, get_nibble(bl, i)); + } + } + } + + (blocks, data, sky_light, block_light) +} + +fn try_decode_section_blocks(section: &HashMap, context: &mut ChunkConversionContext) -> Option<([u8; 4096], [u8; 2048])> { + let old_blocks = NbtHelper::get_byte_array(section, "Blocks"); + if let Some(ref old_b) = old_blocks { + if old_b.len() >= 4096 { + let mut blocks = [0u8; 4096]; + blocks.copy_from_slice(&old_b[..4096]); + let data_arr = NbtHelper::get_byte_array_or_default(section, "Data", 2048); + let mut data = [0u8; 2048]; + let len = data_arr.len().min(2048); + data[..len].copy_from_slice(&data_arr[..len]); + return Some((blocks, data)); + } + } + + try_decode_palette_section(section, context) +} + +fn try_decode_palette_section(section: &HashMap, context: &mut ChunkConversionContext) -> Option<([u8; 4096], [u8; 2048])> { + let block_states_container = NbtHelper::get_compound(section, "block_states"); + let palette = NbtHelper::get_list(section, "Palette") + .or_else(|| block_states_container.as_ref().and_then(|c| NbtHelper::get_list(c, "palette")))?; + + if palette.is_empty() { + return None; + } + + let mut blocks = [0u8; 4096]; + let mut data = [0u8; 2048]; + if palette.len() == 1 { + let entry = match &palette[0] { + NbtTag::Compound(m) => m, + _ => return None, + }; + let legacy = map_palette_entry(entry, context); + for b in blocks.iter_mut() { + *b = legacy.id; + } + if legacy.data != 0 { + for i in 0..4096 { + set_nibble(&mut data, i, legacy.data); + } + } + return Some((blocks, data)); + } + + let block_states = section.get("BlockStates") + .and_then(|t| t.get_long_array()) + .or_else(|| block_states_container.as_ref().and_then(|c| c.get("data").and_then(|t| t.get_long_array())))?; + + if block_states.is_empty() { + return None; + } + + let bits_per_block = std::cmp::max(4, bits_required(palette.len() - 1)); + let values_per_long = std::cmp::max(1, 64 / bits_per_block); + let expected_long_count = (4096 + values_per_long - 1) / values_per_long; + let use_padded = block_states.len() == expected_long_count as usize; + for i in 0..4096 { + let palette_index = if use_padded { + read_padded_block_state(block_states, bits_per_block, i) + } else { + read_compact_block_state(block_states, bits_per_block, i) + }; + + if palette_index >= palette.len() { + continue; + } + + let entry = match &palette[palette_index] { + NbtTag::Compound(m) => m, + _ => continue, + }; + + let legacy = map_palette_entry(entry, context); + blocks[i] = legacy.id; + if legacy.data != 0 { + set_nibble(&mut data, i, legacy.data); + } + } + + Some((blocks, data)) +} + +fn map_palette_entry(entry: &HashMap, context: &mut ChunkConversionContext) -> LegacyBlockState { + let name = NbtHelper::get_string(entry, "Name").unwrap_or_default(); + let properties = NbtHelper::get_compound(entry, "Properties").unwrap_or_default(); + let mut prop_map = HashMap::new(); + for (k, v) in &properties { + if let Some(s) = v.get_string() { + prop_map.insert(k.clone(), s.to_string()); + } + } + map_modern_block_state(&name, &prop_map, context) +} + +fn bits_required(value: usize) -> i32 { + if value == 0 { return 1; } + let mut bits = 0; + let mut v = value; + while v > 0 { + bits += 1; + v >>= 1; + } + bits +} + +fn read_padded_block_state(states: &[i64], bits_per_block: i32, index: usize) -> usize { + let values_per_long = std::cmp::max(1, 64 / bits_per_block); + let long_index = index / values_per_long as usize; + let bit_offset = (index % values_per_long as usize) * bits_per_block as usize; + let mask = (1u64 << bits_per_block) - 1; + (states[long_index] as u64 >> bit_offset & mask) as usize +} + +fn read_compact_block_state(states: &[i64], bits_per_block: i32, index: usize) -> usize { + let start_bit = index * bits_per_block as usize; + let long_index = start_bit >> 6; + let bit_offset = start_bit & 63; + let mask = (1u64 << bits_per_block) - 1; + let mut value = states[long_index] as u64 >> bit_offset; + let bits_read = 64 - bit_offset; + if (bits_read as usize) < bits_per_block as usize && long_index + 1 < states.len() { + value |= (states[long_index + 1] as u64) << bits_read; + } + (value & mask) as usize +} diff --git a/src-tauri/src/console2lce/nbt_util.rs b/src-tauri/src/console2lce/nbt_util.rs new file mode 100644 index 0000000..bdf7c4a --- /dev/null +++ b/src-tauri/src/console2lce/nbt_util.rs @@ -0,0 +1,453 @@ +use std::collections::HashMap; +use std::io::{Read, Write, Cursor}; +use byteorder::{BigEndian, LittleEndian, ReadBytesExt, WriteBytesExt}; +use flate2::read::GzDecoder; +use flate2::read::ZlibDecoder; +use flate2::write::ZlibEncoder; +use flate2::Compression as FlateCompression; +use crate::console2lce::error::ConversionError; +#[derive(Debug, Clone)] +pub enum NbtTag { + Byte(i8), + Short(i16), + Int(i32), + Long(i64), + Float(f32), + Double(f64), + String(String), + ByteArray(Vec), + IntArray(Vec), + LongArray(Vec), + List(Vec, u8), + Compound(HashMap), + End, +} + +impl NbtTag { + pub fn get_byte(&self) -> Option { + if let NbtTag::Byte(v) = self { Some(*v) } else { None } + } + + pub fn get_short(&self) -> Option { + if let NbtTag::Short(v) = self { Some(*v) } else { None } + } + + pub fn get_int(&self) -> Option { + if let NbtTag::Int(v) = self { Some(*v) } else { None } + } + + pub fn get_long(&self) -> Option { + if let NbtTag::Long(v) = self { Some(*v) } else { None } + } + + pub fn get_float(&self) -> Option { + if let NbtTag::Float(v) = self { Some(*v) } else { None } + } + + pub fn get_double(&self) -> Option { + if let NbtTag::Double(v) = self { Some(*v) } else { None } + } + + pub fn get_string(&self) -> Option<&str> { + if let NbtTag::String(v) = self { Some(v.as_str()) } else { None } + } + + pub fn get_byte_array(&self) -> Option<&[u8]> { + if let NbtTag::ByteArray(v) = self { Some(v.as_slice()) } else { None } + } + + pub fn get_int_array(&self) -> Option<&[i32]> { + if let NbtTag::IntArray(v) = self { Some(v.as_slice()) } else { None } + } + + pub fn get_long_array(&self) -> Option<&[i64]> { + if let NbtTag::LongArray(v) = self { Some(v.as_slice()) } else { None } + } + + pub fn get_list(&self) -> Option<(&[NbtTag], u8)> { + if let NbtTag::List(tags, type_id) = self { + Some((tags.as_slice(), *type_id)) + } else { + None + } + } + + pub fn get_compound(&self) -> Option<&HashMap> { + if let NbtTag::Compound(map) = self { Some(map) } else { None } + } + + pub fn get_compound_mut(&mut self) -> Option<&mut HashMap> { + if let NbtTag::Compound(map) = self { Some(map) } else { None } + } + + pub fn as_compound(&self) -> Option<&HashMap> { + self.get_compound() + } + + pub fn lookup(&self, path: &[&str]) -> Option<&NbtTag> { + let mut current = self; + for key in path { + match current { + NbtTag::Compound(map) => { + current = map.get(*key)?; + } + _ => return None, + } + } + Some(current) + } + + pub fn lookup_mut(&mut self, path: &[&str]) -> Option<&mut NbtTag> { + let mut current = self; + for key in path { + match current { + NbtTag::Compound(map) => { + current = map.get_mut(*key)?; + } + _ => return None, + } + } + Some(current) + } +} + +fn read_string(reader: &mut R) -> Result { + let len = reader.read_u16::()? as usize; + let mut buf = vec![0u8; len]; + reader.read_exact(&mut buf)?; + Ok(String::from_utf8(buf).map_err(|e| ConversionError::NbtError(format!("Invalid UTF-8: {}", e)))?) +} + +fn write_string(writer: &mut W, s: &str) -> Result<(), ConversionError> { + let bytes = s.as_bytes(); + if bytes.len() > u16::MAX as usize { + return Err(ConversionError::NbtError("String too long".to_string())); + } + writer.write_u16::(bytes.len() as u16)?; + writer.write_all(bytes)?; + Ok(()) +} + +fn read_tag(reader: &mut R, tag_type: u8) -> Result { + match tag_type { + 0 => Ok(NbtTag::End), + 1 => Ok(NbtTag::Byte(reader.read_i8()?)), + 2 => Ok(NbtTag::Short(reader.read_i16::()?)), + 3 => Ok(NbtTag::Int(reader.read_i32::()?)), + 4 => Ok(NbtTag::Long(reader.read_i64::()?)), + 5 => Ok(NbtTag::Float(reader.read_f32::()?)), + 6 => Ok(NbtTag::Double(reader.read_f64::()?)), + 7 => { + let len = reader.read_i32::()?; + if len < 0 { + return Err(ConversionError::NbtError("Negative byte array length".to_string())); + } + let mut buf = vec![0u8; len as usize]; + reader.read_exact(&mut buf)?; + Ok(NbtTag::ByteArray(buf)) + } + 8 => Ok(NbtTag::String(read_string(reader)?)), + 9 => { + let element_type = reader.read_u8()?; + let len = reader.read_i32::()?; + if len < 0 { + return Err(ConversionError::NbtError("Negative list length".to_string())); + } + let mut tags = Vec::with_capacity(len as usize); + for _ in 0..len { + tags.push(read_tag(reader, element_type)?); + } + Ok(NbtTag::List(tags, element_type)) + } + 10 => { + let mut map = HashMap::new(); + loop { + let item_type = reader.read_u8()?; + if item_type == 0 { break; } + let name = read_string(reader)?; + let tag = read_tag(reader, item_type)?; + map.insert(name, tag); + } + Ok(NbtTag::Compound(map)) + } + 11 => { + let len = reader.read_i32::()?; + if len < 0 { + return Err(ConversionError::NbtError("Negative int array length".to_string())); + } + let mut buf = Vec::with_capacity(len as usize); + for _ in 0..len { + buf.push(reader.read_i32::()?); + } + Ok(NbtTag::IntArray(buf)) + } + 12 => { + let len = reader.read_i32::()?; + if len < 0 { + return Err(ConversionError::NbtError("Negative long array length".to_string())); + } + let mut buf = Vec::with_capacity(len as usize); + for _ in 0..len { + buf.push(reader.read_i64::()?); + } + Ok(NbtTag::LongArray(buf)) + } + _ => Err(ConversionError::NbtError(format!("Unknown tag type: {}", tag_type))), + } +} + +fn write_tag(writer: &mut W, tag: &NbtTag) -> Result<(), ConversionError> { + match tag { + NbtTag::End => writer.write_u8(0)?, + NbtTag::Byte(v) => writer.write_i8(*v)?, + NbtTag::Short(v) => writer.write_i16::(*v)?, + NbtTag::Int(v) => writer.write_i32::(*v)?, + NbtTag::Long(v) => writer.write_i64::(*v)?, + NbtTag::Float(v) => writer.write_f32::(*v)?, + NbtTag::Double(v) => writer.write_f64::(*v)?, + NbtTag::ByteArray(v) => { + writer.write_i32::(v.len() as i32)?; + writer.write_all(v)?; + } + NbtTag::String(s) => write_string(writer, s)?, + NbtTag::List(tags, _) => { + let element_type = if tags.is_empty() { 0 } else { get_tag_type(&tags[0]) }; + writer.write_u8(element_type)?; + writer.write_i32::(tags.len() as i32)?; + for tag in tags { + write_tag(writer, tag)?; + } + } + NbtTag::Compound(map) => { + for (name, tag) in map { + let tag_type = get_tag_type(tag); + writer.write_u8(tag_type)?; + write_string(writer, name)?; + write_tag(writer, tag)?; + } + writer.write_u8(0)?; + } + NbtTag::IntArray(v) => { + writer.write_i32::(v.len() as i32)?; + for val in v { + writer.write_i32::(*val)?; + } + } + NbtTag::LongArray(v) => { + writer.write_i32::(v.len() as i32)?; + for val in v { + writer.write_i64::(*val)?; + } + } + } + Ok(()) +} + +fn get_tag_type(tag: &NbtTag) -> u8 { + match tag { + NbtTag::End => 0, + NbtTag::Byte(_) => 1, + NbtTag::Short(_) => 2, + NbtTag::Int(_) => 3, + NbtTag::Long(_) => 4, + NbtTag::Float(_) => 5, + NbtTag::Double(_) => 6, + NbtTag::ByteArray(_) => 7, + NbtTag::String(_) => 8, + NbtTag::List(_, _) => 9, + NbtTag::Compound(_) => 10, + NbtTag::IntArray(_) => 11, + NbtTag::LongArray(_) => 12, + } +} + +pub fn read_nbt(data: &[u8]) -> Result<(String, NbtTag), ConversionError> { + let mut reader = Cursor::new(data); + let root_type = reader.read_u8()?; + if root_type == 0 { + return Ok((String::new(), NbtTag::End)); + } + let name = read_string(&mut reader)?; + let tag = read_tag(&mut reader, root_type)?; + Ok((name, tag)) +} + +pub fn write_nbt(name: &str, tag: &NbtTag) -> Result, ConversionError> { + let mut buf = Vec::new(); + let tag_type = get_tag_type(tag); + buf.write_u8(tag_type)?; + write_string(&mut buf, name)?; + write_tag(&mut buf, tag)?; + Ok(buf) +} + +pub fn read_gzipped_nbt(data: &[u8]) -> Result<(String, NbtTag), ConversionError> { + let mut decoder = GzDecoder::new(data); + let mut decompressed = Vec::new(); + decoder.read_to_end(&mut decompressed)?; + read_nbt(&decompressed) +} + +pub fn read_zlibbed_nbt(data: &[u8]) -> Result<(String, NbtTag), ConversionError> { + let mut decoder = ZlibDecoder::new(data); + let mut decompressed = Vec::new(); + decoder.read_to_end(&mut decompressed)?; + read_nbt(&decompressed) +} + +pub fn write_zlibbed_nbt(name: &str, tag: &NbtTag) -> Result, ConversionError> { + let nbt_data = write_nbt(name, tag)?; + let mut encoder = ZlibEncoder::new(Vec::new(), FlateCompression::default()); + encoder.write_all(&nbt_data)?; + let compressed = encoder.finish()?; + Ok(compressed) +} + +pub fn write_nbt_le(tag: &NbtTag) -> Result, ConversionError> { + let mut buf = Vec::new(); + let tag_type = get_tag_type(tag); + buf.write_u8(tag_type)?; + write_tag_le(&mut buf, tag)?; + Ok(buf) +} + +fn write_tag_le(writer: &mut W, tag: &NbtTag) -> Result<(), ConversionError> { + match tag { + NbtTag::End => writer.write_u8(0)?, + NbtTag::Byte(v) => writer.write_i8(*v)?, + NbtTag::Short(v) => writer.write_i16::(*v)?, + NbtTag::Int(v) => writer.write_i32::(*v)?, + NbtTag::Long(v) => writer.write_i64::(*v)?, + NbtTag::Float(v) => writer.write_f32::(*v)?, + NbtTag::Double(v) => writer.write_f64::(*v)?, + NbtTag::ByteArray(v) => { + writer.write_i32::(v.len() as i32)?; + writer.write_all(v)?; + } + NbtTag::String(s) => { + let bytes = s.as_bytes(); + writer.write_u16::(bytes.len() as u16)?; + writer.write_all(bytes)?; + } + NbtTag::List(tags, _) => { + let element_type = if tags.is_empty() { 0 } else { get_tag_type(&tags[0]) }; + writer.write_u8(element_type)?; + writer.write_i32::(tags.len() as i32)?; + for t in tags { + write_tag_le(writer, t)?; + } + } + NbtTag::Compound(map) => { + for (name, t) in map { + let tt = get_tag_type(t); + writer.write_u8(tt)?; + let bytes = name.as_bytes(); + writer.write_u16::(bytes.len() as u16)?; + writer.write_all(bytes)?; + write_tag_le(writer, t)?; + } + writer.write_u8(0)?; + } + NbtTag::IntArray(v) => { + writer.write_i32::(v.len() as i32)?; + for val in v { + writer.write_i32::(*val)?; + } + } + NbtTag::LongArray(v) => { + writer.write_i32::(v.len() as i32)?; + for val in v { + writer.write_i64::(*val)?; + } + } + } + Ok(()) +} + +pub fn read_mcr_chunk(data: &[u8], _compressed_size: u32) -> Result, ConversionError> { + if data.len() < 5 { + return Err(ConversionError::InvalidFormat("Chunk data too short".to_string())); + } + let compression_scheme = data[4]; + let payload = &data[5..]; + match compression_scheme { + 1 => { + let mut decoder = GzDecoder::new(payload); + let mut decompressed = Vec::new(); + decoder.read_to_end(&mut decompressed)?; + Ok(decompressed) + } + 2 => { + let mut decoder = ZlibDecoder::new(payload); + let mut decompressed = Vec::new(); + decoder.read_to_end(&mut decompressed)?; + Ok(decompressed) + } + 3 => { + Ok(payload.to_vec()) + } + _ => Err(ConversionError::DecompressionFailed( + format!("Unknown compression scheme: {}", compression_scheme) + )), + } +} + +#[derive(Debug, Clone)] +pub struct NbtHelper; +impl NbtHelper { + pub fn get_byte_array(tag: &HashMap, name: &str) -> Option> { + tag.get(name).and_then(|t| t.get_byte_array().map(|v| v.to_vec())) + } + + pub fn get_byte_array_or_default(tag: &HashMap, name: &str, default_size: usize) -> Vec { + Self::get_byte_array(tag, name).map(|v| { + if v.len() >= default_size { + v[..default_size].to_vec() + } else { + let mut padded = vec![0u8; default_size]; + padded[..v.len()].copy_from_slice(&v); + padded + } + }).unwrap_or_else(|| vec![0u8; default_size]) + } + + pub fn get_int(tag: &HashMap, name: &str) -> Option { + tag.get(name).and_then(|t| t.get_int()) + } + + pub fn get_long(tag: &HashMap, name: &str) -> Option { + tag.get(name).and_then(|t| t.get_long()) + } + + pub fn get_short(tag: &HashMap, name: &str) -> Option { + tag.get(name).and_then(|t| t.get_short()) + } + + pub fn get_byte(tag: &HashMap, name: &str) -> Option { + tag.get(name).and_then(|t| t.get_byte()) + } + + pub fn get_string(tag: &HashMap, name: &str) -> Option { + tag.get(name).and_then(|t| t.get_string().map(|s| s.to_string())) + } + + pub fn get_list(tag: &HashMap, name: &str) -> Option> { + tag.get(name).and_then(|t| { + if let NbtTag::List(tags, _) = t { + Some(tags.clone()) + } else { + None + } + }) + } + + pub fn get_compound(tag: &HashMap, name: &str) -> Option> { + tag.get(name).and_then(|t| { + if let NbtTag::Compound(map) = t { + Some(map.clone()) + } else { + None + } + }) + } +} diff --git a/src-tauri/src/console2lce/region.rs b/src-tauri/src/console2lce/region.rs new file mode 100644 index 0000000..414640f --- /dev/null +++ b/src-tauri/src/console2lce/region.rs @@ -0,0 +1,124 @@ +use crate::console2lce::error::ConversionError; +const SECTOR_SIZE: u32 = 4096; +const REGION_SIZE_X: usize = 32; +const REGION_SIZE_Z: usize = 32; +#[derive(Debug, Clone, Default)] +pub struct RegionEntry { + pub offset: u32, + pub sector_count: u32, + pub timestamp: u32, + pub exists: bool, +} + +#[derive(Debug, Clone)] +pub struct Xbox360Region { + pub entries: [[RegionEntry; REGION_SIZE_Z]; REGION_SIZE_X], + pub raw_data: Vec, +} + +pub fn parse_xbox360_region(data: &[u8]) -> Result { + if data.len() < 8192 { + return Err(ConversionError::InvalidFormat("Region data too short, need at least 8192 bytes for header".to_string())); + } + + let mut region = Xbox360Region { + entries: Default::default(), + raw_data: data.to_vec(), + }; + + for z in 0..REGION_SIZE_Z { + for x in 0..REGION_SIZE_X { + let index = z * REGION_SIZE_X + x; + let offset_raw = read_be_u32(&data[index * 4..index * 4 + 4]); + let timestamp_raw = read_be_u32(&data[4096 + index * 4..4096 + index * 4 + 4]); + let offset = offset_raw >> 8; + let sector_count = offset_raw & 0xFF; + region.entries[x][z] = RegionEntry { + offset, + sector_count, + timestamp: timestamp_raw, + exists: offset_raw != 0 && sector_count != 0, + }; + } + } + + Ok(region) +} + +pub fn read_region_chunk(region: &Xbox360Region, x: usize, z: usize) -> Result, ConversionError> { + if x >= REGION_SIZE_X || z >= REGION_SIZE_Z { + return Err(ConversionError::InvalidFormat(format!( + "Chunk coordinates out of range: ({}, {}), max ({}, {})", + x, z, REGION_SIZE_X, REGION_SIZE_Z + ))); + } + + let entry = ®ion.entries[x][z]; + if !entry.exists { + return Err(ConversionError::MissingData(format!( + "Chunk ({}, {}) does not exist in region", x, z + ))); + } + + let sector_offset = (entry.offset as usize) * SECTOR_SIZE as usize; + let total_size = (entry.sector_count as usize) * SECTOR_SIZE as usize; + if sector_offset + total_size > region.raw_data.len() { + return Err(ConversionError::InvalidFormat(format!( + "Chunk data at sector offset {} with size {} exceeds region data length {}", + sector_offset, total_size, region.raw_data.len() + ))); + } + + let sector_data = ®ion.raw_data[sector_offset..sector_offset + 5]; + let _compressed_size = read_be_u32(§or_data[0..4]); + let _compression_scheme = sector_data[4]; + let chunk_data = region.raw_data[sector_offset..sector_offset + total_size].to_vec(); + Ok(chunk_data) +} + +pub fn read_java_mcr_region(data: &[u8]) -> Result)>, ConversionError> { + if data.len() < 8192 { + return Err(ConversionError::InvalidFormat("MCR data too short".to_string())); + } + + let mut chunks = Vec::new(); + for z in 0..REGION_SIZE_Z { + for x in 0..REGION_SIZE_X { + let index = z * REGION_SIZE_X + x; + let offset_raw = read_be_u32(&data[index * 4..index * 4 + 4]); + if offset_raw == 0 { + continue; + } + + let sector_offset = (offset_raw >> 8) as usize; + let _sector_count = (offset_raw & 0xFF) as usize; + let file_offset = sector_offset * SECTOR_SIZE as usize; + if file_offset + 5 > data.len() { + continue; + } + + let chunk_len = read_be_u32(&data[file_offset..file_offset + 4]) as usize; + let compression = data[file_offset + 4]; + if chunk_len == 0 || file_offset + 5 + chunk_len > data.len() { + continue; + } + + let mut chunk_data = vec![0u8; 5 + chunk_len]; + chunk_data[0..4].copy_from_slice(&data[file_offset..file_offset + 4]); + chunk_data[4] = compression; + chunk_data[5..].copy_from_slice(&data[file_offset + 5..file_offset + 5 + chunk_len]); + chunks.push((x, z, chunk_data)); + } + } + + Ok(chunks) +} + +pub fn read_java_mca_region(data: &[u8]) -> Result)>, ConversionError> { + read_java_mcr_region(data) +} + +fn read_be_u32(bytes: &[u8]) -> u32 { + if bytes.len() < 4 { return 0; } + ((bytes[0] as u32) << 24) | ((bytes[1] as u32) << 16) | ((bytes[2] as u32) << 8) | (bytes[3] as u32) +} diff --git a/src-tauri/src/console2lce/save_data_container.rs b/src-tauri/src/console2lce/save_data_container.rs new file mode 100644 index 0000000..e338009 --- /dev/null +++ b/src-tauri/src/console2lce/save_data_container.rs @@ -0,0 +1,54 @@ +use std::collections::HashMap; +use byteorder::{LittleEndian, WriteBytesExt}; +use crate::console2lce::error::ConversionError; +const SAVE_MAGIC: [u8; 6] = [0x01, 0x00, 0x00, 0x00, 0x01, 0x00]; +const CONTAINER_VERSION: u32 = 2; +pub fn write_save_data_container( + files: &[(String, Vec)], +) -> Result, ConversionError> { + let mut data = Vec::new(); + data.extend_from_slice(&SAVE_MAGIC); + data.write_u32::(CONTAINER_VERSION)?; + data.write_u32::(files.len() as u32)?; + let mut file_offsets = Vec::new(); + for (name, content) in files { + file_offsets.push((name.clone(), data.len() as u64, content.len() as u64)); + data.extend_from_slice(content); + while data.len() % 4 != 0 { + data.push(0); + } + } + + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + for (name, offset, length) in &file_offsets { + data.write_u64::(*offset)?; + data.write_u64::(*length)?; + data.write_u64::(timestamp)?; + let name_bytes = name.as_bytes(); + data.write_u16::(name_bytes.len() as u16)?; + data.extend_from_slice(name_bytes); + data.push(0); + while data.len() % 4 != 0 { + data.push(0); + } + } + + Ok(data) +} + +pub fn write_save_data_ms( + level_dat: &[u8], + regions: &HashMap>, +) -> Result, ConversionError> { + let mut files = Vec::new(); + files.push(("level.dat".to_string(), level_dat.to_vec())); + for (path, region_data) in regions { + files.push((path.clone(), region_data.clone())); + } + + write_save_data_container(&files) +} diff --git a/src-tauri/src/console2lce/stfs.rs b/src-tauri/src/console2lce/stfs.rs new file mode 100644 index 0000000..6ef274d --- /dev/null +++ b/src-tauri/src/console2lce/stfs.rs @@ -0,0 +1,635 @@ +use crate::console2lce::error::ConversionError; +use lzxd::{Lzxd, WindowSize}; + +const CON_MAGIC: u32 = 0x434F4E20; + +fn read_be_u32(bytes: &[u8]) -> u32 { + ((bytes[0] as u32) << 24) | ((bytes[1] as u32) << 16) | ((bytes[2] as u32) << 8) | (bytes[3] as u32) +} + +fn read_be_u16(bytes: &[u8]) -> u16 { + ((bytes[0] as u16) << 8) | (bytes[1] as u16) +} + +fn read_le_u16(bytes: &[u8]) -> u16 { + (bytes[0] as u16) | ((bytes[1] as u16) << 8) +} + +fn read_le_u24(bytes: &[u8]) -> i32 { + (bytes[0] as i32) | ((bytes[1] as i32) << 8) | ((bytes[2] as i32) << 16) +} + +pub fn is_stfs_package(data: &[u8]) -> bool { + if data.len() < 4 { return false; } + read_be_u32(&data[0..4]) == CON_MAGIC +} + +struct StfsVD { + _size: u8, + block_separation: u8, + file_table_block_count: u16, + file_table_block_num: i32, + alloc_block_count: u32, + _unallocated_block_count: u32, +} + +fn read_stfs_vd(data: &[u8]) -> Result { + if data.len() < 0x24 { + return Err(ConversionError::InvalidFormat("STFS VD too short".to_string())); + } + let size = data[0]; + let block_separation = data[2]; + let file_table_block_count = read_le_u16(&data[3..5]); + let file_table_block_num = read_le_u24(&data[5..8]); + let alloc_block_count = read_be_u32(&data[0x1C..0x20]); + let unallocated_block_count = read_be_u32(&data[0x20..0x24]); + Ok(StfsVD { + _size: size, + block_separation, + file_table_block_count, + file_table_block_num, + alloc_block_count, + _unallocated_block_count: unallocated_block_count, + }) +} + +struct StfsPackage<'a> { + data: &'a [u8], + header_size: u32, + vd: StfsVD, + package_sex: u8, //neo: 0=female, 1=male + block_step: [u32; 2], + first_hash_addr: u32, + top_level: u8, + top_table: Vec, + alloc_block_count: u32, +} + +#[derive(Clone, Copy)] +struct HashEntry { + _hash: [u8; 20], + status: u8, + next_block: i32, +} + +impl<'a> StfsPackage<'a> { + fn parse(data: &'a [u8]) -> Result { + if data.len() < 0x400 { + return Err(ConversionError::InvalidFormat("STFS data too short for header".to_string())); + } + + if !is_stfs_package(data) { + return Err(ConversionError::InvalidFormat("Not a valid STFS package".to_string())); + } + + let header_size = read_be_u32(&data[0x340..0x344]); + let content_type = read_be_u32(&data[0x344..0x348]); + if content_type != 1 { + return Err(ConversionError::InvalidFormat(format!( + "Not a savegame (content_type={})", content_type + ))); + } + + let file_system = read_be_u32(&data[0x3A9..0x3AD]); + if file_system != 0 { + return Err(ConversionError::InvalidFormat("Not STFS format".to_string())); + } + + let vd = read_stfs_vd(&data[0x379..])?; + let alloc_block_count = vd.alloc_block_count; + if alloc_block_count == 0 { + return Err(ConversionError::InvalidFormat("No allocated blocks".to_string())); + } + + let package_sex = (!vd.block_separation) & 1; + let block_step: [u32; 2] = if package_sex == 0 { + [0xAB, 0x718F] + } else { + [0xAC, 0x723A] + }; + + let first_hash_addr = (header_size + 0x0FFF) & !0x0FFF; + let top_level = if alloc_block_count <= 0xAA { + 0 + } else if alloc_block_count <= 0x70E4 { + 1 + } else if alloc_block_count <= 0x4AF768 { + 2 + } else { + return Err(ConversionError::InvalidFormat("Too many allocated blocks".to_string())); + }; + + let top_true_block = compute_level_n_backing_hash_block_number(0, top_level, &block_step, package_sex); + let base_addr = (top_true_block << 12) + first_hash_addr; + let top_addr = base_addr + (((vd.block_separation & 2) as u32) << 11); + let top_addr = top_addr as usize; + let data_blocks_per_level: [u32; 3] = [1, 0xAA, 0x70E4]; + let divisor = data_blocks_per_level[top_level as usize]; + let mut entry_count = alloc_block_count / divisor; + if alloc_block_count > 0x70E4 && alloc_block_count % 0x70E4 != 0 { + entry_count += 1; + } else if alloc_block_count > 0xAA && alloc_block_count % 0xAA != 0 { + entry_count += 1; + } + + let mut top_table = Vec::with_capacity(entry_count as usize); + for i in 0..entry_count { + let off = top_addr + (i as usize) * 24; + if off + 24 > data.len() { + break; + } + let mut hash = [0u8; 20]; + hash.copy_from_slice(&data[off..off + 20]); + let status = data[off + 20]; + let next_block = read_le_u24(&data[off + 21..off + 24]); + top_table.push(HashEntry { + _hash: hash, + status, + next_block, + }); + } + + Ok(StfsPackage { + data, + header_size, + vd, + package_sex, + block_step, + first_hash_addr, + top_level, + top_table, + alloc_block_count, + }) + } + + fn block_to_address(&self, block_num: u32) -> u32 { + (self.compute_backing_data_block_number(block_num) << 12) + self.first_hash_addr + } + + fn compute_backing_data_block_number(&self, block_num: u32) -> u32 { + let psex = self.package_sex as u32; + let to_return = (((block_num + 0xAA) / 0xAA) << psex) + block_num; + if block_num < 0xAA { + return to_return; + } else if block_num < 0x70E4 { + return to_return + (((block_num + 0x70E4) / 0x70E4) << psex); + } else { + return (1 << psex) + (to_return + (((block_num + 0x70E4) / 0x70E4) << psex)); + } + } + + fn get_hash_entry(&self, block_num: u32) -> Result { + if block_num >= self.alloc_block_count { + return Err(ConversionError::InvalidFormat("Block number out of range".to_string())); + } + let hash_addr = self.get_hash_address_of_block(block_num); + let off = hash_addr as usize; + if off + 24 > self.data.len() { + return Err(ConversionError::InvalidFormat("Hash address out of file bounds".to_string())); + } + let mut hash = [0u8; 20]; + hash.copy_from_slice(&self.data[off..off + 20]); + let status = self.data[off + 20]; + let next_block = read_le_u24(&self.data[off + 21..off + 24]); + Ok(HashEntry { _hash: hash, status, next_block }) + } + + fn get_hash_address_of_block(&self, block_num: u32) -> u32 { + let level0 = compute_level_0_backing_hash_block_number(block_num, &self.block_step, self.package_sex); + let mut hash_addr = (level0 << 12) + self.first_hash_addr; + hash_addr += (block_num % 0xAA) * 24; + match self.top_level { + 0 => { + hash_addr += (self.vd.block_separation as u32 & 2) << 11; + } + 1 => { + let idx = (block_num / 0xAA) as usize; + if idx < self.top_table.len() { + hash_addr += ((self.top_table[idx].status & 0x40) as u32) << 6; + } + } + 2 => { + let l1_idx = (block_num / 0x70E4) as usize; + if l1_idx < self.top_table.len() { + let l1_off = (self.top_table[l1_idx].status as u32 & 0x40) << 6; + let l1_block = compute_level_1_backing_hash_block_number(block_num, &self.block_step, self.package_sex); + let pos = ((l1_block << 12) + self.first_hash_addr + l1_off) + + ((block_num % 0xAA) * 24); + let pos = pos as usize; + if pos + 21 <= self.data.len() { + hash_addr += (self.data[pos + 20] as u32 & 0x40) << 6; + } + } + } + _ => {} + } + hash_addr + } + + fn read_file_listing(&self) -> Result, ConversionError> { + let mut entries = Vec::new(); + let block_count = self.vd.file_table_block_count as u32; + let mut block = self.vd.file_table_block_num as u32; + for _ in 0..block_count { + let addr = self.block_to_address(block) as usize; + if addr + 0x1000 > self.data.len() { + break; + } + for i in 0..0x40u32 { + let off = addr + (i as usize) * 0x40; + if off + 0x40 > self.data.len() { + break; + } + let name_bytes = &self.data[off..off + 0x28]; + let name_len_byte = self.data[off + 0x28]; + let name_len = name_len_byte & 0x3F; + if name_len == 0 { + continue; + } + let name = String::from_utf8_lossy(&name_bytes[..name_len as usize]).to_string(); + if name.is_empty() { + break; + } + let blocks_for_file = read_le_u24(&self.data[off + 0x29..off + 0x2C]); + let starting_block_num = read_le_u24(&self.data[off + 0x2F..off + 0x32]); + let path_indicator = read_be_u16(&self.data[off + 0x32..off + 0x34]); + let file_size = read_be_u32(&self.data[off + 0x34..off + 0x38]); + let created_ts = read_be_u32(&self.data[off + 0x38..off + 0x3C]); + let accessed_ts = read_be_u32(&self.data[off + 0x3C..off + 0x40]); + let flags = name_len_byte >> 6; + entries.push(StfsFileEntry { + name, + flags, + blocks_for_file, + starting_block_num, + path_indicator, + file_size, + created_ts, + accessed_ts, + }); + } + + let he = self.get_hash_entry(block)?; + block = he.next_block as u32; + } + + Ok(entries) + } + + fn extract_file(&self, entry: &StfsFileEntry) -> Result, ConversionError> { + let file_size = entry.file_size; + if file_size == 0 { + return Ok(Vec::new()); + } + + if entry.flags & 1 != 0 { + let mut out = Vec::with_capacity(file_size as usize); + let start_addr = self.block_to_address(entry.starting_block_num as u32); + let pos = start_addr as usize; + let block_count = (compute_level_0_backing_hash_block_number( + entry.starting_block_num as u32, &self.block_step, self.package_sex + ) + self.block_step[0]) - ((start_addr - self.first_hash_addr) >> 12); + let initial = if (entry.blocks_for_file as u32) <= block_count { + let sz = entry.file_size as usize; + if pos + sz <= self.data.len() { + out.extend_from_slice(&self.data[pos..pos + sz]); + } + return Ok(out); + } else { + let amount = (block_count << 12) as usize; + if pos + amount <= self.data.len() { + out.extend_from_slice(&self.data[pos..pos + amount]); + } + amount + }; + + let mut remaining = (file_size as usize).wrapping_sub(initial); + let mut cur_pos = pos + initial; + while remaining >= 0xAA000 { + cur_pos += self.get_hash_table_skip_size(cur_pos as u32) as usize; + let end = (cur_pos + 0xAA000).min(self.data.len()); + out.extend_from_slice(&self.data[cur_pos..end]); + remaining = remaining.wrapping_sub(0xAA000); + cur_pos = end; + } + if remaining > 0 { + cur_pos += self.get_hash_table_skip_size(cur_pos as u32) as usize; + let end = (cur_pos + remaining).min(self.data.len()); + out.extend_from_slice(&self.data[cur_pos..end]); + } + Ok(out) + } else { + let full_reads = file_size / 0x1000; + let remainder = file_size % 0x1000; + let mut out = Vec::with_capacity(file_size as usize); + let mut block = entry.starting_block_num as u32; + for _ in 0..full_reads { + let addr = self.block_to_address(block) as usize; + if addr + 0x1000 <= self.data.len() { + out.extend_from_slice(&self.data[addr..addr + 0x1000]); + } + block = self.get_hash_entry(block)?.next_block as u32; + } + if remainder > 0 { + let addr = self.block_to_address(block) as usize; + if addr + remainder as usize <= self.data.len() { + out.extend_from_slice(&self.data[addr..addr + remainder as usize]); + } + } + Ok(out) + } + } + + fn get_hash_table_skip_size(&self, table_addr: u32) -> u32 { + let psex = self.package_sex as u32; + let true_block = (table_addr - self.first_hash_addr) >> 12; + if true_block == 0 { + return 0x1000 << psex; + } + let mut tb = true_block; + if tb == self.block_step[1] { + return 0x3000 << psex; + } else if tb > self.block_step[1] { + tb -= self.block_step[1] + (1 << psex); + } + if tb == self.block_step[0] || tb % self.block_step[1] == 0 { + return 0x2000 << psex; + } + 0x1000 << psex + } +} + +#[derive(Debug, Clone)] +struct StfsFileEntry { + name: String, + flags: u8, + blocks_for_file: i32, + starting_block_num: i32, + path_indicator: u16, + file_size: u32, + created_ts: u32, + accessed_ts: u32, +} + +fn compute_level_n_backing_hash_block_number( + block_num: u32, level: u8, block_step: &[u32; 2], package_sex: u8, +) -> u32 { + match level { + 0 => compute_level_0_backing_hash_block_number(block_num, block_step, package_sex), + 1 => compute_level_1_backing_hash_block_number(block_num, block_step, package_sex), + 2 => block_step[1], + _ => 0, + } +} + +fn compute_level_0_backing_hash_block_number( + block_num: u32, block_step: &[u32; 2], package_sex: u8, +) -> u32 { + let psex = package_sex as u32; + if block_num < 0xAA { return 0; } + let mut num = (block_num / 0xAA) * block_step[0]; + num += ((block_num / 0x70E4) + 1) << psex; + if block_num / 0x70E4 == 0 { return num; } + num + (1 << psex) +} + +fn compute_level_1_backing_hash_block_number( + block_num: u32, block_step: &[u32; 2], package_sex: u8, +) -> u32 { + let psex = package_sex as u32; + if block_num < 0x70E4 { return block_step[0]; } + (1 << psex) + (block_num / 0x70E4) * block_step[1] +} + +pub fn extract_savegame_from_stfs(data: &[u8]) -> Result, ConversionError> { + let pkg = StfsPackage::parse(data)?; + let listing = pkg.read_file_listing()?; + let entry = listing.iter().find(|e| e.name == "savegame.dat") + .ok_or_else(|| ConversionError::InvalidFormat("savegame.dat not found in STFS listing".to_string()))?; + + eprintln!("[stfs] found savegame.dat: size={} blocks={} start_block={}", + entry.file_size, entry.blocks_for_file, entry.starting_block_num); + + let raw_data = pkg.extract_file(entry)?; + eprintln!("[stfs] extracted {} bytes of savegame.dat", raw_data.len()); + + Ok(raw_data) +} + +pub fn try_decompress_stfs_savegame(data: &[u8]) -> Result, ConversionError> { + if data.len() < 12 { + return Err(ConversionError::InvalidFormat("STFS savegame data too short for header".to_string())); + } + let total_size = read_be_u32(&data[0..4]); + let decompressed_size = u64::from(read_be_u32(&data[4..8])) << 32 | read_be_u32(&data[8..12]) as u64; + let src_size = total_size.wrapping_sub(8) as usize; + let decompressed_size = decompressed_size as usize; + eprintln!("[stfs] savegame header: total_size={} src_size={} decompressed_size={}", + total_size, src_size, decompressed_size); + + if src_size == 0 || src_size > data.len().saturating_sub(12) { + return Err(ConversionError::InvalidFormat(format!( + "Invalid compressed size: {} (data len: {})", src_size, data.len() + ))); + } + if decompressed_size == 0 || decompressed_size > 100_000_000 { + return Err(ConversionError::InvalidFormat(format!( + "Invalid decompressed size: {}", decompressed_size + ))); + } + + let compressed = &data[12..12 + src_size]; + decompress_lzx_chunked(compressed, decompressed_size) +} + +pub fn decompress_lzx_chunked(data: &[u8], decompressed_size: usize) -> Result, ConversionError> { + let mut lzxd = Lzxd::new(WindowSize::KB64); + let mut output = Vec::with_capacity(decompressed_size); + let mut pos = 0; + let mut num_chunks = 0; + let mut comp_count = 0; + let mut uncomp_count = 0; + let mut lzx_fallbacks = 0; + while pos < data.len() && output.len() < decompressed_size { + if pos + 2 > data.len() { + break; + } + let chunk_size = read_be_u16(&data[pos..pos + 2]) as usize; + pos += 2; + if chunk_size == 0 { + break; + } + + let is_compressed = (chunk_size & 0x8000) != 0; + let actual_size = chunk_size & 0x7FFF; + num_chunks += 1; + + if pos + actual_size > data.len() { + eprintln!("[lzx] chunk {} header overrun: need {} have {} at pos {}", + num_chunks, actual_size, data.len() - pos + 2, pos - 2); + break; + } + + let chunk_data = &data[pos..pos + actual_size]; + pos += actual_size; + + if is_compressed { + let out_len = if output.len() + 32768 > decompressed_size { + decompressed_size - output.len() + } else { + 32768 + }; + comp_count += 1; + match lzxd.decompress_next(chunk_data, out_len) { + Ok(decompressed) => { + eprintln!("[lzx] chunk {} C: compressed={} decompressed={} total_so_far={}", + num_chunks, actual_size, decompressed.len(), output.len() + decompressed.len()); + output.extend_from_slice(decompressed); + } + Err(e) => { + return Err(ConversionError::DecompressionFailed( + format!("chunk {} LZXD failed at pos {}: {}", num_chunks, pos - actual_size - 2, e) + )); + } + } + } else { + let mut chunk_lzxd = Lzxd::new(WindowSize::KB32); + match chunk_lzxd.decompress_next(chunk_data, 32768) { + Ok(decompressed) => { + lzx_fallbacks += 1; + eprintln!("[lzx] chunk {} U->C (LZX fallback): {} -> {} total_so_far={}", + num_chunks, actual_size, decompressed.len(), output.len() + decompressed.len()); + output.extend_from_slice(decompressed); + } + Err(_) => { + eprintln!("[lzx] chunk {} U: size={} total_so_far={}", + num_chunks, actual_size, output.len() + actual_size); + output.extend_from_slice(chunk_data); + uncomp_count += 1; + } + } + } + } + eprintln!("[lzx] processed {} chunks ({} marked C + {} LZX-fallback + {} pure U), produced {} of {} expected bytes, consumed {}/{}", + num_chunks, comp_count, lzx_fallbacks, uncomp_count, output.len(), decompressed_size, pos, data.len()); + + output.truncate(decompressed_size); + Ok(output) +} + +pub fn try_decompress_savegame(data: &[u8]) -> Result, ConversionError> { + if data.len() < 8 { + return Err(ConversionError::InvalidFormat("Savegame data too short".to_string())); + } + let magic = &data[0..8]; + let expected_magic = [0x0B, 0xFC, 0x4A, 0x46, 0xAE, 0x7A, 0x43, 0x2B]; + if magic != expected_magic { + return Err(ConversionError::InvalidFormat("Invalid savegame magic".to_string())); + } + + if data.len() < 16 { + return Err(ConversionError::InvalidFormat("Savegame data too short for header".to_string())); + } + + let decompressed_size = read_be_u32(&data[8..12]) as usize; + let compression_type = read_be_u32(&data[12..16]); + let payload = &data[16..]; + match compression_type { + 0 => { + if payload.len() >= decompressed_size { + Ok(payload[..decompressed_size].to_vec()) + } else { + Ok(payload.to_vec()) + } + } + 1 => { + decompress_lzx(payload, decompressed_size) + } + 2 => { + use std::io::Read; + let mut decoder = flate2::read::ZlibDecoder::new(payload); + let mut out = Vec::with_capacity(decompressed_size); + decoder.read_to_end(&mut out)?; + Ok(out) + } + 3 => { + decode_rle(payload, decompressed_size) + } + _ => Err(ConversionError::DecompressionFailed( + format!("Unknown compression type: {}", compression_type) + )), + } +} + +fn decompress_lzx(data: &[u8], decompressed_size: usize) -> Result, ConversionError> { + let mut output = Vec::with_capacity(decompressed_size); + let mut pos = 0; + while pos < data.len() && output.len() < decompressed_size { + if pos + 2 > data.len() { + break; + } + let chunk_size = read_be_u16(&data[pos..pos + 2]) as usize; + pos += 2; + if chunk_size == 0 { + break; + } + + let is_compressed = (chunk_size & 0x8000) != 0; + let actual_size = chunk_size & 0x7FFF; + if is_compressed { + if pos + actual_size > data.len() { + break; + } + let compressed_chunk = &data[pos..pos + actual_size]; + pos += actual_size; + let mut lzxd = Lzxd::new(WindowSize::KB32); + match lzxd.decompress_next(compressed_chunk, 32768) { + Ok(decompressed) => { + output.extend_from_slice(decompressed); + } + Err(e) => { + eprintln!("[lzx] chunk LZXD decompress failed at pos {}: {}; filling with zeros", + pos - actual_size - 2, e); + output.extend_from_slice(&vec![0u8; 32768]); + } + } + } else { + if pos + actual_size > data.len() { + break; + } + output.extend_from_slice(&data[pos..pos + actual_size]); + pos += actual_size; + } + } + + output.truncate(decompressed_size); + Ok(output) +} + +pub fn decode_rle(data: &[u8], decompressed_size: usize) -> Result, ConversionError> { + let mut output = Vec::with_capacity(decompressed_size); + let mut pos = 0; + while pos < data.len() && output.len() < decompressed_size { + let b = data[pos]; + pos += 1; + if b != 0xFF { + if output.len() + 1 > decompressed_size { + break; + } + output.push(b); + } else { + if pos + 1 >= data.len() { + break; + } + let run_len = data[pos] as usize; + let value = data[pos + 1]; + pos += 2; + let actual_len = std::cmp::min(run_len, decompressed_size - output.len()); + for _ in 0..actual_len { + output.push(value); + } + } + } + output.truncate(decompressed_size); + Ok(output) +} diff --git a/src-tauri/src/console2lce/world_profile.rs b/src-tauri/src/console2lce/world_profile.rs new file mode 100644 index 0000000..0d0d152 --- /dev/null +++ b/src-tauri/src/console2lce/world_profile.rs @@ -0,0 +1,26 @@ +#[derive(Debug, Clone, Copy)] +pub struct WorldProfile { + pub name: &'static str, + pub xz_size: u32, + pub hell_scale: u32, + pub flat: bool, +} + +pub const PROFILES: &[WorldProfile] = &[ + WorldProfile { name: "classic", xz_size: 54, hell_scale: 54, flat: false }, + WorldProfile { name: "small", xz_size: 64, hell_scale: 64, flat: false }, + WorldProfile { name: "medium", xz_size: 192, hell_scale: 192, flat: false }, + WorldProfile { name: "large", xz_size: 320, hell_scale: 320, flat: false }, + WorldProfile { name: "flat", xz_size: 54, hell_scale: 54, flat: true }, + WorldProfile { name: "flat-small", xz_size: 64, hell_scale: 64, flat: true }, + WorldProfile { name: "flat-medium", xz_size: 192, hell_scale: 192, flat: true }, + WorldProfile { name: "flat-large", xz_size: 320, hell_scale: 320, flat: true }, +]; + +pub fn get_profile(name: &str) -> Option<&'static WorldProfile> { + PROFILES.iter().find(|p| p.name == name) +} + +pub fn world_center_offset(xz_size: u32) -> i32 { + (xz_size as i32) * 8 +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f9dbaf9..7a34617 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -4,6 +4,7 @@ mod config; mod util; mod platform; mod networking; +pub mod console2lce; mod commands; use std::collections::HashMap; use std::sync::Arc; @@ -78,6 +79,7 @@ pub fn run() { steam::add_to_steam, proxy_cmd::http_proxy_request, game::get_instance_path, + commands::console2lce::import_world, stun::stun_discover, direct::start_direct_proxy, relay::start_relay_proxy, diff --git a/src/components/modals/ImportWorldModal.tsx b/src/components/modals/ImportWorldModal.tsx new file mode 100644 index 0000000..20f371a --- /dev/null +++ b/src/components/modals/ImportWorldModal.tsx @@ -0,0 +1,348 @@ +import { useState, useEffect } from "react"; +import { motion } from "framer-motion"; +import { TauriService } from "../../services/TauriService"; + +type WorldType = "java" | "xbox360" | "windows64" | null; + +export default function ImportWorldModal({ + isOpen, + onClose, + playPressSound, + playBackSound, + targetInstanceId, + targetInstanceName, +}: { + isOpen: boolean; + onClose: () => void; + playPressSound: (s?: string) => void; + playBackSound: (s?: string) => void; + targetInstanceId: string; + targetInstanceName: string; +}) { + const [worldType, setWorldType] = useState(null); + const [focusIndex, setFocusIndex] = useState(0); + const [status, setStatus] = useState(""); + const [error, setError] = useState(""); + const [isImporting, setIsImporting] = useState(false); + + const typeOptions: { id: WorldType; label: string; desc: string }[] = [ + { + id: "java", + label: "Java Edition", + desc: "Import a Java world folder (contains level.dat)", + }, + { + id: "xbox360", + label: "Xbox 360", + desc: "Import an Xbox 360 STFS save (.bin / .dat)", + }, + { + id: "windows64", + label: "Windows64", + desc: "Copy an existing LCE save (.ms file or GameHDD folder)", + }, + ]; + + useEffect(() => { + if (!isOpen) { + setWorldType(null); + setFocusIndex(0); + setStatus(""); + setError(""); + setIsImporting(false); + } + }, [isOpen]); + + const handleImport = async () => { + if (!worldType || !targetInstanceId) return; + playPressSound(); + setIsImporting(true); + setError(""); + setStatus("Selecting source..."); + + try { + let inputPath = ""; + let worldName = ""; + + if (worldType === "java") { + setStatus("Selecting Java world folder..."); + const folder = await TauriService.pickFolder(); + if (!folder) { + setIsImporting(false); + return; + } + inputPath = folder; + worldName = deriveWorldName(folder); + setStatus("Converting Java world to LCE..."); + } else if (worldType === "xbox360") { + setStatus("Selecting Xbox 360 save..."); + const file = await TauriService.pickFile("Select Xbox 360 save", [ + "*.bin", + "*.dat", + "*", + ]); + if (!file) { + setIsImporting(false); + return; + } + inputPath = file; + worldName = deriveWorldName(file); + setStatus("Converting Xbox 360 save to LCE..."); + } else { + setStatus("Selecting LCE save folder or .ms file..."); + const picked = await TauriService.pickFile( + "Select saveData.ms or GameHDD folder", + ["*.ms", "*"], + ); + if (!picked) { + setIsImporting(false); + return; + } + inputPath = picked; + worldName = picked.endsWith(".ms") + ? deriveWorldName(picked) + : deriveWorldName(picked); + setStatus("Copying LCE save..."); + } + + const instancePath = await TauriService.getInstancePath(targetInstanceId); + const saveDir = `${instancePath}/Windows64/GameHDD/${worldName}`; + if (worldType === "windows64") { + await TauriService.importWorld(inputPath, `${saveDir}/saveData.ms`); + } else { + await TauriService.importWorld(inputPath, `${saveDir}/saveData.ms`); + } + + setStatus(`World imported into "${targetInstanceName}"!`); + setTimeout(() => { + onClose(); + }, 2000); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : String(e)); + setStatus(""); + setIsImporting(false); + } + }; + + const handleKey = (e: KeyboardEvent) => { + if (e.key === "Escape") { + playBackSound(); + if (isImporting) return; + onClose(); + } else if ( + e.key === "ArrowDown" || + e.key === "ArrowUp" || + e.key === "Tab" + ) { + e.preventDefault(); + const max = typeOptions.length + 1; + setFocusIndex((prev) => { + if (e.key === "ArrowUp") return (prev - 1 + max) % max; + return (prev + 1) % max; + }); + } else if (e.key === "Enter") { + if (focusIndex < typeOptions.length) { + const opt = typeOptions[focusIndex]; + setWorldType(opt.id); + playPressSound(); + } else { + onClose(); + } + } + }; + + useEffect(() => { + if (!isOpen) return; + window.addEventListener("keydown", handleKey); + return () => window.removeEventListener("keydown", handleKey); + }, [isOpen, focusIndex, worldType, isImporting]); + + if (!isOpen) return null; + if (worldType && !isImporting) { + return ( + +
+

+ Import {typeOptions.find((t) => t.id === worldType)?.label} +

+

+ Destination:{" "} + {targetInstanceName} +

+

+ {typeOptions.find((t) => t.id === worldType)?.desc} +

+ +
+ + +
+
+
+ ); + } + + return ( + +
+ {!isImporting ? ( + <> +

+ Import World +

+

+ Import into:{" "} + {targetInstanceName} +

+

+ What type of world are you importing? +

+ +
+ {typeOptions.map((opt, i) => { + const isSelected = worldType === opt.id; + const isFocused = focusIndex === i; + return ( +
{ + playPressSound(); + setWorldType(opt.id); + }} + onMouseEnter={() => setFocusIndex(i)} + className={`w-full px-4 py-3 cursor-pointer flex flex-col gap-0.5 transition-all outline-none border-none ${ + isSelected + ? "bg-white/15 border-l-4 border-[#FFFF55]" + : isFocused + ? "bg-white/10 border-l-4 border-[#FFFF55]" + : "bg-black/20 hover:bg-black/30 border-l-4 border-transparent" + }`} + style={{ imageRendering: "pixelated" }} + > +
+
+ {isSelected && ( +
+ )} +
+ + {opt.label} + +
+

{opt.desc}

+
+ ); + })} +
+ + {error && ( +
+ {error} +
+ )} + +
+ +
+ + ) : ( + <> +

+ Importing World +

+
+
+

+ {status} +

+
+ {error && ( +
+ {error} +
+ )} + + )} +
+ + ); +} + +function deriveWorldName(inputPath: string): string { + const name = + inputPath.replace(/\\/g, "/").split("/").filter(Boolean).pop() || + "ImportedWorld"; + return name.replace(/[^a-zA-Z0-9_\- ]/g, "_").slice(0, 64); +} diff --git a/src/components/views/VersionsView.tsx b/src/components/views/VersionsView.tsx index 347a93b..a66c397 100644 --- a/src/components/views/VersionsView.tsx +++ b/src/components/views/VersionsView.tsx @@ -3,6 +3,7 @@ import { motion } from "framer-motion"; import { TauriService } from "../../services/TauriService"; import CustomTUModal from "../modals/CustomTUModal"; import SetUidModal from "../modals/SetUidModal"; +import ImportWorldModal from "../modals/ImportWorldModal"; import { useUI, useConfig, @@ -75,6 +76,8 @@ const VersionsView = memo(function VersionsView() { const [isSetUidModalOpen, setIsSetUidModalOpen] = useState(false); const [setUidTargetId, setSetUidTargetId] = useState(""); const [editingEdition, setEditingEdition] = useState(null); + const [isImportWorldModalOpen, setIsImportWorldModalOpen] = useState(false); + const [importWorldTarget, setImportWorldTarget] = useState<{ id: string; name: string } | null>(null); const [initialPath, setInitialPath] = useState(""); const [hoveredBtn, setHoveredBtn] = useState<{ row: number; @@ -207,6 +210,12 @@ const VersionsView = memo(function VersionsView() { } }; + const handleImportWorld = (instanceId: string) => { + const edition = editions.find((e: Edition) => e.instanceId === instanceId); + setImportWorldTarget({ id: instanceId, name: edition?.name ?? instanceId }); + setIsImportWorldModalOpen(true); + }; + return ( Add to Steam +