This commit is contained in:
/home/neo 2026-07-18 20:00:05 +03:00 committed by GitHub
commit 15e8ed4bad
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 7058 additions and 47 deletions

View file

@ -229,6 +229,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)
---

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,251 @@
use super::nbt::{self, NbtCompound, NbtValue};
pub struct SpawnPoint {
pub x: i32,
pub y: i32,
pub z: i32,
}
pub fn read_spawn(root: &NbtCompound) -> SpawnPoint {
let data = match root.compound("Data") {
Some(d) => d,
None => {
return SpawnPoint { x: 0, y: 64, z: 0 };
}
};
if let (Some(sx), Some(sz)) = (data.int("SpawnX"), data.int("SpawnZ")) {
return SpawnPoint {
x: sx,
y: data.int("SpawnY").unwrap_or(64),
z: sz,
};
}
if let Some(spawn_comp) = data.compound("spawn") {
if let Some(pos) = spawn_comp.int_array("pos") {
if pos.len() >= 3 {
return SpawnPoint {
x: pos[0],
y: pos[1],
z: pos[2],
};
}
}
}
SpawnPoint { x: 0, y: 64, z: 0 }
}
pub fn convert_java_to_lce(
java_root: &NbtCompound,
spawn_chunk_x: i32,
spawn_chunk_z: i32,
xz_size: i32,
flat_world: bool,
override_spawn_y: Option<i32>,
) -> Vec<u8> {
let java_data = java_root
.compound("Data")
.expect("Java level.dat missing 'Data' compound tag");
let data_version = java_data
.int("DataVersion")
.or_else(|| java_root.int("DataVersion"))
.unwrap_or(0);
let is_modern_world = data_version >= 1519;
let hell_scale: i32 = 3;
let spawn = read_spawn(java_root);
let spawn_x = spawn.x;
let spawn_y = if let Some(oy) = override_spawn_y {
oy.clamp(1, 127)
} else if is_modern_world {
64
} else {
spawn.y.clamp(1, 127)
};
let spawn_z = spawn.z;
let new_spawn_x = spawn_x - (spawn_chunk_x * 16);
let new_spawn_z = spawn_z - (spawn_chunk_z * 16);
let safe_generator_name = if flat_world {
"flat".to_string()
} else if is_modern_world {
"default".to_string()
} else {
get_string(java_data, "generatorName", "default")
};
let safe_generator_version = if flat_world {
0
} else if is_modern_world {
1
} else {
get_int(java_data, "generatorVersion")
};
let safe_generator_options = if flat_world {
get_string(java_data, "generatorOptions", "2;7,2x3,2;1;")
} else if is_modern_world {
"".to_string()
} else {
get_string(java_data, "generatorOptions", "")
};
let mut lce_data = NbtCompound::new("Data");
lce_data.insert("RandomSeed", NbtValue::Long(get_long(java_data, "RandomSeed")));
lce_data.insert("generatorName", NbtValue::String(safe_generator_name));
lce_data.insert("generatorVersion", NbtValue::Int(safe_generator_version));
lce_data.insert("generatorOptions", NbtValue::String(safe_generator_options));
lce_data.insert("GameType", NbtValue::Int(get_int(java_data, "GameType")));
lce_data.insert("MapFeatures", NbtValue::Byte(get_bool(java_data, "MapFeatures", true)));
lce_data.insert("SpawnX", NbtValue::Int(new_spawn_x));
lce_data.insert("SpawnY", NbtValue::Int(spawn_y));
lce_data.insert("SpawnZ", NbtValue::Int(new_spawn_z));
lce_data.insert("Time", NbtValue::Long(get_long(java_data, "Time")));
lce_data.insert("DayTime", NbtValue::Long(get_long(java_data, "DayTime")));
lce_data.insert("SizeOnDisk", NbtValue::Long(0));
lce_data.insert("LastPlayed", NbtValue::Long(unix_timestamp_millis()));
lce_data.insert(
"LevelName",
NbtValue::String(get_string(java_data, "LevelName", "Converted World")),
);
lce_data.insert("version", NbtValue::Int(19133));
lce_data.insert("rainTime", NbtValue::Int(get_int(java_data, "rainTime")));
lce_data.insert("raining", NbtValue::Byte(get_bool(java_data, "raining", false)));
lce_data.insert("thunderTime", NbtValue::Int(get_int(java_data, "thunderTime")));
lce_data.insert(
"thundering",
NbtValue::Byte(get_bool(java_data, "thundering", false)),
);
lce_data.insert(
"hardcore",
NbtValue::Byte(get_bool(java_data, "hardcore", false)),
);
lce_data.insert(
"allowCommands",
NbtValue::Byte(get_bool(java_data, "allowCommands", false)),
);
lce_data.insert(
"initialized",
NbtValue::Byte(get_bool(java_data, "initialized", true)),
);
lce_data.insert("newSeaLevel", NbtValue::Byte(1));
lce_data.insert(
"hasBeenInCreative",
NbtValue::Byte(get_bool(java_data, "hasBeenInCreative", false)),
);
lce_data.insert(
"spawnBonusChest",
NbtValue::Byte(get_bool(java_data, "spawnBonusChest", false)),
);
lce_data.insert("hasStronghold", NbtValue::Byte(0));
lce_data.insert("StrongholdX", NbtValue::Int(0));
lce_data.insert("StrongholdY", NbtValue::Int(0));
lce_data.insert("StrongholdZ", NbtValue::Int(0));
lce_data.insert("hasStrongholdEndPortal", NbtValue::Byte(0));
lce_data.insert("StrongholdEndPortalX", NbtValue::Int(0));
lce_data.insert("StrongholdEndPortalZ", NbtValue::Int(0));
lce_data.insert("XZSize", NbtValue::Int(xz_size));
lce_data.insert("HellScale", NbtValue::Int(hell_scale));
if !is_modern_world {
if let Some(game_rules) = java_data.get("GameRules") {
lce_data.insert("GameRules", game_rules.clone());
}
}
let mut root = NbtCompound::new("");
root.insert("Data", NbtValue::Compound(lce_data));
nbt::write_nbt(&root)
}
pub fn convert_lce_to_java(
lce_root: &NbtCompound,
override_spawn_x: Option<i32>,
override_spawn_y: Option<i32>,
override_spawn_z: Option<i32>,
embedded_player: Option<&NbtCompound>,
) -> Vec<u8> {
let lce_data = lce_root.compound("Data").unwrap_or(lce_root);
let mut java_data = lce_data.clone();
let lce_only_fields = [
"newSeaLevel",
"hasBeenInCreative",
"spawnBonusChest",
"XZSize",
"xzSize",
"HellScale",
"hellScale",
"hasStronghold",
"StrongholdX",
"StrongholdY",
"StrongholdZ",
"hasStrongholdEndPortal",
"StrongholdEndPortalX",
"StrongholdEndPortalZ",
"xStronghold",
"yStronghold",
"zStronghold",
"hasStrongholdEP",
"xStrongholdEP",
"zStrongholdEP",
];
for field in lce_only_fields {
java_data.remove(field);
}
if let Some(sx) = override_spawn_x {
java_data.insert("SpawnX", NbtValue::Int(sx));
}
if let Some(sy) = override_spawn_y {
java_data.insert("SpawnY", NbtValue::Int(sy));
}
if let Some(sz) = override_spawn_z {
java_data.insert("SpawnZ", NbtValue::Int(sz));
}
java_data.insert("version", NbtValue::Int(19133));
java_data.insert("DataVersion", NbtValue::Int(1343));
let mut version_compound = NbtCompound::new("Version");
version_compound.insert("Id", NbtValue::Int(1343));
version_compound.insert("Name", NbtValue::String("1.12.2".to_string()));
version_compound.insert("Snapshot", NbtValue::Byte(0));
java_data.insert("Version", NbtValue::Compound(version_compound));
if let Some(player) = embedded_player {
let mut player_tag = player.clone();
player_tag.name = "Player".to_string();
java_data.insert("Player", NbtValue::Compound(player_tag));
}
java_data.insert("LastPlayed", NbtValue::Long(unix_timestamp_millis()));
let mut java_root = NbtCompound::new("");
let mut data_tag = NbtCompound::new("Data");
for (name, value) in &java_data.tags {
data_tag.insert(name, value.clone());
}
java_root.insert("Data", NbtValue::Compound(data_tag));
nbt::write_gzip_nbt(&java_root)
}
fn get_long(compound: &NbtCompound, name: &str) -> i64 {
compound.long(name).unwrap_or(0)
}
fn get_int(compound: &NbtCompound, name: &str) -> i32 {
compound.int(name).unwrap_or(0)
}
fn get_string(compound: &NbtCompound, name: &str, default: &str) -> String {
compound.string(name).unwrap_or(default).to_string()
}
fn get_bool(compound: &NbtCompound, name: &str, default: bool) -> i8 {
compound.byte(name).unwrap_or(if default { 1 } else { 0 })
}
fn unix_timestamp_millis() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as i64
}

View file

