Skip to main content

bedrock_world/
level_dat.rs

1//! `level.dat` parsing and atomic write helpers.
2//!
3//! Bedrock `level.dat` starts with an 8-byte little-endian header followed by a
4//! little-endian NBT compound. The read API keeps header warnings explicit so
5//! tools can surface tolerated data issues without failing the entire open path.
6
7use crate::error::{BedrockWorldError, Result};
8use crate::nbt::{NbtTag, nbt_tags_equal_for_write, parse_root_nbt, serialize_root_nbt};
9use std::fs;
10use std::io::Write;
11use std::path::{Path, PathBuf};
12
13const MAX_LEVEL_DAT_PAYLOAD_BYTES: usize = u32::MAX as usize;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16/// Header metadata read from a `level.dat` file.
17pub struct LevelDatHeader {
18    /// Bedrock file format version field.
19    pub version: u32,
20    /// Payload length declared by the header.
21    pub declared_len: u32,
22    /// Payload bytes actually parsed by this crate.
23    pub actual_payload_len: usize,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq)]
27/// Non-fatal conditions observed while reading `level.dat`.
28pub enum LevelDatReadWarning {
29    /// Header length exceeded the bytes available after the header.
30    DeclaredLengthTooLarge {
31        /// Length declared by the header.
32        declared_len: u32,
33        /// Bytes available after the header.
34        actual_payload_len: usize,
35    },
36    /// Additional bytes were present after the declared payload.
37    TrailingBytes {
38        /// Length declared by the header.
39        declared_len: u32,
40        /// Bytes available after the header.
41        actual_payload_len: usize,
42    },
43}
44
45#[derive(Debug, Clone, PartialEq)]
46/// Parsed `level.dat` document with header, root NBT, and warnings.
47pub struct LevelDatDocument {
48    /// Parsed header values.
49    pub header: LevelDatHeader,
50    /// Root little-endian NBT compound.
51    pub root: NbtTag,
52    /// Non-fatal read warnings.
53    pub warnings: Vec<LevelDatReadWarning>,
54}
55
56impl LevelDatDocument {
57    #[must_use]
58    /// Creates a document with the given format version and root tag.
59    pub fn new(version: u32, root: NbtTag) -> Self {
60        Self {
61            header: LevelDatHeader {
62                version,
63                declared_len: 0,
64                actual_payload_len: 0,
65            },
66            root,
67            warnings: Vec::new(),
68        }
69    }
70
71    #[must_use]
72    /// Returns the Bedrock `level.dat` format version from the header.
73    pub const fn version(&self) -> u32 {
74        self.header.version
75    }
76}
77
78/// Parses a complete `level.dat` byte slice.
79pub fn parse_level_dat_document(data: &[u8]) -> Result<LevelDatDocument> {
80    if data.len() < 8 {
81        return Err(BedrockWorldError::CorruptWorld(
82            "level.dat is shorter than its 8-byte header".to_string(),
83        ));
84    }
85
86    let version = read_header_u32(data, 0)?;
87    let declared_len = read_header_u32(data, 4)?;
88    let remaining = data.len().saturating_sub(8);
89    let declared_len_usize = declared_len as usize;
90
91    let mut warnings = Vec::new();
92    let payload = if declared_len_usize <= remaining {
93        if declared_len_usize < remaining {
94            warnings.push(LevelDatReadWarning::TrailingBytes {
95                declared_len,
96                actual_payload_len: remaining,
97            });
98        }
99        &data[8..8 + declared_len_usize]
100    } else {
101        warnings.push(LevelDatReadWarning::DeclaredLengthTooLarge {
102            declared_len,
103            actual_payload_len: remaining,
104        });
105        &data[8..]
106    };
107
108    let root = parse_root_nbt(payload)?;
109    Ok(LevelDatDocument {
110        header: LevelDatHeader {
111            version,
112            declared_len,
113            actual_payload_len: payload.len(),
114        },
115        root,
116        warnings,
117    })
118}
119
120/// Reads and parses a `level.dat` file from disk.
121pub fn read_level_dat_document(path: &Path) -> Result<LevelDatDocument> {
122    let bytes = fs::read(path)?;
123    parse_level_dat_document(&bytes)
124}
125
126/// Alias for [`read_level_dat_document`].
127pub fn read_level_dat(path: &Path) -> Result<LevelDatDocument> {
128    read_level_dat_document(path)
129}
130
131/// Writes a `level.dat` document through a temporary file and replacement.
132pub fn write_level_dat_document(path: &Path, document: &LevelDatDocument) -> Result<()> {
133    if path.file_name().is_some_and(|name| name != "level.dat") {
134        return Err(BedrockWorldError::Validation(format!(
135            "refusing to write non-level.dat file: {}",
136            path.display()
137        )));
138    }
139    if let Some(parent) = path.parent() {
140        if !parent.is_dir() {
141            return Err(BedrockWorldError::Validation(format!(
142                "level.dat parent directory does not exist: {}",
143                parent.display()
144            )));
145        }
146    }
147
148    let payload = serialize_root_nbt(&document.root)?;
149    if payload.len() > MAX_LEVEL_DAT_PAYLOAD_BYTES {
150        return Err(BedrockWorldError::Validation(
151            "level.dat payload is too large".to_string(),
152        ));
153    }
154
155    let mut bytes = Vec::with_capacity(payload.len() + 8);
156    bytes.extend_from_slice(&document.header.version.to_le_bytes());
157    bytes.extend_from_slice(&(payload.len() as u32).to_le_bytes());
158    bytes.extend_from_slice(&payload);
159    validate_level_dat_bytes_for_write(&bytes, &document.root, document.header.version)?;
160
161    let temporary_path = temporary_level_dat_path(path);
162    let mut file = fs::File::create(&temporary_path)?;
163    file.write_all(&bytes)?;
164    file.sync_all()?;
165    drop(file);
166
167    replace_file(&temporary_path, path)
168}
169
170/// Alias for [`write_level_dat_document`].
171pub fn write_level_dat_atomic(path: &Path, document: &LevelDatDocument) -> Result<()> {
172    write_level_dat_document(path, document)
173}
174
175#[cfg(feature = "async")]
176/// Async wrapper for [`read_level_dat`] using `tokio::task::spawn_blocking`.
177pub async fn read_level_dat_async(path: impl AsRef<Path>) -> Result<LevelDatDocument> {
178    let path = path.as_ref().to_path_buf();
179    tokio::task::spawn_blocking(move || read_level_dat(&path))
180        .await
181        .map_err(|error| BedrockWorldError::Join(error.to_string()))?
182}
183
184#[cfg(feature = "async")]
185/// Async wrapper for [`write_level_dat_atomic`] using `tokio::task::spawn_blocking`.
186pub async fn write_level_dat_atomic_async(
187    path: impl AsRef<Path>,
188    document: LevelDatDocument,
189) -> Result<()> {
190    let path = path.as_ref().to_path_buf();
191    tokio::task::spawn_blocking(move || write_level_dat_atomic(&path, &document))
192        .await
193        .map_err(|error| BedrockWorldError::Join(error.to_string()))?
194}
195
196/// Re-parses candidate bytes before replacing `level.dat`.
197pub fn validate_level_dat_bytes_for_write(
198    bytes: &[u8],
199    expected_root: &NbtTag,
200    expected_version: u32,
201) -> Result<()> {
202    let parsed = parse_level_dat_document(bytes)?;
203    if parsed.header.version != expected_version {
204        return Err(BedrockWorldError::Validation(
205            "level.dat version changed during write validation".to_string(),
206        ));
207    }
208    if parsed.header.declared_len as usize != bytes.len().saturating_sub(8) {
209        return Err(BedrockWorldError::Validation(
210            "level.dat declared length does not match payload".to_string(),
211        ));
212    }
213    if !parsed.warnings.is_empty() {
214        return Err(BedrockWorldError::Validation(format!(
215            "level.dat validation produced warnings: {:?}",
216            parsed.warnings
217        )));
218    }
219    if !nbt_tags_equal_for_write(&parsed.root, expected_root) {
220        return Err(BedrockWorldError::Validation(
221            "level.dat roundtrip root mismatch".to_string(),
222        ));
223    }
224    Ok(())
225}
226
227fn read_header_u32(data: &[u8], offset: usize) -> Result<u32> {
228    let bytes = data.get(offset..offset + 4).ok_or_else(|| {
229        BedrockWorldError::CorruptWorld("level.dat header is incomplete".to_string())
230    })?;
231    let bytes: [u8; 4] = bytes
232        .try_into()
233        .map_err(|_| BedrockWorldError::CorruptWorld("invalid level.dat header".to_string()))?;
234    Ok(u32::from_le_bytes(bytes))
235}
236
237fn temporary_level_dat_path(path: &Path) -> PathBuf {
238    path.with_file_name("level.dat.bmcbtmp")
239}
240
241fn replace_file(source: &Path, target: &Path) -> Result<()> {
242    replace_file_impl(source, target)
243}
244
245fn replace_file_impl(source: &Path, target: &Path) -> Result<()> {
246    if fs::rename(source, target).is_ok() {
247        return Ok(());
248    }
249    if target.exists() {
250        fs::remove_file(target)?;
251    }
252    fs::rename(source, target)?;
253    Ok(())
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259    use crate::nbt::NbtTag;
260    use indexmap::IndexMap;
261
262    #[test]
263    fn level_dat_header_roundtrips() {
264        let mut root = IndexMap::new();
265        root.insert("LevelName".to_string(), NbtTag::String("Test".to_string()));
266        let root = NbtTag::Compound(root);
267        let payload = serialize_root_nbt(&root).expect("serialize");
268
269        let mut bytes = Vec::new();
270        bytes.extend_from_slice(&10_u32.to_le_bytes());
271        bytes.extend_from_slice(&(payload.len() as u32).to_le_bytes());
272        bytes.extend_from_slice(&payload);
273
274        let document = parse_level_dat_document(&bytes).expect("parse");
275        assert_eq!(document.header.version, 10);
276        assert_eq!(document.header.actual_payload_len, payload.len());
277        assert!(document.warnings.is_empty());
278    }
279
280    #[test]
281    fn level_dat_warns_when_declared_length_is_too_large() {
282        let root = NbtTag::Compound(IndexMap::new());
283        let payload = serialize_root_nbt(&root).expect("serialize");
284
285        let mut bytes = Vec::new();
286        bytes.extend_from_slice(&10_u32.to_le_bytes());
287        bytes.extend_from_slice(&((payload.len() + 8) as u32).to_le_bytes());
288        bytes.extend_from_slice(&payload);
289
290        let document = parse_level_dat_document(&bytes).expect("parse");
291        assert_eq!(
292            document.warnings,
293            vec![LevelDatReadWarning::DeclaredLengthTooLarge {
294                declared_len: (payload.len() + 8) as u32,
295                actual_payload_len: payload.len(),
296            }]
297        );
298    }
299}