From aec030793939cad709221fbf3021c4914d733367 Mon Sep 17 00:00:00 2001
From: neoapps-dev
Date: Wed, 15 Jul 2026 23:34:49 +0300
Subject: [PATCH] feat: xbox360+ps3->windows64 world conversion
---
README.md | 1 +
src-tauri/src/commands/console2lce.rs | 1193 +++++++++++++++++++-
src-tauri/src/lib.rs | 1 +
src/components/modals/ImportWorldModal.tsx | 159 ++-
src/services/TauriService.ts | 7 +
5 files changed, 1319 insertions(+), 42 deletions(-)
diff --git a/README.md b/README.md
index 6f2d1ba..876c90b 100644
--- a/README.md
+++ b/README.md
@@ -184,6 +184,7 @@ sudo apt install --reinstall libwebkit2gtk-4.1-0
- **4J Studios & Mojang** - Original creators of Legacy Console Edition
- **The LCE Community** - Research and foundations for LCE on PC
- **Str1k3r** - Original creator of LCEOnline
+- **DTention** - Original creator of [LCE-Save-Converter](https://github.com/dtentiion/LCE-Save-Converter)
---
diff --git a/src-tauri/src/commands/console2lce.rs b/src-tauri/src/commands/console2lce.rs
index 503749e..fd5a75f 100644
--- a/src-tauri/src/commands/console2lce.rs
+++ b/src-tauri/src/commands/console2lce.rs
@@ -1,6 +1,1157 @@
-use std::path::Path;
+use std::collections::HashMap;
+use std::collections::HashSet;
use std::fs;
+use std::io::{Read, Write};
+use std::path::Path;
use tauri::AppHandle;
+const STFS_MAGIC: [u8; 4] = *b"CON ";
+const STFS_BASE_OFFSET: usize = 0xA000;
+const STFS_BLOCK_SIZE: usize = 0x1000;
+const STFS_BLOCKS_PER_GRP: usize = 170;
+const FILE_ENTRY_SIZE: usize = 144;
+const REGION_SECT_COUNT: usize = 1024;
+const LZX_BLOCK_SIZE: usize = 0x8000;
+const SFO_FMT_UTF8_RAW: u16 = 0x0004;
+const SFO_FMT_UTF8_STR: u16 = 0x0204;
+const SFO_FMT_INT32: u16 = 0x0404;
+const LEVELNAME_SIG: &[u8] = b"\x08\x00\x09LevelName";
+const PLAYER_POS_SIG: &[u8] = b"\x09\x00\x03Pos\x06\x00\x00\x00\x03";
+struct StfsEntry {
+ name: String,
+ start_block: usize,
+ size: usize,
+}
+
+struct FileEntry {
+ filename: String,
+ length: u32,
+ start_offset: u32,
+ last_mod: i64,
+}
+
+fn s32(buf: &mut [u8], o: usize) {
+ buf[o..o + 4].reverse();
+}
+
+fn read_hdr_be(data: &[u8]) -> (u32, u32, i16, i16) {
+ let ho = u32::from_be_bytes([data[0], data[1], data[2], data[3]]);
+ let ne = u32::from_be_bytes([data[4], data[5], data[6], data[7]]);
+ let ov = i16::from_be_bytes([data[8], data[9]]);
+ let cv = i16::from_be_bytes([data[10], data[11]]);
+ (ho, ne, ov, cv)
+}
+
+fn parse_ftable_be(data: &[u8], ho: u32, ne: u32) -> Vec {
+ let mut out = Vec::new();
+ for i in 0..ne {
+ let base = ho as usize + i as usize * FILE_ENTRY_SIZE;
+ if base + FILE_ENTRY_SIZE > data.len() {
+ break;
+ }
+ let raw = &data[base..base + FILE_ENTRY_SIZE];
+ let fn_u16s: Vec = (0..64)
+ .map(|j| u16::from_be_bytes([raw[j * 2], raw[j * 2 + 1]]))
+ .collect();
+ let fn_str: String = fn_u16s
+ .iter()
+ .take_while(|&&c| c != 0)
+ .filter_map(|&c| char::from_u32(c as u32))
+ .collect();
+ let length = u32::from_be_bytes([raw[128], raw[129], raw[130], raw[131]]);
+ let start_offset = u32::from_be_bytes([raw[132], raw[133], raw[134], raw[135]]);
+ let last_mod = i64::from_be_bytes([
+ raw[136], raw[137], raw[138], raw[139], raw[140], raw[141], raw[142], raw[143],
+ ]);
+ if !fn_str.is_empty() && length > 0 {
+ out.push(FileEntry {
+ filename: fn_str,
+ length,
+ start_offset,
+ last_mod,
+ });
+ }
+ }
+ out
+}
+
+fn sanitise(name: &str) -> String {
+ let mut s = String::new();
+ let mut prev_underscore = false;
+ for c in name.chars() {
+ let replace = c.is_control()
+ || matches!(
+ c,
+ '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*'
+ );
+ let c = if replace { '_' } else { c };
+ if c == '_' {
+ if !prev_underscore {
+ s.push(c);
+ }
+ prev_underscore = true;
+ } else {
+ s.push(c);
+ prev_underscore = false;
+ }
+ }
+ let s = s.trim_matches(|c: char| c == '_' || c == '.' || c == ' ');
+ let s = if s.len() > 64 { &s[..64] } else { s };
+ if s.is_empty() {
+ "MinecraftSave".to_string()
+ } else {
+ s.to_string()
+ }
+}
+
+fn stfs_block_offset(block_num: usize, table_shift: usize) -> usize {
+ let g = block_num / STFS_BLOCKS_PER_GRP;
+ let hash_before = if g == 0 {
+ 2
+ } else {
+ 3 + 2 * g + table_shift
+ };
+ STFS_BASE_OFFSET + (block_num + hash_before) * STFS_BLOCK_SIZE
+}
+
+fn stfs_get_hash_entry(raw: &[u8], block_num: usize, table_shift: usize) -> Option {
+ let group = block_num / STFS_BLOCKS_PER_GRP;
+ let first_data = stfs_block_offset(group * STFS_BLOCKS_PER_GRP, table_shift);
+ let idx = (block_num % STFS_BLOCKS_PER_GRP) * 0x18;
+ for gap in &[2, 1] {
+ let hash_off = first_data - STFS_BLOCK_SIZE * gap;
+ let entry_off = hash_off + idx;
+ if entry_off + 0x18 > raw.len() {
+ continue;
+ }
+ let nxt = ((raw[entry_off + 0x15] as usize) << 16)
+ | ((raw[entry_off + 0x16] as usize) << 8)
+ | raw[entry_off + 0x17] as usize;
+ if nxt != 0xFFFFFF {
+ return Some(nxt);
+ }
+ }
+ None
+}
+
+fn stfs_read_file(raw: &[u8], start: usize, size: usize, table_shift: usize) -> Vec {
+ let max_block = (raw.len().saturating_sub(STFS_BASE_OFFSET)) / STFS_BLOCK_SIZE;
+ let mut out = Vec::with_capacity(size);
+ let mut blk = start;
+ let mut rem = size;
+ while rem > 0 {
+ let off = stfs_block_offset(blk, table_shift);
+ let end = (off + STFS_BLOCK_SIZE).min(raw.len());
+ if off >= raw.len() {
+ break;
+ }
+ let chunk = &raw[off..end];
+ let take = rem.min(chunk.len());
+ out.extend_from_slice(&chunk[..take]);
+ rem -= take;
+ match stfs_get_hash_entry(raw, blk, table_shift) {
+ Some(nxt) if nxt > 0 && nxt < max_block => blk = nxt,
+ _ => blk += 1,
+ }
+ }
+ out
+}
+
+struct StfsPackage {
+ raw: Vec,
+ table_shift: usize,
+}
+
+impl StfsPackage {
+ const DISP_OFF: usize = 0x0411;
+ const DISP_LEN: usize = 128;
+ const THUMB_OFF: usize = 0x171A;
+ fn new(raw: Vec) -> Result {
+ if raw.len() < 4 || raw[..4] != STFS_MAGIC {
+ return Err(format!(
+ "Not an STFS CON file (magic={:?})",
+ &raw[..4.min(raw.len())]
+ ));
+ }
+ Ok(Self {
+ raw,
+ table_shift: 1,
+ })
+ }
+
+ fn read_block(&self, b: usize) -> Vec {
+ let o = stfs_block_offset(b, self.table_shift);
+ let end = (o + STFS_BLOCK_SIZE).min(self.raw.len());
+ self.raw[o..end].to_vec()
+ }
+
+ fn display_name(&self) -> String {
+ let start = Self::DISP_OFF;
+ let end = (start + Self::DISP_LEN * 2).min(self.raw.len());
+ let nb = &self.raw[start..end];
+ let mut name = String::new();
+ let mut i = 0;
+ while i + 1 < nb.len() {
+ let c = u16::from_be_bytes([nb[i], nb[i + 1]]);
+ if c == 0 {
+ break;
+ }
+ if let Some(ch) = char::from_u32(c as u32) {
+ name.push(ch);
+ }
+ i += 2;
+ }
+ if name.is_empty() {
+ "Unknown".to_string()
+ } else {
+ name
+ }
+ }
+
+ fn thumbnail(&self) -> Option> {
+ let png_magic = b"\x89PNG\r\n\x1a\n";
+ let search_start = Self::THUMB_OFF;
+ let idx = self.raw[search_start..]
+ .windows(png_magic.len())
+ .position(|w| w == png_magic)?;
+ let abs_idx = search_start + idx;
+ let iend = self.raw[abs_idx..].windows(4).position(|w| w == b"IEND")?;
+ let abs_iend = abs_idx + iend;
+ Some(self.raw[abs_idx..(abs_iend + 12).min(self.raw.len())].to_vec())
+ }
+
+ fn file_table(&self) -> Vec {
+ let ft_block = (self.raw[0x37E] as usize)
+ | ((self.raw[0x37F] as usize) << 8)
+ | ((self.raw[0x380] as usize) << 16);
+ let blk_data = self.read_block(ft_block);
+ let mut entries = Vec::new();
+ for i in 0..64 {
+ let base = i * 64;
+ if base + 64 > blk_data.len() {
+ break;
+ }
+ let e = &blk_data[base..base + 64];
+ let name_len = (e[0x28] & 0x3F) as usize;
+ if name_len == 0 {
+ continue;
+ }
+ if (e[0x28] >> 6) & 0x02 != 0 {
+ continue;
+ }
+ let name: String = e[..name_len].iter().map(|&b| b as char).collect();
+ let start_block = (e[0x2F] as usize)
+ | ((e[0x30] as usize) << 8)
+ | ((e[0x31] as usize) << 16);
+ let file_size =
+ u32::from_be_bytes([e[0x34], e[0x35], e[0x36], e[0x37]]) as usize;
+ if !name.is_empty() && file_size > 0 {
+ entries.push(StfsEntry {
+ name,
+ start_block,
+ size: file_size,
+ });
+ }
+ }
+ entries
+ }
+
+ fn extract_savegame_dat(&self) -> Option> {
+ for cand in &["savegame.dat", "SAVEGAME.DAT"] {
+ for entry in self.file_table() {
+ if entry.name.to_lowercase() == cand.to_lowercase() {
+ return Some(stfs_read_file(
+ &self.raw,
+ entry.start_block,
+ entry.size,
+ self.table_shift,
+ ));
+ }
+ }
+ }
+ None
+ }
+}
+
+fn parse_region_filename(name: &str) -> Option<(&'static str, i32, i32)> {
+ let n = name.to_lowercase().replace('\\', "/");
+ if let Some(rest) = n.strip_prefix("dim1/r.") {
+ if let Some(coords) = rest.strip_suffix(".mcr") {
+ let dot_pos = coords.find('.')?;
+ let x = coords[..dot_pos].parse().ok()?;
+ let z = coords[dot_pos + 1..].parse().ok()?;
+ return Some(("end", x, z));
+ }
+ }
+ if let Some(rest) = n.strip_prefix("dim-1r.") {
+ if let Some(coords) = rest.strip_suffix(".mcr") {
+ let dot_pos = coords.find('.')?;
+ let x = coords[..dot_pos].parse().ok()?;
+ let z = coords[dot_pos + 1..].parse().ok()?;
+ return Some(("nether", x, z));
+ }
+ }
+ if let Some(rest) = n.strip_prefix("r.") {
+ if let Some(coords) = rest.strip_suffix(".mcr") {
+ let dot_pos = coords.find('.')?;
+ let x = coords[..dot_pos].parse().ok()?;
+ let z = coords[dot_pos + 1..].parse().ok()?;
+ return Some(("overworld", x, z));
+ }
+ }
+ None
+}
+
+fn spawn_sig(axis: char) -> [u8; 9] {
+ let mut sig = [0u8; 9];
+ sig[0] = 0x03;
+ sig[1] = 0x00;
+ sig[2] = 0x06;
+ sig[3..9].copy_from_slice(format!("Spawn{}", axis).as_bytes());
+ sig
+}
+
+fn read_spawn(level_dat: &[u8]) -> Option<(i32, i32, i32)> {
+ let mut coords = Vec::new();
+ for axis in ['X', 'Y', 'Z'] {
+ let sig = spawn_sig(axis);
+ let idx = level_dat
+ .windows(sig.len())
+ .position(|w| w == sig)?;
+ let val_off = idx + sig.len();
+ if val_off + 4 > level_dat.len() {
+ return None;
+ }
+ coords.push(i32::from_be_bytes([
+ level_dat[val_off],
+ level_dat[val_off + 1],
+ level_dat[val_off + 2],
+ level_dat[val_off + 3],
+ ]));
+ }
+ Some((coords[0], coords[1], coords[2]))
+}
+
+fn patch_spawn(level_dat: &mut [u8], x: i32, y: i32, z: i32) {
+ for (axis, val) in [('X', x), ('Y', y), ('Z', z)] {
+ let sig = spawn_sig(axis);
+ if let Some(idx) = level_dat.windows(sig.len()).position(|w| w == sig) {
+ let val_off = idx + sig.len();
+ if val_off + 4 <= level_dat.len() {
+ level_dat[val_off..val_off + 4].copy_from_slice(&val.to_be_bytes());
+ }
+ }
+ }
+}
+
+fn read_player_pos(player_dat: &[u8]) -> Option<(f64, f64, f64)> {
+ let idx = player_dat
+ .windows(PLAYER_POS_SIG.len())
+ .position(|w| w == PLAYER_POS_SIG)?;
+ let p = idx + PLAYER_POS_SIG.len();
+ if p + 24 > player_dat.len() {
+ return None;
+ }
+ let x = f64::from_be_bytes(player_dat[p..p + 8].try_into().ok()?);
+ let y = f64::from_be_bytes(player_dat[p + 8..p + 16].try_into().ok()?);
+ let z = f64::from_be_bytes(player_dat[p + 16..p + 24].try_into().ok()?);
+ Some((x, y, z))
+}
+
+fn patch_player_pos(player_dat: &mut [u8], x: f64, y: f64, z: f64) {
+ if let Some(idx) = player_dat
+ .windows(PLAYER_POS_SIG.len())
+ .position(|w| w == PLAYER_POS_SIG)
+ {
+ let p = idx + PLAYER_POS_SIG.len();
+ if p + 24 <= player_dat.len() {
+ player_dat[p..p + 8].copy_from_slice(&x.to_be_bytes());
+ player_dat[p + 8..p + 16].copy_from_slice(&y.to_be_bytes());
+ player_dat[p + 16..p + 24].copy_from_slice(&z.to_be_bytes());
+ }
+ }
+}
+
+fn compress_rle(data: &[u8]) -> Vec {
+ let mut out = Vec::new();
+ let n = data.len();
+ let mut i = 0;
+ while i < n {
+ let b = data[i];
+ let mut run = 1usize;
+ while i + run < n && data[i + run] == b && run < 256 {
+ run += 1;
+ }
+ if b == 0xFF {
+ if run <= 3 {
+ out.push(0xFF);
+ out.push((run - 1) as u8);
+ } else {
+ out.push(0xFF);
+ out.push((run - 1) as u8);
+ out.push(0xFF);
+ }
+ } else if run < 4 {
+ out.extend(std::iter::repeat(b).take(run));
+ } else {
+ out.push(0xFF);
+ out.push((run - 1) as u8);
+ out.push(b);
+ }
+ i += run;
+ }
+ out
+}
+
+fn build_empty_chunk_nbt(chunk_x: i32, chunk_z: i32) -> Vec {
+ let mut out = Vec::new();
+ out.extend_from_slice(b"\x0a\x00\x00");
+ out.extend_from_slice(b"\x0a\x00\x05Level");
+ out.extend_from_slice(b"\x07\x00\x06Blocks");
+ out.extend_from_slice(&32768i32.to_be_bytes());
+ out.extend(std::iter::repeat(0u8).take(32768));
+ out.extend_from_slice(b"\x07\x00\x04Data");
+ out.extend_from_slice(&16384i32.to_be_bytes());
+ out.extend(std::iter::repeat(0u8).take(16384));
+ out.extend_from_slice(b"\x07\x00\x08SkyLight");
+ out.extend_from_slice(&16384i32.to_be_bytes());
+ out.extend(std::iter::repeat(0xffu8).take(16384));
+ out.extend_from_slice(b"\x07\x00\x0aBlockLight");
+ out.extend_from_slice(&16384i32.to_be_bytes());
+ out.extend(std::iter::repeat(0u8).take(16384));
+ out.extend_from_slice(b"\x07\x00\x09HeightMap");
+ out.extend_from_slice(&256i32.to_be_bytes());
+ out.extend(std::iter::repeat(0u8).take(256));
+ out.extend_from_slice(b"\x03\x00\x04xPos");
+ out.extend_from_slice(&chunk_x.to_be_bytes());
+ out.extend_from_slice(b"\x03\x00\x04zPos");
+ out.extend_from_slice(&chunk_z.to_be_bytes());
+ out.extend_from_slice(b"\x04\x00\x0aLastUpdate");
+ out.extend_from_slice(&0i64.to_be_bytes());
+ out.extend_from_slice(b"\x01\x00\x10TerrainPopulated\x01");
+ out.extend_from_slice(b"\x09\x00\x08Entities\x0a\x00\x00\x00\x00");
+ out.extend_from_slice(b"\x09\x00\x0cTileEntities\x0a\x00\x00\x00\x00");
+ out.push(0x00);
+ out.push(0x00);
+ out
+}
+
+fn find_safe_spawn(
+ dropped: &HashSet<(i32, i32)>,
+ orig: (i32, i32, i32),
+) -> Option<(i32, i32, i32)> {
+ if dropped.is_empty() {
+ return None;
+ }
+ let (sx, sy, sz) = orig;
+ let sc_x = sx >> 4;
+ let sc_z = sz >> 4;
+ let too_close = |cx: i32, cz: i32| -> bool {
+ dropped
+ .iter()
+ .any(|&(dx, dz)| (cx - dx).abs().max((cz - dz).abs()) < 12)
+ };
+ if !too_close(sc_x, sc_z) {
+ return None;
+ }
+ for radius in 1..200i32 {
+ for dz in -radius..=radius {
+ for dx in -radius..=radius {
+ if dx.abs().max(dz.abs()) != radius {
+ continue;
+ }
+ let cx = sc_x + dx;
+ let cz = sc_z + dz;
+ if !too_close(cx, cz) {
+ return Some((cx * 16 + 8, sy, cz * 16 + 8));
+ }
+ }
+ }
+ }
+ None
+}
+
+fn lzxd_window_from_bits(bits: usize) -> Option {
+ match bits {
+ 15 => Some(lzxd::WindowSize::KB32),
+ 16 => Some(lzxd::WindowSize::KB64),
+ 17 => Some(lzxd::WindowSize::KB128),
+ 18 => Some(lzxd::WindowSize::KB256),
+ 19 => Some(lzxd::WindowSize::KB512),
+ 20 => Some(lzxd::WindowSize::MB1),
+ 21 => Some(lzxd::WindowSize::MB2),
+ _ => None,
+ }
+}
+
+fn try_lzxd(lzx_raw: &[u8], output_size: usize, window: lzxd::WindowSize) -> Option> {
+ let mut lzxd = lzxd::Lzxd::new(window);
+ lzxd.decompress_next(lzx_raw, output_size)
+ .ok()
+ .map(|slice| slice.to_vec())
+}
+
+fn decompress_region_chunk(xbox_data: &[u8]) -> Result, String> {
+ let hi = xbox_data[0];
+ let (output_size, _src_sz, lzx_raw) = if hi == 0xFF {
+ let output_size =
+ ((xbox_data[1] as usize) << 8) | xbox_data[2] as usize;
+ let src_sz =
+ ((xbox_data[3] as usize) << 8) | xbox_data[4] as usize;
+ let lzx_raw = &xbox_data[5..(5 + src_sz).min(xbox_data.len())];
+ (output_size, src_sz, lzx_raw)
+ } else {
+ let src_sz =
+ ((hi as usize) << 8) | xbox_data[1] as usize;
+ let lzx_raw = &xbox_data[2..(2 + src_sz).min(xbox_data.len())];
+ (LZX_BLOCK_SIZE, src_sz, lzx_raw)
+ };
+
+ if let Some(out) = try_lzxd(lzx_raw, output_size, lzxd::WindowSize::KB128) {
+ return Ok(out);
+ }
+ for w in [16, 15, 18, 19, 20, 21] {
+ if let Some(ws) = lzxd_window_from_bits(w) {
+ if let Some(out) = try_lzxd(lzx_raw, output_size, ws) {
+ return Ok(out);
+ }
+ }
+ }
+ Err("LZX decode failed (all window sizes rejected the chunk)".to_string())
+}
+
+fn convert_region(
+ data: &[u8],
+ dropped_slots: &mut Vec,
+ region_coords: Option<(i32, i32)>,
+) -> Result, String> {
+ let sect = 4096usize;
+ let mut buf = data.to_vec();
+ for i in 0..REGION_SECT_COUNT * 2 {
+ if (i + 1) * 4 <= buf.len() {
+ s32(&mut buf, i * 4);
+ }
+ }
+ let mut chunk_positions: HashMap = HashMap::new();
+ for slot in 0..REGION_SECT_COUNT {
+ if (slot + 1) * 4 > buf.len() {
+ break;
+ }
+ let off = u32::from_le_bytes([
+ buf[slot * 4],
+ buf[slot * 4 + 1],
+ buf[slot * 4 + 2],
+ buf[slot * 4 + 3],
+ ]) as usize;
+ if off == 0 {
+ continue;
+ }
+ let sn = (off >> 8) & 0xFFFFFF;
+ let count = off & 0xFF;
+ if sn < 2 {
+ continue;
+ }
+ let fo = sn * sect;
+ if fo + 8 > data.len() {
+ continue;
+ }
+ chunk_positions.insert(fo, (slot, sn, count));
+ }
+ if chunk_positions.is_empty() {
+ return Ok(buf);
+ }
+ let mut new_buf = vec![0u8; buf.len()];
+ let copy_end = (sect * 2).min(buf.len()).min(new_buf.len());
+ new_buf[..copy_end].copy_from_slice(&buf[..copy_end]);
+ let mut next_sector = 2usize;
+ let mut sorted_fos: Vec = chunk_positions.keys().copied().collect();
+ sorted_fos.sort();
+ for fo in sorted_fos {
+ let (slot, _sn, _count) = chunk_positions[&fo];
+ let raw_comp_len = u32::from_be_bytes([
+ data[fo],
+ data[fo + 1],
+ data[fo + 2],
+ data[fo + 3],
+ ]);
+ let raw_decomp_len = u32::from_be_bytes([
+ data[fo + 4],
+ data[fo + 5],
+ data[fo + 6],
+ data[fo + 7],
+ ]);
+ let use_rle = (raw_comp_len & 0x80000000) != 0;
+ let comp_len = (raw_comp_len & 0x7FFFFFFF) as usize;
+ let decomp_len = raw_decomp_len as usize;
+ if comp_len == 0 || fo + 8 + comp_len > data.len() {
+ continue;
+ }
+ let xbox_data = &data[fo + 8..fo + 8 + comp_len];
+ let rle_data = match decompress_region_chunk(xbox_data) {
+ Ok(d) => d,
+ Err(_) => {
+ dropped_slots.push(slot);
+ if let Some((rx, rz)) = region_coords {
+ let cx = rx * 32 + (slot % 32) as i32;
+ let cz = rz * 32 + (slot / 32) as i32;
+ let synth_nbt = build_empty_chunk_nbt(cx, cz);
+ let synth_rle = compress_rle(&synth_nbt);
+ let mut synth_encoder =
+ flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::new(6));
+ let _ = synth_encoder.write_all(&synth_rle);
+ let synth_zlib = synth_encoder.finish().unwrap_or_default();
+ let new_comp_len = synth_zlib.len();
+ let needed = (8 + new_comp_len + sect - 1) / sect;
+ let dest_off = next_sector * sect;
+ while dest_off + 8 + new_comp_len > new_buf.len() {
+ new_buf.extend(std::iter::repeat(0u8).take(sect));
+ }
+ new_buf[dest_off..dest_off + 4]
+ .copy_from_slice(&(new_comp_len as u32 | 0x80000000).to_le_bytes());
+ new_buf[dest_off + 4..dest_off + 8]
+ .copy_from_slice(&(synth_nbt.len() as u32).to_le_bytes());
+ new_buf[dest_off + 8..dest_off + 8 + new_comp_len]
+ .copy_from_slice(&synth_zlib);
+ new_buf[slot * 4..slot * 4 + 4]
+ .copy_from_slice(&((next_sector << 8) | needed).to_le_bytes());
+ next_sector += needed;
+ } else {
+ new_buf[slot * 4..slot * 4 + 4].copy_from_slice(&0u32.to_le_bytes());
+ }
+ continue;
+ }
+ };
+ let zlib_data = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::new(6));
+ let mut encoder = zlib_data;
+ encoder.write_all(&rle_data).map_err(|e| e.to_string())?;
+ let zlib_data = encoder.finish().map_err(|e| e.to_string())?;
+ let new_comp_len = zlib_data.len();
+ let needed = (8 + new_comp_len + sect - 1) / sect;
+ let dest_off = next_sector * sect;
+ while dest_off + 8 + new_comp_len > new_buf.len() {
+ new_buf.extend(std::iter::repeat(0u8).take(sect));
+ }
+ let comp_flag = if use_rle {
+ new_comp_len as u32 | 0x80000000
+ } else {
+ new_comp_len as u32
+ };
+ new_buf[dest_off..dest_off + 4].copy_from_slice(&comp_flag.to_le_bytes());
+ new_buf[dest_off + 4..dest_off + 8].copy_from_slice(&(decomp_len as u32).to_le_bytes());
+ new_buf[dest_off + 8..dest_off + 8 + new_comp_len].copy_from_slice(&zlib_data);
+ let new_off = (next_sector << 8) | needed;
+ new_buf[slot * 4..slot * 4 + 4].copy_from_slice(&(new_off as u32).to_le_bytes());
+ next_sector += needed;
+ }
+ let end = (next_sector * sect).min(new_buf.len());
+ new_buf.truncate(end);
+ Ok(new_buf)
+}
+
+fn convert_region_ps3(data: &[u8]) -> Result, String> {
+ let sect = 4096usize;
+ let mut buf = data.to_vec();
+ for i in 0..REGION_SECT_COUNT * 2 {
+ if (i + 1) * 4 <= buf.len() {
+ s32(&mut buf, i * 4);
+ }
+ }
+ let mut chunk_positions: HashMap = HashMap::new();
+ for slot in 0..REGION_SECT_COUNT {
+ if (slot + 1) * 4 > buf.len() {
+ break;
+ }
+ let off = u32::from_le_bytes([
+ buf[slot * 4],
+ buf[slot * 4 + 1],
+ buf[slot * 4 + 2],
+ buf[slot * 4 + 3],
+ ]) as usize;
+ if off == 0 {
+ continue;
+ }
+ let sn = (off >> 8) & 0xFFFFFF;
+ let count = off & 0xFF;
+ if sn < 2 {
+ continue;
+ }
+ let fo = sn * sect;
+ if fo + 8 > data.len() {
+ continue;
+ }
+ chunk_positions.insert(fo, (slot, sn, count));
+ }
+ if chunk_positions.is_empty() {
+ return Ok(buf);
+ }
+ let mut new_buf = vec![0u8; buf.len()];
+ let copy_end = (sect * 2).min(buf.len()).min(new_buf.len());
+ new_buf[..copy_end].copy_from_slice(&buf[..copy_end]);
+ let mut next_sector = 2usize;
+ let mut sorted_fos: Vec = chunk_positions.keys().copied().collect();
+ sorted_fos.sort();
+ for fo in sorted_fos {
+ let (slot, _sn, _count) = chunk_positions[&fo];
+ let raw_comp_len = u32::from_be_bytes([
+ data[fo],
+ data[fo + 1],
+ data[fo + 2],
+ data[fo + 3],
+ ]);
+ let raw_decomp_len = u32::from_be_bytes([
+ data[fo + 4],
+ data[fo + 5],
+ data[fo + 6],
+ data[fo + 7],
+ ]);
+ let use_rle = (raw_comp_len & 0x80000000) != 0;
+ let comp_len = (raw_comp_len & 0x7FFFFFFF) as usize;
+ let decomp_len = raw_decomp_len as usize;
+ if comp_len == 0 || fo + 8 + comp_len > data.len() {
+ continue;
+ }
+ let chunk_body = &data[fo + 8..fo + 8 + comp_len];
+ if chunk_body.len() < 5 {
+ new_buf[slot * 4..slot * 4 + 4].copy_from_slice(&0u32.to_le_bytes());
+ continue;
+ }
+ let mut decoder = flate2::read::DeflateDecoder::new(&chunk_body[4..]);
+ let mut rle_data = Vec::new();
+ if decoder.read_to_end(&mut rle_data).is_err() {
+ new_buf[slot * 4..slot * 4 + 4].copy_from_slice(&0u32.to_le_bytes());
+ continue;
+ }
+ let mut encoder =
+ flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::new(6));
+ encoder.write_all(&rle_data).map_err(|e| e.to_string())?;
+ let zlib_data = encoder.finish().map_err(|e| e.to_string())?;
+ let new_comp_len = zlib_data.len();
+ let needed = (8 + new_comp_len + sect - 1) / sect;
+ let dest_off = next_sector * sect;
+ while dest_off + 8 + new_comp_len > new_buf.len() {
+ new_buf.extend(std::iter::repeat(0u8).take(sect));
+ }
+ let comp_flag = if use_rle {
+ new_comp_len as u32 | 0x80000000
+ } else {
+ new_comp_len as u32
+ };
+ new_buf[dest_off..dest_off + 4].copy_from_slice(&comp_flag.to_le_bytes());
+ new_buf[dest_off + 4..dest_off + 8].copy_from_slice(&(decomp_len as u32).to_le_bytes());
+ new_buf[dest_off + 8..dest_off + 8 + new_comp_len].copy_from_slice(&zlib_data);
+ let new_off = (next_sector << 8) | needed;
+ new_buf[slot * 4..slot * 4 + 4].copy_from_slice(&(new_off as u32).to_le_bytes());
+ next_sector += needed;
+ }
+ let end = (next_sector * sect).min(new_buf.len());
+ new_buf.truncate(end);
+ Ok(new_buf)
+}
+
+fn decompress_xmemcompress(dat: &[u8]) -> Result, String> {
+ if dat.len() < 8 {
+ return Err("savegame.dat too small".to_string());
+ }
+ let meta_off = u32::from_be_bytes([dat[0], dat[1], dat[2], dat[3]]) as usize;
+ if meta_off > dat.len() {
+ return Err("invalid metadata offset".to_string());
+ }
+ let lzx_stream = &dat[8..meta_off];
+ if lzx_stream.len() < 4 {
+ return Err("LZX stream too small".to_string());
+ }
+ let uncomp_total = u32::from_be_bytes([
+ lzx_stream[0],
+ lzx_stream[1],
+ lzx_stream[2],
+ lzx_stream[3],
+ ]) as usize;
+ let mut lzxd = lzxd::Lzxd::new(lzxd::WindowSize::KB128);
+ let mut output = Vec::with_capacity(uncomp_total);
+ let mut pos = 4;
+ while pos < lzx_stream.len() && output.len() < uncomp_total {
+ let hi = lzx_stream[pos];
+ let (src_sz, dst_sz, header_len) = if hi == 0xFF {
+ if pos + 5 > lzx_stream.len() {
+ break;
+ }
+ let dst_sz = ((lzx_stream[pos + 1] as usize) << 8) | lzx_stream[pos + 2] as usize;
+ let src_sz = ((lzx_stream[pos + 3] as usize) << 8) | lzx_stream[pos + 4] as usize;
+ (src_sz, dst_sz, 5)
+ } else {
+ if pos + 2 > lzx_stream.len() {
+ break;
+ }
+ let src_sz = ((hi as usize) << 8) | lzx_stream[pos + 1] as usize;
+ (src_sz, LZX_BLOCK_SIZE, 2)
+ };
+ pos += header_len;
+ if src_sz == 0 || dst_sz == 0 {
+ break;
+ }
+ if pos + src_sz > lzx_stream.len() {
+ break;
+ }
+ let lzx_raw = &lzx_stream[pos..pos + src_sz];
+ let remaining = uncomp_total - output.len();
+ let out_sz = dst_sz.min(remaining);
+ match lzxd.decompress_next(lzx_raw, out_sz) {
+ Ok(decompressed) => output.extend_from_slice(decompressed),
+ Err(e) => {
+ return Err(format!("LZX decompression failed at offset {}: {}", pos, e))
+ }
+ }
+ pos += src_sz;
+ }
+ Ok(output)
+}
+
+fn patch_level_name(nbt: &[u8], new_name: &str) -> Vec {
+ let idx = match nbt
+ .windows(LEVELNAME_SIG.len())
+ .position(|w| w == LEVELNAME_SIG)
+ {
+ Some(i) => i,
+ None => return nbt.to_vec(),
+ };
+ let str_off = idx + LEVELNAME_SIG.len();
+ if str_off + 2 > nbt.len() {
+ return nbt.to_vec();
+ }
+ let old_len = u16::from_be_bytes([nbt[str_off], nbt[str_off + 1]]) as usize;
+ let end = str_off + 2 + old_len;
+ let mut new_val = new_name.as_bytes().to_vec();
+ if new_val.len() > 0xFFFF {
+ new_val.truncate(0xFFFF);
+ }
+ let mut payload = Vec::with_capacity(2 + new_val.len());
+ payload.extend_from_slice(&(new_val.len() as u16).to_be_bytes());
+ payload.extend_from_slice(&new_val);
+ let mut out = Vec::with_capacity(str_off + payload.len() + nbt.len().saturating_sub(end));
+ out.extend_from_slice(&nbt[..str_off]);
+ out.extend_from_slice(&payload);
+ if end < nbt.len() {
+ out.extend_from_slice(&nbt[end..]);
+ }
+ out
+}
+
+fn parse_param_sfo(path: &Path) -> HashMap {
+ let data = match fs::read(path) {
+ Ok(d) => d,
+ Err(_) => return HashMap::new(),
+ };
+ if data.len() < 0x14 || &data[..4] != b"\x00PSF" {
+ return HashMap::new();
+ }
+ let key_tbl = u32::from_le_bytes(data[8..12].try_into().unwrap()) as usize;
+ let data_tbl = u32::from_le_bytes(data[12..16].try_into().unwrap()) as usize;
+ let entries = u32::from_le_bytes(data[16..20].try_into().unwrap()) as usize;
+ let mut result = HashMap::new();
+ for i in 0..entries {
+ let e = 0x14 + i * 16;
+ if e + 16 > data.len() {
+ break;
+ }
+ let key_off = u16::from_le_bytes([data[e], data[e + 1]]) as usize;
+ let fmt = u16::from_le_bytes([data[e + 2], data[e + 3]]);
+ let dlen = u16::from_le_bytes([data[e + 4], data[e + 5]]) as usize;
+ let doff = u32::from_le_bytes(data[e + 8..e + 12].try_into().unwrap()) as usize;
+ let ka = key_tbl + key_off;
+ let ke = data[ka..]
+ .iter()
+ .position(|&b| b == 0)
+ .map(|p| ka + p)
+ .unwrap_or(data.len());
+ let key = String::from_utf8_lossy(&data[ka..ke]).to_string();
+ let da = data_tbl + doff;
+ let val = if (fmt == SFO_FMT_UTF8_RAW || fmt == SFO_FMT_UTF8_STR) && da + dlen <= data.len()
+ {
+ let raw = &data[da..da + dlen];
+ let null_pos = raw.iter().position(|&b| b == 0).unwrap_or(raw.len());
+ String::from_utf8_lossy(&raw[..null_pos]).to_string()
+ } else if fmt == SFO_FMT_INT32 && da + 4 <= data.len() {
+ u32::from_le_bytes(data[da..da + 4].try_into().unwrap()).to_string()
+ } else {
+ continue;
+ };
+ result.insert(key, val);
+ }
+ result
+}
+
+fn looks_like_4j_header(data: &[u8]) -> bool {
+ if data.len() < 12 {
+ return false;
+ }
+ let (ho, ne, _ov, cv) = read_hdr_be(data);
+ if ho < 12 || ho as usize >= data.len() {
+ return false;
+ }
+ if data.len() - ho as usize != ne as usize * FILE_ENTRY_SIZE {
+ return false;
+ }
+ if ne == 0 || ne > 4096 {
+ return false;
+ }
+ if cv < 0 || cv > 20 {
+ return false;
+ }
+ true
+}
+
+fn convert_bin_to_win64(bin_path: &str, game_dir: &str) -> Result {
+ let raw = fs::read(bin_path).map_err(|e| format!("Failed to read file: {}", e))?;
+ let pkg = StfsPackage::new(raw)?;
+ let name = pkg.display_name();
+ let dat = pkg
+ .extract_savegame_dat()
+ .ok_or("savegame.dat not found in STFS package")?;
+ let decompressed = decompress_xmemcompress(&dat)?;
+ let (ho, ne, ov, cv) = read_hdr_be(&decompressed);
+ let entries = parse_ftable_be(&decompressed, ho, ne);
+ let mut file_blobs: Vec> = Vec::new();
+ let mut dropped_overworld: HashSet<(i32, i32)> = HashSet::new();
+ for e in &entries {
+ let fn_lower = e.filename.to_lowercase();
+ let s = e.start_offset as usize;
+ let l = e.length as usize;
+ let raw_file = if s + l <= decompressed.len() {
+ decompressed[s..s + l].to_vec()
+ } else {
+ Vec::new()
+ };
+ if fn_lower.ends_with(".mcr") && !raw_file.is_empty() {
+ let dim_info = parse_region_filename(&e.filename);
+ let region_xy = dim_info.map(|d| (d.1, d.2));
+ let mut slots = Vec::new();
+ let converted =
+ convert_region(&raw_file, &mut slots, region_xy)?;
+ if let Some(("overworld", rx, rz)) = dim_info {
+ for slot in slots {
+ let cx = rx * 32 + (slot % 32) as i32;
+ let cz = rz * 32 + (slot / 32) as i32;
+ dropped_overworld.insert((cx, cz));
+ }
+ }
+ file_blobs.push(converted);
+ } else {
+ file_blobs.push(raw_file);
+ }
+ }
+ if !dropped_overworld.is_empty() {
+ let mut new_spawn: Option<(i32, i32, i32)> = None;
+ let mut spawn: Option<(i32, i32, i32)> = None;
+ for (i, e) in entries.iter().enumerate() {
+ if e.filename.to_lowercase() != "level.dat" {
+ continue;
+ }
+ spawn = read_spawn(&file_blobs[i]);
+ if let Some(s) = spawn {
+ if let Some(ns) = find_safe_spawn(&dropped_overworld, s) {
+ let blob = &mut file_blobs[i];
+ patch_spawn(blob, ns.0, ns.1, ns.2);
+ new_spawn = Some(ns);
+ }
+ }
+ break;
+ }
+ let safe = new_spawn.or(spawn);
+ if let Some((sx, sy, sz)) = safe {
+ let _sx_chunk = sx >> 4;
+ let _sz_chunk = sz >> 4;
+ let too_close = |cx: i32, cz: i32| -> bool {
+ dropped_overworld
+ .iter()
+ .any(|&(dx, dz)| (cx - dx).abs().max((cz - dz).abs()) < 12)
+ };
+ let mut moved_players = 0i32;
+ for (i, e) in entries.iter().enumerate() {
+ if !e.filename.starts_with("players/") || !e.filename.ends_with(".dat") {
+ continue;
+ }
+ if let Some((px, _py, pz)) = read_player_pos(&file_blobs[i]) {
+ let cx = px as i32 / 16;
+ let cz = pz as i32 / 16;
+ if too_close(cx, cz) {
+ let blob = &mut file_blobs[i];
+ patch_player_pos(blob, sx as f64 + 0.5, sy as f64, sz as f64 + 0.5);
+ moved_players += 1;
+ }
+ }
+ }
+ if moved_players > 0 {
+ eprintln!("Relocated {} player(s) to safe spawn", moved_players);
+ }
+ }
+ }
+ let header_size = 12usize;
+ let mut body = Vec::new();
+ let mut new_entries: Vec = Vec::new();
+ let mut cursor = header_size;
+ for (i, e) in entries.iter().enumerate() {
+ let blob = &file_blobs[i];
+ new_entries.push(FileEntry {
+ filename: e.filename.clone(),
+ length: blob.len() as u32,
+ start_offset: cursor as u32,
+ last_mod: e.last_mod,
+ });
+ body.extend_from_slice(blob);
+ cursor += blob.len();
+ }
+ let new_fto = cursor;
+ let out_cv = if cv <= 9 { cv } else { 9 };
+ let mut header = Vec::with_capacity(header_size);
+ header.extend_from_slice(&(new_fto as u32).to_le_bytes());
+ header.extend_from_slice(&(entries.len() as u32).to_le_bytes());
+ header.extend_from_slice(&ov.to_le_bytes());
+ header.extend_from_slice(&out_cv.to_le_bytes());
+ let mut raw_le = Vec::with_capacity(header.len() + body.len() + ne as usize * FILE_ENTRY_SIZE);
+ raw_le.extend_from_slice(&header);
+ raw_le.extend_from_slice(&body);
+ for ne_entry in &new_entries {
+ let fn_bytes: Vec = ne_entry.filename.encode_utf16().map(|c| c.to_le()).collect();
+ let mut fn_padded = vec![0u16; 64];
+ for (j, &c) in fn_bytes.iter().take(64).enumerate() {
+ fn_padded[j] = c;
+ }
+ for &c in &fn_padded {
+ raw_le.extend_from_slice(&c.to_le_bytes());
+ }
+ raw_le.extend_from_slice(&ne_entry.length.to_le_bytes());
+ raw_le.extend_from_slice(&ne_entry.start_offset.to_le_bytes());
+ raw_le.extend_from_slice(&ne_entry.last_mod.to_le_bytes());
+ }
+ let mut encoder =
+ flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::new(6));
+ encoder.write_all(&raw_le).map_err(|e| e.to_string())?;
+ let compressed = encoder.finish().map_err(|e| e.to_string())?;
+ let mut win64 = Vec::with_capacity(8 + compressed.len());
+ win64.extend_from_slice(&0u32.to_le_bytes());
+ win64.extend_from_slice(&(raw_le.len() as u32).to_le_bytes());
+ win64.extend_from_slice(&compressed);
+ let folder = sanitise(&name);
+ let dst = Path::new(game_dir).join(&folder);
+ fs::create_dir_all(&dst).map_err(|e| format!("Failed to create output directory: {}", e))?;
+ fs::write(dst.join("saveData.ms"), &win64)
+ .map_err(|e| format!("Failed to write saveData.ms: {}", e))?;
+ if let Some(thumb) = pkg.thumbnail() {
+ let thumb_dir = dst.join("thumbnails");
+ let _ = fs::create_dir_all(&thumb_dir);
+ let _ = fs::write(thumb_dir.join("thumbData.png"), &thumb);
+ }
+ Ok(dst.to_string_lossy().to_string())
+}
+
+fn convert_ps3_to_win64(ps3_save_dir: &str, game_dir: &str) -> Result {
+ let src = Path::new(ps3_save_dir);
+ if !src.is_dir() {
+ return Err(format!(
+ "'{}' is not a folder",
+ src.display()
+ ));
+ }
+ let gamedata_path = src.join("GAMEDATA");
+ if !gamedata_path.exists() {
+ return Err(format!(
+ "GAMEDATA not found in {}",
+ src.file_name().unwrap_or_default().to_string_lossy()
+ ));
+ }
+ let sfo = parse_param_sfo(&src.join("PARAM.SFO"));
+ let save_title = sfo
+ .get("SUB_TITLE")
+ .cloned()
+ .unwrap_or_else(|| {
+ src.file_name()
+ .unwrap_or_default()
+ .to_string_lossy()
+ .to_string()
+ });
+ let decompressed =
+ fs::read(&gamedata_path).map_err(|e| format!("Failed to read GAMEDATA: {}", e))?;
+ if !looks_like_4j_header(&decompressed) {
+ return Err("GAMEDATA does not look like a valid Minecraft PS3 save".to_string());
+ }
+ let (ho, ne, ov, _cv) = read_hdr_be(&decompressed);
+ let entries = parse_ftable_be(&decompressed, ho, ne);
+ let mut file_blobs: Vec> = Vec::new();
+ for e in &entries {
+ let fn_lower = e.filename.to_lowercase();
+ let s = e.start_offset as usize;
+ let l = e.length as usize;
+ let mut raw_file = if s + l <= decompressed.len() {
+ decompressed[s..s + l].to_vec()
+ } else {
+ Vec::new()
+ };
+ if fn_lower.ends_with(".mcr") && !raw_file.is_empty() {
+ raw_file = convert_region_ps3(&raw_file)?;
+ } else if fn_lower == "level.dat" && !save_title.is_empty() && !raw_file.is_empty() {
+ raw_file = patch_level_name(&raw_file, &save_title);
+ }
+ file_blobs.push(raw_file);
+ }
+ let header_size = 12usize;
+ let mut body = Vec::new();
+ let mut new_entries: Vec = Vec::new();
+ let mut cursor = header_size;
+ for (i, e) in entries.iter().enumerate() {
+ let blob = &file_blobs[i];
+ new_entries.push(FileEntry {
+ filename: e.filename.clone(),
+ length: blob.len() as u32,
+ start_offset: cursor as u32,
+ last_mod: e.last_mod,
+ });
+ body.extend_from_slice(blob);
+ cursor += blob.len();
+ }
+ let new_fto = cursor;
+ let mut raw_le = Vec::with_capacity(header_size + body.len() + ne as usize * FILE_ENTRY_SIZE);
+ raw_le.extend_from_slice(&(new_fto as u32).to_le_bytes());
+ raw_le.extend_from_slice(&ne.to_le_bytes());
+ raw_le.extend_from_slice(&ov.to_le_bytes());
+ raw_le.extend_from_slice(&9i16.to_le_bytes());
+ raw_le.extend_from_slice(&body);
+ for ne_entry in &new_entries {
+ let fn_bytes: Vec = ne_entry.filename.encode_utf16().map(|c| c.to_le()).collect();
+ let mut fn_padded = vec![0u16; 64];
+ for (j, &c) in fn_bytes.iter().take(64).enumerate() {
+ fn_padded[j] = c;
+ }
+ for &c in &fn_padded {
+ raw_le.extend_from_slice(&c.to_le_bytes());
+ }
+ raw_le.extend_from_slice(&ne_entry.length.to_le_bytes());
+ raw_le.extend_from_slice(&ne_entry.start_offset.to_le_bytes());
+ raw_le.extend_from_slice(&ne_entry.last_mod.to_le_bytes());
+ }
+ let mut encoder =
+ flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::new(6));
+ encoder.write_all(&raw_le).map_err(|e| e.to_string())?;
+ let compressed = encoder.finish().map_err(|e| e.to_string())?;
+ let mut win64 = Vec::with_capacity(8 + compressed.len());
+ win64.extend_from_slice(&0u32.to_le_bytes());
+ win64.extend_from_slice(&(raw_le.len() as u32).to_le_bytes());
+ win64.extend_from_slice(&compressed);
+ let folder = sanitise(&save_title);
+ let dst = Path::new(game_dir).join(&folder);
+ fs::create_dir_all(&dst).map_err(|e| format!("Failed to create output directory: {}", e))?;
+ fs::write(dst.join("saveData.ms"), &win64)
+ .map_err(|e| format!("Failed to write saveData.ms: {}", e))?;
+ let thumb_path = src.join("THUMB");
+ if thumb_path.exists() {
+ if let Ok(thumb_bytes) = fs::read(&thumb_path) {
+ if thumb_bytes.len() >= 8 && thumb_bytes[..8] == *b"\x89PNG\r\n\x1a\n" {
+ let thumb_dir = dst.join("thumbnails");
+ let _ = fs::create_dir_all(&thumb_dir);
+ let _ = fs::write(thumb_dir.join("thumbData.png"), &thumb_bytes);
+ }
+ }
+ }
+ Ok(dst.to_string_lossy().to_string())
+}
#[tauri::command]
#[allow(non_snake_case)]
@@ -16,9 +1167,43 @@ pub async fn import_world(
.map_err(|e| format!("Failed to create output directory {:?}: {}", parent, e))?;
}
}
-
fs::copy(&input_path, &output_path)
.map_err(|e| format!("Failed to copy .ms file: {}", e))?;
-
- Ok(format!("World imported successfully!\nOutput: {}", output_path))
+ Ok(format!(
+ "World imported successfully!\nOutput: {}",
+ output_path
+ ))
+}
+
+#[tauri::command]
+#[allow(non_snake_case)]
+pub async fn import_lce_save(
+ _app: AppHandle,
+ input_path: String,
+ output_dir: String,
+) -> Result {
+ let path = Path::new(&input_path);
+ if path.is_file() {
+ let ext = path
+ .extension()
+ .unwrap_or_default()
+ .to_string_lossy()
+ .to_lowercase();
+ if ext == "ms" {
+ let dst = Path::new(&output_dir);
+ fs::create_dir_all(dst)
+ .map_err(|e| format!("Failed to create output directory: {}", e))?;
+ fs::copy(&input_path, dst.join("saveData.ms"))
+ .map_err(|e| format!("Failed to copy .ms file: {}", e))?;
+ return Ok(dst.to_string_lossy().to_string());
+ }
+ if ext == "bin" || ext == "" {
+ return convert_bin_to_win64(&input_path, &output_dir);
+ }
+ return Err(format!("Unrecognised file extension: .{}", ext));
+ }
+ if path.is_dir() && path.join("GAMEDATA").is_file() {
+ return convert_ps3_to_win64(&input_path, &output_dir);
+ }
+ Err("Input is not a valid LCE save (expected .bin file, .ms file, or PS3 folder with GAMEDATA)".to_string())
}
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index e2852c6..25151d8 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -108,6 +108,7 @@ pub fn run() {
game::backup_instance,
game::restore_instance,
commands::console2lce::import_world,
+ commands::console2lce::import_lce_save,
stun::stun_discover,
relay::start_relay_proxy,
relay::start_host_relay,
diff --git a/src/components/modals/ImportWorldModal.tsx b/src/components/modals/ImportWorldModal.tsx
index 363053e..5cf5bb7 100644
--- a/src/components/modals/ImportWorldModal.tsx
+++ b/src/components/modals/ImportWorldModal.tsx
@@ -1,7 +1,6 @@
import { useState, useEffect } from "react";
import { motion } from "framer-motion";
import { TauriService } from "../../services/TauriService";
-
export default function ImportWorldModal({
isOpen,
onClose,
@@ -20,7 +19,6 @@ export default function ImportWorldModal({
const [status, setStatus] = useState("");
const [error, setError] = useState("");
const [isImporting, setIsImporting] = useState(false);
-
useEffect(() => {
if (!isOpen) {
setStatus("");
@@ -29,19 +27,18 @@ export default function ImportWorldModal({
}
}, [isOpen]);
- const handleImport = async () => {
+ const handleImportMs = async () => {
if (!targetInstanceId) return;
playPressSound();
setIsImporting(true);
setError("");
setStatus("Selecting source...");
-
try {
- setStatus("Selecting LCE save folder or .ms file...");
- const picked = await TauriService.pickFile(
- "Select saveData.ms or GameHDD folder",
- ["*.ms", "*"],
- );
+ setStatus("Selecting saveData.ms file...");
+ const picked = await TauriService.pickFile("Select saveData.ms", [
+ "*.ms",
+ "*",
+ ]);
if (!picked) {
setIsImporting(false);
return;
@@ -64,6 +61,65 @@ export default function ImportWorldModal({
}
};
+ const handleImportXbox = async () => {
+ if (!targetInstanceId) return;
+ playPressSound();
+ setIsImporting(true);
+ setError("");
+ setStatus("Selecting source...");
+ try {
+ setStatus("Selecting Xbox 360 save (.bin)...");
+ const picked = await TauriService.pickFile(
+ "Select Xbox 360 Minecraft save",
+ ["*.bin", "*"],
+ );
+ if (!picked) {
+ setIsImporting(false);
+ return;
+ }
+ setStatus("Converting Xbox 360 save...");
+ const instancePath = await TauriService.getInstancePath(targetInstanceId);
+ const gameHdd = `${instancePath}/Windows64/GameHDD`;
+ await TauriService.importLceSave(picked, gameHdd);
+ setStatus(`Xbox 360 save converted into "${targetInstanceName}"!`);
+ setTimeout(() => {
+ onClose();
+ }, 2000);
+ } catch (e: unknown) {
+ setError(e instanceof Error ? e.message : String(e));
+ setStatus("");
+ setIsImporting(false);
+ }
+ };
+
+ const handleImportPs3 = async () => {
+ if (!targetInstanceId) return;
+ playPressSound();
+ setIsImporting(true);
+ setError("");
+ setStatus("Selecting source...");
+ try {
+ setStatus("Selecting PS3 save folder...");
+ const picked = await TauriService.pickFolder();
+ if (!picked) {
+ setIsImporting(false);
+ return;
+ }
+ setStatus("Converting PS3 save...");
+ const instancePath = await TauriService.getInstancePath(targetInstanceId);
+ const gameHdd = `${instancePath}/Windows64/GameHDD`;
+ await TauriService.importLceSave(picked, gameHdd);
+ setStatus(`PS3 save converted 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();
@@ -104,9 +160,49 @@ export default function ImportWorldModal({
Import into:{" "}
{targetInstanceName}
-
- Select an existing LCE save (.ms file or GameHDD folder)
+
+
+ Import an existing .ms save file:
+
+
+
+ Convert an Xbox 360 or PS3 save:
+
+
+
+
+
{error && (
@@ -114,33 +210,20 @@ export default function ImportWorldModal({
)}
-
-
-
-
+
>
) : (
<>
diff --git a/src/services/TauriService.ts b/src/services/TauriService.ts
index cbfbc37..bc01e99 100644
--- a/src/services/TauriService.ts
+++ b/src/services/TauriService.ts
@@ -420,4 +420,11 @@ export class TauriService {
): Promise {
return invoke("import_world", { inputPath, outputPath });
}
+
+ static async importLceSave(
+ inputPath: string,
+ outputDir: string,
+ ): Promise {
+ return invoke("import_lce_save", { inputPath, outputDir });
+ }
}