@ -0,0 +1,235 @@
pub mod block_mapping;
pub mod chunk;
pub mod payload;
pub mod level_dat;
pub mod nbt;
pub mod region;
use std::fs;
use std::path::Path;
use tauri::AppHandle;
use std::collections::HashMap;
use region::{JavaWorldReader, JavaRegionFileWriter, LceRegionFile, SaveDataContainer};
use chunk::{convert_chunk_for_save, build_modern_anvil_level};
use level_dat::{read_spawn, convert_java_to_lce, convert_lce_to_java};
const REGION_SECT_COUNT: usize = 1024;
#[tauri::command]
#[allow(non_snake_case)]
pub async fn java_to_lce(
_app: AppHandle,
java_world_path: String,
output_ms_path: String,
) -> Result<String, String> {
let reader = JavaWorldReader::new(&java_world_path);
let java_root = reader.read_level_dat().map_err(|e| format!("Failed to read level.dat: {}", e))?;
let spawn = read_spawn(&java_root);
let spawn_chunk_x = spawn.x >> 4;
let spawn_chunk_z = spawn.z >> 4;
let region_files = reader.get_region_files("");
let total_regions = region_files.len();
let mut container = SaveDataContainer::new(0, 0);
let level_dat_bytes = convert_java_to_lce(
&java_root,
spawn_chunk_x,
spawn_chunk_z,
864,
false,
None,
);
let ld_idx = container.create_file("level.dat");
container.write_to_file(ld_idx, &level_dat_bytes);
let mut java_reader = JavaWorldReader::new(&java_world_path);
let mut chunks_written: usize = 0;
let mut errors: Vec<String> = Vec::new();
for (ri, &(_, _, ref region_path)) in region_files.iter().enumerate() {
let parts: Vec<&str> = region_path.split('.').collect();
let region_x: i32 = if parts.len() >= 2 {
parts[parts.len() - 3].parse().unwrap_or(0)
} else {
0
};
let region_z: i32 = if parts.len() >= 2 {
parts[parts.len() - 2].parse().unwrap_or(0)
} else {
0
};
let region_filename = format!("r.{}.{}.mcr", region_x, region_z);
let mut lce_region = LceRegionFile::new(&region_filename);
let mut chunk_count = 0;
for local_z in 0..32 {
for local_x in 0..32 {
if !java_reader.has_chunk(region_path, local_x, local_z) {
continue;
}
match java_reader.read_chunk_nbt(region_path, local_x, local_z) {
Ok(Some(root)) => {
let chunk_x = region_x * 32 + local_x;
let chunk_z = region_z * 32 + local_z;
let payload = convert_chunk_for_save(&root, chunk_x, chunk_z, false);
lce_region.write_chunk(local_x, local_z, payload);
chunk_count += 1;
}
Ok(None) => {}
Err(e) => {
errors.push(format!("r.{}/{}: {}", local_x, local_z, e));
}
}
}
}
if chunk_count > 0 {
lce_region.write_to_container(&mut container);
chunks_written += chunk_count;
}
if ri % 10 == 0 {
eprintln!("java2lce: processed region {}/{}", ri + 1, total_regions);
}
}
container.save(&output_ms_path).map_err(|e| format!("Failed to save output: {}", e))?;
let mut msg = format!(
"Conversion complete!\nChunks: {}\nRegions: {}",
chunks_written, total_regions
);
if !errors.is_empty() {
msg.push_str(&format!("\nErrors: {}", errors.len()));
for err in errors.iter().take(5) {
msg.push_str(&format!("\n {}", err));
}
if errors.len() > 5 {
msg.push_str(&format!("\n ... and {} more", errors.len() - 5));
}
}
eprintln!("{}",msg);
Ok(msg)
}
#[tauri::command]
#[allow(non_snake_case)]
pub async fn lce_to_java(
_app: AppHandle,
input_ms_path: String,
java_world_output: String,
) -> Result<String, String> {
let ms_data = fs::read(&input_ms_path).map_err(|e| format!("Failed to read saveData.ms: {}", e))?;
let decompressed = region::decompress_zlib(&ms_data[8..]).map_err(|e| format!("Failed to decompress save data: {}", e))?;
let footer_offset = u32::from_le_bytes([
decompressed[0], decompressed[1], decompressed[2], decompressed[3],
]) as usize;
let entry_count = u32::from_le_bytes([
decompressed[4], decompressed[5], decompressed[6], decompressed[7],
]) as usize;
let mut files: Vec<(String, Vec<u8>)> = Vec::new();
for i in 0..entry_count {
let base = footer_offset + i * 144;
if base + 144 > decompressed.len() {
break;
}
let name_bytes = &decompressed[base..base + 128];
let name: String = name_bytes
.chunks_exact(2)
.map(|c| u16::from_le_bytes([c[0], c[1]]))
.take_while(|&c| c != 0)
.filter_map(|c| char::from_u32(c as u32))
.collect();
let length = u32::from_le_bytes([
decompressed[base + 128],
decompressed[base + 129],
decompressed[base + 130],
decompressed[base + 131],
]) as usize;
let start_offset = u32::from_le_bytes([
decompressed[base + 132],
decompressed[base + 133],
decompressed[base + 134],
decompressed[base + 135],
]) as usize;
if !name.is_empty() && length > 0 && start_offset + length <= decompressed.len() {
let data = decompressed[start_offset..start_offset + length].to_vec();
files.push((name, data));
}
}
let out_dir = Path::new(&java_world_output);
fs::create_dir_all(out_dir).map_err(|e| format!("Failed to create output directory: {}", e))?;
fs::create_dir_all(out_dir.join("region")).map_err(|e| format!("Failed to create region directory: {}", e))?;
let mut chunks_written: usize = 0;
let mut errors: Vec<String> = Vec::new();
let mut region_writers: HashMap<String, JavaRegionFileWriter> = HashMap::new();
for (name, data) in &files {
if name == "level.dat" {
let lce_root = nbt::read_nbt(data).map_err(|e| format!("Failed to parse level.dat: {}", e))?;
let java_level_bytes = convert_lce_to_java(&lce_root, None, None, None, None);
fs::write(out_dir.join("level.dat"), &java_level_bytes).map_err(|e| format!("Failed to write level.dat: {}", e))?;
continue;
}
if name.ends_with(".mcr") || name.ends_with(".mca") {
let mut reader = region::JavaRegionReader::open_from_bytes(data);
let parts: Vec<&str> = name.split('.').collect();
let region_x: i32 = if parts.len() >= 4 { parts[1].parse().unwrap_or(0) } else { 0 };
let region_z: i32 = if parts.len() >= 4 { parts[2].parse().unwrap_or(0) } else { 0 };
for local_z in 0..32 {
for local_x in 0..32 {
let index = (local_x & 31) + (local_z & 31) * 32;
if reader.offsets[index as usize] == 0 {
continue;
}
match reader.read_chunk(local_x, local_z) {
Ok(Some(lce_chunk_data)) => {
if let Some(legacy_nbt) = payload::try_decode_to_legacy_nbt(&lce_chunk_data) {
let root = nbt::read_nbt(&legacy_nbt).unwrap_or_default();
let source_level = root.compound("Level").unwrap_or(&root);
let cx = region_x * 32 + local_x;
let cz = region_z * 32 + local_z;
let modern_root = build_modern_anvil_level(source_level, cx, cz);
let modern_nbt = nbt::write_nbt(&modern_root);
let out_region_x = cx >> 5;
let out_region_z = cz >> 5;
let out_local_x = cx & 31;
let out_local_z = cz & 31;
let section_region = format!("r.{}.{}.mca", out_region_x, out_region_z);
let region_path = out_dir.join("region").join(&section_region);
let region_path_str = region_path.to_string_lossy().to_string();
let writer = region_writers.entry(section_region.clone()).or_insert_with(|| {
JavaRegionFileWriter::load_from_file(&region_path_str)
});
writer.write_chunk(out_local_x, out_local_z, &modern_nbt);
chunks_written += 1;
}
}
Ok(None) => {}
Err(e) => {
errors.push(format!("{}/{}: {}", name, local_x * 32 + local_z, e));
}
}
}
}
continue;
}
}
for (region_name, writer) in &mut region_writers {
if let Err(e) = writer.save() {
errors.push(format!("Failed to save region {}: {}", region_name, e));
}
}
let mut msg = format!(
"Conversion complete!\nChunks: {}\nFiles: {}",
chunks_written, files.len()
);
if !errors.is_empty() {
msg.push_str(&format!("\nErrors: {}", errors.len()));
for err in errors.iter().take(5) {
msg.push_str(&format!("\n {}", err));
}
}
eprintln!("{}", msg);
Ok(msg)
}

View file

