mirror of
https://github.com/Pumpkin-MC/Pumpkin.git
synced 2026-08-30 20:14:23 +00:00
fix(world): load level.dat instead of discarding it (#2804)
`AnvilLevelInfo::read_world_info` validated the version tags and then returned `LevelData::default(Seed(0))`, so level name, difficulty, spawn, world border and data packs were discarded on every start, and the writer emitted only `DataVersion`, `version` and `LastPlayed`, truncating the rest on save. A world whose seed lives in level.dat's legacy `Data.WorldGenSettings` compound, as converters such as Chunker emit, therefore loaded with seed 0 and generated foreign terrain into its existing region files. level.dat is now read and written tag by tag through pumpkin-nbt, every field falling back to its vanilla default, so a missing optional tag such as `Version.Series` no longer costs the whole file. Saving merges into the existing `Data` compound, so tags Pumpkin does not model survive. A world with no seed in either file aborts startup instead of silently regenerating, and the seed is mirrored into level.dat, the only world file that is backed up. Version tags are re-stamped with the current constants on save so level.dat stays consistent with the chunks Pumpkin writes.
This commit is contained in:
@@ -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<Difficulty> {
|
||||
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<String> {
|
||||
tags.iter()
|
||||
.filter_map(|tag| tag.extract_string().map(ToString::to_string))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn strings_to_nbt(values: &[String]) -> Vec<NbtTag> {
|
||||
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<i64> {
|
||||
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<NbtCompound, WorldInfoError> {
|
||||
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<LevelData, WorldInfoError> {
|
||||
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<i64>) -> 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<LevelDat> = LazyLock::new(|| LevelDat {
|
||||
data: LevelData {
|
||||
allow_commands: true,
|
||||
|
||||
@@ -181,9 +181,11 @@ pub fn read_world_gen_settings(level_folder: &Path) -> Option<WorldGenSettings>
|
||||
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(),
|
||||
})
|
||||
|
||||
@@ -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<String> {
|
||||
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<String>,
|
||||
// List of enabled data packs. By default, this is populated with a single string "vanilla".
|
||||
#[serde(default = "default_enabled_data_packs")]
|
||||
pub enabled: Vec<String>,
|
||||
}
|
||||
|
||||
@@ -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}")]
|
||||
|
||||
@@ -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)));
|
||||
|
||||
Reference in New Issue
Block a user