diff --git a/crates/pumpkin-world/src/world_info/anvil.rs b/crates/pumpkin-world/src/world_info/anvil.rs index c49fca4d4..24305b867 100644 --- a/crates/pumpkin-world/src/world_info/anvil.rs +++ b/crates/pumpkin-world/src/world_info/anvil.rs @@ -1,23 +1,29 @@ use std::{ fs::File, - io::{Cursor, Read}, + io::ErrorKind, path::Path, time::{SystemTime, UNIX_EPOCH}, }; use tracing::error; -use flate2::read::GzDecoder; +use pumpkin_nbt::{ + compound::NbtCompound, + nbt_compress::{read_gzip_compound_tag, write_gzip_compound_tag}, + tag::NbtTag, +}; +use pumpkin_util::{Difficulty, world_seed::Seed}; use serde::{Deserialize, Serialize}; use crate::world_info::{ - MAXIMUM_SUPPORTED_LEVEL_VERSION, MAXIMUM_SUPPORTED_WORLD_DATA_VERSION, - MINIMUM_SUPPORTED_LEVEL_VERSION, MINIMUM_SUPPORTED_WORLD_DATA_VERSION, + DataPacks, MAXIMUM_SUPPORTED_LEVEL_VERSION, MAXIMUM_SUPPORTED_WORLD_DATA_VERSION, + MINIMUM_SUPPORTED_LEVEL_VERSION, MINIMUM_SUPPORTED_WORLD_DATA_VERSION, WorldVersion, data_files::{ minecraft_data_dir, read_game_rules, read_wandering_trader, read_weather, read_world_clocks, read_world_gen_settings, write_custom_boss_events_stub, write_game_rules, write_scheduled_events_stub, write_wandering_trader, write_weather, write_world_clocks, write_world_gen_settings, }, + default_data_packs, }; use super::{LevelData, WorldInfoError, WorldInfoReader, WorldInfoWriter}; @@ -25,20 +31,13 @@ use super::{LevelData, WorldInfoError, WorldInfoReader, WorldInfoWriter}; pub const LEVEL_DAT_FILE_NAME: &str = "level.dat"; pub const LEVEL_DAT_BACKUP_FILE_NAME: &str = "level.dat_old"; +const LEVEL_DATA_TAG: &str = "Data"; +const WORLD_GEN_SETTINGS_TAG: &str = "WorldGenSettings"; + pub struct AnvilLevelInfo; -fn check_file_data_version(raw_nbt: &[u8]) -> Result<(), WorldInfoError> { - let mut cursor = Cursor::new(raw_nbt); - let mut reader = pumpkin_nbt::deserializer::NbtReadHelperJava::new( - pumpkin_nbt::deserializer::NbtStreamReader(&mut cursor), - ); - let nbt = pumpkin_nbt::Nbt::read(&mut reader) - .map_err(|e| WorldInfoError::DeserializationError(e.to_string()))?; - let data_version = nbt - .get_compound("Data") - .and_then(|c| c.get_int("DataVersion")); - - let Some(data_version) = data_version else { +fn check_data_version(data: &NbtCompound) -> Result<(), WorldInfoError> { + let Some(data_version) = data.get_int("DataVersion") else { error!( "The level.dat file does not have a data version! This means it is either corrupt or very old (read unsupported)" ); @@ -56,16 +55,8 @@ fn check_file_data_version(raw_nbt: &[u8]) -> Result<(), WorldInfoError> { } } -fn check_file_level_version(raw_nbt: &[u8]) -> Result<(), WorldInfoError> { - let mut cursor = Cursor::new(raw_nbt); - let mut reader = pumpkin_nbt::deserializer::NbtReadHelperJava::new( - pumpkin_nbt::deserializer::NbtStreamReader(&mut cursor), - ); - let nbt = pumpkin_nbt::Nbt::read(&mut reader) - .map_err(|e| WorldInfoError::DeserializationError(e.to_string()))?; - let level_version = nbt.get_compound("Data").and_then(|c| c.get_int("version")); - - let Some(level_version) = level_version else { +fn check_level_version(data: &NbtCompound) -> Result<(), WorldInfoError> { + let Some(level_version) = data.get_int("version") else { error!( "The level.dat file does not have a level version! This means it is either corrupt or very old (read unsupported)" ); @@ -82,19 +73,250 @@ fn check_file_level_version(raw_nbt: &[u8]) -> Result<(), WorldInfoError> { } } +const fn difficulty_from_id(id: i8) -> Option { + match id { + 0 => Some(Difficulty::Peaceful), + 1 => Some(Difficulty::Easy), + 2 => Some(Difficulty::Normal), + 3 => Some(Difficulty::Hard), + _ => None, + } +} + +fn strings_from_nbt(tags: &[NbtTag]) -> Vec { + tags.iter() + .filter_map(|tag| tag.extract_string().map(ToString::to_string)) + .collect() +} + +fn strings_to_nbt(values: &[String]) -> Vec { + values + .iter() + .map(|value| NbtTag::from(value.as_str())) + .collect() +} + +fn world_version_from_nbt(version: &NbtCompound) -> WorldVersion { + let mut world_version = WorldVersion::default(); + if let Some(name) = version.get_string("Name") { + world_version.name = name.to_string(); + } + if let Some(id) = version.get_int("Id") { + world_version.id = id; + } + if let Some(snapshot) = version.get_bool("Snapshot") { + world_version.snapshot = snapshot; + } + if let Some(series) = version.get_string("Series") { + world_version.series = series.to_string(); + } + world_version +} + +fn world_version_to_nbt(version: &WorldVersion) -> NbtCompound { + let mut compound = NbtCompound::new(); + compound.put_string("Name", version.name.clone()); + compound.put_int("Id", version.id); + compound.put_bool("Snapshot", version.snapshot); + compound.put_string("Series", version.series.clone()); + compound +} + +fn data_packs_from_nbt(packs: &NbtCompound) -> DataPacks { + let mut data_packs = default_data_packs(); + if let Some(disabled) = packs.get_list("Disabled") { + data_packs.disabled = strings_from_nbt(disabled); + } + if let Some(enabled) = packs.get_list("Enabled") { + data_packs.enabled = strings_from_nbt(enabled); + } + data_packs +} + +fn data_packs_to_nbt(packs: &DataPacks) -> NbtCompound { + let mut compound = NbtCompound::new(); + compound.put_list("Disabled", strings_to_nbt(&packs.disabled)); + compound.put_list("Enabled", strings_to_nbt(&packs.enabled)); + compound +} + +fn stored_world_seed(level_folder: &Path, data: &NbtCompound) -> Option { + read_world_gen_settings(level_folder) + .map(|settings| settings.seed) + .or_else(|| { + data.get_compound(WORLD_GEN_SETTINGS_TAG) + .and_then(|settings| settings.get_long("seed")) + }) +} + +fn put_world_gen_settings_seed(data: &mut NbtCompound, seed: i64) { + let mut world_gen_settings = data + .get_compound(WORLD_GEN_SETTINGS_TAG) + .cloned() + .unwrap_or_default(); + world_gen_settings.put_long("seed", seed); + data.put_compound(WORLD_GEN_SETTINGS_TAG, world_gen_settings); +} + +fn update_world_border_from_nbt(level_data: &mut LevelData, data: &NbtCompound) { + if let Some(border_center_x) = data.get_double("BorderCenterX") { + level_data.border_center_x = border_center_x; + } + if let Some(border_center_z) = data.get_double("BorderCenterZ") { + level_data.border_center_z = border_center_z; + } + if let Some(border_damage_per_block) = data.get_double("BorderDamagePerBlock") { + level_data.border_damage_per_block = border_damage_per_block; + } + if let Some(border_size) = data.get_double("BorderSize") { + level_data.border_size = border_size; + } + if let Some(border_safe_zone) = data.get_double("BorderSafeZone") { + level_data.border_safe_zone = border_safe_zone; + } + if let Some(border_size_lerp_target) = data.get_double("BorderSizeLerpTarget") { + level_data.border_size_lerp_target = border_size_lerp_target; + } + if let Some(border_size_lerp_time) = data.get_long("BorderSizeLerpTime") { + level_data.border_size_lerp_time = border_size_lerp_time; + } + if let Some(border_warning_blocks) = data.get_double("BorderWarningBlocks") { + level_data.border_warning_blocks = border_warning_blocks; + } + if let Some(border_warning_time) = data.get_double("BorderWarningTime") { + level_data.border_warning_time = border_warning_time; + } +} + +fn update_spawn_from_nbt(level_data: &mut LevelData, data: &NbtCompound) { + if let Some(spawn_x) = data.get_int("SpawnX") { + level_data.spawn_x = spawn_x; + } + if let Some(spawn_y) = data.get_int("SpawnY") { + level_data.spawn_y = spawn_y; + } + if let Some(spawn_z) = data.get_int("SpawnZ") { + level_data.spawn_z = spawn_z; + } + if let Some(spawn_yaw) = data + .get_float("SpawnAngle") + .or_else(|| data.get_float("SpawnYaw")) + { + level_data.spawn_yaw = spawn_yaw; + } + if let Some(spawn_pitch) = data.get_float("SpawnPitch") { + level_data.spawn_pitch = spawn_pitch; + } +} + +fn level_data_from_nbt(data: &NbtCompound, seed: i64) -> LevelData { + let mut level_data = LevelData::default(Seed(seed as u64)); + + update_world_border_from_nbt(&mut level_data, data); + update_spawn_from_nbt(&mut level_data, data); + + if let Some(allow_commands) = data.get_bool("allowCommands") { + level_data.allow_commands = allow_commands; + } + if let Some(data_packs) = data.get_compound("DataPacks") { + level_data.data_packs = data_packs_from_nbt(data_packs); + } + if let Some(data_version) = data.get_int("DataVersion") { + level_data.data_version = data_version; + } + if let Some(difficulty) = data.get_byte("Difficulty").and_then(difficulty_from_id) { + level_data.difficulty = difficulty; + } + if let Some(difficulty_locked) = data.get_bool("DifficultyLocked") { + level_data.difficulty_locked = difficulty_locked; + } + if let Some(last_played) = data.get_long("LastPlayed") { + level_data.last_played = last_played; + } + if let Some(level_name) = data.get_string("LevelName") { + level_data.level_name = level_name.to_string(); + } + if let Some(world_version) = data.get_compound("Version") { + level_data.world_version = world_version_from_nbt(world_version); + } + if let Some(level_version) = data.get_int("version") { + level_data.level_version = level_version; + } + if let Some(map_id) = data.get_int("map_id") { + level_data.map_id = map_id; + } + if let Some(day_time) = data.get_long("DayTime") { + level_data.day_time = day_time; + } + if let Some(clear_weather_time) = data.get_int("clearWeatherTime") { + level_data.clear_weather_time = clear_weather_time; + } + + level_data +} + +fn level_data_to_nbt(info: &LevelData, data: &mut NbtCompound) { + data.put_bool("allowCommands", info.allow_commands); + data.put_double("BorderCenterX", info.border_center_x); + data.put_double("BorderCenterZ", info.border_center_z); + data.put_double("BorderDamagePerBlock", info.border_damage_per_block); + data.put_double("BorderSize", info.border_size); + data.put_double("BorderSafeZone", info.border_safe_zone); + data.put_double("BorderSizeLerpTarget", info.border_size_lerp_target); + data.put_long("BorderSizeLerpTime", info.border_size_lerp_time); + data.put_double("BorderWarningBlocks", info.border_warning_blocks); + data.put_double("BorderWarningTime", info.border_warning_time); + data.put_compound("DataPacks", data_packs_to_nbt(&info.data_packs)); + data.put_int("DataVersion", info.data_version); + data.put_byte("Difficulty", info.difficulty as i8); + data.put_bool("DifficultyLocked", info.difficulty_locked); + data.put_long("LastPlayed", info.last_played); + data.put_string("LevelName", info.level_name.clone()); + data.put_int("SpawnX", info.spawn_x); + data.put_int("SpawnY", info.spawn_y); + data.put_int("SpawnZ", info.spawn_z); + data.put_float("SpawnAngle", info.spawn_yaw); + data.put_float("SpawnPitch", info.spawn_pitch); + data.put_compound("Version", world_version_to_nbt(&info.world_version)); + data.put_int("version", info.level_version); + data.put_int("map_id", info.map_id); + put_world_gen_settings_seed(data, info.world_gen_settings.seed); +} + +fn stamp_current_version(level_data: &mut LevelData) { + level_data.data_version = MAXIMUM_SUPPORTED_WORLD_DATA_VERSION; + level_data.level_version = MAXIMUM_SUPPORTED_LEVEL_VERSION; + level_data.world_version = WorldVersion::default(); +} + +fn existing_level_dat_root(path: &Path) -> Result { + match File::open(path) { + Ok(file) => read_gzip_compound_tag(file) + .map_err(|e| WorldInfoError::DeserializationError(e.to_string())), + Err(e) if e.kind() == ErrorKind::NotFound => Ok(NbtCompound::new()), + Err(e) => Err(e.into()), + } +} + impl WorldInfoReader for AnvilLevelInfo { fn read_world_info(&self, level_folder: &Path) -> Result { let path = level_folder.join(LEVEL_DAT_FILE_NAME); - let world_info_file = File::open(path)?; - let mut buf = Vec::new(); - GzDecoder::new(world_info_file).read_to_end(&mut buf)?; + let root = read_gzip_compound_tag(File::open(path)?) + .map_err(|e| WorldInfoError::DeserializationError(e.to_string()))?; + let Some(data) = root.get_compound(LEVEL_DATA_TAG) else { + error!("The level.dat file has no {LEVEL_DATA_TAG} compound and is therefore corrupt"); + return Err(WorldInfoError::DeserializationError("Missing Data".into())); + }; - check_file_data_version(&buf)?; - check_file_level_version(&buf)?; + check_data_version(data)?; + check_level_version(data)?; - // For now, construct a default LevelData or parse manually - let mut level_data = LevelData::default(pumpkin_util::world_seed::Seed(0)); + let Some(seed) = stored_world_seed(level_folder, data) else { + return Err(WorldInfoError::MissingWorldSeed); + }; + + let mut level_data = level_data_from_nbt(data, seed); // game_rules.dat – prefer the new file; fall back to level.dat values if minecraft_data_dir(level_folder) @@ -123,15 +345,6 @@ impl WorldInfoReader for AnvilLevelInfo { level_data.clear_weather_time = weather.clear_weather_time; } - // world_gen_settings.dat - if minecraft_data_dir(level_folder) - .join("world_gen_settings.dat") - .exists() - && let Some(wgs) = read_world_gen_settings(level_folder) - { - level_data.world_gen_settings = wgs; - } - Ok(level_data) } } @@ -148,23 +361,23 @@ impl WorldInfoWriter for AnvilLevelInfo { .expect("Time went backwards"); let mut level_data = info.clone(); level_data.last_played = since_the_epoch.as_millis() as i64; + stamp_current_version(&mut level_data); // ── Write level.dat ─────────────────────────────────────────────────── let path = level_folder.join(LEVEL_DAT_FILE_NAME); - let world_info_file = File::create(path)?; - let mut data_comp = pumpkin_nbt::compound::NbtCompound::new(); - data_comp.put_int("DataVersion", level_data.data_version); - data_comp.put_int("version", MAXIMUM_SUPPORTED_LEVEL_VERSION); - data_comp.put_long("LastPlayed", level_data.last_played); + let mut root = existing_level_dat_root(&path)?; + let mut data_comp = root + .get_compound(LEVEL_DATA_TAG) + .cloned() + .unwrap_or_default(); + level_data_to_nbt(&level_data, &mut data_comp); + root.put_compound(LEVEL_DATA_TAG, data_comp); - let mut root = pumpkin_nbt::compound::NbtCompound::new(); - root.put_compound("Data", data_comp); - - pumpkin_nbt::nbt_compress::write_gzip_compound_tag(root, world_info_file) + write_gzip_compound_tag(root, File::create(path)?) .map_err(|e| WorldInfoError::SerializationError(e.to_string()))?; - let data_version = info.data_version; + let data_version = level_data.data_version; // ── Write data/minecraft/*.dat files ───────────────────────────────── @@ -179,11 +392,6 @@ impl WorldInfoWriter for AnvilLevelInfo { { error!("Failed to write world_gen_settings.dat: {e}"); } - if let Err(e) = - write_world_gen_settings(level_folder, &info.world_gen_settings, data_version) - { - error!("Failed to write world_gen_settings.dat: {e}"); - } // world_clocks.dat – persist the overworld day_time; preserve other let mut clocks = read_world_clocks(level_folder); @@ -240,13 +448,76 @@ pub struct LevelDat { mod test { use pumpkin_data::game_rules::GameRuleRegistry; + use pumpkin_nbt::{ + compound::NbtCompound, + nbt_compress::{read_gzip_compound_tag, write_gzip_compound_tag}, + tag::NbtTag, + }; use pumpkin_util::{Difficulty, world_seed::Seed}; - use std::sync::LazyLock; + use std::{ + fs::{self, File}, + path::Path, + sync::LazyLock, + }; use tempfile::TempDir; - use crate::world_info::{DataPacks, LevelData, WorldGenSettings, WorldVersion}; + use crate::{ + CURRENT_MC_VERSION, + world_info::{ + DataPacks, LevelData, MAXIMUM_SUPPORTED_LEVEL_VERSION, + MAXIMUM_SUPPORTED_WORLD_DATA_VERSION, MINIMUM_SUPPORTED_LEVEL_VERSION, + MINIMUM_SUPPORTED_WORLD_DATA_VERSION, WorldGenSettings, WorldInfoError, WorldVersion, + data_files::minecraft_data_dir, + }, + }; - use super::{AnvilLevelInfo, LevelDat, WorldInfoReader, WorldInfoWriter}; + use super::{AnvilLevelInfo, LEVEL_DAT_FILE_NAME, LevelDat, WorldInfoReader, WorldInfoWriter}; + + const CONVERTED_LEVEL_NAME: &str = "Converted World"; + + fn converted_level_dat(seed: Option) -> NbtCompound { + let mut world_version = NbtCompound::new(); + world_version.put_string("Name", "1.21.9".to_string()); + world_version.put_int("Id", MINIMUM_SUPPORTED_WORLD_DATA_VERSION); + + let mut data_packs = NbtCompound::new(); + data_packs.put_list("Enabled", vec![NbtTag::from("vanilla")]); + + let mut data = NbtCompound::new(); + data.put_int("DataVersion", MINIMUM_SUPPORTED_WORLD_DATA_VERSION); + data.put_int("version", MINIMUM_SUPPORTED_LEVEL_VERSION); + data.put_string("LevelName", CONVERTED_LEVEL_NAME.to_string()); + data.put_bool("allowCommands", true); + data.put_byte("Difficulty", Difficulty::Hard as i8); + data.put_int("SpawnX", 128); + data.put_int("SpawnY", 72); + data.put_int("SpawnZ", -64); + data.put_float("SpawnAngle", 90.0); + data.put_double("BorderSize", 1024.0); + data.put_long("DayTime", 5000); + data.put_bool("hardcore", true); + data.put_compound("Version", world_version); + data.put_compound("DataPacks", data_packs); + if let Some(seed) = seed { + let mut world_gen_settings = NbtCompound::new(); + world_gen_settings.put_long("seed", seed); + data.put_compound("WorldGenSettings", world_gen_settings); + } + + let mut root = NbtCompound::new(); + root.put_compound("Data", data); + root + } + + fn write_level_dat(level_folder: &Path, root: NbtCompound) { + let file = File::create(level_folder.join(LEVEL_DAT_FILE_NAME)).unwrap(); + write_gzip_compound_tag(root, file).unwrap(); + } + + fn read_level_dat(level_folder: &Path) -> NbtCompound { + let file = File::open(level_folder.join(LEVEL_DAT_FILE_NAME)).unwrap(); + read_gzip_compound_tag(file).unwrap() + } #[test] fn preserve_level_dat_seed() { @@ -265,6 +536,186 @@ mod test { assert_eq!(data.world_gen_settings.seed, seed); } + #[test] + fn read_level_dat_without_series() { + let temp_dir = TempDir::new().unwrap(); + write_level_dat(temp_dir.path(), converted_level_dat(Some(987_654_321))); + + let data = AnvilLevelInfo.read_world_info(temp_dir.path()).unwrap(); + + assert_eq!(data.world_gen_settings.seed, 987_654_321); + assert_eq!((data.spawn_x, data.spawn_y, data.spawn_z), (128, 72, -64)); + assert_eq!(data.spawn_yaw, 90.0); + assert_eq!(data.level_name, CONVERTED_LEVEL_NAME); + assert_eq!(data.difficulty, Difficulty::Hard); + assert!(data.allow_commands); + assert_eq!(data.border_size, 1024.0); + assert_eq!(data.border_safe_zone, 5.0); + assert_eq!(data.day_time, 5000); + assert_eq!(data.data_version, MINIMUM_SUPPORTED_WORLD_DATA_VERSION); + assert_eq!(data.level_version, MINIMUM_SUPPORTED_LEVEL_VERSION); + assert_eq!(data.world_version.name, "1.21.9"); + assert_eq!(data.world_version.id, MINIMUM_SUPPORTED_WORLD_DATA_VERSION); + assert_eq!(data.world_version.series, "main"); + assert!(!data.world_version.snapshot); + assert_eq!(data.data_packs.enabled, vec!["vanilla".to_string()]); + assert!(data.data_packs.disabled.is_empty()); + } + + #[test] + fn reject_level_dat_without_seed() { + let temp_dir = TempDir::new().unwrap(); + write_level_dat(temp_dir.path(), converted_level_dat(None)); + + let error = AnvilLevelInfo.read_world_info(temp_dir.path()).unwrap_err(); + + assert!(matches!(error, WorldInfoError::MissingWorldSeed)); + } + + #[test] + fn rewrite_level_dat_keeps_unmanaged_tags() { + let temp_dir = TempDir::new().unwrap(); + write_level_dat(temp_dir.path(), converted_level_dat(Some(42))); + + let mut level_data = AnvilLevelInfo.read_world_info(temp_dir.path()).unwrap(); + level_data.level_name = "Renamed World".to_string(); + AnvilLevelInfo + .write_world_info(&level_data, temp_dir.path()) + .unwrap(); + + let root = read_level_dat(temp_dir.path()); + let data = root.get_compound("Data").unwrap(); + assert_eq!(data.get_bool("hardcore"), Some(true)); + assert_eq!(data.get_string("LevelName"), Some("Renamed World")); + assert_eq!( + data.get_compound("Version").unwrap().get_string("Series"), + Some("main") + ); + } + + #[test] + fn rewrite_level_dat_stamps_current_version() { + let temp_dir = TempDir::new().unwrap(); + write_level_dat(temp_dir.path(), converted_level_dat(Some(42))); + + let imported = AnvilLevelInfo.read_world_info(temp_dir.path()).unwrap(); + AnvilLevelInfo + .write_world_info(&imported, temp_dir.path()) + .unwrap(); + + let root = read_level_dat(temp_dir.path()); + let data = root.get_compound("Data").unwrap(); + assert_eq!( + data.get_int("DataVersion"), + Some(MAXIMUM_SUPPORTED_WORLD_DATA_VERSION) + ); + assert_eq!( + data.get_int("version"), + Some(MAXIMUM_SUPPORTED_LEVEL_VERSION) + ); + + let version = data.get_compound("Version").unwrap(); + assert_eq!(version.get_string("Name"), Some(CURRENT_MC_VERSION)); + assert_eq!( + version.get_int("Id"), + Some(MAXIMUM_SUPPORTED_WORLD_DATA_VERSION) + ); + + let game_rules_path = minecraft_data_dir(temp_dir.path()).join("game_rules.dat"); + let game_rules = read_gzip_compound_tag(File::open(game_rules_path).unwrap()).unwrap(); + assert_eq!( + game_rules + .get_compound("data") + .unwrap() + .get_int("DataVersion"), + Some(MAXIMUM_SUPPORTED_WORLD_DATA_VERSION) + ); + } + + #[test] + fn recover_seed_from_level_dat_after_losing_world_gen_settings() { + let temp_dir = TempDir::new().unwrap(); + + AnvilLevelInfo + .write_world_info(&LevelData::default(Seed(8_675_309)), temp_dir.path()) + .unwrap(); + fs::remove_dir_all(temp_dir.path().join("data")).unwrap(); + + let reloaded = AnvilLevelInfo.read_world_info(temp_dir.path()).unwrap(); + + assert_eq!(reloaded.world_gen_settings.seed, 8_675_309); + } + + #[test] + fn round_trip_level_dat() { + let temp_dir = TempDir::new().unwrap(); + + let mut original = LevelData::default(Seed(4242)); + original.level_name = "Round Trip".to_string(); + original.difficulty = Difficulty::Easy; + original.difficulty_locked = true; + original.set_pos(16, -32); + original.spawn_y = 64; + original.spawn_yaw = 45.0; + original.border_size = 2048.0; + original.border_center_x = 8.0; + original.day_time = 12_345; + original.map_id = 3; + + AnvilLevelInfo + .write_world_info(&original, temp_dir.path()) + .unwrap(); + + let root = read_level_dat(temp_dir.path()); + let data = root.get_compound("Data").unwrap(); + assert_eq!( + data.get_int("DataVersion"), + Some(MAXIMUM_SUPPORTED_WORLD_DATA_VERSION) + ); + assert_eq!( + data.get_int("version"), + Some(MAXIMUM_SUPPORTED_LEVEL_VERSION) + ); + assert_eq!(data.get_string("LevelName"), Some("Round Trip")); + assert_eq!(data.get_byte("Difficulty"), Some(Difficulty::Easy as i8)); + assert_eq!(data.get_bool("DifficultyLocked"), Some(true)); + assert_eq!(data.get_bool("allowCommands"), Some(true)); + assert_eq!(data.get_int("SpawnX"), Some(16)); + assert_eq!(data.get_int("SpawnY"), Some(64)); + assert_eq!(data.get_int("SpawnZ"), Some(-32)); + assert_eq!(data.get_float("SpawnAngle"), Some(45.0)); + assert_eq!(data.get_double("BorderSize"), Some(2048.0)); + assert_eq!(data.get_double("BorderCenterX"), Some(8.0)); + assert_eq!(data.get_int("map_id"), Some(3)); + assert!(data.get_long("LastPlayed").is_some_and(|played| played > 0)); + + let version = data.get_compound("Version").unwrap(); + assert_eq!(version.get_string("Name"), Some(CURRENT_MC_VERSION)); + assert_eq!( + version.get_int("Id"), + Some(MAXIMUM_SUPPORTED_WORLD_DATA_VERSION) + ); + assert_eq!(version.get_bool("Snapshot"), Some(false)); + assert_eq!(version.get_string("Series"), Some("main")); + + let data_packs = data.get_compound("DataPacks").unwrap(); + assert_eq!( + data_packs.get_list("Enabled"), + Some([NbtTag::from("vanilla")].as_slice()) + ); + assert!( + data_packs + .get_list("Disabled") + .is_some_and(<[NbtTag]>::is_empty) + ); + + let mut reloaded = AnvilLevelInfo.read_world_info(temp_dir.path()).unwrap(); + assert!(reloaded.last_played > 0); + reloaded.last_played = original.last_played; + + assert_eq!(reloaded, original); + } + static LEVEL_DAT: LazyLock = LazyLock::new(|| LevelDat { data: LevelData { allow_commands: true, diff --git a/crates/pumpkin-world/src/world_info/data_files.rs b/crates/pumpkin-world/src/world_info/data_files.rs index 59b4ae09a..e2cc058ee 100644 --- a/crates/pumpkin-world/src/world_info/data_files.rs +++ b/crates/pumpkin-world/src/world_info/data_files.rs @@ -181,9 +181,11 @@ pub fn read_world_gen_settings(level_folder: &Path) -> Option Ok(compound) => { let seed = compound .get_compound("data") - .and_then(|c| c.get_long("seed")) - .unwrap_or(0); - Some(WorldGenSettings { + .and_then(|c| c.get_long("seed")); + if seed.is_none() { + warn!("world_gen_settings.dat has no seed"); + } + seed.map(|seed| WorldGenSettings { seed, dimensions: std::collections::HashMap::new(), }) diff --git a/crates/pumpkin-world/src/world_info/mod.rs b/crates/pumpkin-world/src/world_info/mod.rs index dd60f1ada..721472bac 100644 --- a/crates/pumpkin-world/src/world_info/mod.rs +++ b/crates/pumpkin-world/src/world_info/mod.rs @@ -106,6 +106,8 @@ const DEFAULT_BORDER_WARNING_TIME: f64 = 15.0; const DEFAULT_DIFFICULTY: Difficulty = Difficulty::Normal; const DEFAULT_LEVEL_NAME: &str = "world"; const DEFAULT_SPAWN_Y: i32 = 200; +const DEFAULT_ENABLED_DATA_PACK: &str = "vanilla"; +const DEFAULT_WORLD_VERSION_SERIES: &str = "main"; const fn default_border_damage_per_block() -> f64 { DEFAULT_BORDER_DAMAGE_PER_BLOCK @@ -122,10 +124,13 @@ const fn default_border_warning_blocks() -> f64 { const fn default_border_warning_time() -> f64 { DEFAULT_BORDER_WARNING_TIME } +fn default_enabled_data_packs() -> Vec { + vec![DEFAULT_ENABLED_DATA_PACK.to_string()] +} fn default_data_packs() -> DataPacks { DataPacks { disabled: vec![], - enabled: vec!["vanilla".to_string()], + enabled: default_enabled_data_packs(), } } const fn default_difficulty() -> Difficulty { @@ -140,11 +145,21 @@ const fn default_spawn_y() -> i32 { const fn default_level_version() -> i32 { MAXIMUM_SUPPORTED_LEVEL_VERSION } +fn default_world_version_name() -> String { + CURRENT_MC_VERSION.to_string() +} +const fn default_world_version_id() -> i32 { + MAXIMUM_SUPPORTED_WORLD_DATA_VERSION +} +fn default_world_version_series() -> String { + DEFAULT_WORLD_VERSION_SERIES.to_string() +} #[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug)] pub struct WorldGenSettings { // the numerical seed of the world pub seed: i64, + #[serde(default)] pub dimensions: Dimensions, } @@ -198,8 +213,10 @@ pub enum BiomeSource { #[serde(rename_all = "PascalCase")] pub struct DataPacks { // List of disabled data packs. + #[serde(default)] pub disabled: Vec, // List of enabled data packs. By default, this is populated with a single string "vanilla". + #[serde(default = "default_enabled_data_packs")] pub enabled: Vec, } @@ -263,22 +280,26 @@ impl WorldGenSettings { #[serde(rename_all = "PascalCase")] pub struct WorldVersion { // The version name as a string, e.g. "15w32b". + #[serde(default = "default_world_version_name")] pub name: String, // An integer displaying the data version. + #[serde(default = "default_world_version_id")] pub id: i32, // Whether the version is a snapshot or not. + #[serde(default)] pub snapshot: bool, // Developing series. In 1.18 experimental snapshots, it was set to "ccpreview". In others, set to "main". + #[serde(default = "default_world_version_series")] pub series: String, } impl Default for WorldVersion { fn default() -> Self { Self { - name: CURRENT_MC_VERSION.to_string(), - id: MAXIMUM_SUPPORTED_WORLD_DATA_VERSION, + name: default_world_version_name(), + id: default_world_version_id(), snapshot: false, - series: "main".to_string(), + series: default_world_version_series(), } } } @@ -333,6 +354,10 @@ pub enum WorldInfoError { InfoNotFound, #[error("Deserialization error: {0}")] DeserializationError(String), + #[error( + "No world seed found: neither level.dat nor data/minecraft/world_gen_settings.dat contains one" + )] + MissingWorldSeed, #[error("Serialization error: {0}")] SerializationError(String), #[error("Unsupported world data version: {0}")] diff --git a/crates/pumpkin/src/server/mod.rs b/crates/pumpkin/src/server/mod.rs index 4697b75cb..987829abd 100644 --- a/crates/pumpkin/src/server/mod.rs +++ b/crates/pumpkin/src/server/mod.rs @@ -165,36 +165,44 @@ impl Server { let block_registry = super::block::registry::default_registry(); - let level_info = AnvilLevelInfo.read_world_info(&world_path); - if let Err(error) = &level_info { - match error { - // If it doesn't exist, just make a new one - WorldInfoError::InfoNotFound => (), - WorldInfoError::UnsupportedDataVersion(_version) - | WorldInfoError::UnsupportedLevelVersion(_version) => { - error!("Failed to load world info!"); - error!("{error}"); - panic!("Unsupported world version! See the logs for more info."); + let level_info = match AnvilLevelInfo.read_world_info(&world_path) { + Ok(level_info) => { + let dat_path = world_path.join(LEVEL_DAT_FILE_NAME); + if dat_path.exists() { + let backup_path = world_path.join(LEVEL_DAT_BACKUP_FILE_NAME); + fs::copy(dat_path, backup_path).unwrap(); } - e => { - panic!("World Error {e}"); + level_info + } + Err(WorldInfoError::InfoNotFound) => { + warn!( + "No {LEVEL_DAT_FILE_NAME} in {}, creating a new world with seed {}", + world_path.display(), + basic_config.seed.0 as i64 + ); + let default_data = LevelData::default(basic_config.seed); + if let Err(err) = AnvilLevelInfo.write_world_info(&default_data, &world_path) { + error!("Failed to save level.dat: {err}"); } + default_data } - } else { - let dat_path = world_path.join(LEVEL_DAT_FILE_NAME); - if dat_path.exists() { - let backup_path = world_path.join(LEVEL_DAT_BACKUP_FILE_NAME); - fs::copy(dat_path, backup_path).unwrap(); + Err( + error @ (WorldInfoError::UnsupportedDataVersion(_) + | WorldInfoError::UnsupportedLevelVersion(_)), + ) => { + error!("Failed to load world info!"); + error!("{error}"); + panic!("Unsupported world version! See the logs for more info."); } - } - let level_info = level_info.unwrap_or_else(|err| { - warn!("Failed to get level_info, using default instead: {err}"); - let default_data = LevelData::default(basic_config.seed); - if let Err(err) = AnvilLevelInfo.write_world_info(&default_data, &world_path) { - error!("Failed to save level.dat: {err}"); + Err(error) => { + error!("Failed to load the world data in {}!", world_path.display()); + error!("{error}"); + error!( + "Refusing to continue: a default world would generate different terrain on top of the existing region files. Restore {LEVEL_DAT_FILE_NAME} from {LEVEL_DAT_BACKUP_FILE_NAME}, which also holds a copy of the world seed, or move the world folder aside to start a new world." + ); + panic!("Failed to load the world data! See the logs for more info."); } - default_data - }); + }; let seed = level_info.world_gen_settings.seed; let level_info = Arc::new(ArcSwap::new(Arc::new(level_info)));