Skip to main content

hexomc_lib/modpack/
cfpack.rs

1//! CurseForge modpacks (a zip holding `manifest.json` and `overrides/`).
2//!
3//! The manifest only lists project/file IDs, so download URLs are resolved through the
4//! CurseForge API (API key required). Files whose authors disallow third-party downloads
5//! have no `downloadUrl` and are returned as [`ManualDownload`]s.
6
7use std::collections::HashMap;
8use std::path::Path;
9
10use serde::{Deserialize, Serialize};
11
12use crate::{
13    download::{download_batch, DownloadTask},
14    error::{HexoError, Result},
15    install::{loader::ProgressFn, vanilla::LoaderType},
16    modpack::{
17        extract_zip_dir, install_pack_loader, instance_game_dir, read_zip_json, safe_join,
18        ManualDownload, ModpackInfo, ModpackInstallResult,
19    },
20    mods::curseforge::CurseForgeClient,
21};
22
23pub const MANIFEST_FILE: &str = "manifest.json";
24
25/// CurseForge `classId` of resource packs.
26const CLASS_RESOURCE_PACKS: u32 = 12;
27/// CurseForge `classId` of shader packs.
28const CLASS_SHADERS: u32 = 6552;
29
30#[derive(Debug, Deserialize, Serialize, Clone)]
31#[serde(rename_all = "camelCase")]
32pub struct CfManifest {
33    pub minecraft: CfManifestMinecraft,
34    #[serde(default)]
35    pub manifest_type: String,
36    #[serde(default)]
37    pub name: String,
38    #[serde(default)]
39    pub version: Option<String>,
40    #[serde(default)]
41    pub author: Option<String>,
42    #[serde(default)]
43    pub files: Vec<CfManifestFile>,
44    #[serde(default = "default_overrides")]
45    pub overrides: String,
46}
47
48fn default_overrides() -> String {
49    "overrides".into()
50}
51
52#[derive(Debug, Deserialize, Serialize, Clone)]
53#[serde(rename_all = "camelCase")]
54pub struct CfManifestMinecraft {
55    pub version: String,
56    #[serde(default)]
57    pub mod_loaders: Vec<CfManifestLoader>,
58}
59
60#[derive(Debug, Deserialize, Serialize, Clone)]
61pub struct CfManifestLoader {
62    /// e.g. `forge-47.2.0`, `neoforge-21.1.77`, `fabric-0.16.5`.
63    pub id: String,
64    #[serde(default)]
65    pub primary: bool,
66}
67
68#[derive(Debug, Deserialize, Serialize, Clone)]
69pub struct CfManifestFile {
70    #[serde(rename = "projectID")]
71    pub project_id: u64,
72    #[serde(rename = "fileID")]
73    pub file_id: u64,
74    #[serde(default = "default_true")]
75    pub required: bool,
76}
77
78fn default_true() -> bool {
79    true
80}
81
82impl CfManifest {
83    pub fn info(&self) -> Result<ModpackInfo> {
84        let loader = self
85            .minecraft
86            .mod_loaders
87            .iter()
88            .find(|l| l.primary)
89            .or_else(|| self.minecraft.mod_loaders.first());
90
91        let (loader, loader_version) = match loader {
92            None => (LoaderType::Vanilla, None),
93            Some(l) => {
94                let (kind, ver) = l
95                    .id
96                    .split_once('-')
97                    .ok_or_else(|| HexoError::UnsupportedLoader(l.id.clone()))?;
98                let kind = match kind {
99                    "forge" => LoaderType::Forge,
100                    "neoforge" => LoaderType::NeoForge,
101                    "fabric" => LoaderType::Fabric,
102                    _ => return Err(HexoError::UnsupportedLoader(l.id.clone())),
103                };
104                (kind, Some(ver.to_string()))
105            }
106        };
107
108        Ok(ModpackInfo {
109            name: self.name.clone(),
110            version: self.version.clone(),
111            mc_version: self.minecraft.version.clone(),
112            loader,
113            loader_version,
114        })
115    }
116}
117
118/// Read a CurseForge modpack's manifest without installing it.
119pub async fn read_cf_manifest(pack_path: &Path) -> Result<CfManifest> {
120    read_zip_json(pack_path, MANIFEST_FILE).await
121}
122
123/// Install a CurseForge modpack into `instance/{instance_name}`. Only `required` files
124/// are downloaded.
125pub async fn install_cfpack(
126    pack_path: &Path,
127    instance_name: &str,
128    base_dir: &Path,
129    java_path: Option<&Path>,
130    curseforge: &CurseForgeClient,
131    progress: ProgressFn,
132) -> Result<ModpackInstallResult> {
133    let manifest = read_cf_manifest(pack_path).await?;
134    let info = manifest.info()?;
135
136    install_pack_loader(&info, instance_name, base_dir, java_path, progress.clone()).await?;
137
138    let game_dir = instance_game_dir(base_dir, instance_name);
139
140    progress(0, 0, "Extracting overrides");
141    extract_zip_dir(pack_path, &manifest.overrides, &game_dir).await?;
142
143    let wanted: Vec<&CfManifestFile> = manifest.files.iter().filter(|f| f.required).collect();
144    if wanted.is_empty() {
145        return Ok(ModpackInstallResult { info, manual_downloads: Vec::new(), skipped: Vec::new() });
146    }
147
148    progress(0, 0, "Resolving CurseForge files");
149    let file_ids: Vec<u64> = wanted.iter().map(|f| f.file_id).collect();
150    let project_ids: Vec<u64> = wanted.iter().map(|f| f.project_id).collect();
151    let files = curseforge.get_files(&file_ids).await?;
152    let projects: HashMap<u64, _> = curseforge
153        .get_mods(&project_ids)
154        .await?
155        .into_iter()
156        .map(|m| (m.id, m))
157        .collect();
158
159    let mut tasks = Vec::new();
160    let mut manual_downloads = Vec::new();
161    for file in files {
162        let project = projects.get(&file.mod_id);
163        let subdir = match project.and_then(|p| p.class_id) {
164            Some(CLASS_RESOURCE_PACKS) => "resourcepacks",
165            Some(CLASS_SHADERS) => "shaderpacks",
166            _ => "mods",
167        };
168        let dest = safe_join(&game_dir.join(subdir), &file.file_name)?;
169
170        match &file.download_url {
171            Some(url) => {
172                let mut task = DownloadTask::new(url, dest);
173                if let Some(sha1) = file.sha1() {
174                    task = task.with_sha1(sha1);
175                }
176                tasks.push(task);
177            }
178            None => manual_downloads.push(ManualDownload {
179                name: project.map_or_else(|| file.display_name.clone(), |p| p.name.clone()),
180                file_name: file.file_name.clone(),
181                website: project
182                    .and_then(|p| p.links.as_ref())
183                    .and_then(|l| l.website_url.as_ref())
184                    .map(|u| format!("{}/files/{}", u, file.id)),
185                dest,
186            }),
187        }
188    }
189
190    let p = progress.clone();
191    download_batch(tasks, 8, move |d, t| p(d, t, "Downloading modpack files")).await?;
192
193    Ok(ModpackInstallResult { info, manual_downloads, skipped: Vec::new() })
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199
200    #[test]
201    fn parse_manifest() {
202        let json = r#"{
203            "minecraft": {
204                "version": "1.20.1",
205                "modLoaders": [{ "id": "forge-47.2.0", "primary": true }]
206            },
207            "manifestType": "minecraftModpack",
208            "manifestVersion": 1,
209            "name": "Test",
210            "version": "1.0",
211            "author": "someone",
212            "files": [
213                { "projectID": 1, "fileID": 2, "required": true },
214                { "projectID": 3, "fileID": 4, "required": false }
215            ],
216            "overrides": "overrides"
217        }"#;
218        let manifest: CfManifest = serde_json::from_str(json).unwrap();
219        let info = manifest.info().unwrap();
220        assert_eq!(info.mc_version, "1.20.1");
221        assert_eq!(info.loader, LoaderType::Forge);
222        assert_eq!(info.loader_version.as_deref(), Some("47.2.0"));
223        assert_eq!(manifest.files.iter().filter(|f| f.required).count(), 1);
224    }
225}