@ -0,0 +1,496 @@
use std::io::{Read, Write};
use flate2::read::GzDecoder;
use flate2::write::GzEncoder;
use flate2::Compression;
#[derive(Debug, Clone, PartialEq)]
pub enum NbtValue {
Byte(i8),
Short(i16),
Int(i32),
Long(i64),
Float(f32),
Double(f64),
ByteArray(Vec<u8>),
String(String),
List(Vec<NbtValue>),
Compound(NbtCompound),
IntArray(Vec<i32>),
LongArray(Vec<i64>),
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct NbtCompound {
pub name: String,
pub tags: Vec<(String, NbtValue)>,
}
impl NbtCompound {
pub fn new(name: &str) -> Self {
Self {
name: name.to_string(),
tags: Vec::new(),
}
}
pub fn insert(&mut self, name: &str, value: NbtValue) {
self.tags.retain(|(n, _)| n != name);
self.tags.push((name.to_string(), value));
}
pub fn get(&self, name: &str) -> Option<&NbtValue> {
self.tags.iter().find(|(n, _)| n == name).map(|(_, v)| v)
}
pub fn get_mut(&mut self, name: &str) -> Option<&mut NbtValue> {
self.tags.iter_mut().find(|(n, _)| n == name).map(|(_, v)| v)
}
pub fn contains(&self, name: &str) -> bool {
self.tags.iter().any(|(n, _)| n == name)
}
pub fn remove(&mut self, name: &str) {
self.tags.retain(|(n, _)| n != name);
}
pub fn compound(&self, name: &str) -> Option<&NbtCompound> {
self.get(name).and_then(|v| match v {
NbtValue::Compound(c) => Some(c),
_ => None,
})
}
pub fn compound_mut(&mut self, name: &str) -> Option<&mut NbtCompound> {
self.get_mut(name).and_then(|v| match v {
NbtValue::Compound(c) => Some(c),
_ => None,
})
}
pub fn byte(&self, name: &str) -> Option<i8> {
self.get(name).and_then(|v| match v {
NbtValue::Byte(b) => Some(*b),
_ => None,
})
}
pub fn short(&self, name: &str) -> Option<i16> {
self.get(name).and_then(|v| match v {
NbtValue::Short(s) => Some(*s),
_ => None,
})
}
pub fn int(&self, name: &str) -> Option<i32> {
self.get(name).and_then(|v| match v {
NbtValue::Int(i) => Some(*i),
_ => None,
})
}
pub fn long(&self, name: &str) -> Option<i64> {
self.get(name).and_then(|v| match v {
NbtValue::Long(l) => Some(*l),
_ => None,
})
}
pub fn float(&self, name: &str) -> Option<f32> {
self.get(name).and_then(|v| match v {
NbtValue::Float(f) => Some(*f),
_ => None,
})
}
pub fn double(&self, name: &str) -> Option<f64> {
self.get(name).and_then(|v| match v {
NbtValue::Double(d) => Some(*d),
_ => None,
})
}
pub fn string(&self, name: &str) -> Option<&str> {
self.get(name).and_then(|v| match v {
NbtValue::String(s) => Some(s.as_str()),
_ => None,
})
}
pub fn byte_array(&self, name: &str) -> Option<&[u8]> {
self.get(name).and_then(|v| match v {
NbtValue::ByteArray(b) => Some(b.as_slice()),
_ => None,
})
}
pub fn int_array(&self, name: &str) -> Option<&[i32]> {
self.get(name).and_then(|v| match v {
NbtValue::IntArray(a) => Some(a.as_slice()),
_ => None,
})
}
pub fn long_array(&self, name: &str) -> Option<&[i64]> {
self.get(name).and_then(|v| match v {
NbtValue::LongArray(a) => Some(a.as_slice()),
_ => None,
})
}
pub fn list(&self, name: &str) -> Option<&[NbtValue]> {
self.get(name).and_then(|v| match v {
NbtValue::List(l) => Some(l.as_slice()),
_ => None,
})
}
pub fn list_compounds(&self, name: &str) -> Vec<&NbtCompound> {
self.list(name)
.unwrap_or(&[])
.iter()
.filter_map(|v| match v {
NbtValue::Compound(c) => Some(c),
_ => None,
})
.collect()
}
}
fn tag_type_id(v: &NbtValue) -> u8 {
match v {
NbtValue::Byte(_) => 1,
NbtValue::Short(_) => 2,
NbtValue::Int(_) => 3,
NbtValue::Long(_) => 4,
NbtValue::Float(_) => 5,
NbtValue::Double(_) => 6,
NbtValue::ByteArray(_) => 7,
NbtValue::String(_) => 8,
NbtValue::List(_) => 9,
NbtValue::Compound(_) => 10,
NbtValue::IntArray(_) => 11,
NbtValue::LongArray(_) => 12,
}
}
fn read_be_i16(data: &[u8], off: usize) -> i16 {
i16::from_be_bytes([data[off], data[off + 1]])
}
fn read_be_i32(data: &[u8], off: usize) -> i32 {
i32::from_be_bytes([data[off], data[off + 1], data[off + 2], data[off + 3]])
}
fn read_be_i64(data: &[u8], off: usize) -> i64 {
i64::from_be_bytes([
data[off],
data[off + 1],
data[off + 2],
data[off + 3],
data[off + 4],
data[off + 5],
data[off + 6],
data[off + 7],
])
}
fn read_be_f32(data: &[u8], off: usize) -> f32 {
f32::from_be_bytes([data[off], data[off + 1], data[off + 2], data[off + 3]])
}
fn read_be_f64(data: &[u8], off: usize) -> f64 {
f64::from_be_bytes([
data[off],
data[off + 1],
data[off + 2],
data[off + 3],
data[off + 4],
data[off + 5],
data[off + 6],
data[off + 7],
])
}
fn read_string(data: &[u8], off: usize) -> Result<(String, usize), String> {
let len = read_be_i16(data, off) as usize;
let start = off + 2;
if start + len > data.len() {
return Err("string extends past end of data".into());
}
let s = String::from_utf8_lossy(&data[start..start + len]).to_string();
Ok((s, start + len))
}
pub fn read_nbt(data: &[u8]) -> Result<NbtCompound, String> {
if data.is_empty() {
return Err("empty NBT data".into());
}
let mut pos = 0;
let tag_type = data[pos];
pos += 1;
if tag_type != 10 {
return Err(format!("root tag is not compound (type={})", tag_type));
}
let (name, new_pos) = read_string(data, pos)?;
pos = new_pos;
let (compound, _new_pos) = read_compound_payload(data, pos)?;
let mut result = compound;
result.name = name;
Ok(result)
}
fn read_compound_payload(data: &[u8], mut pos: usize) -> Result<(NbtCompound, usize), String> {
let mut compound = NbtCompound::default();
loop {
if pos >= data.len() {
return Err("unexpected end in compound".into());
}
let tag_type = data[pos];
pos += 1;
if tag_type == 0 {
break;
}
let (name, new_pos) = read_string(data, pos)?;
pos = new_pos;
let (value, new_pos) = read_value(data, pos, tag_type)?;
pos = new_pos;
compound.tags.push((name, value));
}
Ok((compound, pos))
}
fn read_value(data: &[u8], pos: usize, tag_type: u8) -> Result<(NbtValue, usize), String> {
match tag_type {
1 => Ok((NbtValue::Byte(data[pos] as i8), pos + 1)),
2 => Ok((NbtValue::Short(read_be_i16(data, pos)), pos + 2)),
3 => Ok((NbtValue::Int(read_be_i32(data, pos)), pos + 4)),
4 => Ok((NbtValue::Long(read_be_i64(data, pos)), pos + 8)),
5 => Ok((NbtValue::Float(read_be_f32(data, pos)), pos + 4)),
6 => Ok((NbtValue::Double(read_be_f64(data, pos)), pos + 8)),
7 => {
let len = read_be_i32(data, pos) as usize;
let start = pos + 4;
if start + len > data.len() {
return Err("byte array extends past end".into());
}
Ok((NbtValue::ByteArray(data[start..start + len].to_vec()), start + len))
}
8 => {
let (s, new_pos) = read_string(data, pos)?;
Ok((NbtValue::String(s), new_pos))
}
9 => {
let elem_type = data[pos];
let count = read_be_i32(data, pos + 1) as usize;
let mut list_pos = pos + 5;
let mut list = Vec::with_capacity(count);
for _ in 0..count {
let (val, new_pos) = read_value(data, list_pos, elem_type)?;
list_pos = new_pos;
list.push(val);
}
Ok((NbtValue::List(list), list_pos))
}
10 => {
let (compound, new_pos) = read_compound_payload(data, pos)?;
Ok((NbtValue::Compound(compound), new_pos))
}
11 => {
let len = read_be_i32(data, pos) as usize;
let start = pos + 4;
let mut arr = Vec::with_capacity(len);
for i in 0..len {
let off = start + i * 4;
if off + 4 > data.len() {
return Err("int array extends past end".into());
}
arr.push(read_be_i32(data, off));
}
Ok((NbtValue::IntArray(arr), start + len * 4))
}
12 => {
let len = read_be_i32(data, pos) as usize;
let start = pos + 4;
let mut arr = Vec::with_capacity(len);
for i in 0..len {
let off = start + i * 8;
if off + 8 > data.len() {
return Err("long array extends past end".into());
}
arr.push(read_be_i64(data, off));
}
Ok((NbtValue::LongArray(arr), start + len * 8))
}
_ => Err(format!("unknown NBT tag type: {}", tag_type)),
}
}
pub fn write_nbt(compound: &NbtCompound) -> Vec<u8> {
let mut out = Vec::new();
out.push(10);
write_string(&mut out, &compound.name);
write_compound_payload(&mut out, compound);
out.push(0);
out
}
fn write_string(out: &mut Vec<u8>, s: &str) {
let bytes = s.as_bytes();
let len = (bytes.len() as i16).to_be_bytes();
out.extend_from_slice(&len);
out.extend_from_slice(bytes);
}
fn write_compound_payload(out: &mut Vec<u8>, compound: &NbtCompound) {
for (name, value) in &compound.tags {
out.push(tag_type_id(value));
write_string(out, name);
write_value(out, value);
}
}
fn write_value(out: &mut Vec<u8>, value: &NbtValue) {
match value {
NbtValue::Byte(b) => out.push(*b as u8),
NbtValue::Short(s) => out.extend_from_slice(&s.to_be_bytes()),
NbtValue::Int(i) => out.extend_from_slice(&i.to_be_bytes()),
NbtValue::Long(l) => out.extend_from_slice(&l.to_be_bytes()),
NbtValue::Float(f) => out.extend_from_slice(&f.to_be_bytes()),
NbtValue::Double(d) => out.extend_from_slice(&d.to_be_bytes()),
NbtValue::ByteArray(arr) => {
out.extend_from_slice(&(arr.len() as i32).to_be_bytes());
out.extend_from_slice(arr);
}
NbtValue::String(s) => write_string(out, s),
NbtValue::List(list) => {
let elem_type = list.first().map(|v| tag_type_id(v)).unwrap_or(0);
out.push(elem_type);
out.extend_from_slice(&(list.len() as i32).to_be_bytes());
for item in list {
write_value(out, item);
}
}
NbtValue::Compound(c) => {
write_compound_payload(out, c);
out.push(0);
}
NbtValue::IntArray(arr) => {
out.extend_from_slice(&(arr.len() as i32).to_be_bytes());
for i in arr {
out.extend_from_slice(&i.to_be_bytes());
}
}
NbtValue::LongArray(arr) => {
out.extend_from_slice(&(arr.len() as i32).to_be_bytes());
for l in arr {
out.extend_from_slice(&l.to_be_bytes());
}
}
}
}
pub fn read_gzip_nbt(data: &[u8]) -> Result<NbtCompound, String> {
let mut decoder = GzDecoder::new(data);
let mut buf = Vec::new();
decoder
.read_to_end(&mut buf)
.map_err(|e| format!("gzip decompress failed: {}", e))?;
read_nbt(&buf)
}
pub fn write_gzip_nbt(compound: &NbtCompound) -> Vec<u8> {
let raw = write_nbt(compound);
let mut encoder = GzEncoder::new(Vec::new(), Compression::best());
encoder.write_all(&raw).unwrap();
encoder.finish().unwrap()
}
pub fn read_zlib_nbt(data: &[u8]) -> Result<NbtCompound, String> {
use flate2::read::ZlibDecoder;
let mut decoder = ZlibDecoder::new(data);
let mut buf = Vec::new();
decoder
.read_to_end(&mut buf)
.map_err(|e| format!("zlib decompress failed: {}", e))?;
read_nbt(&buf)
}
pub fn write_zlib_nbt(compound: &NbtCompound) -> Vec<u8> {
use flate2::write::ZlibEncoder;
let raw = write_nbt(compound);
let mut encoder = ZlibEncoder::new(Vec::new(), Compression::best());
encoder.write_all(&raw).unwrap();
encoder.finish().unwrap()
}
pub fn get_byte_array_or(compound: &NbtCompound, name: &str, default_len: usize) -> Vec<u8> {
compound
.byte_array(name)
.map(|b| {
let mut v = b.to_vec();
if v.len() < default_len {
v.resize(default_len, 0);
}
v
})
.unwrap_or_else(|| vec![0u8; default_len])
}
pub fn get_int_array_or(compound: &NbtCompound, name: &str, default_len: usize) -> Vec<i32> {
compound
.int_array(name)
.map(|a| {
let mut v = a.to_vec();
if v.len() < default_len {
v.resize(default_len, 0);
}
v
})
.unwrap_or_else(|| vec![0i32; default_len])
}
pub fn get_long_array_or(compound: &NbtCompound, name: &str, default_len: usize) -> Vec<i64> {
compound
.long_array(name)
.map(|a| {
let mut v = a.to_vec();
if v.len() < default_len {
v.resize(default_len, 0);
}
v
})
.unwrap_or_else(|| vec![0i64; default_len])
}
pub fn get_nibble(data: &[u8], index: usize) -> u8 {
let byte_index = index >> 1;
if byte_index >= data.len() {
return 0;
}
let b = data[byte_index];
if index & 1 == 0 {
b & 0x0F
} else {
(b >> 4) & 0x0F
}
}
pub fn set_nibble(data: &mut [u8], index: usize, value: u8) {
let byte_index = index >> 1;
if byte_index >= data.len() {
return;
}
let val = value & 0x0F;
if index & 1 == 0 {
data[byte_index] = (data[byte_index] & 0xF0) | val;
} else {
data[byte_index] = (data[byte_index] & 0x0F) | (val << 4);
}
}
pub fn clone_or_empty_list(compound: &NbtCompound, name: &str) -> Vec<NbtValue> {
compound
.list(name)
.map(|l| l.to_vec())
.unwrap_or_default()
}

View file

@ -0,0 +1,683 @@
use super::nbt;
const SAVE_FILE_VERSION_COMPRESSED_CHUNK_STORAGE: i16 = 8;
const SAVE_FILE_VERSION_CHUNK_INHABITED_TIME: i16 = 9;
const COMPRESSED_CHUNK_SECTION_HEIGHT: usize = 128;
const BLOCKS_PER_SECTION: usize = COMPRESSED_CHUNK_SECTION_HEIGHT * 16 * 16;
const NIBBLES_PER_SECTION: usize = BLOCKS_PER_SECTION / 2;
const FULL_CHUNK_BLOCKS: usize = 256 * 16 * 16;
const FULL_CHUNK_NIBBLES: usize = FULL_CHUNK_BLOCKS / 2;
const INDEX_TYPE_MASK: u16 = 0x0003;
const INDEX_TYPE_1BIT: u16 = 0x0000;
const INDEX_TYPE_2BIT: u16 = 0x0001;
const INDEX_TYPE_4BIT: u16 = 0x0002;
const INDEX_TYPE_0_OR_8BIT: u16 = 0x0003;
const INDEX_TYPE_0BIT_FLAG: u16 = 0x0004;
const SPARSE_ALL_ZERO_INDEX: u8 = 128;
const SPARSE_ALL_FIFTEEN_INDEX: u8 = 129;
fn read_be_i16(data: &[u8], off: usize) -> i16 {
i16::from_be_bytes([data[off], data[off + 1]])
}
fn read_be_i32(data: &[u8], off: usize) -> i32 {
i32::from_be_bytes([data[off], data[off + 1], data[off + 2], data[off + 3]])
}
fn read_be_i64(data: &[u8], off: usize) -> i64 {
i64::from_be_bytes([
data[off], data[off + 1], data[off + 2], data[off + 3], data[off + 4], data[off + 5],
data[off + 6], data[off + 7],
])
}
fn write_be_i16(out: &mut Vec<u8>, v: i16) {
out.extend_from_slice(&v.to_be_bytes());
}
fn write_be_i32(out: &mut Vec<u8>, v: i32) {
out.extend_from_slice(&v.to_be_bytes());
}
fn write_be_i64(out: &mut Vec<u8>, v: i64) {
out.extend_from_slice(&v.to_be_bytes());
}
fn get_compressed_tile_index(block: usize, tile: usize) -> usize {
let mut index = ((block & 0x180) << 6) | ((block & 0x060) << 4) | ((block & 0x01F) << 2);
index |= ((tile & 0x30) << 7) | ((tile & 0x0C) << 5) | (tile & 0x03);
index
}
fn get_nibble_value(nibble_data: &[u8], xz: usize, y: usize) -> u8 {
let pos = (xz << 7) | y;
let slot = pos >> 1;
let part = pos & 1;
if slot >= nibble_data.len() {
return 0;
}
let b = nibble_data[slot];
if part == 0 {
b & 0x0F
} else {
(b >> 4) & 0x0F
}
}
fn set_nibble_value(nibble_data: &mut [u8], xz: usize, y: usize, value: u8) {
let pos = (xz << 7) | y;
let slot = pos >> 1;
let part = pos & 1;
if slot >= nibble_data.len() {
return;
}
let val = value & 0x0F;
if part == 0 {
nibble_data[slot] = (nibble_data[slot] & 0xF0) | val;
} else {
nibble_data[slot] = (nibble_data[slot] & 0x0F) | (val << 4);
}
}
fn ensure_length(input: &[u8], expected_len: usize) -> Vec<u8> {
if input.len() == expected_len {
return input.to_vec();
}
let mut v = vec![0u8; expected_len];
let copy_len = input.len().min(expected_len);
v[..copy_len].copy_from_slice(&input[..copy_len]);
v
}
fn extract_lower_block_section(full_blocks: &[u8]) -> Vec<u8> {
if full_blocks.len() == BLOCKS_PER_SECTION {
return full_blocks.to_vec();
}
let mut lower = vec![0u8; BLOCKS_PER_SECTION];
for xz in 0..256 {
let src_start = xz * 256;
let dst_start = xz * COMPRESSED_CHUNK_SECTION_HEIGHT;
let copy_len = COMPRESSED_CHUNK_SECTION_HEIGHT.min(full_blocks.len() - src_start.min(full_blocks.len()));
if src_start + copy_len <= full_blocks.len() && dst_start + copy_len <= BLOCKS_PER_SECTION {
lower[dst_start..dst_start + copy_len]
.copy_from_slice(&full_blocks[src_start..src_start + copy_len]);
}
}
lower
}
fn extract_upper_block_section(full_blocks: &[u8]) -> Vec<u8> {
if full_blocks.len() < FULL_CHUNK_BLOCKS {
return vec![0u8; BLOCKS_PER_SECTION];
}
let mut upper = vec![0u8; BLOCKS_PER_SECTION];
for xz in 0..256 {
let src_start = xz * 256 + COMPRESSED_CHUNK_SECTION_HEIGHT;
let dst_start = xz * COMPRESSED_CHUNK_SECTION_HEIGHT;
upper[dst_start..dst_start + COMPRESSED_CHUNK_SECTION_HEIGHT].copy_from_slice(&full_blocks[src_start..src_start + COMPRESSED_CHUNK_SECTION_HEIGHT]);
}
upper
}
fn extract_lower_nibble_section(full_nibbles: &[u8]) -> Vec<u8> {
let nibble_height = COMPRESSED_CHUNK_SECTION_HEIGHT / 2;
if full_nibbles.len() == NIBBLES_PER_SECTION {
return full_nibbles.to_vec();
}
let mut lower = vec![0u8; NIBBLES_PER_SECTION];
for xz in 0..256 {
let src_start = xz * nibble_height * 2;
let dst_start = xz * nibble_height;
let copy_len = nibble_height.min(
full_nibbles.len().saturating_sub(src_start),
);
if copy_len > 0 {
lower[dst_start..dst_start + copy_len].copy_from_slice(&full_nibbles[src_start..src_start + copy_len]);
}
}
lower
}
fn extract_upper_nibble_section(full_nibbles: &[u8]) -> Vec<u8> {
let nibble_height = COMPRESSED_CHUNK_SECTION_HEIGHT / 2;
if full_nibbles.len() < FULL_CHUNK_NIBBLES {
return vec![0u8; NIBBLES_PER_SECTION];
}
let mut upper = vec![0u8; NIBBLES_PER_SECTION];
for xz in 0..256 {
let src_start = xz * nibble_height * 2 + nibble_height;
let dst_start = xz * nibble_height;
upper[dst_start..dst_start + nibble_height]
.copy_from_slice(&full_nibbles[src_start..src_start + nibble_height]);
}
upper
}
fn combine_block_sections(lower: &[u8], upper: &[u8]) -> Vec<u8> {
if lower.len() == FULL_CHUNK_BLOCKS {
return lower.to_vec();
}
let mut combined = vec![0u8; FULL_CHUNK_BLOCKS];
for xz in 0..256 {
let lower_start = xz * COMPRESSED_CHUNK_SECTION_HEIGHT;
let out_start = xz * 256;
let copy_len = COMPRESSED_CHUNK_SECTION_HEIGHT.min(lower.len().saturating_sub(lower_start));
if copy_len > 0 {
combined[out_start..out_start + copy_len].copy_from_slice(&lower[lower_start..lower_start + copy_len]);
}
let upper_start = xz * COMPRESSED_CHUNK_SECTION_HEIGHT;
let out_upper = out_start + COMPRESSED_CHUNK_SECTION_HEIGHT;
let copy_len2 = COMPRESSED_CHUNK_SECTION_HEIGHT.min(upper.len().saturating_sub(upper_start));
if copy_len2 > 0 {
combined[out_upper..out_upper + copy_len2]
.copy_from_slice(&upper[upper_start..upper_start + copy_len2]);
}
}
combined
}
fn combine_nibble_sections(lower: &[u8], upper: &[u8]) -> Vec<u8> {
let nibble_height = COMPRESSED_CHUNK_SECTION_HEIGHT / 2;
if lower.len() == FULL_CHUNK_NIBBLES {
return lower.to_vec();
}
let mut combined = vec![0u8; FULL_CHUNK_NIBBLES];
for xz in 0..256 {
let lower_start = xz * nibble_height;
let out_start = xz * nibble_height * 2;
let copy_len = nibble_height.min(lower.len().saturating_sub(lower_start));
if copy_len > 0 {
combined[out_start..out_start + copy_len]
.copy_from_slice(&lower[lower_start..lower_start + copy_len]);
}
let upper_start = xz * nibble_height;
let out_upper = out_start + nibble_height;
let copy_len2 = nibble_height.min(upper.len().saturating_sub(upper_start));
if copy_len2 > 0 {
combined[out_upper..out_upper + copy_len2]
.copy_from_slice(&upper[upper_start..upper_start + copy_len2]);
}
}
combined
}
pub fn encode_legacy_nbt(level: &nbt::NbtCompound) -> Vec<u8> {
let mut root = nbt::NbtCompound::new("");
root.insert("Level", nbt::NbtValue::Compound(level.clone()));
nbt::write_nbt(&root)
}
fn write_compressed_tile_storage(out: &mut Vec<u8>, blocks: &[u8]) {
let normalized = ensure_length(blocks, BLOCKS_PER_SECTION);
let mut blob = vec![0u8; 1024 + BLOCKS_PER_SECTION];
let mut data_offset: usize = 0;
for block in 0..512 {
let index = (INDEX_TYPE_0_OR_8BIT | ((data_offset as u16) << 1)) as u16;
blob[block * 2..block * 2 + 2].copy_from_slice(&index.to_le_bytes());
for tile in 0..64 {
blob[1024 + data_offset + tile] = normalized[get_compressed_tile_index(block, tile)];
}
data_offset += 64;
}
write_be_i32(out, blob.len() as i32);
out.extend_from_slice(&blob);
}
fn write_empty_compressed_tile_storage(out: &mut Vec<u8>) {
let mut blob = vec![0u8; 1024];
for block in 0..512 {
let index = (INDEX_TYPE_0_OR_8BIT | INDEX_TYPE_0BIT_FLAG) as u16;
blob[block * 2..block * 2 + 2].copy_from_slice(&index.to_le_bytes());
}
write_be_i32(out, blob.len() as i32);
out.extend_from_slice(&blob);
}
fn write_sparse_nibble_storage(out: &mut Vec<u8>, nibble_data: &[u8], supports_all_fifteen: bool) {
let normalized = ensure_length(nibble_data, NIBBLES_PER_SECTION);
let mut plane_indices = [0u8; 128];
let mut planes: Vec<Vec<u8>> = Vec::new();
for y in 0..128 {
let mut all_zero = true;
let mut all_fifteen = supports_all_fifteen;
let mut plane = vec![0u8; 128];
let mut plane_cursor = 0;
for xz in 0..128 {
let first = get_nibble_value(&normalized, xz * 2, y);
let second = get_nibble_value(&normalized, xz * 2 + 1, y);
if first != 0 || second != 0 {
all_zero = false;
}
if supports_all_fifteen && (first != 15 || second != 15) {
all_fifteen = false;
}
plane[plane_cursor] = first | (second << 4);
plane_cursor += 1;
}
if all_zero {
plane_indices[y] = SPARSE_ALL_ZERO_INDEX;
continue;
}
if supports_all_fifteen && all_fifteen {
plane_indices[y] = SPARSE_ALL_FIFTEEN_INDEX;
continue;
}
plane_indices[y] = planes.len() as u8;
planes.push(plane);
}
write_be_i32(out, planes.len() as i32);
out.extend_from_slice(&plane_indices);
for plane in &planes {
out.extend_from_slice(plane);
}
}
fn write_empty_sparse_nibble_storage(out: &mut Vec<u8>, supports_all_fifteen: bool, fill_with_fifteen: bool) {
write_be_i32(out, 0);
let fill = if supports_all_fifteen && fill_with_fifteen {
SPARSE_ALL_FIFTEEN_INDEX
} else {
SPARSE_ALL_ZERO_INDEX
};
let plane_indices = [fill; 128];
out.extend_from_slice(&plane_indices);
}
pub fn encode_compressed_storage(level: &nbt::NbtCompound) -> Vec<u8> {
let blocks = level.byte_array("Blocks").unwrap_or(&[]);
let data = level.byte_array("Data").unwrap_or(&[]);
let sky_light_raw = level.byte_array("SkyLight").unwrap_or(&[]);
let block_light_raw = level.byte_array("BlockLight").unwrap_or(&[]);
let height_map = level.byte_array("HeightMap").unwrap_or(&[]);
let biomes = level.byte_array("Biomes").unwrap_or(&[]);
let default_sky = vec![0xFF; NIBBLES_PER_SECTION];
let default_light = vec![0u8; NIBBLES_PER_SECTION];
let sky_light = if sky_light_raw.is_empty() { default_sky.as_slice() } else { sky_light_raw };
let block_light = if block_light_raw.is_empty() { default_light.as_slice() } else { block_light_raw };
let mut out = Vec::new();
write_be_i16(&mut out, SAVE_FILE_VERSION_CHUNK_INHABITED_TIME);
write_be_i32(&mut out, level.int("xPos").unwrap_or(0));
write_be_i32(&mut out, level.int("zPos").unwrap_or(0));
write_be_i64(&mut out, level.long("LastUpdate").unwrap_or(0));
write_be_i64(&mut out, level.long("InhabitedTime").unwrap_or(0));
write_compressed_tile_storage(&mut out, &extract_lower_block_section(blocks));
write_compressed_tile_storage(&mut out, &extract_upper_block_section(blocks));
write_sparse_nibble_storage(&mut out, &extract_lower_nibble_section(data), false);
write_sparse_nibble_storage(&mut out, &extract_upper_nibble_section(data), false);
write_sparse_nibble_storage(&mut out, &extract_lower_nibble_section(sky_light), true);
write_sparse_nibble_storage(&mut out, &extract_upper_nibble_section(sky_light), true);
write_sparse_nibble_storage(&mut out, &extract_lower_nibble_section(block_light), true);
write_sparse_nibble_storage(&mut out, &extract_upper_nibble_section(block_light), true);
let hm = ensure_length(height_map, 256);
out.extend_from_slice(&hm[..256]);
write_be_i16(&mut out, level.short("TerrainPopulatedFlags").unwrap_or(0));
let bio = ensure_length(biomes, 256);
out.extend_from_slice(&bio[..256]);
let dynamic_root = nbt::NbtCompound::new("");
let dynamic_bytes = nbt::write_nbt(&dynamic_root);
out.extend_from_slice(&dynamic_bytes);
out
}
fn is_compressed_chunk_storage(data: &[u8]) -> bool {
if data.len() < 2 + 4 + 4 + 8 {
return false;
}
let version = read_be_i16(data, 0);
version == SAVE_FILE_VERSION_COMPRESSED_CHUNK_STORAGE || version == SAVE_FILE_VERSION_CHUNK_INHABITED_TIME
}
pub fn try_read_chunk_coordinates(
data: &[u8],
) -> Option<(i32, i32, bool)> {
if let Some((cx, cz, wrapped)) = try_read_legacy_level_coords(data) {
return Some((cx, cz, wrapped));
}
if is_compressed_chunk_storage(data) {
let cx = read_be_i32(data, 2);
let cz = read_be_i32(data, 6);
return Some((cx, cz, false));
}
None
}
fn try_read_legacy_level_coords(data: &[u8]) -> Option<(i32, i32, bool)> {
if data.is_empty() || data[0] != 10 {
return None;
}
let compound = nbt::read_nbt(data).ok()?;
let (level, has_wrapper) = if let Some(nbt::NbtValue::Compound(c)) = compound.get("Level") {
(c, true)
} else {
(&compound, false)
};
let cx = level.int("xPos")?;
let cz = level.int("zPos")?;
Some((cx, cz, has_wrapper))
}
pub fn force_chunk_coordinates(data: &[u8], expected_x: i32, expected_z: i32) -> Vec<u8> {
if data.is_empty() {
return Vec::new();
}
if let Ok((mut level, _)) = read_legacy_level(data) {
level.insert("xPos", nbt::NbtValue::Int(expected_x));
level.insert("zPos", nbt::NbtValue::Int(expected_z));
return encode_legacy_nbt(&level);
}
if is_compressed_chunk_storage(data) {
let mut patched = data.to_vec();
patched[2..6].copy_from_slice(&expected_x.to_be_bytes());
patched[6..10].copy_from_slice(&expected_z.to_be_bytes());
return patched;
}
Vec::new()
}
fn read_compressed_tile_storage(data: &[u8], offset: &mut usize) -> Result<Vec<u8>, String> {
let allocated_size = read_be_i32(data, *offset) as usize;
*offset += 4;
if allocated_size < 1024 || *offset + allocated_size > data.len() {
return Err("Invalid CompressedTileStorage payload.".into());
}
let blob = &data[*offset..*offset + allocated_size];
*offset += allocated_size;
let data_region = &blob[1024..];
let mut blocks = vec![0u8; BLOCKS_PER_SECTION];
for block in 0..512 {
let block_index = u16::from_le_bytes([blob[block * 2], blob[block * 2 + 1]]);
let index_type = block_index & INDEX_TYPE_MASK;
if index_type == INDEX_TYPE_0_OR_8BIT {
if (block_index & INDEX_TYPE_0BIT_FLAG) != 0 {
let value = ((block_index >> 8) & 0xFF) as u8;
for tile in 0..64 {
blocks[get_compressed_tile_index(block, tile)] = value;
}
} else {
let data_offset = ((block_index >> 1) & 0x7FFE) as usize;
if data_offset + 64 > data_region.len() {
return Err("Invalid 8-bit CompressedTileStorage offset.".into());
}
for tile in 0..64 {
blocks[get_compressed_tile_index(block, tile)] = data_region[data_offset + tile];
}
}
continue;
}
let bits_per_tile = match index_type {
INDEX_TYPE_1BIT => 1,
INDEX_TYPE_2BIT => 2,
INDEX_TYPE_4BIT => 4,
_ => return Err("Unsupported CompressedTileStorage index type.".into()),
};
let tile_type_count = 1usize << bits_per_tile;
let tile_type_mask = (tile_type_count - 1) as u8;
let index_shift = 3 - index_type as usize;
let index_mask_bits = (7 >> index_type) as usize;
let index_mask_bytes = (62 >> index_shift) as usize;
let packed_data_size = (8usize << index_type) as usize;
let data_offset_packed = ((block_index >> 1) & 0x7FFE) as usize;
if data_offset_packed + tile_type_count + packed_data_size > data_region.len() {
return Err("Invalid packed CompressedTileStorage offset.".into());
}
let tile_types = &data_region[data_offset_packed..data_offset_packed + tile_type_count];
let packed = &data_region[data_offset_packed + tile_type_count..data_offset_packed + tile_type_count + packed_data_size];
for tile in 0..64 {
let idx = (tile >> index_shift) & index_mask_bytes;
let bit = (tile & index_mask_bits) * bits_per_tile;
let palette_index = (packed[idx] >> bit) & tile_type_mask;
blocks[get_compressed_tile_index(block, tile)] = tile_types[palette_index as usize];
}
}
Ok(blocks)
}
fn skip_compressed_tile_storage(data: &[u8], offset: &mut usize) -> Result<(), String> {
let allocated_size = read_be_i32(data, *offset) as usize;
*offset += 4;
if *offset + allocated_size > data.len() {
return Err("Invalid CompressedTileStorage payload.".into());
}
*offset += allocated_size;
Ok(())
}
fn read_sparse_nibble_storage(data: &[u8], offset: &mut usize, supports_all_fifteen: bool) -> Result<Vec<u8>, String> {
let count = read_be_i32(data, *offset) as usize;
*offset += 4;
let storage_bytes = 128 + count * 128;
if count > data.len() || *offset + storage_bytes > data.len() {
return Err("Invalid SparseStorage payload.".into());
}
let blob = &data[*offset..*offset + storage_bytes];
*offset += storage_bytes;
let plane_indices = &blob[..128];
let plane_data = &blob[128..];
let mut nibble_data = vec![0u8; NIBBLES_PER_SECTION];
for y in 0..128 {
let plane_index = plane_indices[y];
if plane_index == SPARSE_ALL_ZERO_INDEX {
continue;
}
if supports_all_fifteen && plane_index == SPARSE_ALL_FIFTEEN_INDEX {
for xz in 0..256 {
set_nibble_value(&mut nibble_data, xz, y, 15);
}
continue;
}
let plane_offset = plane_index as usize * 128;
if plane_offset + 128 > plane_data.len() {
return Err("Invalid sparse plane index.".into());
}
let plane = &plane_data[plane_offset..plane_offset + 128];
for xz in 0..128 {
let packed = plane[xz];
set_nibble_value(&mut nibble_data, xz * 2, y, packed & 0x0F);
set_nibble_value(&mut nibble_data, xz * 2 + 1, y, (packed >> 4) & 0x0F);
}
}
Ok(nibble_data)
}
fn skip_sparse_nibble_storage(data: &[u8], offset: &mut usize) -> Result<(), String> {
let count = read_be_i32(data, *offset) as usize;
*offset += 4;
let storage_bytes = 128 + count * 128;
if count > data.len() || *offset + storage_bytes > data.len() {
return Err("Invalid SparseStorage payload.".into());
}
*offset += storage_bytes;
Ok(())
}
fn read_sized_bytes(data: &[u8], offset: &mut usize, length: usize) -> Vec<u8> {
let end = (*offset + length).min(data.len());
let result = data[*offset..end].to_vec();
*offset += length;
result
}
fn read_legacy_level(data: &[u8]) -> Result<(nbt::NbtCompound, bool), String> {
if data.is_empty() || data[0] != 10 {
return Err("Not NBT compound".into());
}
let file = nbt::read_nbt(data)?;
let has_level = file.get("Level").is_some();
let level = if let Some(nbt::NbtValue::Compound(c)) = file.get("Level") {
let mut cloned = c.clone();
cloned.name = "Level".to_string();
cloned
} else {
let mut cloned = file.clone();
cloned.name = "Level".to_string();
cloned
};
Ok((level, has_level))
}
pub fn try_decode_to_legacy_nbt(data: &[u8]) -> Option<Vec<u8>> {
if let Ok((mut level, _)) = read_legacy_level(data) {
level.name = "Level".to_string();
return Some(encode_legacy_nbt(&level));
}
if !is_compressed_chunk_storage(data) {
return None;
}
decode_compressed_chunk_to_legacy_root(data).ok().map(|root| nbt::write_nbt(&root))
}
fn decode_compressed_chunk_to_legacy_root(data: &[u8]) -> Result<nbt::NbtCompound, String> {
let mut offset = 0;
let version = read_be_i16(data, offset);
offset = 2;
if version != SAVE_FILE_VERSION_COMPRESSED_CHUNK_STORAGE
&& version != SAVE_FILE_VERSION_CHUNK_INHABITED_TIME
{
return Err(format!("Unsupported compressed chunk version: {}", version));
}
let chunk_x = read_be_i32(data, offset);
offset += 4;
let chunk_z = read_be_i32(data, offset);
offset += 4;
let last_update = read_be_i64(data, offset);
offset += 8;
let inhabited_time = if version >= SAVE_FILE_VERSION_CHUNK_INHABITED_TIME {
let v = read_be_i64(data, offset);
offset += 8;
v
} else {
0
};
let lower_blocks = read_compressed_tile_storage(data, &mut offset)?;
let upper_blocks = read_compressed_tile_storage(data, &mut offset)?;
let lower_data = read_sparse_nibble_storage(data, &mut offset, false)?;
let upper_data = read_sparse_nibble_storage(data, &mut offset, false)?;
let lower_sky = read_sparse_nibble_storage(data, &mut offset, true)?;
let upper_sky = read_sparse_nibble_storage(data, &mut offset, true)?;
let lower_block_light = read_sparse_nibble_storage(data, &mut offset, true)?;
let upper_block_light = read_sparse_nibble_storage(data, &mut offset, true)?;
let height_map = read_sized_bytes(data, &mut offset, 256);
let terrain_populated_flags = read_be_i16(data, offset);
offset += 2;
let biomes = read_sized_bytes(data, &mut offset, 256);
let dynamic_root = if offset < data.len() {
nbt::read_nbt(&data[offset..]).unwrap_or_default()
} else {
nbt::NbtCompound::default()
};
let mut level = nbt::NbtCompound::new("Level");
level.insert("xPos", nbt::NbtValue::Int(chunk_x));
level.insert("zPos", nbt::NbtValue::Int(chunk_z));
level.insert("LastUpdate", nbt::NbtValue::Long(last_update));
level.insert("InhabitedTime", nbt::NbtValue::Long(inhabited_time));
level.insert(
"Blocks",
nbt::NbtValue::ByteArray(combine_block_sections(&lower_blocks, &upper_blocks)),
);
level.insert(
"Data",
nbt::NbtValue::ByteArray(combine_nibble_sections(&lower_data, &upper_data)),
);
level.insert(
"SkyLight",
nbt::NbtValue::ByteArray(combine_nibble_sections(&lower_sky, &upper_sky)),
);
level.insert(
"BlockLight",
nbt::NbtValue::ByteArray(combine_nibble_sections(
&lower_block_light,
&upper_block_light,
)),
);
level.insert("HeightMap", nbt::NbtValue::ByteArray(height_map));
level.insert(
"TerrainPopulatedFlags",
nbt::NbtValue::Short(terrain_populated_flags),
);
level.insert("Biomes", nbt::NbtValue::ByteArray(biomes));
let entities = dynamic_root
.list("Entities")
.map(|l| nbt::NbtValue::List(l.to_vec()))
.unwrap_or_else(|| nbt::NbtValue::List(Vec::new()));
level.insert("Entities", entities);
let tile_entities = dynamic_root
.list("TileEntities")
.map(|l| nbt::NbtValue::List(l.to_vec()))
.unwrap_or_else(|| nbt::NbtValue::List(Vec::new()));
level.insert("TileEntities", tile_entities);
if let Some(tile_ticks) = dynamic_root.get("TileTicks") {
level.insert("TileTicks", tile_ticks.clone());
}
let mut root = nbt::NbtCompound::new("");
root.insert("Level", nbt::NbtValue::Compound(level));
Ok(root)
}
pub fn try_get_compressed_chunk_nbt_offset(data: &[u8]) -> Option<usize> {
if !is_compressed_chunk_storage(data) {
return None;
}
let mut offset = 0;
let version = read_be_i16(data, offset);
offset = 2;
if version != SAVE_FILE_VERSION_COMPRESSED_CHUNK_STORAGE
&& version != SAVE_FILE_VERSION_CHUNK_INHABITED_TIME
{
return None;
}
offset += 8;
if version >= SAVE_FILE_VERSION_CHUNK_INHABITED_TIME {
offset += 8;
}
let r1 = skip_compressed_tile_storage(data, &mut offset);
if r1.is_err() { return None; }
let r2 = skip_compressed_tile_storage(data, &mut offset);
if r2.is_err() { return None; }
let r3 = skip_sparse_nibble_storage(data, &mut offset);
if r3.is_err() { return None; }
let r4 = skip_sparse_nibble_storage(data, &mut offset);
if r4.is_err() { return None; }
let r5 = skip_sparse_nibble_storage(data, &mut offset);
if r5.is_err() { return None; }
let r6 = skip_sparse_nibble_storage(data, &mut offset);
if r6.is_err() { return None; }
let r7 = skip_sparse_nibble_storage(data, &mut offset);
if r7.is_err() { return None; }
let r8 = skip_sparse_nibble_storage(data, &mut offset);
if r8.is_err() { return None; }
offset += 256;
offset += 2;
offset += 256;
if offset > data.len() {
return None;
}
Some(offset)
}

View file

@ -0,0 +1,803 @@
use std::collections::HashMap;
use std::fs;
use std::io::{Read, Write};
use std::path::Path;
use flate2::read::ZlibDecoder;
use flate2::write::ZlibEncoder;
use flate2::Compression;
use super::nbt::{self, NbtCompound, NbtValue};
const SECTOR_BYTES: usize = 4096;
pub struct JavaWorldReader {
world_path: String,
region_readers: HashMap<String, JavaRegionReader>,
}
pub struct JavaRegionReader {
pub data: Vec<u8>,
pub offsets: [u32; 1024],
}
impl JavaWorldReader {
pub fn new(world_path: &str) -> Self {
Self {
world_path: world_path.to_string(),
region_readers: HashMap::new(),
}
}
pub fn read_level_dat(&self) -> Result<NbtCompound, String> {
let path = Path::new(&self.world_path).join("level.dat");
let data = fs::read(&path).map_err(|e| format!("Failed to read level.dat: {}", e))?;
nbt::read_gzip_nbt(&data)
}
pub fn get_region_dir(&self, dimension: &str) -> String {
if dimension.is_empty() {
Path::new(&self.world_path)
.join("region")
.to_string_lossy()
.to_string()
} else {
Path::new(&self.world_path)
.join(dimension)
.join("region")
.to_string_lossy()
.to_string()
}
}
pub fn get_region_files(&self, dimension: &str) -> Vec<(i32, i32, String)> {
let mut result = Vec::new();
let dir = self.get_region_dir(dimension);
let dir_path = Path::new(&dir);
if !dir_path.is_dir() {
return result;
}
if let Ok(entries) = fs::read_dir(dir_path) {
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
let lower = name.to_lowercase();
if lower.ends_with(".mcr") || lower.ends_with(".mca") {
let parts: Vec<&str> = name.split('.').collect();
if parts.len() == 4 && parts[0] == "r" {
if let (Ok(rx), Ok(rz)) =(parts[1].parse::<i32>(), parts[2].parse::<i32>())
{
result.push((rx, rz, entry.path().to_string_lossy().to_string()));
}
}
}
}
}
result
}
pub fn is_anvil_world(&self) -> bool {
let region_dir = self.get_region_dir("");
let dir_path = Path::new(&region_dir);
if !dir_path.is_dir() {
return false;
}
if let Ok(entries) = fs::read_dir(dir_path) {
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
if name.to_lowercase().ends_with(".mca") {
return true;
}
}
}
false
}
fn get_or_create_reader(&mut self, region_path: &str) -> Result<&JavaRegionReader, String> {
if !self.region_readers.contains_key(region_path) {
let reader = JavaRegionReader::open(region_path)?;
self.region_readers.insert(region_path.to_string(), reader);
}
Ok(self.region_readers.get(region_path).unwrap())
}
pub fn has_chunk(&mut self, region_path: &str, local_x: i32, local_z: i32) -> bool {
let reader = match self.get_or_create_reader(region_path) {
Ok(r) => r,
Err(_) => return false,
};
let index = (local_x & 31) + (local_z & 31) * 32;
reader.offsets[index as usize] != 0
}
pub fn read_chunk_nbt(
&mut self,
region_path: &str,
local_x: i32,
local_z: i32,
) -> Result<Option<NbtCompound>, String> {
let reader = self.get_or_create_reader(region_path)?;
let index = (local_x & 31) + (local_z & 31) * 32;
let offset_entry = reader.offsets[index as usize];
if offset_entry == 0 {
return Ok(None);
}
let sector_offset = (offset_entry >> 8) as usize;
let chunk_pos = sector_offset * SECTOR_BYTES;
let data_len = reader.data.len();
if chunk_pos + 5 > data_len {
return Ok(None);
}
let length = u32::from_be_bytes([
reader.data[chunk_pos],
reader.data[chunk_pos + 1],
reader.data[chunk_pos + 2],
reader.data[chunk_pos + 3],
]) as usize;
let compression_type = reader.data[chunk_pos + 4];
if length <= 1 {
return Ok(None);
}
let compressed_length = length - 1;
if chunk_pos + 5 + compressed_length > data_len {
return Ok(None);
}
let compressed_data = &reader.data[chunk_pos + 5..chunk_pos + 5 + compressed_length];
let decompressed = match compression_type {
1 => {
let mut decoder = flate2::read::GzDecoder::new(compressed_data);
let mut buf = Vec::new();
decoder.read_to_end(&mut buf).map_err(|e| format!("gzip decompress failed: {}", e))?;
buf
}
2 => {
let mut decoder = ZlibDecoder::new(compressed_data);
let mut buf = Vec::new();
decoder
.read_to_end(&mut buf)
.map_err(|e| format!("zlib decompress failed: {}", e))?;
buf
}
_ => return Ok(None),
};
if decompressed.is_empty() {
return Ok(None);
}
let compound = nbt::read_nbt(&decompressed)?;
Ok(Some(compound))
}
}
impl JavaRegionReader {
fn open(path: &str) -> Result<Self, String> {
let data = fs::read(path).map_err(|e| format!("Failed to read region file: {}", e))?;
let mut offsets = [0u32; 1024];
for i in 0..1024 {
let off = i * 4;
if off + 4 > data.len() {
break;
}
offsets[i] = u32::from_be_bytes([data[off], data[off + 1], data[off + 2], data[off + 3]]);
}
Ok(Self { data, offsets })
}
pub fn open_from_bytes(data: &[u8]) -> Self {
let data = data.to_vec();
let mut offsets = [0u32; 1024];
for i in 0..1024 {
let off = i * 4;
if off + 4 > data.len() {
break;
}
offsets[i] = u32::from_be_bytes([data[off], data[off + 1], data[off + 2], data[off + 3]]);
}
Self { data, offsets }
}
pub fn read_chunk(
&mut self,
local_x: i32,
local_z: i32,
) -> Result<Option<Vec<u8>>, String> {
let index = (local_x & 31) + (local_z & 31) * 32;
let offset_entry = self.offsets[index as usize];
if offset_entry == 0 {
return Ok(None);
}
let sector_offset = (offset_entry >> 8) as usize;
let chunk_pos = sector_offset * SECTOR_BYTES;
let data_len = self.data.len();
if chunk_pos + 5 > data_len {
return Ok(None);
}
let length = u32::from_be_bytes([
self.data[chunk_pos],
self.data[chunk_pos + 1],
self.data[chunk_pos + 2],
self.data[chunk_pos + 3],
]) as usize;
let compression_type = self.data[chunk_pos + 4];
if length <= 1 {
return Ok(None);
}
let compressed_length = length - 1;
if chunk_pos + 5 + compressed_length > data_len {
return Ok(None);
}
let compressed_data = &self.data[chunk_pos + 5..chunk_pos + 5 + compressed_length];
let decompressed = match compression_type {
1 => {
let mut decoder = flate2::read::GzDecoder::new(compressed_data);
let mut buf = Vec::new();
decoder
.read_to_end(&mut buf)
.map_err(|e| format!("gzip decompress failed: {}", e))?;
buf
}
2 => {
let mut decoder = ZlibDecoder::new(compressed_data);
let mut buf = Vec::new();
decoder
.read_to_end(&mut buf)
.map_err(|e| format!("zlib decompress failed: {}", e))?;
buf
}
_ => return Ok(None),
};
if decompressed.is_empty() {
return Ok(None);
}
Ok(Some(decompressed))
}
}
pub struct JavaRegionFileWriter {
path: String,
buffer: Vec<u8>,
offsets: [i32; 1024],
timestamps: [i32; 1024],
next_sector: usize,
}
impl JavaRegionFileWriter {
pub fn new(path: &str) -> Self {
let buffer = vec![0u8; SECTOR_BYTES * 2];
let offsets = [0i32; 1024];
let timestamps = [0i32; 1024];
Self {
path: path.to_string(),
buffer,
offsets,
timestamps,
next_sector: 2,
}
}
pub fn load_from_file(path: &str) -> Self {
if let Ok(data) = fs::read(path) {
let buffer = data.clone();
let mut offsets = [0i32; 1024];
let mut timestamps = [0i32; 1024];
for i in 0..1024 {
let pos = i * 4;
if pos + 4 <= data.len() {
offsets[i] = i32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]);
}
}
for i in 0..1024 {
let pos = SECTOR_BYTES + i * 4;
if pos + 4 <= data.len() {
timestamps[i] = i32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]);
}
}
let mut max_sector = 2;
for &off in &offsets {
if off != 0 {
let sector = (off >> 8) as usize;
let count = (off & 0xFF) as usize;
let end = sector + count;
if end > max_sector {
max_sector = end;
}
}
}
Self {
path: path.to_string(),
buffer,
offsets,
timestamps,
next_sector: max_sector,
}
} else {
Self::new(path)
}
}
pub fn write_chunk(&mut self, local_x: i32, local_z: i32, uncompressed_nbt: &[u8]) {
if local_x < 0 || local_x > 31 || local_z < 0 || local_z > 31 {
return;
}
let compressed = compress_zlib(uncompressed_nbt);
let payload_length = 1 + compressed.len();
let total_length = 4 + payload_length;
let sectors_needed = (total_length + SECTOR_BYTES - 1) / SECTOR_BYTES;
if sectors_needed >= 256 {
return;
}
let sector_start = self.next_sector;
self.next_sector += sectors_needed;
while self.buffer.len() < (sector_start + sectors_needed) * SECTOR_BYTES {
self.buffer.extend(std::iter::repeat(0u8).take(SECTOR_BYTES));
}
let write_pos = sector_start * SECTOR_BYTES;
self.buffer[write_pos..write_pos + 4]
.copy_from_slice(&(payload_length as u32).to_be_bytes());
self.buffer[write_pos + 4] = 2;
self.buffer[write_pos + 5..write_pos + 5 + compressed.len()]
.copy_from_slice(&compressed);
let padding = sectors_needed * SECTOR_BYTES - total_length;
if padding > 0 {
for i in 0..padding {
self.buffer[write_pos + total_length + i] = 0;
}
}
let offset_index = (local_x & 31) + (local_z & 31) * 32;
self.offsets[offset_index as usize] = ((sector_start as i32) << 8) | (sectors_needed as i32);
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i32;
self.timestamps[offset_index as usize] = now;
}
pub fn save(&mut self) -> Result<(), String> {
while self.buffer.len() < self.next_sector * SECTOR_BYTES {
self.buffer.extend(std::iter::repeat(0u8).take(SECTOR_BYTES));
}
for i in 0..1024 {
let pos = i * 4;
self.buffer[pos..pos + 4].copy_from_slice(&(self.offsets[i] as u32).to_be_bytes());
}
for i in 0..1024 {
let pos = SECTOR_BYTES + i * 4;
self.buffer[pos..pos + 4]
.copy_from_slice(&(self.timestamps[i] as u32).to_be_bytes());
}
let total = self.next_sector * SECTOR_BYTES;
let data = &self.buffer[..total];
if let Some(parent) = Path::new(&self.path).parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create region directory: {}", e))?;
}
fs::write(&self.path, data)
.map_err(|e| format!("Failed to write region file: {}", e))?;
Ok(())
}
}
pub struct LceRegionFile {
filename: String,
data: Vec<u8>,
offsets: [i32; 1024],
timestamps: [i32; 1024],
region_x: i32,
region_z: i32,
sector_count: usize,
}
impl LceRegionFile {
pub fn new(filename: &str) -> Self {
let (region_x, region_z) = parse_region_coordinates(filename);
let data = vec![0u8; SECTOR_BYTES * 2];
Self {
filename: filename.to_string(),
data,
offsets: [0i32; 1024],
timestamps: [0i32; 1024],
region_x,
region_z,
sector_count: 2,
}
}
pub fn write_chunk(&mut self, x: i32, z: i32, mut uncompressed_data: Vec<u8>) {
if x < 0 || x >= 32 || z < 0 || z >= 32 {
return;
}
if uncompressed_data.is_empty() {
return;
}
uncompressed_data = force_chunk_coordinates(
&uncompressed_data,
self.region_x * 32 + x,
self.region_z * 32 + z,
);
if uncompressed_data.is_empty() {
return;
}
let compressed = compress_zlib_only(&uncompressed_data);
let total_size = 8 + compressed.len();
let sectors_needed = (total_size + SECTOR_BYTES - 1) / SECTOR_BYTES;
if sectors_needed >= 256 {
return;
}
let sector_number = self.sector_count;
self.sector_count += sectors_needed;
while self.data.len() < (sector_number + sectors_needed) * SECTOR_BYTES {
self.data.extend(std::iter::repeat(0u8).take(SECTOR_BYTES));
}
let write_pos = sector_number * SECTOR_BYTES;
let stored_length = compressed.len() as u32;
self.data[write_pos..write_pos + 4]
.copy_from_slice(&stored_length.to_le_bytes());
self.data[write_pos + 4..write_pos + 8]
.copy_from_slice(&(uncompressed_data.len() as u32).to_le_bytes());
self.data[write_pos + 8..write_pos + 8 + compressed.len()]
.copy_from_slice(&compressed);
let padding = sectors_needed * SECTOR_BYTES - total_size;
if padding > 0 {
for i in 0..padding {
self.data[write_pos + total_size + i] = 0;
}
}
let offset_index = (x + z * 32) as usize;
self.offsets[offset_index] = ((sector_number as i32) << 8) | (sectors_needed as i32);
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i32;
self.timestamps[offset_index] = now;
}
pub fn write_to_container(&mut self, container: &mut SaveDataContainer) {
let _sorted_entries: Vec<(usize, i32, i32)> = (0..1024)
.map(|i| (i, self.offsets[i], self.timestamps[i]))
.filter(|(_, off, _)| *off != 0)
.collect();
for i in 0..1024 {
let pos = i * 4;
self.data[pos..pos + 4].copy_from_slice(&(self.offsets[i] as u32).to_le_bytes());
}
for i in 0..1024 {
let pos = SECTOR_BYTES + i * 4;
self.data[pos..pos + 4]
.copy_from_slice(&(self.timestamps[i] as u32).to_le_bytes());
}
let total = self.sector_count * SECTOR_BYTES;
let region_bytes = self.data[..total].to_vec();
let entry = container.create_file(&self.filename);
container.write_to_file(entry, &region_bytes);
}
}
fn parse_region_coordinates(filename: &str) -> (i32, i32) {
let name = Path::new(filename)
.file_stem()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let parts: Vec<&str> = name.split('.').collect();
if parts.len() < 3 {
return (0, 0);
}
let rx = parts[parts.len() - 2].parse().unwrap_or(0);
let rz = parts[parts.len() - 1].parse().unwrap_or(0);
(rx, rz)
}
pub fn force_chunk_coordinates(data: &[u8], expected_x: i32, expected_z: i32) -> Vec<u8> {
if data.is_empty() {
return Vec::new();
}
if let Ok(mut compound) = nbt::read_nbt(data) {
let has_level = compound.get("Level").is_some();
if has_level {
if let Some(nbt::NbtValue::Compound(ref mut c)) = compound.get_mut("Level") {
c.insert("xPos", NbtValue::Int(expected_x));
c.insert("zPos", NbtValue::Int(expected_z));
}
} else {
compound.insert("xPos", NbtValue::Int(expected_x));
compound.insert("zPos", NbtValue::Int(expected_z));
}
let mut root = NbtCompound::new("");
root.insert("Level", NbtValue::Compound(compound));
nbt::write_nbt(&root)
} else if data.len() >= 10 {
let version = i16::from_be_bytes([data[0], data[1]]);
if version == 8 || version == 9 {
let mut patched = data.to_vec();
patched[2..6].copy_from_slice(&expected_x.to_be_bytes());
patched[6..10].copy_from_slice(&expected_z.to_be_bytes());
return patched;
}
Vec::new()
} else {
Vec::new()
}
}
pub struct SaveDataContainer {
original_save_version: i16,
current_save_version: i16,
entries: Vec<SaveFileEntry>,
blob: Vec<u8>,
data_end: usize,
}
pub struct SaveFileEntry {
pub name: String,
pub length: u32,
pub start_offset: u32,
pub last_mod: i64,
pub current_pointer: u32,
}
impl SaveDataContainer {
pub const FILE_ENTRY_SIZE: usize = 144;
pub fn new(original_save_version: i16, current_save_version: i16) -> Self {
Self {
original_save_version,
current_save_version,
entries: Vec::new(),
blob: vec![0u8; 2 * 1024 * 1024],
data_end: 12,
}
}
pub fn create_file(&mut self, name: &str) -> usize {
if let Some(idx) = self.entries.iter().position(|e| e.name == name) {
return idx;
}
let entry = SaveFileEntry {
name: name.to_string(),
length: 0,
start_offset: self.data_end as u32,
last_mod: 0,
current_pointer: self.data_end as u32,
};
self.entries.push(entry);
self.entries.len() - 1
}
pub fn write_to_file(&mut self, index: usize, data: &[u8]) {
if data.is_empty() {
return;
}
let needs_reinit = self.entries[index].length == 0;
if needs_reinit {
self.entries[index].start_offset = self.data_end as u32;
self.entries[index].current_pointer = self.data_end as u32;
}
let write_pos = self.entries[index].current_pointer as usize;
let end_pos = write_pos + data.len();
self.ensure_capacity(end_pos);
self.blob[write_pos..write_pos + data.len()].copy_from_slice(data);
self.entries[index].current_pointer += data.len() as u32;
let written = self.entries[index].current_pointer - self.entries[index].start_offset;
if written > self.entries[index].length {
self.entries[index].length = written;
}
let entry_end = (self.entries[index].start_offset + self.entries[index].length) as usize;
if entry_end > self.data_end {
self.data_end = entry_end;
}
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as i64;
self.entries[index].last_mod = now;
}
pub fn save(&mut self, output_path: &str) -> Result<(), String> {
self.write_header();
let total_size = self.data_end + self.entries.len() * Self::FILE_ENTRY_SIZE;
self.ensure_capacity(total_size);
self.write_footer();
let raw_blob = self.blob[..total_size].to_vec();
let mut encoder = ZlibEncoder::new(Vec::new(), Compression::best());
encoder
.write_all(&raw_blob)
.map_err(|e| format!("zlib compress failed: {}", e))?;
let compressed_data = encoder
.finish()
.map_err(|e| format!("zlib finish failed: {}", e))?;
if let Some(parent) = Path::new(output_path).parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create output directory: {}", e))?;
}
let mut file = fs::File::create(output_path)
.map_err(|e| format!("Failed to create output file: {}", e))?;
file.write_all(&(0u32).to_le_bytes())
.map_err(|e| format!("write failed: {}", e))?;
file.write_all(&(total_size as u32).to_le_bytes())
.map_err(|e| format!("write failed: {}", e))?;
file.write_all(&compressed_data)
.map_err(|e| format!("write failed: {}", e))?;
Ok(())
}
fn write_header(&mut self) {
self.blob[0..4]
.copy_from_slice(&(self.data_end as u32).to_le_bytes());
self.blob[4..8]
.copy_from_slice(&(self.entries.len() as u32).to_le_bytes());
self.blob[8..10]
.copy_from_slice(&self.original_save_version.to_le_bytes());
self.blob[10..12]
.copy_from_slice(&self.current_save_version.to_le_bytes());
}
fn write_footer(&mut self) {
let mut sorted: Vec<usize> = (0..self.entries.len()).collect();
sorted.sort_by_key(|&i| self.entries[i].start_offset);
let mut pos = self.data_end;
for &i in &sorted {
let entry = &self.entries[i];
let mut name_bytes = vec![0u8; 128];
let encoded: Vec<u8> = entry
.name
.encode_utf16()
.flat_map(|c| c.to_le_bytes())
.collect();
let copy_len = encoded.len().min(126);
name_bytes[..copy_len].copy_from_slice(&encoded[..copy_len]);
self.blob[pos..pos + 128].copy_from_slice(&name_bytes);
pos += 128;
self.blob[pos..pos + 4].copy_from_slice(&entry.length.to_le_bytes());
pos += 4;
self.blob[pos..pos + 4].copy_from_slice(&entry.start_offset.to_le_bytes());
pos += 4;
self.blob[pos..pos + 8].copy_from_slice(&entry.last_mod.to_le_bytes());
pos += 8;
}
}
fn ensure_capacity(&mut self, required: usize) {
if required <= self.blob.len() {
return;
}
let mut new_size = self.blob.len();
while new_size < required {
new_size *= 2;
}
self.blob.resize(new_size, 0);
}
}
pub fn compress_zlib(data: &[u8]) -> Vec<u8> {
let mut encoder = ZlibEncoder::new(Vec::new(), Compression::best());
encoder.write_all(data).unwrap();
encoder.finish().unwrap()
}
pub fn compress_zlib_only(data: &[u8]) -> Vec<u8> {
compress_zlib(data)
}
pub fn decompress_zlib(data: &[u8]) -> Result<Vec<u8>, String> {
let mut decoder = ZlibDecoder::new(data);
let mut buf = Vec::new();
decoder.read_to_end(&mut buf).map_err(|e| format!("zlib decompress failed: {}", e))?;
Ok(buf)
}
pub fn decompress_rle_zlib(data: &[u8], decompressed_size: usize) -> Result<Vec<u8>, String> {
let rle_data = decompress_zlib(data)?;
Ok(rle_decode(&rle_data, decompressed_size))
}
pub fn rle_encode(data: &[u8]) -> Vec<u8> {
let mut out = Vec::new();
let mut i = 0;
while i < data.len() {
let current = data[i];
i += 1;
let mut count = 1usize;
while i < data.len() && data[i] == current && count < 256 {
i += 1;
count += 1;
}
if count <= 3 {
if current == 0xFF {
out.push(0xFF);
out.push((count - 1) as u8);
} else {
for _ in 0..count {
out.push(current);
}
}
} else {
out.push(0xFF);
out.push((count - 1) as u8);
out.push(current);
}
}
out
}
pub fn rle_decode(data: &[u8], expected_size: usize) -> Vec<u8> {
let mut output = vec![0u8; expected_size];
let mut in_pos = 0;
let mut out_pos = 0;
while in_pos < data.len() && out_pos < expected_size {
let current = data[in_pos];
in_pos += 1;
if current == 0xFF {
if in_pos >= data.len() {
break;
}
let count = data[in_pos] as usize;
in_pos += 1;
if count < 3 {
let run = count + 1;
for _ in 0..run {
if out_pos < expected_size {
output[out_pos] = 0xFF;
out_pos += 1;
}
}
} else {
let run = count + 1;
if in_pos >= data.len() {
break;
}
let value = data[in_pos];
in_pos += 1;
for _ in 0..run {
if out_pos < expected_size {
output[out_pos] = value;
out_pos += 1;
}
}
}
} else {
output[out_pos] = current;
out_pos += 1;
}
}
output
}

