use binrw::BinWrite;
use byteorder::{WriteBytesExt, LE};
use std::borrow::Cow;
use std::collections::{HashMap, HashSet};
use std::io::{self, BufWriter, Seek, SeekFrom, Write};
use std::path::Path;
use xxhash_rust::xxh3::xxh3_64;
use crate::{
chunk::ModpkgChunk,
metadata::{ModpkgMetadata, METADATA_CHUNK_PATH},
thumbnail::THUMBNAIL_CHUNK_PATH,
ChunkKey, LayerHash, LayerIndex, ModpkgCompression, PathHash, WadIndex, WadNameHash,
};
use crate::{ChunkPath, Slug, BASE_LAYER_NAME, LICENSE_CHUNK_PATH, README_CHUNK_PATH};
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum ModpkgBuilderError {
#[error("io error")]
Io(#[from] io::Error),
#[error("Failed to write binary layout")]
BinWrite(#[source] Box<dyn std::error::Error + Send + Sync>),
#[error("modpkg error")]
Modpkg(#[from] crate::error::ModpkgError),
#[error("unsupported compression type: {0:?}")]
UnsupportedCompressionType(ModpkgCompression),
#[error("missing base layer")]
MissingBaseLayer,
#[error("layer not found: {0}")]
LayerNotFound(String),
#[error("invalid chunk name: {0}")]
InvalidChunkName(String),
#[error("invalid layer name")]
InvalidLayerName(#[from] crate::error::InvalidSlugError),
#[error(
"inconsistent content for chunk {path} in layer {layer}: \
WADs {first_wad} and {second_wad}"
)]
InconsistentChunk {
path: String,
layer: String,
first_wad: String,
second_wad: String,
},
}
impl From<binrw::Error> for ModpkgBuilderError {
fn from(error: binrw::Error) -> Self {
Self::BinWrite(Box::new(error))
}
}
#[derive(Debug, Clone, Default)]
pub struct ModpkgBuilder {
readme: Option<Vec<u8>>,
license_text: Option<Vec<u8>>,
thumbnail: Option<Vec<u8>>,
metadata: ModpkgMetadata,
chunks: HashMap<(ChunkKey, WadNameHash), ModpkgChunkBuilder>,
layers: Vec<ModpkgLayerBuilder>,
}
type ContentKey = (u64, u64);
#[derive(Debug)]
struct MetaChunk<'builder> {
path: &'static str,
data: Cow<'builder, [u8]>,
compression: ModpkgCompression,
}
impl MetaChunk<'_> {
fn path_hash(&self) -> PathHash {
ChunkPath::new(self.path).hash()
}
}
#[derive(Debug, Clone, Default)]
pub struct ModpkgChunkBuilder {
path_hash: PathHash,
path: String,
compression: ModpkgCompression,
layer: String,
wad: String,
}
#[derive(Debug, Clone)]
pub struct ModpkgLayerBuilder {
name: Slug,
priority: i32,
}
impl Default for ModpkgLayerBuilder {
fn default() -> Self {
Self::base()
}
}
impl ModpkgBuilder {
pub fn with_layer(mut self, layer: ModpkgLayerBuilder) -> Self {
self.layers.push(layer);
self
}
pub fn with_chunk(mut self, chunk: ModpkgChunkBuilder) -> Self {
let key = chunk.full_key();
self.chunks.insert(key, chunk);
self
}
pub fn build_to_writer<
TWriter: io::Write + io::Seek,
TChunkDataProvider: FnMut(&ModpkgChunkBuilder) -> Result<Vec<u8>, ModpkgBuilderError>,
>(
self,
writer: &mut TWriter,
provide_chunk_data: TChunkDataProvider,
) -> Result<(), ModpkgBuilderError> {
let mut writer = BufWriter::new(writer);
let meta_chunks = self.meta_chunks()?;
let meta_path_hashes: HashSet<PathHash> =
meta_chunks.iter().map(MetaChunk::path_hash).collect();
let regular_chunks = self.collect_regular_chunks(&meta_path_hashes);
let (chunk_paths, chunk_path_indices) =
Self::collect_unique_paths(&meta_chunks, ®ular_chunks);
let (layers, _) = Self::collect_unique_layers(®ular_chunks);
let (wads, wad_indices) = Self::collect_unique_wads(®ular_chunks);
Self::validate_layers(&self.layers, &layers)?;
let total_chunks = meta_chunks.len() + regular_chunks.len();
Self::write_header(&mut writer, total_chunks)?;
Self::write_layers(&mut writer, &self.layers)?;
Self::write_chunk_paths(&mut writer, &chunk_paths)?;
Self::write_wads(&mut writer, &wads)?;
Self::write_alignment(&mut writer)?;
let chunk_toc_offset = writer.stream_position()?;
writer.write_all(&vec![0; total_chunks * ModpkgChunk::RECORD_SIZE])?;
let layer_index_map = Self::build_layer_index_map(&self.layers);
let all_chunks = Self::process_all_chunks(
&mut writer,
provide_chunk_data,
&meta_chunks,
®ular_chunks,
&chunk_path_indices,
&layer_index_map,
&wad_indices,
)?;
Self::write_chunk_toc(&mut writer, chunk_toc_offset, &all_chunks)?;
Ok(())
}
fn write_header<W: io::Write>(
writer: &mut W,
total_chunks: usize,
) -> Result<(), ModpkgBuilderError> {
writer.write_all(b"_modpkg_")?;
writer.write_u32::<LE>(1)?;
writer.write_u32::<LE>(0)?; writer.write_u32::<LE>(total_chunks as u32)?;
let signature = Vec::new();
writer.write_all(&signature)?;
Ok(())
}
fn write_layers<W: io::Write>(
writer: &mut W,
layers: &[ModpkgLayerBuilder],
) -> Result<(), ModpkgBuilderError> {
writer.write_u32::<LE>(layers.len() as u32)?;
for layer in layers {
writer.write_u32::<LE>(layer.name.as_str().len() as u32)?;
writer.write_all(layer.name.as_str().as_bytes())?;
writer.write_i32::<LE>(layer.priority)?;
}
Ok(())
}
fn write_chunk_paths<W: io::Write>(
writer: &mut W,
chunk_paths: &[String],
) -> Result<(), ModpkgBuilderError> {
writer.write_u32::<LE>(chunk_paths.len() as u32)?;
for path in chunk_paths {
writer.write_all(path.as_bytes())?;
writer.write_all(&[0])?; }
Ok(())
}
fn write_wads<W: io::Write>(writer: &mut W, wads: &[String]) -> Result<(), ModpkgBuilderError> {
writer.write_u32::<LE>(wads.len() as u32)?;
for wad in wads {
writer.write_all(wad.as_bytes())?;
writer.write_all(&[0])?; }
Ok(())
}
fn write_alignment<W: io::Write + io::Seek>(writer: &mut W) -> Result<(), ModpkgBuilderError> {
let current_pos = writer.stream_position()?;
let padding = (8 - (current_pos % 8)) % 8;
for _ in 0..padding {
writer.write_all(&[0])?;
}
Ok(())
}
fn build_layer_index_map(layers: &[ModpkgLayerBuilder]) -> HashMap<LayerHash, LayerIndex> {
let mut layer_index_map = HashMap::new();
for (idx, layer) in layers.iter().enumerate() {
layer_index_map.insert(
LayerHash::from_name(layer.name.as_str()),
LayerIndex::new(idx as u32),
);
}
layer_index_map
}
fn process_all_chunks<
TWriter: io::Write + io::Seek,
TChunkDataProvider: FnMut(&ModpkgChunkBuilder) -> Result<Vec<u8>, ModpkgBuilderError>,
>(
writer: &mut BufWriter<TWriter>,
provide_chunk_data: TChunkDataProvider,
meta_chunks: &[MetaChunk<'_>],
regular_chunks: &[&ModpkgChunkBuilder],
chunk_path_indices: &HashMap<PathHash, u32>,
layer_index_map: &HashMap<LayerHash, LayerIndex>,
wad_indices: &HashMap<WadNameHash, WadIndex>,
) -> Result<Vec<ModpkgChunk>, ModpkgBuilderError> {
let mut all_chunks = Self::process_meta_chunks(writer, meta_chunks, chunk_path_indices)?;
let mut processed_regular_chunks = Self::process_chunks(
regular_chunks,
writer,
provide_chunk_data,
chunk_path_indices,
layer_index_map,
wad_indices,
)?;
all_chunks.append(&mut processed_regular_chunks);
Ok(all_chunks)
}
fn collect_regular_chunks(
&self,
meta_path_hashes: &HashSet<PathHash>,
) -> Vec<&ModpkgChunkBuilder> {
let mut regular_chunks: Vec<_> = self
.chunks
.values()
.filter(|chunk| !meta_path_hashes.contains(&chunk.path_hash))
.collect();
regular_chunks.sort_by(|a, b| a.wad.cmp(&b.wad).then(a.layer.cmp(&b.layer)));
regular_chunks
}
fn write_chunk_toc<W: io::Write + io::Seek>(
writer: &mut W,
chunk_toc_offset: u64,
chunks: &[ModpkgChunk],
) -> Result<(), ModpkgBuilderError> {
writer.seek(SeekFrom::Start(chunk_toc_offset))?;
for chunk in chunks {
chunk.write(writer)?;
}
Ok(())
}
fn meta_chunks(&self) -> Result<Vec<MetaChunk<'_>>, ModpkgBuilderError> {
let mut metadata_bytes = Vec::new();
self.metadata.write(&mut metadata_bytes)?;
let mut meta_chunks = vec![MetaChunk {
path: METADATA_CHUNK_PATH,
data: Cow::Owned(metadata_bytes),
compression: ModpkgCompression::None,
}];
if let Some(thumbnail) = self.thumbnail.as_deref() {
meta_chunks.push(MetaChunk {
path: THUMBNAIL_CHUNK_PATH,
data: Cow::Borrowed(thumbnail),
compression: ModpkgCompression::None,
});
}
if let Some(readme) = self.readme.as_deref() {
meta_chunks.push(MetaChunk {
path: README_CHUNK_PATH,
data: Cow::Borrowed(readme),
compression: ModpkgCompression::None,
});
}
if let Some(license_text) = self.license_text.as_deref() {
meta_chunks.push(MetaChunk {
path: LICENSE_CHUNK_PATH,
data: Cow::Borrowed(license_text),
compression: ModpkgCompression::Zstd,
});
}
Ok(meta_chunks)
}
fn process_meta_chunks<TWriter: io::Write + io::Seek>(
writer: &mut BufWriter<TWriter>,
meta_chunks: &[MetaChunk<'_>],
chunk_path_indices: &HashMap<PathHash, u32>,
) -> Result<Vec<ModpkgChunk>, ModpkgBuilderError> {
let mut processed = Vec::with_capacity(meta_chunks.len());
for meta_chunk in meta_chunks {
processed.push(Self::write_meta_chunk(
meta_chunk.path_hash(),
&meta_chunk.data,
meta_chunk.compression,
writer,
chunk_path_indices,
)?);
}
Ok(processed)
}
fn write_meta_chunk<TWriter: io::Write + io::Seek>(
path_hash: PathHash,
data: &[u8],
compression: ModpkgCompression,
writer: &mut BufWriter<TWriter>,
chunk_path_indices: &HashMap<PathHash, u32>,
) -> Result<ModpkgChunk, ModpkgBuilderError> {
let uncompressed_size = data.len();
let uncompressed_checksum = xxh3_64(data);
let (stored_data, compression) = Self::compress_chunk_data(data, compression)?;
let compressed_size = stored_data.len();
let compressed_checksum = xxh3_64(&stored_data);
let data_offset = writer.stream_position()?;
writer.write_all(&stored_data)?;
Ok(ModpkgChunk {
path_hash,
data_offset,
compression,
compressed_size: compressed_size as u64,
uncompressed_size: uncompressed_size as u64,
compressed_checksum,
uncompressed_checksum,
path_index: *chunk_path_indices.get(&path_hash).unwrap_or(&0),
layer_index: LayerIndex::NONE,
wad_index: WadIndex::NONE,
})
}
const MAX_COMPRESSED_SIZE_PERCENT: u64 = 95;
fn compress_chunk_data(
data: &[u8],
compression: ModpkgCompression,
) -> Result<(Vec<u8>, ModpkgCompression), ModpkgBuilderError> {
match compression {
ModpkgCompression::None => Ok((data.to_vec(), ModpkgCompression::None)),
ModpkgCompression::Zstd => {
let compressed = zstd::bulk::compress(data, 3)?;
if (compressed.len() as u64) * 100
>= (data.len() as u64) * Self::MAX_COMPRESSED_SIZE_PERCENT
{
Ok((data.to_vec(), ModpkgCompression::None))
} else {
Ok((compressed, ModpkgCompression::Zstd))
}
}
}
}
fn collect_unique_layers(
chunks: &[&ModpkgChunkBuilder],
) -> (Vec<String>, HashMap<LayerHash, LayerIndex>) {
let mut layers = Vec::new();
let mut layer_indices = HashMap::new();
for chunk in chunks {
if chunk.layer.is_empty() {
continue;
}
let hash = LayerHash::from_name(&chunk.layer);
layer_indices.entry(hash).or_insert_with(|| {
let index = layers.len();
layers.push(chunk.layer.clone());
LayerIndex::new(index as u32)
});
}
(layers, layer_indices)
}
fn collect_unique_paths(
meta_chunks: &[MetaChunk<'_>],
regular_chunks: &[&ModpkgChunkBuilder],
) -> (Vec<String>, HashMap<PathHash, u32>) {
let mut paths = Vec::new();
let mut path_indices = HashMap::new();
let meta_paths = meta_chunks
.iter()
.map(|meta| (meta.path_hash(), meta.path.to_string()));
let regular_paths = regular_chunks
.iter()
.map(|chunk| (chunk.path_hash, chunk.path.clone()));
for (path_hash, path) in meta_paths.chain(regular_paths) {
path_indices.entry(path_hash).or_insert_with(|| {
let index = paths.len();
paths.push(path);
index as u32
});
}
(paths, path_indices)
}
fn collect_unique_wads(
chunks: &[&ModpkgChunkBuilder],
) -> (Vec<String>, HashMap<WadNameHash, WadIndex>) {
let mut wads = Vec::new();
let mut wad_indices = HashMap::new();
for chunk in chunks {
if chunk.wad.is_empty() {
continue;
}
wad_indices
.entry(WadNameHash::from_name(&chunk.wad))
.or_insert_with(|| {
let index = wads.len();
wads.push(chunk.wad.clone());
WadIndex::new(index as u32)
});
}
(wads, wad_indices)
}
fn validate_layers(
defined_layers: &[ModpkgLayerBuilder],
unique_layers: &[String],
) -> Result<(), ModpkgBuilderError> {
if !defined_layers
.iter()
.any(|layer| layer.name == BASE_LAYER_NAME)
{
return Err(ModpkgBuilderError::MissingBaseLayer);
}
for layer in unique_layers {
if layer.is_empty() {
continue;
}
if !defined_layers.iter().any(|l| l.name == layer.as_str()) {
return Err(ModpkgBuilderError::LayerNotFound(layer.to_string()));
}
}
Ok(())
}
fn process_chunks<
TWriter: io::Write + io::Seek,
TChunkDataProvider: FnMut(&ModpkgChunkBuilder) -> Result<Vec<u8>, ModpkgBuilderError>,
>(
chunks: &[&ModpkgChunkBuilder],
writer: &mut BufWriter<TWriter>,
mut provide_chunk_data: TChunkDataProvider,
chunk_path_indices: &HashMap<PathHash, u32>,
layer_indices: &HashMap<LayerHash, LayerIndex>,
wad_indices: &HashMap<WadNameHash, WadIndex>,
) -> Result<Vec<ModpkgChunk>, ModpkgBuilderError> {
let mut final_chunks = Vec::new();
let mut written_by_content: HashMap<ContentKey, (u64, u64, u64, ModpkgCompression)> =
HashMap::new();
let mut content_by_identity: HashMap<ChunkKey, (ContentKey, &str)> = HashMap::new();
for chunk_builder in chunks {
let uncompressed_data = provide_chunk_data(chunk_builder)?;
let uncompressed_size = uncompressed_data.len();
let uncompressed_checksum = xxh3_64(&uncompressed_data);
let content_key: ContentKey = (uncompressed_checksum, uncompressed_size as u64);
match content_by_identity.get(&chunk_builder.key()) {
Some(&(first_content, first_wad)) => {
if first_content != content_key {
return Err(ModpkgBuilderError::InconsistentChunk {
path: chunk_builder.path.clone(),
layer: chunk_builder.layer.clone(),
first_wad: first_wad.to_string(),
second_wad: chunk_builder.wad.clone(),
});
}
}
None => {
content_by_identity.insert(
chunk_builder.key(),
(content_key, chunk_builder.wad.as_str()),
);
}
}
let (data_offset, compressed_size, compressed_checksum, compression) =
match written_by_content.get(&content_key) {
Some(&existing) => existing,
None => {
let (compressed_data, compression) = Self::compress_chunk_data(
&uncompressed_data,
chunk_builder.compression,
)?;
let compressed_size = compressed_data.len() as u64;
let compressed_checksum = xxh3_64(&compressed_data);
let data_offset = writer.stream_position()?;
writer.write_all(&compressed_data)?;
let written = (
data_offset,
compressed_size,
compressed_checksum,
compression,
);
written_by_content.insert(content_key, written);
written
}
};
let path_hash = chunk_builder.path_hash;
let layer_index = if chunk_builder.layer.is_empty() {
LayerIndex::NONE
} else {
layer_indices
.get(&LayerHash::from_name(&chunk_builder.layer))
.copied()
.unwrap_or(LayerIndex::NONE)
};
let wad_index = if chunk_builder.wad.is_empty() {
WadIndex::NONE
} else {
wad_indices
.get(&WadNameHash::from_name(&chunk_builder.wad))
.copied()
.unwrap_or(WadIndex::NONE)
};
let chunk = ModpkgChunk {
path_hash,
data_offset,
compression,
compressed_size,
uncompressed_size: uncompressed_size as u64,
compressed_checksum,
uncompressed_checksum,
path_index: *chunk_path_indices.get(&path_hash).unwrap_or(&0),
layer_index,
wad_index,
};
final_chunks.push(chunk);
}
Ok(final_chunks)
}
}
impl ModpkgChunkBuilder {
const DEFAULT_LAYER: &'static str = "base";
pub fn new() -> Self {
Self {
path_hash: PathHash::new(0),
path: String::new(),
compression: ModpkgCompression::None,
layer: Self::DEFAULT_LAYER.to_string(),
wad: String::new(),
}
}
pub fn with_path(mut self, path: &str) -> Self {
let path = ChunkPath::new(path);
self.path_hash = path.hash();
self.path = path.into_string();
self
}
pub fn with_hashed_chunk_name(mut self, hashed_name: &str) -> Result<Self, ModpkgBuilderError> {
let display_path = hashed_name.to_lowercase();
let filename = Path::new(&display_path)
.file_name()
.and_then(|s| s.to_str())
.unwrap_or(&display_path);
self.path_hash = PathHash::from_hex_name(filename)
.ok_or_else(|| ModpkgBuilderError::InvalidChunkName(display_path.clone()))?;
self.path = display_path;
Ok(self)
}
pub fn with_compression(mut self, compression: ModpkgCompression) -> Self {
self.compression = compression;
self
}
pub fn with_layer(mut self, layer: &str) -> Self {
self.layer = layer.to_string();
self
}
pub fn with_wad(mut self, wad: &str) -> Self {
self.wad = wad.to_lowercase();
self
}
pub fn path_hash(&self) -> PathHash {
self.path_hash
}
pub fn path(&self) -> &str {
&self.path
}
pub fn compression(&self) -> ModpkgCompression {
self.compression
}
pub fn layer(&self) -> &str {
&self.layer
}
pub fn wad(&self) -> &str {
&self.wad
}
pub fn key(&self) -> ChunkKey {
let layer_hash = if self.layer.is_empty() {
LayerHash::NONE
} else {
LayerHash::from_name(&self.layer)
};
ChunkKey::new(self.path_hash, layer_hash)
}
pub fn wad_hash(&self) -> WadNameHash {
if self.wad.is_empty() {
WadNameHash::NONE
} else {
WadNameHash::from_name(&self.wad)
}
}
pub fn full_key(&self) -> (ChunkKey, WadNameHash) {
(self.key(), self.wad_hash())
}
}
impl ModpkgBuilder {
pub fn with_metadata(mut self, metadata: ModpkgMetadata) -> Self {
self.metadata = metadata;
self
}
pub fn with_readme(mut self, readme: impl Into<Vec<u8>>) -> Self {
self.readme = Some(readme.into());
self
}
pub fn with_license_text(mut self, license_text: impl Into<Vec<u8>>) -> Self {
self.license_text = Some(license_text.into());
self
}
pub fn with_thumbnail(mut self, thumbnail: impl Into<Vec<u8>>) -> Self {
self.thumbnail = Some(thumbnail.into());
self
}
}
impl ModpkgLayerBuilder {
pub fn new(name: impl AsRef<str>) -> Result<Self, ModpkgBuilderError> {
Ok(Self {
name: Slug::new(name)?,
priority: 0,
})
}
pub fn from_slug(name: Slug) -> Self {
Self { name, priority: 0 }
}
pub fn with_name(mut self, name: impl AsRef<str>) -> Result<Self, ModpkgBuilderError> {
self.name = Slug::new(name)?;
Ok(self)
}
pub fn with_priority(mut self, priority: i32) -> Self {
self.priority = priority;
self
}
pub fn base() -> Self {
Self {
name: Slug::base(),
priority: 0,
}
}
pub fn name(&self) -> &Slug {
&self.name
}
pub fn priority(&self) -> i32 {
self.priority
}
}
#[cfg(test)]
mod tests {
use crate::{Modpkg, ModpkgError, ModpkgLayer};
use super::*;
use std::io::Cursor;
#[test]
fn test_modpkg_builder() {
let scratch = Vec::new();
let mut cursor = Cursor::new(scratch);
let builder = ModpkgBuilder::default()
.with_metadata(ModpkgMetadata::default())
.with_layer(ModpkgLayerBuilder::new("base").unwrap().with_priority(0))
.with_chunk(
ModpkgChunkBuilder::new()
.with_path("test.png")
.with_compression(ModpkgCompression::Zstd)
.with_layer("base"),
);
builder
.build_to_writer(&mut cursor, |_| Ok(vec![0xAA; 100]))
.expect("Failed to build Modpkg");
cursor.set_position(0);
let modpkg = Modpkg::mount_from_reader(&mut cursor).unwrap();
assert_eq!(modpkg.chunks().len(), 2);
let chunk = modpkg
.chunks()
.get(&ChunkKey::new(
ChunkPath::new("test.png").hash(),
LayerHash::from_name("base"),
))
.unwrap();
assert_eq!(
modpkg.chunk_paths().get(&ChunkPath::new("test.png").hash()),
Some(&"test.png".to_string())
);
assert_eq!(chunk.compression, ModpkgCompression::Zstd);
assert_eq!(chunk.uncompressed_size, 100);
assert_eq!(chunk.compressed_size, 17);
assert_eq!(chunk.uncompressed_checksum, xxh3_64(&[0xAA; 100]));
assert_eq!(chunk.path_index, 1);
assert_eq!(modpkg.layers().len(), 1);
assert_eq!(
modpkg.layers().get(&LayerHash::from_name("base")),
Some(&ModpkgLayer {
name: "base".to_string(),
priority: 0,
})
);
}
#[test]
fn test_with_hashed_chunk_name() {
let chunk = ModpkgChunkBuilder::new()
.with_hashed_chunk_name("abcdef1234567890.dds")
.unwrap();
assert_eq!(chunk.path_hash(), PathHash::new(0xabcdef1234567890));
assert_eq!(chunk.path(), "abcdef1234567890.dds");
let chunk = ModpkgChunkBuilder::new()
.with_hashed_chunk_name("fedcba9876543210.txt")
.unwrap();
assert_eq!(chunk.path_hash(), PathHash::new(0xfedcba9876543210));
assert_eq!(chunk.path(), "fedcba9876543210.txt");
let chunk = ModpkgChunkBuilder::new()
.with_hashed_chunk_name("1234abc456def789.dds")
.unwrap();
assert_eq!(chunk.path_hash(), PathHash::new(0x1234abc456def789));
assert_eq!(chunk.path(), "1234abc456def789.dds");
let chunk = ModpkgChunkBuilder::new()
.with_hashed_chunk_name("789def0011223344")
.unwrap();
assert_eq!(chunk.path_hash(), PathHash::new(0x789def0011223344));
assert_eq!(chunk.path(), "789def0011223344");
assert!(ModpkgChunkBuilder::new()
.with_hashed_chunk_name("not_hex.bin")
.is_err());
let chunk = ModpkgChunkBuilder::new()
.with_hashed_chunk_name("abcdef1234567890.texture.dds")
.unwrap();
assert_eq!(chunk.path_hash(), PathHash::new(0xabcdef1234567890));
assert!(ModpkgChunkBuilder::new()
.with_hashed_chunk_name("0xabcdef1234567890.dds")
.is_err());
}
#[test]
fn incompressible_chunk_falls_back_to_raw_storage() {
let scratch = Vec::new();
let mut cursor = Cursor::new(scratch);
let mut state = 0x9E3779B97F4A7C15u64;
let noise: Vec<u8> = (0..4096)
.map(|_| {
state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
state as u8
})
.collect();
let builder = ModpkgBuilder::default()
.with_layer(ModpkgLayerBuilder::base())
.with_chunk(
ModpkgChunkBuilder::new()
.with_path("noise.bin")
.with_compression(ModpkgCompression::Zstd)
.with_layer("base"),
);
let noise_clone = noise.clone();
builder
.build_to_writer(&mut cursor, move |_| Ok(noise_clone.clone()))
.expect("Failed to build Modpkg");
cursor.set_position(0);
let mut modpkg = Modpkg::mount_from_reader(cursor).unwrap();
let chunk = *modpkg
.chunks()
.get(&ChunkKey::new(
ChunkPath::new("noise.bin").hash(),
LayerHash::from_name("base"),
))
.unwrap();
assert_eq!(chunk.compression, ModpkgCompression::None);
assert_eq!(chunk.compressed_size, chunk.uncompressed_size);
let loaded = modpkg
.load_chunk_decompressed_by_path("noise.bin", Some("base"))
.unwrap();
assert_eq!(&loaded[..], &noise[..]);
}
#[test]
fn identical_chunk_content_is_stored_once() {
let scratch = Vec::new();
let mut cursor = Cursor::new(scratch);
let shared_data = vec![0xAB; 10_000];
let unique_data = vec![0xCD; 10_000];
let builder = ModpkgBuilder::default()
.with_layer(ModpkgLayerBuilder::base())
.with_chunk(
ModpkgChunkBuilder::new()
.with_path("a.bin")
.with_compression(ModpkgCompression::Zstd)
.with_layer("base"),
)
.with_chunk(
ModpkgChunkBuilder::new()
.with_path("b.bin")
.with_compression(ModpkgCompression::Zstd)
.with_layer("base"),
)
.with_chunk(
ModpkgChunkBuilder::new()
.with_path("c.bin")
.with_compression(ModpkgCompression::Zstd)
.with_layer("base"),
);
let (shared_clone, unique_clone) = (shared_data.clone(), unique_data.clone());
builder
.build_to_writer(&mut cursor, move |chunk| {
if chunk.path() == "c.bin" {
Ok(unique_clone.clone())
} else {
Ok(shared_clone.clone())
}
})
.expect("Failed to build Modpkg");
cursor.set_position(0);
let mut modpkg = Modpkg::mount_from_reader(cursor).unwrap();
let base = LayerHash::from_name("base");
let a = *modpkg
.chunks()
.get(&ChunkKey::new(ChunkPath::new("a.bin").hash(), base))
.unwrap();
let b = *modpkg
.chunks()
.get(&ChunkKey::new(ChunkPath::new("b.bin").hash(), base))
.unwrap();
let c = *modpkg
.chunks()
.get(&ChunkKey::new(ChunkPath::new("c.bin").hash(), base))
.unwrap();
assert_eq!(a.data_offset, b.data_offset);
assert_eq!(a.compressed_size, b.compressed_size);
assert_eq!(a.compressed_checksum, b.compressed_checksum);
assert_ne!(c.data_offset, a.data_offset);
assert_ne!(c.uncompressed_checksum, a.uncompressed_checksum);
let loaded_a = modpkg
.load_chunk_decompressed_by_path("a.bin", Some("base"))
.unwrap();
let loaded_b = modpkg
.load_chunk_decompressed_by_path("b.bin", Some("base"))
.unwrap();
let loaded_c = modpkg
.load_chunk_decompressed_by_path("c.bin", Some("base"))
.unwrap();
assert_eq!(&loaded_a[..], &shared_data[..]);
assert_eq!(&loaded_b[..], &shared_data[..]);
assert_eq!(&loaded_c[..], &unique_data[..]);
}
fn shared_chunk(wad: &str) -> ModpkgChunkBuilder {
ModpkgChunkBuilder::new()
.with_path("data/shared.bin")
.with_compression(ModpkgCompression::Zstd)
.with_layer("base")
.with_wad(wad)
}
#[test]
fn shared_chunk_across_wads_mounts_under_both() {
let mut cursor = Cursor::new(Vec::new());
ModpkgBuilder::default()
.with_layer(ModpkgLayerBuilder::base())
.with_chunk(shared_chunk("aatrox.wad.client"))
.with_chunk(shared_chunk("ahri.wad.client"))
.build_to_writer(&mut cursor, |_| Ok(vec![0xCC; 64]))
.expect("Failed to build Modpkg");
cursor.set_position(0);
let mut modpkg = Modpkg::mount_from_reader(cursor).unwrap();
assert_eq!(modpkg.chunks().len(), 2);
let key = ChunkKey::new(
ChunkPath::new("data/shared.bin").hash(),
LayerHash::from_name("base"),
);
let layer_index = modpkg.layer_index("base").unwrap();
for wad in ["aatrox.wad.client", "ahri.wad.client"] {
let wad_index = modpkg.wad_index(wad).expect("wad in table");
assert_eq!(
modpkg.chunks_for_wad_layer(wad_index, layer_index),
[key],
"{wad} should hold the shared chunk"
);
}
let loaded = modpkg
.load_chunk_decompressed_by_path("data/shared.bin", Some("base"))
.unwrap();
assert_eq!(&loaded[..], &[0xCC; 64]);
}
#[test]
fn inconsistent_shared_chunk_content_fails_the_build() {
let mut cursor = Cursor::new(Vec::new());
let err = ModpkgBuilder::default()
.with_layer(ModpkgLayerBuilder::base())
.with_chunk(shared_chunk("aatrox.wad.client"))
.with_chunk(shared_chunk("ahri.wad.client"))
.build_to_writer(&mut cursor, |chunk| Ok(chunk.wad().as_bytes().to_vec()))
.unwrap_err();
assert!(
matches!(
err,
ModpkgBuilderError::InconsistentChunk {
ref path,
ref layer,
ref first_wad,
ref second_wad,
} if path == "data/shared.bin"
&& layer == "base"
&& first_wad == "aatrox.wad.client"
&& second_wad == "ahri.wad.client"
),
"Expected InconsistentChunk, got: {err}"
);
}
#[test]
fn mount_rejects_inconsistent_duplicate_records() {
let mut cursor = Cursor::new(Vec::new());
ModpkgBuilder::default()
.with_layer(ModpkgLayerBuilder::base())
.with_chunk(shared_chunk("aatrox.wad.client"))
.with_chunk(shared_chunk("ahri.wad.client"))
.build_to_writer(&mut cursor, |_| Ok(vec![0xCC; 64]))
.expect("Failed to build Modpkg");
let mut bytes = cursor.into_inner();
let path_hash = ChunkPath::new("data/shared.bin").hash();
let needle = path_hash.value().to_le_bytes();
let records: Vec<usize> = (0..=bytes.len() - 8)
.filter(|&pos| bytes[pos..pos + 8] == needle)
.collect();
assert_eq!(records.len(), 2, "expected exactly two TOC records");
for byte in &mut bytes[records[1] + 41..records[1] + 49] {
*byte ^= 0xFF;
}
let err = Modpkg::mount_from_reader(Cursor::new(bytes)).unwrap_err();
assert!(
matches!(err, ModpkgError::ChunksInconsistent(hash) if hash == path_hash),
"Expected ChunksInconsistent, got: {err}"
);
}
#[test]
fn layer_builder_rejects_names_the_packer_would_reject() {
for name in ["", "High Res", "-leading", "trailing-"] {
assert!(
ModpkgLayerBuilder::new(name).is_err(),
"{name} should be rejected"
);
}
assert!(ModpkgLayerBuilder::new("high-res").is_ok());
}
#[test]
fn with_path_normalizes_backslashes() {
let chunk = ModpkgChunkBuilder::new()
.with_path("ASSETS\\Characters\\Aatrox\\Skins\\Base\\Aatrox.dds");
assert_eq!(chunk.path, "assets/characters/aatrox/skins/base/aatrox.dds");
let forward =
ModpkgChunkBuilder::new().with_path("assets/characters/aatrox/skins/base/aatrox.dds");
assert_eq!(chunk.path_hash(), forward.path_hash());
}
#[test]
fn roundtrip_backslash_paths_normalized() {
let scratch = Vec::new();
let mut cursor = Cursor::new(scratch);
let builder = ModpkgBuilder::default()
.with_layer(ModpkgLayerBuilder::base())
.with_chunk(
ModpkgChunkBuilder::new()
.with_path("ASSETS\\Characters\\Aatrox\\Aatrox.dds")
.with_compression(ModpkgCompression::None)
.with_layer("base"),
);
builder
.build_to_writer(&mut cursor, |_| Ok(vec![0xBB; 50]))
.expect("Failed to build Modpkg");
cursor.set_position(0);
let modpkg = Modpkg::mount_from_reader(&mut cursor).unwrap();
let normalized = "assets/characters/aatrox/aatrox.dds";
let path_hash = ChunkPath::new(normalized).hash();
assert_eq!(
modpkg.chunk_paths().get(&path_hash),
Some(&normalized.to_string()),
"chunk_paths should contain the normalized path"
);
assert!(
modpkg
.chunks()
.contains_key(&ChunkKey::new(path_hash, LayerHash::from_name("base"))),
"chunk should be retrievable with normalized path hash"
);
}
}