diff --git a/.vscode/settings.json b/.vscode/settings.json index bdf72fb..ad8edf4 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -30,5 +30,6 @@ }, "[rust]": { "editor.defaultFormatter": "rust-lang.rust-analyzer" - } + }, + "rust-analyzer.cargo.features": "all" } diff --git a/Cargo.lock b/Cargo.lock index 4018b88..ee3c780 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1689,6 +1689,7 @@ version = "0.4.0" dependencies = [ "serde", "serde_json", + "thiserror 2.0.18", "toml", ] diff --git a/crates/league-mod/src/commands/pack.rs b/crates/league-mod/src/commands/pack.rs index a2ef534..4064c46 100644 --- a/crates/league-mod/src/commands/pack.rs +++ b/crates/league-mod/src/commands/pack.rs @@ -80,7 +80,7 @@ fn pack_to_modpkg( let output_path = output_dir.join(&modpkg_file_name); // Use the shared packing logic from ltk_modpkg - modpkg_project::pack_from_project(project_root, &output_path, &mod_project) + modpkg_project::pack_from_project_with_config(project_root, &output_path, &mod_project) .map_err(|e| convert_pack_error(e, project_root))?; println_pad!( diff --git a/crates/ltk_mod_project/Cargo.toml b/crates/ltk_mod_project/Cargo.toml index 8f3e869..7542626 100644 --- a/crates/ltk_mod_project/Cargo.toml +++ b/crates/ltk_mod_project/Cargo.toml @@ -14,5 +14,6 @@ authors = ["LeagueToolkit"] [dependencies] serde = { version = "1.0", features = ["derive"] } +thiserror = "2.0" toml = "0.8.19" serde_json = "1.0" diff --git a/crates/ltk_mod_project/src/lib.rs b/crates/ltk_mod_project/src/lib.rs index a26b571..bbc41af 100644 --- a/crates/ltk_mod_project/src/lib.rs +++ b/crates/ltk_mod_project/src/lib.rs @@ -1,6 +1,7 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::fmt; +use std::path::Path; fn serde_fmt(value: &T, f: &mut fmt::Formatter<'_>) -> fmt::Result { let json = serde_json::to_string(value).map_err(|_| fmt::Error)?; @@ -85,8 +86,30 @@ impl From for ModMap { } } +/// Config file names to search for, in priority order. +const CONFIG_FILE_NAMES: [&str; 2] = ["mod.config.json", "mod.config.toml"]; + +/// Error returned when loading a mod project configuration. +#[derive(Debug, thiserror::Error)] +pub enum ModProjectError { + #[error("Config file not found in {0} (expected mod.config.json or mod.config.toml)")] + ConfigNotFound(std::path::PathBuf), + + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + + #[error("Failed to parse JSON config: {0}")] + Json(#[from] serde_json::Error), + + #[error("Failed to parse TOML config: {0}")] + Toml(#[from] toml::de::Error), + + #[error("Unsupported config file extension: {0}")] + UnsupportedExtension(String), +} + /// Describes a mod project configuration file -#[derive(Serialize, Deserialize, Debug, PartialEq)] +#[derive(Serialize, Deserialize, Debug, PartialEq, Clone)] pub struct ModProject { /// The name of the mod /// Must not contain spaces or special characters except for underscores and hyphens @@ -147,6 +170,44 @@ pub struct ModProject { pub thumbnail: Option, } +impl ModProject { + /// Load a mod project from a project directory. + /// + /// Searches for `mod.config.json` (preferred) or `mod.config.toml` in the + /// given directory and parses the first one found. + pub fn load(project_dir: &Path) -> Result { + for name in CONFIG_FILE_NAMES { + let path = project_dir.join(name); + if path.exists() { + return Self::load_from_file(&path); + } + } + Err(ModProjectError::ConfigNotFound(project_dir.to_owned())) + } + + /// Load a mod project from a specific config file path. + /// + /// The format is determined by the file extension (`.json` or `.toml`). + pub fn load_from_file(path: &Path) -> Result { + let ext = path + .extension() + .and_then(|e| e.to_str()) + .unwrap_or_default(); + + match ext { + "json" => { + let file = std::fs::File::open(path)?; + Ok(serde_json::from_reader(file)?) + } + "toml" => { + let content = std::fs::read_to_string(path)?; + Ok(toml::from_str(&content)?) + } + other => Err(ModProjectError::UnsupportedExtension(other.to_string())), + } + } +} + /// Represents a layer in a mod project #[derive(Serialize, Deserialize, Debug, PartialEq, Clone)] pub struct ModProjectLayer { @@ -178,14 +239,14 @@ pub struct ModProjectLayer { pub string_overrides: HashMap>, } -#[derive(Serialize, Deserialize, Debug, PartialEq)] +#[derive(Serialize, Deserialize, Debug, PartialEq, Clone)] #[serde(untagged)] pub enum ModProjectAuthor { Name(String), Role { name: String, role: String }, } -#[derive(Serialize, Deserialize, Debug, PartialEq)] +#[derive(Serialize, Deserialize, Debug, PartialEq, Clone)] #[serde(untagged)] pub enum ModProjectLicense { Spdx(String), diff --git a/crates/ltk_modpkg/Cargo.toml b/crates/ltk_modpkg/Cargo.toml index 0c66618..41bdc87 100644 --- a/crates/ltk_modpkg/Cargo.toml +++ b/crates/ltk_modpkg/Cargo.toml @@ -7,7 +7,7 @@ description = "League Toolkit mod package (.modpkg) reader/writer and utilities" repository = "https://github.com/LeagueToolkit/league-mod" homepage = "https://github.com/LeagueToolkit/league-mod" documentation = "https://github.com/LeagueToolkit/league-mod/wiki" -readme = "../../README.md" +readme = "README.md" keywords = ["league-of-legends", "modding", "gaming", "toolkit"] categories = ["game-development"] authors = ["LeagueToolkit"] diff --git a/crates/ltk_modpkg/README.md b/crates/ltk_modpkg/README.md new file mode 100644 index 0000000..e7bc0ca --- /dev/null +++ b/crates/ltk_modpkg/README.md @@ -0,0 +1,114 @@ +# ltk_modpkg + +A Rust library for reading, writing, and packing `.modpkg` archives — the binary mod distribution format for League of Legends mods in the [League Mod Toolkit](https://github.com/LeagueToolkit/league-mod). + +## Overview + +A `.modpkg` file is a binary container that stores mod content organized by layers and WAD targets, with per-chunk zstd compression, xxhash checksums, and embedded metadata (name, version, authors, license, thumbnail, etc.). + +This crate provides: + +- **Reading** — mount a modpkg from any `Read + Seek` source and access chunks by path hash +- **Writing** — build a modpkg from scratch using `ModpkgBuilder` +- **Project packing** — scan a mod project directory and produce a modpkg in one call (requires `project` feature) +- **Extraction** — extract modpkg contents back to disk +- **Metadata** — read/write msgpack-encoded mod metadata + +## Usage + +### Reading a modpkg + +```rust +use ltk_modpkg::Modpkg; +use std::fs::File; + +let file = File::open("my-mod_1.0.0.modpkg")?; +let mut modpkg = Modpkg::mount_from_reader(file)?; + +// Read metadata +let metadata = modpkg.load_metadata()?; +println!("{} v{}", metadata.name, metadata.version); + +// List WADs +for wad_name in modpkg.wads.values() { + println!("WAD: {wad_name}"); +} +``` + +### Packing a mod project (requires `project` feature) + +```rust +use ltk_modpkg::project::ProjectPacker; +use camino::Utf8PathBuf; + +// Loads mod.config.json/toml automatically from the project directory +let packer = ProjectPacker::new(Utf8PathBuf::from("my-mod"))?; +packer.pack("build/my-mod_1.0.0.modpkg".into())?; +``` + +Or pack to an in-memory buffer: + +```rust +use ltk_modpkg::project::ProjectPacker; +use camino::Utf8PathBuf; + +let mut buffer = std::io::Cursor::new(Vec::new()); +ProjectPacker::new(Utf8PathBuf::from("my-mod"))? + .pack_to_writer(&mut buffer)?; +``` + +### Building a modpkg programmatically + +```rust +use ltk_modpkg::builder::{ModpkgBuilder, ModpkgChunkBuilder, ModpkgLayerBuilder}; +use ltk_modpkg::ModpkgCompression; + +let builder = ModpkgBuilder::default() + .with_layer(ModpkgLayerBuilder::base()) + .with_chunk( + ModpkgChunkBuilder::new() + .with_path("data/characters/graves/skin0.bin") + .unwrap() + .with_compression(ModpkgCompression::Zstd) + .with_layer("base") + .with_wad("Graves.wad.client"), + ); + +let mut output = std::fs::File::create("out.modpkg")?; +builder.build_to_writer(&mut output, |chunk, cursor| { + // provide raw chunk data here + Ok(()) +})?; +``` + +## Features + +| Feature | Default | Description | +|---------|---------|-------------| +| `project` | no | Enables `ProjectPacker` and mod project packing from disk. Adds `ltk_mod_project` dependency. | + +## Project structure + +The expected mod project layout (used by `ProjectPacker`): + +``` +my-mod/ +├── mod.config.json # or mod.config.toml +├── README.md # optional, embedded in modpkg +├── thumbnail.webp # optional, embedded in modpkg +├── content/ +│ ├── base/ # base layer (priority 0) +│ │ ├── Graves.wad.client/ # WAD target directory +│ │ │ ├── data/ +│ │ │ └── assets/ +│ │ └── Map11.wad.client/ +│ │ └── data/ +│ └── high-res/ # additional layer +│ └── Graves.wad.client/ +│ └── assets/ +└── build/ # output directory +``` + +## License + +MIT OR Apache-2.0 diff --git a/crates/ltk_modpkg/src/project.rs b/crates/ltk_modpkg/src/project.rs deleted file mode 100644 index d5a3bfe..0000000 --- a/crates/ltk_modpkg/src/project.rs +++ /dev/null @@ -1,613 +0,0 @@ -//! High-level utilities for packing mod projects to `.modpkg` format. -//! -//! This module requires the `project` feature to be enabled. -//! -//! # Example -//! -//! ```ignore -//! use ltk_modpkg::project::{pack_from_project, PackOptions}; -//! use camino::Utf8Path; -//! -//! let project_root = Utf8Path::new("my-mod"); -//! let output_path = Utf8Path::new("build/my-mod_1.0.0.modpkg"); -//! -//! pack_from_project(project_root, output_path, &mod_project)?; -//! ``` - -use crate::{ - builder::{ModpkgBuilder, ModpkgBuilderError, ModpkgChunkBuilder, ModpkgLayerBuilder}, - metadata::CURRENT_SCHEMA_VERSION, - utils::hash_layer_name, - ModpkgCompression, ModpkgLayerMetadata, ModpkgMetadata, -}; -use camino::{Utf8Path, Utf8PathBuf}; -use image::ImageFormat; -use ltk_mod_project::{ModProject, ModProjectAuthor, ModProjectLayer, ModProjectLicense}; -use std::collections::HashMap; -use std::fs::{self, File}; -use std::io::{self, BufReader, BufWriter, Cursor, Read, Write}; - -/// Error type for project packing operations. -#[derive(Debug, thiserror::Error)] -pub enum PackError { - #[error("IO error: {0}")] - Io(#[from] io::Error), - - #[error("Builder error: {0}")] - Builder(#[from] ModpkgBuilderError), - - #[error("Config file not found in project directory: {0}")] - ConfigNotFound(Utf8PathBuf), - - #[error("Layer directory missing: {layer} at {path}")] - LayerDirMissing { layer: String, path: Utf8PathBuf }, - - #[error("Invalid layer name: {0}")] - InvalidLayerName(String), - - #[error("Base layer must have priority 0, got: {0}")] - InvalidBaseLayerPriority(i32), - - #[error("Failed to process thumbnail: {0}")] - ThumbnailError(String), - - #[error("Invalid version format: {0}")] - InvalidVersion(String), - - #[error("Glob pattern error: {0}")] - GlobError(#[from] glob::PatternError), - - #[error("Invalid UTF-8 path: {0}")] - InvalidUtf8Path(String), -} - -/// Options for packing a mod project. -#[derive(Debug, Clone, Default)] -pub struct PackOptions { - /// Custom output file name (without path). If None, uses `{name}_{version}.modpkg`. - pub file_name: Option, -} - -/// Result of a successful pack operation. -#[derive(Debug)] -pub struct PackResult { - /// The path to the created `.modpkg` file. - pub output_path: Utf8PathBuf, -} - -/// Create a standard modpkg file name from a mod project. -/// -/// If `custom_name` is provided, it will be used (with `.modpkg` extension added if missing). -/// Otherwise, generates `{name}_{version}.modpkg`. -pub fn create_file_name(mod_project: &ModProject, custom_name: Option) -> String { - match custom_name { - Some(name) => { - if name.ends_with(".modpkg") { - name - } else { - format!("{}.modpkg", name) - } - } - None => { - format!("{}_{}.modpkg", mod_project.name, mod_project.version) - } - } -} - -/// Pack a mod project to a `.modpkg` file. -/// -/// # Arguments -/// -/// * `project_root` - Path to the mod project directory (containing `mod.config.json` or `mod.config.toml`) -/// * `output_path` - Path where the `.modpkg` file will be written -/// * `mod_project` - The parsed mod project configuration -/// -/// # Returns -/// -/// Returns `PackResult` on success with the output path. -pub fn pack_from_project( - project_root: &Utf8Path, - output_path: &Utf8Path, - mod_project: &ModProject, -) -> Result { - let content_dir = project_root.join("content"); - - // Validate layers - validate_layers(mod_project, project_root)?; - - // Build the modpkg - let mut builder = ModpkgBuilder::default().with_layer(ModpkgLayerBuilder::base()); - let mut chunk_filepaths: HashMap<(u64, u64), Utf8PathBuf> = HashMap::new(); - - // Add metadata - builder = build_metadata(builder, mod_project)?; - - // Add layers and their content - builder = build_layers(builder, &content_dir, mod_project, &mut chunk_filepaths)?; - - // Add meta chunks (README, thumbnail) - builder = add_meta_chunks(builder, project_root, mod_project)?; - - // Create output directory if needed - if let Some(parent) = output_path.parent() { - if !parent.exists() { - fs::create_dir_all(parent)?; - } - } - - // Write the modpkg file - let mut writer = BufWriter::new(File::create(output_path)?); - - builder - .build_to_writer(&mut writer, |chunk_builder, cursor| { - write_chunk_payload(chunk_builder, cursor, &chunk_filepaths) - .map_err(ModpkgBuilderError::from) - }) - .map_err(PackError::Builder)?; - - Ok(PackResult { - output_path: output_path.to_owned(), - }) -} - -/// Validate that all layers exist and have valid configuration. -fn validate_layers(mod_project: &ModProject, project_root: &Utf8Path) -> Result<(), PackError> { - for layer in &mod_project.layers { - // Validate layer name is a valid slug - if !is_valid_slug(&layer.name) { - return Err(PackError::InvalidLayerName(layer.name.clone())); - } - - // Base layer must have priority 0 - if layer.name == "base" && layer.priority != 0 { - return Err(PackError::InvalidBaseLayerPriority(layer.priority)); - } - - // Check layer directory exists - let layer_dir = project_root.join("content").join(&layer.name); - if !layer_dir.exists() { - return Err(PackError::LayerDirMissing { - layer: layer.name.clone(), - path: layer_dir, - }); - } - } - - Ok(()) -} - -/// Check if a string is a valid slug (lowercase alphanumeric with hyphens). -fn is_valid_slug(s: &str) -> bool { - !s.is_empty() - && s.chars() - .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') - && !s.starts_with('-') - && !s.ends_with('-') -} - -fn build_metadata( - builder: ModpkgBuilder, - mod_project: &ModProject, -) -> Result { - let version = semver::Version::parse(&mod_project.version) - .map_err(|e| PackError::InvalidVersion(e.to_string()))?; - - let builder = builder - .with_metadata(ModpkgMetadata { - schema_version: CURRENT_SCHEMA_VERSION, - name: mod_project.name.clone(), - display_name: mod_project.display_name.clone(), - description: Some(mod_project.description.clone()), - version, - distributor: None, - authors: mod_project - .authors - .iter() - .map(convert_project_author) - .collect(), - license: convert_project_license(mod_project.license.as_ref()), - tags: mod_project.tags.iter().map(|t| t.to_string()).collect(), - champions: mod_project.champions.clone(), - maps: mod_project.maps.iter().map(|m| m.to_string()).collect(), - layers: build_metadata_layers(mod_project), - }) - .map_err(PackError::Builder)?; - - Ok(builder) -} - -/// Convert a project author to modpkg metadata author format. -fn convert_project_author(author: &ModProjectAuthor) -> crate::ModpkgAuthor { - match author { - ModProjectAuthor::Name(name) => crate::ModpkgAuthor { - name: name.clone(), - role: None, - }, - ModProjectAuthor::Role { name, role } => crate::ModpkgAuthor { - name: name.clone(), - role: Some(role.clone()), - }, - } -} - -/// Convert a project license to modpkg metadata license format. -fn convert_project_license(license: Option<&ModProjectLicense>) -> crate::ModpkgLicense { - match license { - None => crate::ModpkgLicense::None, - Some(ModProjectLicense::Spdx(id)) => crate::ModpkgLicense::Spdx { - spdx_id: id.clone(), - }, - Some(ModProjectLicense::Custom { name, url }) => crate::ModpkgLicense::Custom { - name: name.clone(), - url: url.clone(), - }, - } -} - -/// Build the per-layer metadata section. -fn build_metadata_layers(mod_project: &ModProject) -> Vec { - let mut layers = Vec::new(); - - // Base layer: always present, even if omitted from config - let base_from_config = mod_project.layers.iter().find(|l| l.name == "base"); - let base_display_name = base_from_config.and_then(|l| l.display_name.clone()); - let base_description = base_from_config - .and_then(|l| l.description.clone()) - .or_else(|| Some("Base layer of the mod".to_string())); - let base_string_overrides = base_from_config - .map(|l| l.string_overrides.clone()) - .unwrap_or_default(); - - layers.push(ModpkgLayerMetadata { - name: "base".to_string(), - display_name: base_display_name, - priority: 0, - description: base_description, - string_overrides: base_string_overrides, - }); - - // Non-base layers - for layer in mod_project.layers.iter().filter(|l| l.name != "base") { - layers.push(ModpkgLayerMetadata { - name: layer.name.clone(), - display_name: layer.display_name.clone(), - priority: layer.priority, - description: layer.description.clone(), - string_overrides: layer.string_overrides.clone(), - }); - } - - layers -} - -fn build_layers( - mut builder: ModpkgBuilder, - content_dir: &Utf8Path, - mod_project: &ModProject, - chunk_filepaths: &mut HashMap<(u64, u64), Utf8PathBuf>, -) -> Result { - // Process base layer - builder = build_layer_from_dir( - builder, - content_dir, - &ModProjectLayer::base(), - chunk_filepaths, - )?; - - // Process additional layers - for layer in &mod_project.layers { - if layer.name == "base" { - continue; - } - - builder = - builder.with_layer(ModpkgLayerBuilder::new(&layer.name).with_priority(layer.priority)); - builder = build_layer_from_dir(builder, content_dir, layer, chunk_filepaths)?; - } - - Ok(builder) -} - -fn build_layer_from_dir( - mut builder: ModpkgBuilder, - content_dir: &Utf8Path, - layer: &ModProjectLayer, - chunk_filepaths: &mut HashMap<(u64, u64), Utf8PathBuf>, -) -> Result { - let layer_dir = content_dir.join(&layer.name); - let pattern = layer_dir.join("**/*"); - - for entry in glob::glob(pattern.as_str())? - .filter_map(Result::ok) - .filter(|e| e.is_file()) - { - let entry = Utf8PathBuf::from_path_buf(entry) - .map_err(|p| PackError::InvalidUtf8Path(p.display().to_string()))?; - - let layer_hash = hash_layer_name(&layer.name); - let (new_builder, path_hash) = build_chunk_from_file(builder, layer, &entry, &layer_dir)?; - - chunk_filepaths - .entry((path_hash, layer_hash)) - .or_insert(entry); - - builder = new_builder; - } - - Ok(builder) -} - -fn build_chunk_from_file( - builder: ModpkgBuilder, - layer: &ModProjectLayer, - file_path: &Utf8Path, - layer_dir: &Utf8Path, -) -> Result<(ModpkgBuilder, u64), PackError> { - let relative_path = file_path - .strip_prefix(layer_dir) - .map_err(|e| PackError::Io(io::Error::other(e.to_string())))?; - - let relative_str = relative_path.as_str(); - - // Detect WAD association from the directory structure. - // If the first path component is a WAD name (e.g., "Aatrox.wad.client"), - // strip it from the chunk path and track it via wad_index instead. - let (chunk_path, wad_name) = match relative_str.split_once('/') { - Some((first, rest)) if first.to_ascii_lowercase().ends_with(".wad.client") => { - (rest, Some(first)) - } - _ => (relative_str, None), - }; - - let compression = compression_for_extension(file_path.extension()); - - let mut chunk_builder = ModpkgChunkBuilder::new() - .with_path(chunk_path) - .map_err(PackError::Builder)? - .with_compression(compression) - .with_layer(&layer.name); - - if let Some(wad) = wad_name { - chunk_builder = chunk_builder.with_wad(wad); - } - - let path_hash = chunk_builder.path_hash(); - Ok((builder.with_chunk(chunk_builder), path_hash)) -} - -/// Determine the best compression strategy based on file extension. -/// -/// Pre-compressed formats (textures, audio) gain little from zstd and -/// waste CPU time at both compression and decompression. -fn compression_for_extension(ext: Option<&str>) -> ModpkgCompression { - match ext.map(|e| e.to_ascii_lowercase()).as_deref() { - // Pre-compressed textures and images - Some("dds" | "tex" | "webp" | "png" | "jpg" | "jpeg") => ModpkgCompression::None, - // Pre-compressed audio - Some("bnk" | "wpk" | "wem" | "ogg") => ModpkgCompression::None, - // Everything else benefits from compression - _ => ModpkgCompression::Zstd, - } -} - -fn add_meta_chunks( - mut builder: ModpkgBuilder, - project_root: &Utf8Path, - mod_project: &ModProject, -) -> Result { - // README.md as meta chunk (optional) - let readme_path = project_root.join("README.md"); - if readme_path.exists() { - let readme_content = fs::read_to_string(&readme_path)?; - builder = builder - .with_readme(&readme_content) - .map_err(PackError::Builder)?; - } - - // Thumbnail as meta chunk (optional) - let thumbnail_path = mod_project - .thumbnail - .as_ref() - .map(|p| project_root.join(p)) - .unwrap_or_else(|| project_root.join("thumbnail.webp")); - - if thumbnail_path.exists() { - let thumbnail_data = load_thumbnail(&thumbnail_path)?; - builder = builder - .with_thumbnail(thumbnail_data) - .map_err(PackError::Builder)?; - } - - Ok(builder) -} - -/// Maximum thumbnail file size: 5MB -pub const MAX_THUMBNAIL_SIZE: u64 = 5 * 1024 * 1024; - -/// Load and convert a thumbnail image to WebP format. -/// -/// Supports all common image formats (PNG, JPEG, GIF, BMP, TIFF, ICO, WebP). -/// Animated GIFs are converted to animated WebP. -/// Validates file size (max 5MB). -/// -/// # Arguments -/// -/// * `path` - Path to the thumbnail image file -/// -/// # Returns -/// -/// WebP-encoded image data as bytes -pub fn load_thumbnail(path: &Utf8Path) -> Result, PackError> { - let metadata = fs::metadata(path).map_err(PackError::Io)?; - if metadata.len() > MAX_THUMBNAIL_SIZE { - return Err(PackError::ThumbnailError(format!( - "Thumbnail file size ({} bytes) exceeds maximum allowed size ({} bytes / 5MB)", - metadata.len(), - MAX_THUMBNAIL_SIZE - ))); - } - - let extension = path - .extension() - .map(|ext| ext.to_lowercase()) - .unwrap_or_default(); - - if extension == "webp" { - let data = fs::read(path).map_err(PackError::Io)?; - // Validate WebP magic bytes - if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" { - return Ok(data); - } - return Err(PackError::ThumbnailError( - "Invalid WebP file format".to_string(), - )); - } - - if extension == "gif" { - return convert_gif_to_webp(path); - } - - let img = image::open(path) - .map_err(|e| PackError::ThumbnailError(format!("Failed to open image: {}", e)))?; - - let mut buffer = Cursor::new(Vec::new()); - img.write_to(&mut buffer, ImageFormat::WebP) - .map_err(|e| PackError::ThumbnailError(format!("Failed to convert to WebP: {}", e)))?; - - Ok(buffer.into_inner()) -} - -fn convert_gif_to_webp(path: &Utf8Path) -> Result, PackError> { - let file = File::open(path).map_err(PackError::Io)?; - let reader = BufReader::new(file); - let decoder = image::codecs::gif::GifDecoder::new(reader) - .map_err(|e| PackError::ThumbnailError(format!("Failed to decode GIF: {}", e)))?; - - let frames: Vec<_> = image::AnimationDecoder::into_frames(decoder) - .collect::, _>>() - .map_err(|e| PackError::ThumbnailError(format!("Failed to read GIF frames: {}", e)))?; - - if frames.is_empty() { - return Err(PackError::ThumbnailError("GIF has no frames".to_string())); - } - - if frames.len() == 1 { - let frame = &frames[0]; - let img = frame.buffer(); - let mut buffer = Cursor::new(Vec::new()); - img.write_to(&mut buffer, ImageFormat::WebP).map_err(|e| { - PackError::ThumbnailError(format!("Failed to convert GIF to WebP: {}", e)) - })?; - return Ok(buffer.into_inner()); - } - - encode_animated_webp(&frames) -} - -fn encode_animated_webp(frames: &[image::Frame]) -> Result, PackError> { - use webp_animation::prelude::*; - - if frames.is_empty() { - return Err(PackError::ThumbnailError("No frames to encode".to_string())); - } - - let first_frame = frames[0].buffer(); - let (width, height) = first_frame.dimensions(); - - let mut encoder = Encoder::new((width, height)).map_err(|e| { - PackError::ThumbnailError(format!("Failed to create WebP encoder: {:?}", e)) - })?; - - let mut timestamp_ms = 0i32; - for frame in frames { - let img_buffer = frame.buffer(); - let delay = frame.delay(); - let rgba_data = img_buffer.as_raw(); - - encoder - .add_frame(rgba_data, timestamp_ms) - .map_err(|e| PackError::ThumbnailError(format!("Failed to add frame: {:?}", e)))?; - - let delay_ms = delay.numer_denom_ms(); - timestamp_ms += delay_ms.0 as i32; - } - - let webp_data = encoder - .finalize(timestamp_ms) - .map_err(|e| PackError::ThumbnailError(format!("Failed to finalize animation: {:?}", e)))?; - - Ok(webp_data.to_vec()) -} - -fn write_chunk_payload( - chunk_builder: &ModpkgChunkBuilder, - cursor: &mut Cursor>, - chunk_filepaths: &HashMap<(u64, u64), Utf8PathBuf>, -) -> io::Result<()> { - // Content chunks - look up file path from the map - let key = ( - chunk_builder.path_hash(), - hash_layer_name(chunk_builder.layer()), - ); - if let Some(file_path) = chunk_filepaths.get(&key) { - let mut file = File::open(file_path)?; - let mut buffer = Vec::new(); - file.read_to_end(&mut buffer)?; - cursor.write_all(&buffer)?; - return Ok(()); - } - - Err(io::Error::new( - io::ErrorKind::NotFound, - format!( - "Missing file path for chunk: {} (layer: '{}')", - chunk_builder.path, - chunk_builder.layer() - ), - )) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_create_file_name() { - let project = ModProject { - name: "my-mod".to_string(), - display_name: "My Mod".to_string(), - version: "1.2.3".to_string(), - description: String::new(), - authors: vec![], - license: None, - tags: vec![], - champions: vec![], - maps: vec![], - thumbnail: None, - layers: vec![], - transformers: vec![], - }; - - assert_eq!(create_file_name(&project, None), "my-mod_1.2.3.modpkg"); - assert_eq!( - create_file_name(&project, Some("custom".to_string())), - "custom.modpkg" - ); - assert_eq!( - create_file_name(&project, Some("custom.modpkg".to_string())), - "custom.modpkg" - ); - } - - #[test] - fn test_is_valid_slug() { - assert!(is_valid_slug("base")); - assert!(is_valid_slug("my-layer")); - assert!(is_valid_slug("layer123")); - assert!(!is_valid_slug("")); - assert!(!is_valid_slug("-invalid")); - assert!(!is_valid_slug("invalid-")); - assert!(!is_valid_slug("UPPERCASE")); - assert!(!is_valid_slug("has spaces")); - } -} diff --git a/crates/ltk_modpkg/src/project/mod.rs b/crates/ltk_modpkg/src/project/mod.rs new file mode 100644 index 0000000..0a00055 --- /dev/null +++ b/crates/ltk_modpkg/src/project/mod.rs @@ -0,0 +1,123 @@ +//! High-level utilities for packing mod projects to `.modpkg` format. +//! +//! This module requires the `project` feature to be enabled. +//! +//! # Example +//! +//! ```ignore +//! use ltk_modpkg::project::ProjectPacker; +//! use camino::Utf8PathBuf; +//! +//! let project_root = Utf8PathBuf::from("my-mod"); +//! +//! ProjectPacker::new(project_root)? +//! .pack("build/my-mod_1.0.0.modpkg".into())?; +//! ``` + +mod packer; +pub mod thumbnail; + +#[cfg(test)] +mod tests; + +pub use packer::ProjectPacker; +pub use thumbnail::{load_thumbnail, MAX_THUMBNAIL_SIZE}; + +use crate::builder::ModpkgBuilderError; +use camino::Utf8Path; +use camino::Utf8PathBuf; +use ltk_mod_project::ModProject; +use std::io; + +// --------------------------------------------------------------------------- +// Error & result types +// --------------------------------------------------------------------------- + +/// Error type for project packing operations. +#[derive(Debug, thiserror::Error)] +pub enum PackError { + #[error("IO error: {0}")] + Io(#[from] io::Error), + + #[error("Builder error: {0}")] + Builder(#[from] ModpkgBuilderError), + + #[error("Config file not found in project directory: {0}")] + ConfigNotFound(Utf8PathBuf), + + #[error("Failed to load project config: {0}")] + ConfigError(String), + + #[error("Layer directory missing: {layer} at {path}")] + LayerDirMissing { layer: String, path: Utf8PathBuf }, + + #[error("Invalid layer name: {0}")] + InvalidLayerName(String), + + #[error("Base layer must have priority 0, got: {0}")] + InvalidBaseLayerPriority(i32), + + #[error("Failed to process thumbnail: {0}")] + ThumbnailError(String), + + #[error("Invalid version format: {0}")] + InvalidVersion(String), + + #[error("Glob pattern error: {0}")] + GlobError(#[from] glob::PatternError), + + #[error("Invalid UTF-8 path: {0}")] + InvalidUtf8Path(String), +} + +/// Result of a successful pack operation. +#[derive(Debug)] +pub struct PackResult { + /// The path to the created `.modpkg` file. + pub output_path: Utf8PathBuf, +} + +// --------------------------------------------------------------------------- +// Convenience functions +// --------------------------------------------------------------------------- + +/// Create a standard modpkg file name from a mod project. +/// +/// If `custom_name` is provided, it will be used (with `.modpkg` extension added if missing). +/// Otherwise, generates `{name}_{version}.modpkg`. +pub fn create_file_name(mod_project: &ModProject, custom_name: Option) -> String { + match custom_name { + Some(name) => { + if name.ends_with(".modpkg") { + name + } else { + format!("{}.modpkg", name) + } + } + None => { + format!("{}_{}.modpkg", mod_project.name, mod_project.version) + } + } +} + +/// Pack a mod project directory to a `.modpkg` file. +/// +/// Loads the config from `project_root` automatically. +/// This is a convenience wrapper around [`ProjectPacker`]. +pub fn pack_from_project( + project_root: &Utf8Path, + output_path: &Utf8Path, +) -> Result { + ProjectPacker::new(project_root.to_owned())?.pack(output_path) +} + +/// Pack a mod project to a `.modpkg` file with an already-loaded config. +/// +/// Use this when you have a [`ModProject`] from another source. +pub fn pack_from_project_with_config( + project_root: &Utf8Path, + output_path: &Utf8Path, + mod_project: &ModProject, +) -> Result { + ProjectPacker::with_mod_project(mod_project.clone(), project_root.to_owned())?.pack(output_path) +} diff --git a/crates/ltk_modpkg/src/project/packer.rs b/crates/ltk_modpkg/src/project/packer.rs new file mode 100644 index 0000000..83e9bc1 --- /dev/null +++ b/crates/ltk_modpkg/src/project/packer.rs @@ -0,0 +1,481 @@ +//! [`ProjectPacker`] — scans a mod project directory and builds a `.modpkg` archive. + +use super::thumbnail::load_thumbnail; +use super::PackError; +use crate::{ + builder::{ModpkgBuilder, ModpkgBuilderError, ModpkgChunkBuilder, ModpkgLayerBuilder}, + metadata::CURRENT_SCHEMA_VERSION, + utils::hash_layer_name, + ModpkgCompression, ModpkgLayerMetadata, ModpkgMetadata, +}; +use camino::{Utf8Path, Utf8PathBuf}; +use ltk_mod_project::{ModProject, ModProjectAuthor, ModProjectLayer, ModProjectLicense}; +use std::collections::HashMap; +use std::fs::{self, File}; +use std::io::{self, BufWriter, Read, Seek, Write}; + +use super::PackResult; + +/// Maps `(path_hash, layer_hash)` to the source file on disk for each chunk. +type ChunkFileMap = HashMap<(u64, u64), Utf8PathBuf>; + +/// Packs a mod project directory into a `.modpkg` archive. +/// +/// The packer validates the project structure, scans the content directory for +/// files, and collects all the information needed to build the archive. Call +/// [`pack`](Self::pack) to write to a file or +/// [`pack_to_writer`](Self::pack_to_writer) to write to an arbitrary output. +/// +/// # Example +/// +/// ```ignore +/// use ltk_modpkg::project::ProjectPacker; +/// use camino::Utf8Path; +/// +/// // Pack to a file on disk +/// let result = ProjectPacker::new(project_root)? +/// .pack(Utf8Path::new("build/my-mod_1.0.0.modpkg"))?; +/// +/// // Pack to an in-memory buffer (with a pre-loaded config) +/// let mut buffer = std::io::Cursor::new(Vec::new()); +/// ProjectPacker::with_mod_project(mod_project, project_root)? +/// .pack_to_writer(&mut buffer)?; +/// ``` +#[derive(Debug)] +pub struct ProjectPacker { + mod_project: ModProject, + project_root: Utf8PathBuf, + chunks: Vec, + readme: Option, + thumbnail: Option>, +} + +/// An individual content file collected during the project scan. +#[derive(Debug)] +struct ChunkEntry { + /// Path relative to the WAD directory (or layer directory for non-WAD content). + rel_path: String, + /// Layer this chunk belongs to. + layer_name: String, + /// WAD association, if the file is inside a `.wad.client` directory. + wad_name: Option, + /// Absolute path to the source file on disk. + file_path: Utf8PathBuf, + /// Compression strategy based on file extension. + compression: ModpkgCompression, +} + +impl ProjectPacker { + /// Create a new packer by loading the mod project config from a directory. + /// + /// Looks for `mod.config.json` or `mod.config.toml` in `project_root`, + /// validates the project, and scans all layer directories for content. + pub fn new(project_root: Utf8PathBuf) -> Result { + let mod_project = ModProject::load(project_root.as_std_path()) + .map_err(|e| PackError::ConfigError(e.to_string()))?; + + Self::with_mod_project(mod_project, project_root) + } + + /// Create a new packer with an already-loaded mod project config. + /// + /// Use this when you have a [`ModProject`] from another source (e.g. an + /// in-memory config or a workshop import). For the common case of packing + /// from a project directory, prefer [`new`](Self::new). + pub fn with_mod_project( + mod_project: ModProject, + project_root: Utf8PathBuf, + ) -> Result { + validate_project(&mod_project, &project_root)?; + + let mut packer = Self { + mod_project, + project_root, + chunks: Vec::new(), + readme: None, + thumbnail: None, + }; + + packer.scan_layers()?; + packer.scan_meta_files()?; + + Ok(packer) + } + + /// Pack to a file on disk, creating parent directories if needed. + /// + /// Returns [`PackResult`] with the output path on success. + pub fn pack(self, output_path: &Utf8Path) -> Result { + if let Some(parent) = output_path.parent() { + if !parent.exists() { + fs::create_dir_all(parent)?; + } + } + + let mut writer = BufWriter::new(File::create(output_path)?); + self.pack_to_writer(&mut writer)?; + + Ok(PackResult { + output_path: output_path.to_owned(), + }) + } + + /// Pack to an arbitrary writer. + /// + /// This is useful for writing to in-memory buffers (e.g. for tests) or + /// streaming to a network socket. + pub fn pack_to_writer(self, writer: &mut W) -> Result<(), PackError> { + let (builder, file_map) = self.into_builder()?; + + builder + .build_to_writer(writer, |chunk_builder, cursor| { + let key = ( + chunk_builder.path_hash(), + hash_layer_name(chunk_builder.layer()), + ); + let file_path = file_map.get(&key).ok_or_else(|| { + ModpkgBuilderError::from(io::Error::new( + io::ErrorKind::NotFound, + format!( + "Missing file path for chunk: {} (layer: '{}')", + chunk_builder.path, + chunk_builder.layer() + ), + )) + })?; + + let mut file = File::open(file_path)?; + let mut buffer = Vec::new(); + file.read_to_end(&mut buffer)?; + cursor.write_all(&buffer)?; + Ok(()) + }) + .map_err(PackError::Builder)?; + + Ok(()) + } + + // -- scanning ---------------------------------------------------------- + + fn scan_layers(&mut self) -> Result<(), PackError> { + let content_dir = self.project_root.join("content"); + + self.scan_layer_dir(&content_dir, &ModProjectLayer::base())?; + + // Clone layer list to avoid borrowing self.mod_project while mutating self.chunks + let layers: Vec<_> = self + .mod_project + .layers + .iter() + .filter(|l| l.name != "base") + .cloned() + .collect(); + + for layer in &layers { + self.scan_layer_dir(&content_dir, layer)?; + } + + Ok(()) + } + + fn scan_layer_dir( + &mut self, + content_dir: &Utf8Path, + layer: &ModProjectLayer, + ) -> Result<(), PackError> { + let layer_dir = content_dir.join(&layer.name); + + for entry in fs::read_dir(layer_dir.as_std_path())? { + let entry = entry?; + let entry_path = utf8_path_from(entry.path())?; + + if entry_path.is_dir() { + self.scan_directory(&layer_dir, &entry_path, layer)?; + } else if entry_path.is_file() { + let rel_path = strip_prefix(&entry_path, &layer_dir)?; + self.push_chunk(rel_path, layer, None, entry_path); + } + } + + Ok(()) + } + + fn scan_directory( + &mut self, + layer_dir: &Utf8Path, + dir_path: &Utf8Path, + layer: &ModProjectLayer, + ) -> Result<(), PackError> { + let dir_name = dir_path + .file_name() + .ok_or_else(|| PackError::InvalidUtf8Path(dir_path.to_string()))?; + + let is_wad = dir_name.to_ascii_lowercase().ends_with(".wad.client"); + let wad_name = is_wad.then(|| dir_name.to_string()); + + // For WAD directories: chunk path is relative to the WAD dir (WAD name + // stored separately). For other directories: relative to the layer dir + // so the directory name is preserved in the chunk path. + let strip_base = if is_wad { dir_path } else { layer_dir }; + + let pattern = dir_path.join("**/*"); + for file in glob::glob(pattern.as_str())? + .filter_map(Result::ok) + .filter(|e| e.is_file()) + { + let file_path = utf8_path_from(file)?; + let rel_path = strip_prefix(&file_path, strip_base)?; + self.push_chunk(rel_path, layer, wad_name.clone(), file_path); + } + + Ok(()) + } + + fn push_chunk( + &mut self, + rel_path: String, + layer: &ModProjectLayer, + wad_name: Option, + file_path: Utf8PathBuf, + ) { + let compression = compression_for_extension(file_path.extension()); + self.chunks.push(ChunkEntry { + rel_path, + layer_name: layer.name.clone(), + wad_name, + file_path, + compression, + }); + } + + fn scan_meta_files(&mut self) -> Result<(), PackError> { + let readme_path = self.project_root.join("README.md"); + if readme_path.exists() { + self.readme = Some(fs::read_to_string(&readme_path)?); + } + + let thumbnail_path = self + .mod_project + .thumbnail + .as_ref() + .map(|p| self.project_root.join(p)) + .unwrap_or_else(|| self.project_root.join("thumbnail.webp")); + + if thumbnail_path.exists() { + self.thumbnail = Some(load_thumbnail(&thumbnail_path)?); + } + + Ok(()) + } + + // -- building ---------------------------------------------------------- + + /// Consume the packer and produce a configured `ModpkgBuilder` plus a map + /// from chunk keys to source file paths. + fn into_builder(self) -> Result<(ModpkgBuilder, ChunkFileMap), PackError> { + let mut builder = ModpkgBuilder::default().with_layer(ModpkgLayerBuilder::base()); + + // Layers + for layer in &self.mod_project.layers { + if layer.name == "base" { + continue; + } + builder = builder + .with_layer(ModpkgLayerBuilder::new(&layer.name).with_priority(layer.priority)); + } + + // Metadata + builder = builder + .with_metadata(self.build_metadata()?) + .map_err(PackError::Builder)?; + + // Content chunks + let mut file_map = ChunkFileMap::new(); + for entry in &self.chunks { + let mut cb = ModpkgChunkBuilder::new() + .with_path(&entry.rel_path) + .map_err(PackError::Builder)? + .with_compression(entry.compression) + .with_layer(&entry.layer_name); + + if let Some(wad) = &entry.wad_name { + cb = cb.with_wad(wad); + } + + let key = (cb.path_hash(), hash_layer_name(&entry.layer_name)); + file_map + .entry(key) + .or_insert_with(|| entry.file_path.clone()); + builder = builder.with_chunk(cb); + } + + // Meta chunks + if let Some(readme) = &self.readme { + builder = builder.with_readme(readme).map_err(PackError::Builder)?; + } + if let Some(thumbnail) = self.thumbnail { + builder = builder + .with_thumbnail(thumbnail) + .map_err(PackError::Builder)?; + } + + Ok((builder, file_map)) + } + + fn build_metadata(&self) -> Result { + let version = semver::Version::parse(&self.mod_project.version) + .map_err(|e| PackError::InvalidVersion(e.to_string()))?; + + Ok(ModpkgMetadata { + schema_version: CURRENT_SCHEMA_VERSION, + name: self.mod_project.name.clone(), + display_name: self.mod_project.display_name.clone(), + description: Some(self.mod_project.description.clone()), + version, + distributor: None, + authors: self + .mod_project + .authors + .iter() + .map(convert_author) + .collect(), + license: convert_license(self.mod_project.license.as_ref()), + tags: self + .mod_project + .tags + .iter() + .map(|t| t.to_string()) + .collect(), + champions: self.mod_project.champions.clone(), + maps: self + .mod_project + .maps + .iter() + .map(|m| m.to_string()) + .collect(), + layers: build_layer_metadata(&self.mod_project), + }) + } +} + +// --------------------------------------------------------------------------- +// Validation +// --------------------------------------------------------------------------- + +fn validate_project(mod_project: &ModProject, project_root: &Utf8Path) -> Result<(), PackError> { + for layer in &mod_project.layers { + if !is_valid_slug(&layer.name) { + return Err(PackError::InvalidLayerName(layer.name.clone())); + } + if layer.name == "base" && layer.priority != 0 { + return Err(PackError::InvalidBaseLayerPriority(layer.priority)); + } + let layer_dir = project_root.join("content").join(&layer.name); + if !layer_dir.exists() { + return Err(PackError::LayerDirMissing { + layer: layer.name.clone(), + path: layer_dir, + }); + } + } + Ok(()) +} + +/// Check if a string is a valid slug (lowercase alphanumeric with hyphens). +pub(super) fn is_valid_slug(s: &str) -> bool { + !s.is_empty() + && s.chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') + && !s.starts_with('-') + && !s.ends_with('-') +} + +// --------------------------------------------------------------------------- +// Metadata conversion +// --------------------------------------------------------------------------- + +fn convert_author(author: &ModProjectAuthor) -> crate::ModpkgAuthor { + match author { + ModProjectAuthor::Name(name) => crate::ModpkgAuthor { + name: name.clone(), + role: None, + }, + ModProjectAuthor::Role { name, role } => crate::ModpkgAuthor { + name: name.clone(), + role: Some(role.clone()), + }, + } +} + +fn convert_license(license: Option<&ModProjectLicense>) -> crate::ModpkgLicense { + match license { + None => crate::ModpkgLicense::None, + Some(ModProjectLicense::Spdx(id)) => crate::ModpkgLicense::Spdx { + spdx_id: id.clone(), + }, + Some(ModProjectLicense::Custom { name, url }) => crate::ModpkgLicense::Custom { + name: name.clone(), + url: url.clone(), + }, + } +} + +fn build_layer_metadata(mod_project: &ModProject) -> Vec { + let mut layers = Vec::new(); + + let base_from_config = mod_project.layers.iter().find(|l| l.name == "base"); + layers.push(ModpkgLayerMetadata { + name: "base".to_string(), + display_name: base_from_config.and_then(|l| l.display_name.clone()), + priority: 0, + description: base_from_config + .and_then(|l| l.description.clone()) + .or_else(|| Some("Base layer of the mod".to_string())), + string_overrides: base_from_config + .map(|l| l.string_overrides.clone()) + .unwrap_or_default(), + }); + + for layer in mod_project.layers.iter().filter(|l| l.name != "base") { + layers.push(ModpkgLayerMetadata { + name: layer.name.clone(), + display_name: layer.display_name.clone(), + priority: layer.priority, + description: layer.description.clone(), + string_overrides: layer.string_overrides.clone(), + }); + } + + layers +} + +// --------------------------------------------------------------------------- +// Path utilities +// --------------------------------------------------------------------------- + +/// Convert a `std::path::PathBuf` to a `Utf8PathBuf`, returning a `PackError` on failure. +fn utf8_path_from(path: std::path::PathBuf) -> Result { + Utf8PathBuf::from_path_buf(path) + .map_err(|p| PackError::InvalidUtf8Path(p.display().to_string())) +} + +/// Strip a prefix from a path and return the remainder as a normalized string +/// (forward slashes, for cross-platform consistency). +fn strip_prefix(path: &Utf8Path, base: &Utf8Path) -> Result { + let rel = path + .strip_prefix(base) + .map_err(|e| PackError::Io(io::Error::other(e.to_string())))?; + Ok(rel.as_str().replace('\\', "/")) +} + +/// Determine the best compression strategy based on file extension. +/// +/// Pre-compressed formats (textures, audio) gain little from zstd and +/// waste CPU time at both compression and decompression. +pub(super) fn compression_for_extension(ext: Option<&str>) -> ModpkgCompression { + match ext.map(|e| e.to_ascii_lowercase()).as_deref() { + Some("dds" | "tex" | "webp" | "png" | "jpg" | "jpeg") => ModpkgCompression::None, + Some("bnk" | "wpk" | "wem" | "ogg") => ModpkgCompression::None, + _ => ModpkgCompression::Zstd, + } +} diff --git a/crates/ltk_modpkg/src/project/tests.rs b/crates/ltk_modpkg/src/project/tests.rs new file mode 100644 index 0000000..7599830 --- /dev/null +++ b/crates/ltk_modpkg/src/project/tests.rs @@ -0,0 +1,377 @@ +use super::packer::{compression_for_extension, is_valid_slug}; +use super::*; +use crate::{Modpkg, ModpkgCompression}; +use camino::{Utf8Path, Utf8PathBuf}; +use ltk_mod_project::{ModProject, ModProjectAuthor, ModProjectLayer, ModProjectLicense}; +use std::collections::HashMap; +use std::fs::{self, File}; +use std::io::Cursor; + +// -- validation tests ------------------------------------------------------ + +#[test] +fn new_rejects_invalid_layer_slug() { + let tmp = tempfile::tempdir().unwrap(); + let root = utf8_tempdir(&tmp); + + fs::create_dir_all(root.join("content/base")).unwrap(); + + let project = test_mod_project(vec![ModProjectLayer { + name: "UPPERCASE".to_string(), + display_name: None, + priority: 1, + description: None, + string_overrides: HashMap::new(), + }]); + + let err = ProjectPacker::with_mod_project(project, root.clone()).unwrap_err(); + assert!( + matches!(err, PackError::InvalidLayerName(ref n) if n == "UPPERCASE"), + "Expected InvalidLayerName, got: {err}" + ); +} + +#[test] +fn new_rejects_base_layer_with_wrong_priority() { + let tmp = tempfile::tempdir().unwrap(); + let root = utf8_tempdir(&tmp); + + fs::create_dir_all(root.join("content/base")).unwrap(); + + let project = test_mod_project(vec![ModProjectLayer { + name: "base".to_string(), + display_name: None, + priority: 5, + description: None, + string_overrides: HashMap::new(), + }]); + + let err = ProjectPacker::with_mod_project(project, root.clone()).unwrap_err(); + assert!( + matches!(err, PackError::InvalidBaseLayerPriority(5)), + "Expected InvalidBaseLayerPriority(5), got: {err}" + ); +} + +#[test] +fn new_rejects_missing_layer_directory() { + let tmp = tempfile::tempdir().unwrap(); + let root = utf8_tempdir(&tmp); + + fs::create_dir_all(root.join("content/base")).unwrap(); + // "high-res" layer declared but directory not created + + let project = test_mod_project(vec![ + ModProjectLayer::base(), + ModProjectLayer { + name: "high-res".to_string(), + display_name: None, + priority: 1, + description: None, + string_overrides: HashMap::new(), + }, + ]); + + let err = ProjectPacker::with_mod_project(project, root.clone()).unwrap_err(); + assert!( + matches!(err, PackError::LayerDirMissing { ref layer, .. } if layer == "high-res"), + "Expected LayerDirMissing for high-res, got: {err}" + ); +} + +// -- packing tests --------------------------------------------------------- + +#[test] +fn new_loads_config_from_directory() { + let tmp = tempfile::tempdir().unwrap(); + let root = utf8_tempdir(&tmp); + + // Write a mod.config.json + let config = r#"{ + "name": "auto-load-test", + "display_name": "Auto Load Test", + "version": "1.0.0", + "description": "", + "authors": [], + "layers": [{"name": "base", "priority": 0}] + }"#; + fs::write(root.join("mod.config.json"), config).unwrap(); + create_content_file(&root, "base", "X.wad.client/f.bin", b"data"); + + let output = root.join("build/out.modpkg"); + ProjectPacker::new(root).unwrap().pack(&output).unwrap(); + + let modpkg = mount_modpkg(&output); + assert_eq!(modpkg.wads.len(), 1); +} + +#[test] +fn new_returns_error_for_missing_config() { + let tmp = tempfile::tempdir().unwrap(); + let root = utf8_tempdir(&tmp); + + let err = ProjectPacker::new(root).unwrap_err(); + assert!( + matches!(err, PackError::ConfigError(_)), + "Expected ConfigError, got: {err}" + ); +} + +#[test] +fn pack_single_wad() { + let tmp = tempfile::tempdir().unwrap(); + let root = utf8_tempdir(&tmp); + + create_content_file(&root, "base", "Graves.wad.client/data/skin0.bin", b"bin"); + create_content_file(&root, "base", "Graves.wad.client/assets/tex.dds", b"dds"); + + let project = test_mod_project(vec![ModProjectLayer::base()]); + let output = root.join("build/out.modpkg"); + + ProjectPacker::with_mod_project(project, root.clone()) + .unwrap() + .pack(&output) + .unwrap(); + + let modpkg = mount_modpkg(&output); + + assert_eq!(modpkg.wads.len(), 1); + assert_eq!(modpkg.wads.values().next().unwrap(), "graves.wad.client"); + + let layer_idx = modpkg.layer_index("base").expect("base layer"); + let wad_idx = modpkg.wad_index("graves.wad.client").unwrap(); + assert_eq!(modpkg.chunks_for_wad_layer(wad_idx, layer_idx).len(), 2); + + for path in modpkg.chunk_paths.values() { + assert!( + !path.contains("graves.wad.client"), + "WAD prefix leaked: {path}" + ); + } +} + +#[test] +fn pack_non_wad_directory_preserves_path() { + let tmp = tempfile::tempdir().unwrap(); + let root = utf8_tempdir(&tmp); + + create_content_file(&root, "base", "some_dir/file.bin", b"data"); + + let project = test_mod_project(vec![ModProjectLayer::base()]); + let output = root.join("build/out.modpkg"); + + ProjectPacker::with_mod_project(project, root.clone()) + .unwrap() + .pack(&output) + .unwrap(); + + let modpkg = mount_modpkg(&output); + + assert_eq!(modpkg.wads.len(), 0); + assert!(modpkg + .chunk_paths + .values() + .any(|p| p == "some_dir/file.bin")); +} + +#[test] +fn pack_multi_wad_multi_layer() { + let tmp = tempfile::tempdir().unwrap(); + let root = utf8_tempdir(&tmp); + + create_content_file(&root, "base", "Aatrox.wad.client/data/skin0.bin", b"s"); + create_content_file(&root, "base", "Map11.wad.client/data/map.bin", b"m"); + create_content_file(&root, "high-res", "Aatrox.wad.client/assets/tex.dds", b"t"); + + let project = test_mod_project(vec![ + ModProjectLayer::base(), + ModProjectLayer { + name: "high-res".to_string(), + display_name: None, + priority: 1, + description: None, + string_overrides: HashMap::new(), + }, + ]); + + let output = root.join("build/out.modpkg"); + ProjectPacker::with_mod_project(project, root.clone()) + .unwrap() + .pack(&output) + .unwrap(); + + let modpkg = mount_modpkg(&output); + + assert_eq!(modpkg.wads.len(), 2); + let wad_names: Vec<&str> = modpkg.wads.values().map(|s| s.as_str()).collect(); + assert!(wad_names.contains(&"aatrox.wad.client")); + assert!(wad_names.contains(&"map11.wad.client")); + + let base_idx = modpkg.layer_index("base").unwrap(); + let hires_idx = modpkg.layer_index("high-res").unwrap(); + let aatrox_idx = modpkg.wad_index("aatrox.wad.client").unwrap(); + let map_idx = modpkg.wad_index("map11.wad.client").unwrap(); + + assert_eq!(modpkg.chunks_for_wad_layer(aatrox_idx, base_idx).len(), 1); + assert_eq!(modpkg.chunks_for_wad_layer(map_idx, base_idx).len(), 1); + assert_eq!(modpkg.chunks_for_wad_layer(aatrox_idx, hires_idx).len(), 1); + assert_eq!(modpkg.chunks_for_wad_layer(map_idx, hires_idx).len(), 0); +} + +#[test] +fn pack_to_writer_produces_valid_modpkg() { + let tmp = tempfile::tempdir().unwrap(); + let root = utf8_tempdir(&tmp); + + create_content_file(&root, "base", "Graves.wad.client/data/skin.bin", b"data"); + + let project = test_mod_project(vec![ModProjectLayer::base()]); + + let mut buffer = Cursor::new(Vec::new()); + ProjectPacker::with_mod_project(project, root.clone()) + .unwrap() + .pack_to_writer(&mut buffer) + .unwrap(); + + buffer.set_position(0); + let modpkg = Modpkg::mount_from_reader(buffer).unwrap(); + + assert_eq!(modpkg.wads.len(), 1); + assert_eq!(modpkg.wads.values().next().unwrap(), "graves.wad.client"); +} + +#[test] +fn pack_preserves_metadata() { + let tmp = tempfile::tempdir().unwrap(); + let root = utf8_tempdir(&tmp); + + create_content_file(&root, "base", "X.wad.client/f.bin", b"x"); + + let project = ModProject { + name: "cool-mod".to_string(), + display_name: "Cool Mod".to_string(), + version: "2.1.0".to_string(), + description: "A cool mod".to_string(), + authors: vec![ModProjectAuthor::Name("Alice".to_string())], + license: Some(ModProjectLicense::Spdx("MIT".to_string())), + tags: vec![], + champions: vec!["Graves".to_string()], + maps: vec![], + thumbnail: None, + layers: vec![ModProjectLayer::base()], + transformers: vec![], + }; + + let mut buffer = Cursor::new(Vec::new()); + ProjectPacker::with_mod_project(project, root.clone()) + .unwrap() + .pack_to_writer(&mut buffer) + .unwrap(); + + buffer.set_position(0); + let mut modpkg = Modpkg::mount_from_reader(buffer).unwrap(); + let meta = modpkg.load_metadata().unwrap(); + + assert_eq!(meta.name, "cool-mod"); + assert_eq!(meta.display_name, "Cool Mod"); + assert_eq!(meta.version.to_string(), "2.1.0"); + assert_eq!(meta.description, Some("A cool mod".to_string())); + assert_eq!(meta.authors.len(), 1); + assert_eq!(meta.authors[0].name, "Alice"); + assert_eq!(meta.champions, vec!["Graves"]); +} + +// -- utility tests --------------------------------------------------------- + +#[test] +fn test_create_file_name() { + let project = test_mod_project(vec![]); + + assert_eq!(create_file_name(&project, None), "test-mod_1.0.0.modpkg"); + assert_eq!( + create_file_name(&project, Some("custom".to_string())), + "custom.modpkg" + ); + assert_eq!( + create_file_name(&project, Some("custom.modpkg".to_string())), + "custom.modpkg" + ); +} + +#[test] +fn test_is_valid_slug() { + assert!(is_valid_slug("base")); + assert!(is_valid_slug("my-layer")); + assert!(is_valid_slug("layer123")); + assert!(!is_valid_slug("")); + assert!(!is_valid_slug("-invalid")); + assert!(!is_valid_slug("invalid-")); + assert!(!is_valid_slug("UPPERCASE")); + assert!(!is_valid_slug("has spaces")); +} + +#[test] +fn test_compression_for_extension() { + assert_eq!( + compression_for_extension(Some("dds")), + ModpkgCompression::None + ); + assert_eq!( + compression_for_extension(Some("DDS")), + ModpkgCompression::None + ); + assert_eq!( + compression_for_extension(Some("bnk")), + ModpkgCompression::None + ); + assert_eq!( + compression_for_extension(Some("wem")), + ModpkgCompression::None + ); + assert_eq!( + compression_for_extension(Some("bin")), + ModpkgCompression::Zstd + ); + assert_eq!( + compression_for_extension(Some("anm")), + ModpkgCompression::Zstd + ); + assert_eq!(compression_for_extension(None), ModpkgCompression::Zstd); +} + +// -- test helpers ---------------------------------------------------------- + +fn test_mod_project(layers: Vec) -> ModProject { + ModProject { + name: "test-mod".to_string(), + display_name: "Test Mod".to_string(), + version: "1.0.0".to_string(), + description: String::new(), + authors: vec![], + license: None, + tags: vec![], + champions: vec![], + maps: vec![], + thumbnail: None, + layers, + transformers: vec![], + } +} + +fn utf8_tempdir(tmp: &tempfile::TempDir) -> Utf8PathBuf { + Utf8PathBuf::from_path_buf(tmp.path().to_path_buf()).unwrap() +} + +/// Create a file inside `content/{layer}/{rel_path}`, creating directories as needed. +fn create_content_file(root: &Utf8Path, layer: &str, rel_path: &str, data: &[u8]) { + let full_path = root.join("content").join(layer).join(rel_path); + if let Some(parent) = full_path.parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::write(&full_path, data).unwrap(); +} + +fn mount_modpkg(path: &Utf8Path) -> Modpkg { + let file = File::open(path.as_std_path()).unwrap(); + Modpkg::mount_from_reader(file).unwrap() +} diff --git a/crates/ltk_modpkg/src/project/thumbnail.rs b/crates/ltk_modpkg/src/project/thumbnail.rs new file mode 100644 index 0000000..3ee5bc2 --- /dev/null +++ b/crates/ltk_modpkg/src/project/thumbnail.rs @@ -0,0 +1,116 @@ +//! Thumbnail loading and WebP conversion. + +use super::PackError; +use camino::Utf8Path; +use image::ImageFormat; +use std::fs::{self, File}; +use std::io::{BufReader, Cursor}; + +/// Maximum thumbnail file size: 5MB +pub const MAX_THUMBNAIL_SIZE: u64 = 5 * 1024 * 1024; + +/// Load and convert a thumbnail image to WebP format. +/// +/// Supports all common image formats (PNG, JPEG, GIF, BMP, TIFF, ICO, WebP). +/// Animated GIFs are converted to animated WebP. +/// Validates file size (max 5MB). +pub fn load_thumbnail(path: &Utf8Path) -> Result, PackError> { + let metadata = fs::metadata(path).map_err(PackError::Io)?; + if metadata.len() > MAX_THUMBNAIL_SIZE { + return Err(PackError::ThumbnailError(format!( + "Thumbnail file size ({} bytes) exceeds maximum allowed size ({} bytes / 5MB)", + metadata.len(), + MAX_THUMBNAIL_SIZE + ))); + } + + let extension = path + .extension() + .map(|ext| ext.to_lowercase()) + .unwrap_or_default(); + + if extension == "webp" { + let data = fs::read(path).map_err(PackError::Io)?; + if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" { + return Ok(data); + } + return Err(PackError::ThumbnailError( + "Invalid WebP file format".to_string(), + )); + } + + if extension == "gif" { + return convert_gif_to_webp(path); + } + + let img = image::open(path) + .map_err(|e| PackError::ThumbnailError(format!("Failed to open image: {}", e)))?; + + let mut buffer = Cursor::new(Vec::new()); + img.write_to(&mut buffer, ImageFormat::WebP) + .map_err(|e| PackError::ThumbnailError(format!("Failed to convert to WebP: {}", e)))?; + + Ok(buffer.into_inner()) +} + +fn convert_gif_to_webp(path: &Utf8Path) -> Result, PackError> { + let file = File::open(path).map_err(PackError::Io)?; + let reader = BufReader::new(file); + let decoder = image::codecs::gif::GifDecoder::new(reader) + .map_err(|e| PackError::ThumbnailError(format!("Failed to decode GIF: {}", e)))?; + + let frames: Vec<_> = image::AnimationDecoder::into_frames(decoder) + .collect::, _>>() + .map_err(|e| PackError::ThumbnailError(format!("Failed to read GIF frames: {}", e)))?; + + if frames.is_empty() { + return Err(PackError::ThumbnailError("GIF has no frames".to_string())); + } + + if frames.len() == 1 { + let frame = &frames[0]; + let img = frame.buffer(); + let mut buffer = Cursor::new(Vec::new()); + img.write_to(&mut buffer, ImageFormat::WebP).map_err(|e| { + PackError::ThumbnailError(format!("Failed to convert GIF to WebP: {}", e)) + })?; + return Ok(buffer.into_inner()); + } + + encode_animated_webp(&frames) +} + +fn encode_animated_webp(frames: &[image::Frame]) -> Result, PackError> { + use webp_animation::prelude::*; + + if frames.is_empty() { + return Err(PackError::ThumbnailError("No frames to encode".to_string())); + } + + let first_frame = frames[0].buffer(); + let (width, height) = first_frame.dimensions(); + + let mut encoder = Encoder::new((width, height)).map_err(|e| { + PackError::ThumbnailError(format!("Failed to create WebP encoder: {:?}", e)) + })?; + + let mut timestamp_ms = 0i32; + for frame in frames { + let img_buffer = frame.buffer(); + let delay = frame.delay(); + let rgba_data = img_buffer.as_raw(); + + encoder + .add_frame(rgba_data, timestamp_ms) + .map_err(|e| PackError::ThumbnailError(format!("Failed to add frame: {:?}", e)))?; + + let delay_ms = delay.numer_denom_ms(); + timestamp_ms += delay_ms.0 as i32; + } + + let webp_data = encoder + .finalize(timestamp_ms) + .map_err(|e| PackError::ThumbnailError(format!("Failed to finalize animation: {:?}", e)))?; + + Ok(webp_data.to_vec()) +}