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:
MCbabel
2026-08-07 10:09:06 +02:00
committed by GitHub
parent f2444bcefd
commit b0d2dfa434
4 changed files with 578 additions and 92 deletions

View File

@@ -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,

View File

@@ -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(),
})

View File

@@ -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}")]

View File

@@ -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)));