Add gzip compression

This commit is contained in:
2025-08-21 16:26:40 +02:00
parent b9e740e80a
commit b96e7f505f
4 changed files with 399 additions and 6 deletions

43
Cargo.lock generated
View File

@@ -2,6 +2,49 @@
# It is not intended for manual editing. # It is not intended for manual editing.
version = 4 version = 4
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "cfg-if"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9"
[[package]]
name = "crc32fast"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
dependencies = [
"cfg-if",
]
[[package]]
name = "flate2"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a3d7db9596fecd151c5f638c0ee5d5bd487b6e0ea232e5dc96d5250f6f94b1d"
dependencies = [
"crc32fast",
"miniz_oxide",
]
[[package]]
name = "miniz_oxide"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
]
[[package]] [[package]]
name = "nbt" name = "nbt"
version = "0.1.0" version = "0.1.0"
dependencies = [
"flate2",
]

View File

@@ -4,3 +4,4 @@ version = "0.1.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
flate2 = "1.1.2"

View File

@@ -4,12 +4,28 @@ use std::{
}; };
use crate::{Tag, TagId}; use crate::{Tag, TagId};
use flate2::{Compression, read::GzDecoder, write::GzEncoder};
/// Binary reader for NBT format /// Binary reader for NBT format
pub struct Reader<R: Read> { pub struct Reader<R: Read> {
inner: R, inner: R,
} }
// --- Reader gzip ---
impl<R: Read> Reader<GzDecoder<R>> {
pub fn from_gzip(inner: R) -> Self {
Reader {
inner: GzDecoder::new(inner),
}
}
}
impl<W: Write> Writer<GzEncoder<W>> {
pub fn to_gzip(inner: W) -> Self {
Writer {
inner: GzEncoder::new(inner, Compression::default()),
}
}
}
impl<R: Read> Reader<R> { impl<R: Read> Reader<R> {
pub fn new(inner: R) -> Self { pub fn new(inner: R) -> Self {
Reader { inner } Reader { inner }
@@ -134,13 +150,22 @@ impl<R: Read> Reader<R> {
Ok(i64::from_be_bytes(buf)) Ok(i64::from_be_bytes(buf))
} }
fn read_f32(&mut self) -> Result<f32> { fn read_f32(&mut self) -> Result<f32> {
Ok(f32::from_bits(self.read_i32()? as u32)) let mut b = [0u8; 4];
self.inner.read_exact(&mut b)?;
Ok(f32::from_bits(u32::from_be_bytes(b)))
} }
fn read_f64(&mut self) -> Result<f64> { fn read_f64(&mut self) -> Result<f64> {
Ok(f64::from_bits(self.read_i64()? as u64)) let mut b = [0u8; 8];
self.inner.read_exact(&mut b)?;
Ok(f64::from_bits(u64::from_be_bytes(b)))
}
fn read_u16(&mut self) -> Result<u16> {
let mut b = [0u8; 2];
self.inner.read_exact(&mut b)?;
Ok(u16::from_be_bytes(b))
} }
fn read_string(&mut self) -> Result<String> { fn read_string(&mut self) -> Result<String> {
let len = self.read_i16()? as usize; let len = self.read_u16()? as usize;
let mut buf = vec![0u8; len]; let mut buf = vec![0u8; len];
self.inner.read_exact(&mut buf)?; self.inner.read_exact(&mut buf)?;
String::from_utf8(buf).map_err(|e| Error::new(ErrorKind::InvalidData, e)) String::from_utf8(buf).map_err(|e| Error::new(ErrorKind::InvalidData, e))
@@ -240,14 +265,17 @@ impl<W: Write> Writer<W> {
self.inner.write_all(&v.to_be_bytes()) self.inner.write_all(&v.to_be_bytes())
} }
fn write_f32(&mut self, v: f32) -> Result<()> { fn write_f32(&mut self, v: f32) -> Result<()> {
self.write_i32(v.to_bits() as i32) self.inner.write_all(&v.to_bits().to_be_bytes())
} }
fn write_f64(&mut self, v: f64) -> Result<()> { fn write_f64(&mut self, v: f64) -> Result<()> {
self.write_i64(v.to_bits() as i64) self.inner.write_all(&v.to_bits().to_be_bytes())
} }
fn write_string(&mut self, s: &str) -> Result<()> { fn write_string(&mut self, s: &str) -> Result<()> {
let bytes = s.as_bytes(); let bytes = s.as_bytes();
self.write_i16(bytes.len() as i16)?; if bytes.len() > u16::MAX as usize {
return Err(Error::new(ErrorKind::InvalidInput, "string too long"));
}
self.inner.write_all(&(bytes.len() as u16).to_be_bytes())?;
self.inner.write_all(bytes) self.inner.write_all(bytes)
} }
} }

View File

@@ -164,3 +164,324 @@ fn roundtrip_compound() {
let decoded = read_nbt(&buf[..]).unwrap(); let decoded = read_nbt(&buf[..]).unwrap();
assert_eq!(decoded, root); assert_eq!(decoded, root);
} }
// ---------------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use std::io::{Cursor, Read};
use flate2::bufread::GzDecoder;
use crate::{Reader, Tag, Writer};
fn sample_level_compound() -> Tag {
use std::collections::HashMap;
let mut entries = HashMap::new();
entries.insert(
"DataVersion".into(),
Tag::Int {
name: Some("DataVersion".into()),
value: 3837,
},
);
entries.insert(
"LevelName".into(),
Tag::String {
name: Some("LevelName".into()),
value: "test".into(),
},
);
entries.insert(
"GameType".into(),
Tag::Int {
name: Some("GameType".into()),
value: 0,
},
);
entries.insert(
"Difficulty".into(),
Tag::Byte {
name: Some("Difficulty".into()),
value: 2,
},
);
entries.insert(
"hardcore".into(),
Tag::Byte {
name: Some("hardcore".into()),
value: 0,
},
);
entries.insert(
"allowCommands".into(),
Tag::Byte {
name: Some("allowCommands".into()),
value: 1,
},
);
entries.insert(
"SpawnX".into(),
Tag::Int {
name: Some("SpawnX".into()),
value: 0,
},
);
entries.insert(
"SpawnY".into(),
Tag::Int {
name: Some("SpawnY".into()),
value: 64,
},
);
entries.insert(
"SpawnZ".into(),
Tag::Int {
name: Some("SpawnZ".into()),
value: 0,
},
);
let list = Tag::List {
name: Some("TestList".into()),
element_id: 3,
elements: vec![
Tag::Int {
name: None,
value: 1,
},
Tag::Int {
name: None,
value: 2,
},
Tag::Int {
name: None,
value: 3,
},
],
};
entries.insert("TestList".into(), list);
Tag::Compound {
name: Some("Data".into()),
entries,
}
}
fn write_uncompressed(tag: &Tag) -> Vec<u8> {
let mut buf = Cursor::new(Vec::new());
let mut w = Writer::new(&mut buf);
w.write_tag(tag).unwrap();
buf.into_inner()
}
fn read_uncompressed(bytes: &[u8]) -> Tag {
let mut r = Reader::new(Cursor::new(bytes));
r.read_tag().unwrap()
}
#[test]
fn roundtrip_uncompressed() {
let root = sample_level_compound();
let bytes = write_uncompressed(&root);
let back = read_uncompressed(&bytes);
match back {
Tag::Compound { name, .. } => assert_eq!(name.as_deref(), Some("Data")),
_ => panic!("root not a Compound"),
}
}
#[test]
fn list_elements_have_no_names() {
let root = sample_level_compound();
let bytes = write_uncompressed(&root);
let back = read_uncompressed(&bytes);
let Tag::Compound { entries, .. } = back else {
panic!("not compound");
};
let Tag::List {
element_id,
elements,
..
} = entries.get("TestList").expect("missing TestList")
else {
panic!("not list");
};
assert_eq!(*element_id, 3);
assert_eq!(elements.len(), 3);
for e in elements {
match e {
Tag::Int { name, value } => {
assert!(name.is_none());
assert!(*value >= 1 && *value <= 3);
}
_ => panic!("list element not Int"),
}
}
}
#[test]
fn string_length_u16_boundary() {
let s = "a".repeat(300);
let tag = Tag::String {
name: Some("S".into()),
value: s.clone(),
};
let root = Tag::Compound {
name: Some("Data".into()),
entries: {
let mut m = std::collections::HashMap::new();
m.insert("S".into(), tag);
m
},
};
let bytes = write_uncompressed(&root);
let back = read_uncompressed(&bytes);
let Tag::Compound { entries, .. } = back else {
panic!("not compound");
};
let Tag::String { value, .. } = entries.get("S").expect("missing S") else {
panic!("not string");
};
assert_eq!(value, &s);
}
#[test]
fn roundtrip_gzip() {
let root = sample_level_compound();
let mut out = Vec::new();
{
let cursor = Cursor::new(&mut out);
let mut w = Writer::to_gzip(cursor);
w.write_tag(&root).unwrap();
}
assert!(out.len() >= 2);
assert_eq!(out[0], 0x1f);
assert_eq!(out[1], 0x8b);
let mut r = Reader::from_gzip(Cursor::new(&out));
let back = r.read_tag().unwrap();
match back {
Tag::Compound { name, .. } => assert_eq!(name.as_deref(), Some("Data")),
_ => panic!("root not a Compound"),
}
}
#[test]
fn error_on_unknown_tag_id() {
let mut raw = Vec::new();
raw.push(99u8);
raw.extend_from_slice(&0u16.to_be_bytes());
let mut r = Reader::new(Cursor::new(raw));
let err = r.read_tag().err().expect("should error");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
}
#[test]
fn compound_end_marker_required() {
let mut raw = Vec::new();
raw.push(10u8);
raw.extend_from_slice(&(4u16.to_be_bytes()));
raw.extend_from_slice(b"Data");
let mut r = Reader::new(Cursor::new(raw));
let res = r.read_tag();
assert!(res.is_err(), "Reader should fail on missing TAG_End");
}
#[test]
fn float_and_double_are_be() {
let mut entries = std::collections::HashMap::new();
entries.insert(
"F".into(),
Tag::Float {
name: Some("F".into()),
value: 1234.5f32,
},
);
entries.insert(
"D".into(),
Tag::Double {
name: Some("D".into()),
value: -0.25f64,
},
);
let root = Tag::Compound {
name: Some("Data".into()),
entries,
};
let bytes = write_uncompressed(&root);
let back = read_uncompressed(&bytes);
let Tag::Compound { entries, .. } = back else {
panic!("not compound");
};
let Tag::Float { value: f, .. } = entries.get("F").unwrap() else {
panic!("F missing");
};
let Tag::Double { value: d, .. } = entries.get("D").unwrap() else {
panic!("D missing");
};
assert!((*f - 1234.5).abs() < 1e-4);
assert!((*d + 0.25).abs() < 1e-12);
}
#[test]
fn int_long_arrays_roundtrip() {
let ia = (0..16).map(|i| i - 8).collect::<Vec<_>>();
let la = (0..8).map(|i| (i as i64) * (1 << 33)).collect::<Vec<_>>();
let mut entries = std::collections::HashMap::new();
entries.insert(
"IA".into(),
Tag::IntArray {
name: Some("IA".into()),
value: ia.clone(),
},
);
entries.insert(
"LA".into(),
Tag::LongArray {
name: Some("LA".into()),
value: la.clone(),
},
);
let root = Tag::Compound {
name: Some("Data".into()),
entries,
};
let bytes = write_uncompressed(&root);
let back = read_uncompressed(&bytes);
let Tag::Compound { entries, .. } = back else {
panic!("not compound");
};
let Tag::IntArray { value: ia2, .. } = entries.get("IA").unwrap() else {
panic!("IA");
};
let Tag::LongArray { value: la2, .. } = entries.get("LA").unwrap() else {
panic!("LA");
};
assert_eq!(ia2, &ia);
assert_eq!(la2, &la);
}
#[test]
fn gzip_stream_is_valid_by_gzdecoder() {
let root = sample_level_compound();
let mut out = Vec::new();
{
let cursor = Cursor::new(&mut out);
let mut w = Writer::to_gzip(cursor);
w.write_tag(&root).unwrap();
}
let mut dec = GzDecoder::new(Cursor::new(&out));
let mut raw = Vec::new();
dec.read_to_end(&mut raw).unwrap();
assert!(!raw.is_empty());
assert_eq!(raw[0], 10u8);
}
}