Pumpkin/crates/pumpkin-world/src/world_info/anvil.rs
Alexander Medvedev edb848d608 fix: ci
2026-08-23 12:42:37 +02:00

1038 lines
38 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

use std::{
fs::File,
io::ErrorKind,
path::Path,
time::{SystemTime, UNIX_EPOCH},
};
use tracing::error;
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::{
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_random_sequences_stub, write_scheduled_events_stub,
write_scoreboard_stub, write_stopwatches_stub, write_wandering_trader, write_weather,
write_world_clocks, write_world_gen_settings,
},
default_data_packs,
};
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_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)"
);
return Err(WorldInfoError::DeserializationError(
"Missing DataVersion".into(),
));
};
if (MINIMUM_SUPPORTED_WORLD_DATA_VERSION..=MAXIMUM_SUPPORTED_WORLD_DATA_VERSION)
.contains(&data_version)
{
Ok(())
} else {
Err(WorldInfoError::UnsupportedDataVersion(data_version))
}
}
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)"
);
return Err(WorldInfoError::DeserializationError(
"Missing version".into(),
));
};
if (MINIMUM_SUPPORTED_LEVEL_VERSION..=MAXIMUM_SUPPORTED_LEVEL_VERSION).contains(&level_version)
{
Ok(())
} else {
Err(WorldInfoError::UnsupportedLevelVersion(level_version))
}
}
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_comp) = data.get_compound("spawn") {
if let Some(pos) = spawn_comp.get_int_array("pos")
&& pos.len() >= 3
{
level_data.spawn_x = pos[0];
level_data.spawn_y = pos[1];
level_data.spawn_z = pos[2];
}
if let Some(spawn_yaw) = spawn_comp
.get_float("yaw")
.or_else(|| spawn_comp.get_float("SpawnAngle"))
{
level_data.spawn_yaw = spawn_yaw;
}
if let Some(spawn_pitch) = spawn_comp.get_float("pitch") {
level_data.spawn_pitch = spawn_pitch;
}
}
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(diff_comp) = data.get_compound("difficulty_settings") {
if let Some(diff_str) = diff_comp.get_string("difficulty") {
match diff_str {
"peaceful" => level_data.difficulty = Difficulty::Peaceful,
"easy" => level_data.difficulty = Difficulty::Easy,
"normal" => level_data.difficulty = Difficulty::Normal,
"hard" => level_data.difficulty = Difficulty::Hard,
_ => {}
}
}
if let Some(difficulty_locked) = diff_comp.get_bool("locked") {
level_data.difficulty_locked = difficulty_locked;
}
} else {
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);
// 26.2 difficulty_settings
let mut diff_comp = NbtCompound::new();
let diff_name = match info.difficulty {
Difficulty::Peaceful => "peaceful",
Difficulty::Easy => "easy",
Difficulty::Normal => "normal",
Difficulty::Hard => "hard",
};
diff_comp.put_string("difficulty", diff_name.to_string());
diff_comp.put_bool("hardcore", false);
diff_comp.put_bool("locked", info.difficulty_locked);
data.put_compound("difficulty_settings", diff_comp);
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());
// 26.2 spawn
let mut spawn_comp = NbtCompound::new();
spawn_comp.put_string("dimension", "minecraft:overworld".to_string());
spawn_comp.put(
"pos",
NbtTag::IntArray(vec![info.spawn_x, info.spawn_y, info.spawn_z]),
);
spawn_comp.put_float("pitch", info.spawn_pitch);
spawn_comp.put_float("yaw", info.spawn_yaw);
data.put_compound("spawn", spawn_comp);
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 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_data_version(data)?;
check_level_version(data)?;
let Some(seed) = stored_world_seed(level_folder, data) else {
return Err(WorldInfoError::MissingWorldSeed);
};
let mut level_data = level_data_from_nbt(data, seed);
if let Some(wgs) = read_world_gen_settings(level_folder) {
level_data.world_gen_settings = wgs;
}
// game_rules.dat – prefer the new file; fall back to level.dat values
if minecraft_data_dir(level_folder)
.join("game_rules.dat")
.exists()
{
level_data.game_rules = read_game_rules(level_folder);
}
if minecraft_data_dir(level_folder)
.join("world_clocks.dat")
.exists()
{
let clocks = read_world_clocks(level_folder);
if let Some(overworld) = clocks.clocks.get("minecraft:overworld") {
level_data.day_time = overworld.total_ticks;
}
}
// weather.dat
if minecraft_data_dir(level_folder)
.join("weather.dat")
.exists()
{
let weather = read_weather(level_folder);
level_data.clear_weather_time = weather.clear_weather_time;
}
Ok(level_data)
}
}
impl WorldInfoWriter for AnvilLevelInfo {
fn write_world_info(
&self,
info: &LevelData,
level_folder: &Path,
) -> Result<(), WorldInfoError> {
let start = SystemTime::now();
let since_the_epoch = start.duration_since(UNIX_EPOCH).unwrap_or_default();
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 path_new = level_folder.join("level.dat_new");
let path_old = level_folder.join(LEVEL_DAT_BACKUP_FILE_NAME);
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);
write_gzip_compound_tag(root, File::create(&path_new)?)
.map_err(|e| WorldInfoError::SerializationError(e.to_string()))?;
if path.exists() {
let _ = std::fs::copy(&path, &path_old);
}
let _ = std::fs::rename(&path_new, &path);
let data_version = level_data.data_version;
// ── Write data/minecraft/*.dat files ─────────────────────────────────
// game_rules.dat
if let Err(e) = write_game_rules(level_folder, &info.game_rules, data_version) {
error!("Failed to write game_rules.dat: {e}");
}
// world_gen_settings.dat
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);
clocks.data_version = data_version;
clocks
.clocks
.entry("minecraft:overworld".to_string())
.and_modify(|c| c.total_ticks = info.day_time)
.or_insert(crate::world_info::data_files::DimensionClock {
total_ticks: info.day_time,
});
if let Err(e) = write_world_clocks(level_folder, &clocks) {
error!("Failed to write world_clocks.dat: {e}");
}
// weather.dat
let mut weather = read_weather(level_folder);
weather.clear_weather_time = info.clear_weather_time;
weather.data_version = data_version;
if let Err(e) = write_weather(level_folder, &weather) {
error!("Failed to write weather.dat: {e}");
}
// wandering_trader.dat (stub / load-save)
let mut wandering_trader = read_wandering_trader(level_folder);
wandering_trader.data_version = data_version;
if let Err(e) = write_wandering_trader(level_folder, &wandering_trader) {
error!("Failed to write wandering_trader.dat: {e}");
}
// custom_boss_events.dat
if let Err(e) = write_custom_boss_events_stub(level_folder, data_version) {
error!("Failed to write custom_boss_events.dat: {e}");
}
// scheduled_events.dat
if let Err(e) = write_scheduled_events_stub(level_folder, data_version) {
error!("Failed to write scheduled_events.dat: {e}");
}
// random_sequences.dat
if let Err(e) = write_random_sequences_stub(level_folder, data_version) {
error!("Failed to write random_sequences.dat: {e}");
}
// scoreboard.dat
if let Err(e) = write_scoreboard_stub(level_folder, data_version) {
error!("Failed to write scoreboard.dat: {e}");
}
// stopwatches.dat
if let Err(e) = write_stopwatches_stub(level_folder, data_version) {
error!("Failed to write stopwatches.dat: {e}");
}
Ok(())
}
}
#[derive(Debug, PartialEq, Serialize, Deserialize, Clone)]
pub struct LevelDat {
// This tag contains all the level data.
#[serde(rename = "Data")]
pub data: LevelData,
}
#[cfg(test)]
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::{
fs::{self, File},
path::Path,
sync::LazyLock,
};
use tempfile::TempDir;
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, read_world_gen_settings, write_world_gen_settings},
},
};
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() {
let seed = 1337;
let data = LevelData::default(Seed(1337));
let temp_dir = TempDir::new().unwrap();
AnvilLevelInfo
.write_world_info(&data, temp_dir.path())
.unwrap();
let data = AnvilLevelInfo.read_world_info(temp_dir.path()).unwrap();
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,
border_center_x: 0.0,
border_center_z: 0.0,
border_damage_per_block: 0.2,
border_size: 59_999_968.0,
border_safe_zone: 5.0,
border_size_lerp_target: 59_999_968.0,
border_size_lerp_time: 0,
border_warning_blocks: 5.0,
border_warning_time: 15.0,
clear_weather_time: 0,
data_packs: DataPacks {
disabled: vec![
"minecart_improvements".to_string(),
"redstone_experiments".to_string(),
"trade_rebalance".to_string(),
],
enabled: vec!["vanilla".to_string()],
},
data_version: 4189,
day_time: 1727,
difficulty: Difficulty::Normal,
difficulty_locked: false,
game_rules: GameRuleRegistry {
block_explosion_drop_decay: true,
command_block_output: true,
drowning_damage: true,
ender_pearls_vanish_on_death: true,
fall_damage: true,
fire_damage: true,
forgive_dead_players: true,
freeze_damage: true,
global_sound_events: true,
keep_inventory: false,
lava_source_conversion: false,
log_admin_commands: true,
max_entity_cramming: 24,
mob_explosion_drop_decay: true,
mob_griefing: true,
players_nether_portal_creative_delay: 0,
players_nether_portal_default_delay: 80,
players_sleeping_percentage: 100,
projectiles_can_break_blocks: true,
random_tick_speed: 3,
reduced_debug_info: false,
send_command_feedback: true,
show_death_messages: true,
spectators_generate_chunks: true,
tnt_explosion_drop_decay: false,
universal_anger: false,
water_source_conversion: true,
..Default::default()
},
world_gen_settings: WorldGenSettings::new(Seed(1)),
last_played: 1733847709327,
level_name: "New World".to_string(),
spawn_x: 160,
spawn_y: 70,
spawn_z: 160,
spawn_yaw: 0.0,
spawn_pitch: 0.0,
level_version: 19133,
world_version: WorldVersion {
name: "1.21.4".to_string(),
id: 4189,
snapshot: false,
series: "main".to_string(),
},
map_id: 0,
},
});
// #[test]
// fn deserialize_level_dat() {
// let raw_compressed_nbt = fs::read("assets/level_1_21_4.dat").unwrap();
// assert!(!raw_compressed_nbt.is_empty());
// let mut decoder = GzDecoder::new(&raw_compressed_nbt[..]);
// let mut buf = Vec::new();
// decoder.read_to_end(&mut buf).unwrap();
// let level_dat: LevelDat = from_bytes(Cursor::new(buf)).expect("Failed to decode from file");
// assert_eq!(level_dat, *LEVEL_DAT);
// }
#[test]
fn serialize_level_dat() {
let mut data_comp = pumpkin_nbt::compound::NbtCompound::new();
data_comp.put_int("DataVersion", LEVEL_DAT.data.data_version);
data_comp.put_long("LastPlayed", LEVEL_DAT.data.last_played);
let mut root = pumpkin_nbt::compound::NbtCompound::new();
root.put_compound("Data", data_comp);
let bytes = pumpkin_nbt::Nbt::from(root).write();
assert!(!bytes.is_empty());
}
#[test]
fn spawn_and_difficulty_format_26_2() {
let temp_dir = TempDir::new().unwrap();
let mut data_comp = NbtCompound::new();
data_comp.put_int("DataVersion", 4903);
data_comp.put_int("version", 19133);
data_comp.put_string("LevelName", "26.2 Test".to_string());
data_comp.put_long("LastPlayed", 123456789);
data_comp.put_long("DayTime", 1000);
// 26.2 difficulty_settings compound
let mut diff_comp = NbtCompound::new();
diff_comp.put_string("difficulty", "hard".to_string());
diff_comp.put_bool("locked", true);
diff_comp.put_bool("hardcore", false);
data_comp.put_compound("difficulty_settings", diff_comp);
// 26.2 spawn compound
let mut spawn_comp = NbtCompound::new();
spawn_comp.put_string("dimension", "minecraft:overworld".to_string());
spawn_comp.put("pos", NbtTag::IntArray(vec![42, -60, 99]));
spawn_comp.put_float("pitch", 15.0);
spawn_comp.put_float("yaw", 180.0);
data_comp.put_compound("spawn", spawn_comp);
let mut root = NbtCompound::new();
root.put_compound("Data", data_comp);
write_level_dat(temp_dir.path(), root);
// Write world_gen_settings.dat
let mut wgs = WorldGenSettings::new(Seed(777));
wgs.dimensions.insert(
"minecraft:overworld".to_string(),
crate::world_info::Dimension {
generator: crate::world_info::Generator {
settings: Some(crate::world_info::GeneratorSettings::Compound(
serde_json::json!({
"biome": "minecraft:the_void",
"layers": [
{ "block": "minecraft:air", "height": 1 }
]
}),
)),
biome_source: None,
generator_type: "minecraft:flat".to_string(),
},
dimension_type: "minecraft:overworld".to_string(),
},
);
write_world_gen_settings(temp_dir.path(), &wgs, 4903).unwrap();
let loaded = AnvilLevelInfo.read_world_info(temp_dir.path()).unwrap();
assert_eq!(loaded.spawn_x, 42);
assert_eq!(loaded.spawn_y, -60);
assert_eq!(loaded.spawn_z, 99);
assert_eq!(loaded.spawn_pitch, 15.0);
assert_eq!(loaded.spawn_yaw, 180.0);
assert_eq!(loaded.difficulty, Difficulty::Hard);
assert!(loaded.difficulty_locked);
let read_wgs = read_world_gen_settings(temp_dir.path()).unwrap();
assert_eq!(read_wgs.seed, 777);
let overworld_dim = read_wgs.dimensions.get("minecraft:overworld").unwrap();
assert_eq!(overworld_dim.generator.generator_type, "minecraft:flat");
if let Some(crate::world_info::GeneratorSettings::Compound(val)) =
&overworld_dim.generator.settings
{
assert_eq!(val["biome"], "minecraft:the_void");
} else {
panic!("Expected Compound generator settings");
}
}
#[test]
fn all_26_2_minecraft_data_files_written_and_read() {
let temp_dir = TempDir::new().unwrap();
let mut level_data = LEVEL_DAT.data.clone();
level_data.data_version = 4903;
level_data.day_time = 24000;
level_data.clear_weather_time = 100;
level_data.world_gen_settings = WorldGenSettings::new(Seed(9999));
AnvilLevelInfo
.write_world_info(&level_data, temp_dir.path())
.unwrap();
let data_dir = temp_dir.path().join("data").join("minecraft");
let expected_files = [
"game_rules.dat",
"random_sequences.dat",
"scoreboard.dat",
"stopwatches.dat",
"wandering_trader.dat",
"world_clocks.dat",
"world_gen_settings.dat",
"scheduled_events.dat",
"custom_boss_events.dat",
"weather.dat",
];
for file_name in &expected_files {
let file_path = data_dir.join(file_name);
assert!(
file_path.exists(),
"Expected file {file_name} to exist in data/minecraft/"
);
}
// Verify world_gen_settings.dat read
let loaded_wgs =
crate::world_info::data_files::read_world_gen_settings(temp_dir.path()).unwrap();
assert_eq!(loaded_wgs.seed, 9999);
// Verify weather.dat read
let loaded_weather = crate::world_info::data_files::read_weather(temp_dir.path());
assert_eq!(loaded_weather.clear_weather_time, 100);
// Verify world_clocks.dat read
let loaded_clocks = crate::world_info::data_files::read_world_clocks(temp_dir.path());
assert_eq!(
loaded_clocks
.clocks
.get("minecraft:overworld")
.unwrap()
.total_ticks,
24000
);
// Verify wandering_trader.dat read
let loaded_wt = crate::world_info::data_files::read_wandering_trader(temp_dir.path());
assert_eq!(loaded_wt.spawn_delay, 24000);
assert_eq!(loaded_wt.spawn_chance, 25);
}
}