Skip to main content

hexomc_lib/modpack/
mrpack.rs

1//! Modrinth modpacks (`.mrpack`).
2//!
3//! A zip holding `modrinth.index.json` plus `overrides/` and `client-overrides/`.
4//! Spec: <https://support.modrinth.com/en/articles/8802351-modrinth-modpack-format-mrpack>
5
6use std::collections::HashMap;
7use std::path::Path;
8
9use serde::{Deserialize, Serialize};
10
11use crate::{
12    download::{download_batch, DownloadTask},
13    error::{HexoError, Result},
14    install::{loader::ProgressFn, vanilla::LoaderType},
15    modpack::{
16        extract_zip_dir, install_pack_loader, instance_game_dir, read_zip_json, safe_join,
17        ModpackInfo, ModpackInstallResult,
18    },
19};
20
21pub const INDEX_FILE: &str = "modrinth.index.json";
22
23#[derive(Debug, Deserialize, Serialize, Clone)]
24#[serde(rename_all = "camelCase")]
25pub struct MrpackIndex {
26    pub format_version: u32,
27    pub game: String,
28    pub version_id: String,
29    pub name: String,
30    #[serde(default)]
31    pub summary: Option<String>,
32    pub files: Vec<MrpackFile>,
33    /// `minecraft`, `forge`, `neoforge`, `fabric-loader` or `quilt-loader` -> version.
34    pub dependencies: HashMap<String, String>,
35}
36
37#[derive(Debug, Deserialize, Serialize, Clone)]
38#[serde(rename_all = "camelCase")]
39pub struct MrpackFile {
40    /// Path relative to the game directory, e.g. `mods/sodium.jar`.
41    pub path: String,
42    pub hashes: HashMap<String, String>,
43    #[serde(default)]
44    pub env: Option<MrpackEnv>,
45    pub downloads: Vec<String>,
46    #[serde(default)]
47    pub file_size: u64,
48}
49
50#[derive(Debug, Deserialize, Serialize, Clone)]
51pub struct MrpackEnv {
52    pub client: EnvSupport,
53    pub server: EnvSupport,
54}
55
56#[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq)]
57#[serde(rename_all = "lowercase")]
58pub enum EnvSupport {
59    Required,
60    Optional,
61    Unsupported,
62}
63
64impl MrpackFile {
65    pub fn is_client_supported(&self) -> bool {
66        self.env
67            .as_ref()
68            .is_none_or(|e| e.client != EnvSupport::Unsupported)
69    }
70}
71
72impl MrpackIndex {
73    pub fn info(&self) -> Result<ModpackInfo> {
74        let mc_version = self
75            .dependencies
76            .get("minecraft")
77            .cloned()
78            .ok_or_else(|| HexoError::Other("mrpack has no minecraft dependency".into()))?;
79
80        let deps = &self.dependencies;
81        let (loader, loader_version) = if let Some(v) = deps.get("neoforge") {
82            (LoaderType::NeoForge, Some(v.clone()))
83        } else if let Some(v) = deps.get("forge") {
84            (LoaderType::Forge, Some(v.clone()))
85        } else if let Some(v) = deps.get("fabric-loader") {
86            (LoaderType::Fabric, Some(v.clone()))
87        } else if let Some(v) = deps.get("quilt-loader") {
88            return Err(HexoError::UnsupportedLoader(format!("quilt-loader {}", v)));
89        } else {
90            (LoaderType::Vanilla, None)
91        };
92
93        Ok(ModpackInfo {
94            name: self.name.clone(),
95            version: Some(self.version_id.clone()),
96            mc_version,
97            loader,
98            loader_version,
99        })
100    }
101}
102
103/// Read a `.mrpack`'s index without installing it.
104pub async fn read_mrpack_index(pack_path: &Path) -> Result<MrpackIndex> {
105    read_zip_json(pack_path, INDEX_FILE).await
106}
107
108/// Install a `.mrpack` into `instance/{instance_name}`.
109///
110/// Files whose client support is `required` or `optional` are installed; `unsupported`
111/// ones are skipped. `client-overrides/` is applied after `overrides/`.
112pub async fn install_mrpack(
113    pack_path: &Path,
114    instance_name: &str,
115    base_dir: &Path,
116    java_path: Option<&Path>,
117    progress: ProgressFn,
118) -> Result<ModpackInstallResult> {
119    let index = read_mrpack_index(pack_path).await?;
120    if index.game != "minecraft" {
121        return Err(HexoError::Other(format!("unsupported game: {}", index.game)));
122    }
123    let info = index.info()?;
124
125    install_pack_loader(&info, instance_name, base_dir, java_path, progress.clone()).await?;
126
127    let game_dir = instance_game_dir(base_dir, instance_name);
128
129    progress(0, 0, "Extracting overrides");
130    extract_zip_dir(pack_path, "overrides", &game_dir).await?;
131    extract_zip_dir(pack_path, "client-overrides", &game_dir).await?;
132
133    let mut tasks = Vec::new();
134    for file in index.files.iter().filter(|f| f.is_client_supported()) {
135        let url = file.downloads.first().ok_or_else(|| HexoError::DownloadFailed {
136            url: format!("no download URL for {}", file.path),
137        })?;
138        let mut task = DownloadTask::new(url, safe_join(&game_dir, &file.path)?);
139        if let Some(sha1) = file.hashes.get("sha1") {
140            task = task.with_sha1(sha1);
141        }
142        tasks.push(task);
143    }
144
145    let p = progress.clone();
146    download_batch(tasks, 8, move |d, t| p(d, t, "Downloading modpack files")).await?;
147
148    Ok(ModpackInstallResult { info, manual_downloads: Vec::new(), skipped: Vec::new() })
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154
155    const SAMPLE: &str = r#"{
156        "formatVersion": 1,
157        "game": "minecraft",
158        "versionId": "1.0.0",
159        "name": "Test Pack",
160        "files": [
161            {
162                "path": "mods/sodium.jar",
163                "hashes": { "sha1": "abc", "sha512": "def" },
164                "env": { "client": "required", "server": "unsupported" },
165                "downloads": ["https://cdn.modrinth.com/data/x/sodium.jar"],
166                "fileSize": 123
167            },
168            {
169                "path": "mods/server-only.jar",
170                "hashes": { "sha1": "123" },
171                "env": { "client": "unsupported", "server": "required" },
172                "downloads": ["https://cdn.modrinth.com/data/y/server.jar"],
173                "fileSize": 1
174            }
175        ],
176        "dependencies": { "minecraft": "1.21.1", "fabric-loader": "0.16.5" }
177    }"#;
178
179    #[test]
180    fn parse_index() {
181        let index: MrpackIndex = serde_json::from_str(SAMPLE).unwrap();
182        let info = index.info().unwrap();
183        assert_eq!(info.mc_version, "1.21.1");
184        assert_eq!(info.loader, LoaderType::Fabric);
185        assert_eq!(info.loader_version.as_deref(), Some("0.16.5"));
186
187        let client: Vec<_> = index.files.iter().filter(|f| f.is_client_supported()).collect();
188        assert_eq!(client.len(), 1);
189        assert_eq!(client[0].path, "mods/sodium.jar");
190    }
191
192    #[test]
193    fn quilt_is_unsupported() {
194        let mut index: MrpackIndex = serde_json::from_str(SAMPLE).unwrap();
195        index.dependencies.remove("fabric-loader");
196        index.dependencies.insert("quilt-loader".into(), "0.26.0".into());
197        assert!(matches!(index.info(), Err(HexoError::UnsupportedLoader(_))));
198    }
199}