Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,6 @@
},
"[rust]": {
"editor.defaultFormatter": "rust-lang.rust-analyzer"
}
},
"rust-analyzer.cargo.features": "all"
}
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion crates/league-mod/src/commands/pack.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!(
Expand Down
1 change: 1 addition & 0 deletions crates/ltk_mod_project/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,6 @@ authors = ["LeagueToolkit"]

[dependencies]
serde = { version = "1.0", features = ["derive"] }
thiserror = "2.0"
toml = "0.8.19"
serde_json = "1.0"
67 changes: 64 additions & 3 deletions crates/ltk_mod_project/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fmt;
use std::path::Path;

fn serde_fmt<T: Serialize>(value: &T, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let json = serde_json::to_string(value).map_err(|_| fmt::Error)?;
Expand Down Expand Up @@ -85,8 +86,30 @@ impl From<String> 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
Expand Down Expand Up @@ -147,6 +170,44 @@ pub struct ModProject {
pub thumbnail: Option<String>,
}

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<Self, ModProjectError> {
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<Self, ModProjectError> {
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 {
Expand Down Expand Up @@ -178,14 +239,14 @@ pub struct ModProjectLayer {
pub string_overrides: HashMap<String, HashMap<String, String>>,
}

#[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),
Expand Down
2 changes: 1 addition & 1 deletion crates/ltk_modpkg/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
114 changes: 114 additions & 0 deletions crates/ltk_modpkg/README.md
Original file line number Diff line number Diff line change
@@ -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
Loading