View file

@ -4,6 +4,7 @@ pub mod dlc;
pub mod download;
pub mod file_dialogs;
pub mod game;
pub mod java2lce;
pub mod macos_setup;
pub mod plugins;
pub mod proxy_cmd;

View file

@ -108,6 +108,9 @@ pub fn run() {
game::backup_instance,
game::restore_instance,
commands::console2lce::import_world,
commands::console2lce::import_lce_save,
commands::java2lce::java_to_lce,
commands::java2lce::lce_to_java,
stun::stun_discover,
relay::start_relay_proxy,
relay::start_host_relay,

View file

@ -1,8 +1,6 @@
use tauri::State;
use tauri::webview::cookie::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use tokio::time::sleep;
use tokio_util::sync::CancellationToken;
use crate::state::ProxyGuard;
const PROXY_ADDR: &str = "proxy.mclegacyedition.xyz:2052"; //neo: yeah bro im hardcoding it

View file

@ -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,129 @@ 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 handleImportJava = async () => {
if (!targetInstanceId) return;
playPressSound();
setIsImporting(true);
setError("");
setStatus("Selecting source...");
try {
setStatus("Selecting Java world folder...");
const picked = await TauriService.pickFolder();
if (!picked) {
setIsImporting(false);
return;
}
const worldName = deriveWorldName(picked);
setStatus("Converting Java world to LCE...");
const instancePath = await TauriService.getInstancePath(targetInstanceId);
const saveDir = `${instancePath}/Windows64/GameHDD/${worldName}`;
await TauriService.javaToLce(picked, `${saveDir}/saveData.ms`);
setStatus(`Java world converted into "${targetInstanceName}"!`);
setTimeout(() => {
onClose();
}, 2000);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : String(e));
setStatus("");
setIsImporting(false);
}
};
const handleExportJava = async () => {
if (!targetInstanceId) return;
playPressSound();
setIsImporting(true);
setError("");
setStatus("Selecting source...");
try {
setStatus("Selecting saveData.ms file...");
const picked = await TauriService.pickFile("Select saveData.ms", [
"*.ms",
"*",
]);
if (!picked) {
setIsImporting(false);
return;
}
setStatus("Selecting output folder for Java world...");
const outputFolder = await TauriService.pickFolder();
if (!outputFolder) {
setIsImporting(false);
return;
}
setStatus("Converting LCE save to Java world...");
await TauriService.lceToJava(picked, outputFolder);
setStatus(`Java world exported to "${outputFolder}"!`);
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 +224,77 @@ export default function ImportWorldModal({
Import into:{" "}
<span className="text-[#FFFF55]">{targetInstanceName}</span>
</p>
<p className="text-gray-400 text-xs mc-text-shadow mb-4 text-center">
Select an existing LCE save (.ms file or GameHDD folder)
<p className="text-gray-400 text-xs mc-text-shadow mb-2 text-center">
Import an existing .ms save file:
</p>
<button
onClick={handleImportMs}
className="w-48 h-9 flex items-center justify-center text-sm text-white mc-text-shadow hover:text-[#FFFF55] mb-4"
style={{
backgroundImage: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
Select .ms File
</button>
<p className="text-gray-400 text-xs mc-text-shadow mb-2 text-center">
Convert an Xbox 360 or PS3 save:
</p>
<div className="flex gap-3 mb-2">
<button
onClick={handleImportXbox}
className="w-40 h-9 flex items-center justify-center text-sm text-white mc-text-shadow hover:text-[#FFFF55]"
style={{
backgroundImage: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
Xbox 360 (.bin)
</button>
<button
onClick={handleImportPs3}
className="w-40 h-9 flex items-center justify-center text-sm text-white mc-text-shadow hover:text-[#FFFF55]"
style={{
backgroundImage: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
PS3 (Folder)
</button>
</div>
<p className="text-gray-400 text-xs mc-text-shadow mb-2 text-center">
Java Edition world conversion:
</p>
<div className="flex gap-3 mb-2">
<button
onClick={handleImportJava}
className="w-40 h-9 flex items-center justify-center text-sm text-white mc-text-shadow hover:text-[#FFFF55]"
style={{
backgroundImage: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
Java LCE
</button>
<button
onClick={handleExportJava}
className="w-40 h-9 flex items-center justify-center text-sm text-white mc-text-shadow hover:text-[#FFFF55]"
style={{
backgroundImage: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
LCE Java
</button>
</div>
{error && (
<div className="text-red-500 text-center mc-text-shadow uppercase text-xs tracking-widest mb-3">
@ -114,33 +302,20 @@ export default function ImportWorldModal({
</div>
)}
<div className="flex gap-4 w-full justify-center">
<button
onClick={() => {
playBackSound();
onClose();
}}
className="w-32 h-10 flex items-center justify-center text-xl text-white mc-text-shadow"
style={{
backgroundImage: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
Cancel
</button>
<button
onClick={handleImport}
className="w-40 h-10 flex items-center justify-center text-xl text-white mc-text-shadow hover:text-[#FFFF55]"
style={{
backgroundImage: "url('/images/button_highlighted.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
Select File
</button>
</div>
<button
onClick={() => {
playBackSound();
onClose();
}}
className="w-32 h-10 flex items-center justify-center text-xl text-white mc-text-shadow mt-2"
style={{
backgroundImage: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
Cancel
</button>
</>
) : (
<>

View file

@ -25,7 +25,7 @@ const LceOnlineView = memo(function LceOnlineView({
onClearAddFriendTarget,
invites: invitesProp,
}: LceOnlineViewProps) {
const { setActiveView } = useUI();
const { setActiveView, setIsUiHidden } = useUI();
const { animationsEnabled } = useConfig();
const { playPressSound, playBackSound } = useAudio();
const game = useGame();
@ -364,6 +364,122 @@ const LceOnlineView = memo(function LceOnlineView({
}
}, [focusIndex, isAddingFriend]);
const touhouOverlayRef = useRef<HTMLDivElement | null>(null);
const touhouBlobUrlRef = useRef<string | null>(null);
const touhouAudioRef = useRef<HTMLAudioElement | null>(null);
useEffect(() => {
const GIF_URL =
"https://raw.githubusercontent.com/neoapps-dev/neoapps-dev/main/badapple_small.gif";
const MP3_URL =
"https://raw.githubusercontent.com/Soldr/bad-apple-but-its-node.js/master/bad-apple.mp3";
const stopAudio = () => {
if (touhouAudioRef.current) {
touhouAudioRef.current.pause();
touhouAudioRef.current.src = "";
touhouAudioRef.current = null;
}
};
const onlineUser = lceOnlineService.account?.username;
if (onlineUser === "TOUHOU") {
if (!touhouOverlayRef.current) {
setIsUiHidden(true);
const overlay = document.createElement("div");
overlay.style.cssText =
"position:fixed;inset:0;z-index:99999;background:#000;display:flex;align-items:center;justify-content:center;cursor:pointer";
const spinner = document.createElement("div");
spinner.textContent = "Loading...";
spinner.style.cssText =
"color:#fff;font-family:'Mojangles',monospace;font-size:24px;letter-spacing:4px";
overlay.appendChild(spinner);
document.body.appendChild(overlay);
const img = document.createElement("img");
img.style.cssText = "width:100%;height:100%;object-fit:contain";
const audio = new Audio(MP3_URL);
audio.loop = true;
audio.volume = 0.5;
touhouAudioRef.current = audio;
const audioReady = new Promise<void>((resolve) => {
if (audio.readyState >= 3) resolve();
else {
audio.oncanplaythrough = () => resolve();
audio.onerror = () => resolve();
}
});
const gifReady = fetch(GIF_URL)
.then((r) => r.arrayBuffer())
.then((buf) => {
const blob = new Blob([buf], { type: "image/gif" });
const url = URL.createObjectURL(blob);
touhouBlobUrlRef.current = url;
img.src = url;
return new Promise<void>((resolve) => {
if (img.complete) resolve();
else {
img.onload = () => resolve();
img.onerror = () => resolve();
}
});
})
.catch(() => {
img.src = GIF_URL;
return new Promise<void>((resolve) => {
if (img.complete) resolve();
else {
img.onload = () => resolve();
img.onerror = () => resolve();
}
});
});
Promise.all([gifReady, audioReady]).then(() => {
spinner.remove();
overlay.appendChild(img);
audio.currentTime = 2;
audio.play().catch(() => {});
});
const cleanup = () => {
stopAudio();
setIsUiHidden(false);
if (overlay.parentNode) overlay.remove();
touhouOverlayRef.current = null;
if (touhouBlobUrlRef.current) {
URL.revokeObjectURL(touhouBlobUrlRef.current);
touhouBlobUrlRef.current = null;
}
};
overlay.onclick = cleanup;
touhouOverlayRef.current = overlay;
}
} else {
if (touhouOverlayRef.current) {
stopAudio();
touhouOverlayRef.current.remove();
touhouOverlayRef.current = null;
if (touhouBlobUrlRef.current) {
URL.revokeObjectURL(touhouBlobUrlRef.current);
touhouBlobUrlRef.current = null;
}
setIsUiHidden(false);
}
}
return () => {
stopAudio();
setIsUiHidden(false);
if (touhouOverlayRef.current) {
touhouOverlayRef.current.remove();
touhouOverlayRef.current = null;
if (touhouBlobUrlRef.current) {
URL.revokeObjectURL(touhouBlobUrlRef.current);
touhouBlobUrlRef.current = null;
}
}
};
}, [isSignedIn, setIsUiHidden]);
const renderContent = () => {
if (!isSignedIn) {
return (

View file

@ -71,7 +71,7 @@ export const BASE_EDITIONS = [
id: "moon_edition",
name: "Minecraft: Moon Edition",
desc: "Galacticraft LCE port (Modded build!)",
url: "https://github.com/blazin-blaze/moon-edition/releases/download/v1.0.1/moonEditionWindows64.zip",
url: "https://github.com/blazin-blaze/moon-edition/releases/latest/download/moonEditionWindows64.zip",
titleImage: "/images/minecraft_title_moon.png",
supportsSlimSkins: false,
logo: "/images/moonEdition.png",
@ -660,4 +660,4 @@ export function useGameManager({
updateCustomization,
saveCustomPath,
};
}
}

View file

@ -420,4 +420,25 @@ export class TauriService {
): Promise<string> {
return invoke("import_world", { inputPath, outputPath });
}
static async importLceSave(
inputPath: string,
outputDir: string,
): Promise<string> {
return invoke("import_lce_save", { inputPath, outputDir });
}
static async javaToLce(
javaWorldPath: string,
outputMsPath: string,
): Promise<string> {
return invoke("java_to_lce", { javaWorldPath, outputMsPath });
}
static async lceToJava(
inputMsPath: string,
javaWorldOutput: string,
): Promise<string> {
return invoke("lce_to_java", { inputMsPath, javaWorldOutput });
}
}