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        ModpackInfo {
94            name: self.name.clone(),
95            version: Some(self.version_id.clone()),
96            mc_version,
97            loader,
98            loader_version,
99        }.validated()
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    install_mrpack_files(pack_path, instance_name, base_dir, progress).await
128}
129
130/// Extract overrides and download client files without installing Minecraft or its loader.
131/// No Java is required and `instance_config.json` is not created. Use the returned
132/// [`ModpackInfo`] to install Minecraft and the loader before the first launch.
133pub async fn install_mrpack_files(
134    pack_path: &Path,
135    instance_name: &str,
136    base_dir: &Path,
137    progress: ProgressFn,
138) -> Result<ModpackInstallResult> {
139    let index = read_mrpack_index(pack_path).await?;
140    if index.game != "minecraft" {
141        return Err(HexoError::Other(format!("unsupported game: {}", index.game)));
142    }
143    let info = index.info()?;
144
145    let game_dir = instance_game_dir(base_dir, instance_name);
146    tokio::fs::create_dir_all(&game_dir).await?;
147
148    progress(0, 0, "Extracting overrides");
149    extract_zip_dir(pack_path, "overrides", &game_dir).await?;
150    extract_zip_dir(pack_path, "client-overrides", &game_dir).await?;
151
152    let mut tasks = Vec::new();
153    for file in index.files.iter().filter(|f| f.is_client_supported()) {
154        let url = file.downloads.first().ok_or_else(|| HexoError::DownloadFailed {
155            url: format!("no download URL for {}", file.path),
156        })?;
157        let mut task = DownloadTask::new(url, safe_join(&game_dir, &file.path)?);
158        if let Some(sha1) = file.hashes.get("sha1") {
159            task = task.with_sha1(sha1);
160        }
161        tasks.push(task);
162    }
163
164    let p = progress.clone();
165    download_batch(tasks, 8, move |d, t| p(d, t, "Downloading modpack files")).await?;
166
167    Ok(ModpackInstallResult { info, manual_downloads: Vec::new(), skipped: Vec::new() })
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    const SAMPLE: &str = r#"{
175        "formatVersion": 1,
176        "game": "minecraft",
177        "versionId": "1.0.0",
178        "name": "Test Pack",
179        "files": [
180            {
181                "path": "mods/sodium.jar",
182                "hashes": { "sha1": "abc", "sha512": "def" },
183                "env": { "client": "required", "server": "unsupported" },
184                "downloads": ["https://cdn.modrinth.com/data/x/sodium.jar"],
185                "fileSize": 123
186            },
187            {
188                "path": "mods/server-only.jar",
189                "hashes": { "sha1": "123" },
190                "env": { "client": "unsupported", "server": "required" },
191                "downloads": ["https://cdn.modrinth.com/data/y/server.jar"],
192                "fileSize": 1
193            }
194        ],
195        "dependencies": { "minecraft": "1.21.1", "fabric-loader": "0.16.5" }
196    }"#;
197
198    #[test]
199    fn parse_index() {
200        let index: MrpackIndex = serde_json::from_str(SAMPLE).unwrap();
201        let info = index.info().unwrap();
202        assert_eq!(info.mc_version, "1.21.1");
203        assert_eq!(info.loader, LoaderType::Fabric);
204        assert_eq!(info.loader_version.as_deref(), Some("0.16.5"));
205
206        let client: Vec<_> = index.files.iter().filter(|f| f.is_client_supported()).collect();
207        assert_eq!(client.len(), 1);
208        assert_eq!(client[0].path, "mods/sodium.jar");
209    }
210
211    #[test]
212    fn quilt_is_unsupported() {
213        let mut index: MrpackIndex = serde_json::from_str(SAMPLE).unwrap();
214        index.dependencies.remove("fabric-loader");
215        index.dependencies.insert("quilt-loader".into(), "0.26.0".into());
216        assert!(matches!(index.info(), Err(HexoError::UnsupportedLoader(_))));
217    }
218}