Skip to main content

hexomc_lib/modpack/
ftbpack.rs

1//! FTB packs installed from remote version manifests, without a local pack format.
2
3use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5
6use serde::{de::DeserializeOwned, Deserialize, Serialize};
7
8use crate::{
9    download::{download_batch, DownloadTask},
10    error::{HexoError, Result},
11    install::{loader::ProgressFn, vanilla::LoaderType},
12    mods::curseforge::{CfMod, CfModFile, CurseForgeClient},
13};
14
15use super::{
16    install_pack_loader, instance_game_dir, safe_join, ManualDownload, ModpackInfo,
17    ModpackInstallResult,
18};
19
20const API_BASE: &str = "https://api.feed-the-beast.com/v1/modpacks/public";
21
22/// Pack metadata and available versions from the FTB API.
23#[derive(Debug, Deserialize, Serialize, Clone)]
24pub struct FtbPack {
25    pub id: u64,
26    pub name: String,
27    #[serde(default)]
28    pub private: bool,
29    #[serde(default)]
30    pub versions: Vec<FtbVersion>,
31}
32
33/// One FTB pack version, including archived versions.
34#[derive(Debug, Deserialize, Serialize, Clone)]
35pub struct FtbVersion {
36    pub id: u64,
37    pub name: String,
38    #[serde(default)]
39    pub r#type: String,
40    #[serde(default)]
41    pub updated: i64,
42    #[serde(default)]
43    pub private: bool,
44    #[serde(default)]
45    pub targets: Vec<FtbTarget>,
46}
47
48/// A game, modloader, or runtime required by a pack version.
49#[derive(Debug, Deserialize, Serialize, Clone)]
50pub struct FtbTarget {
51    pub name: String,
52    pub version: String,
53    #[serde(default)]
54    pub r#type: String,
55}
56
57/// Installation manifest. `name` is the version name, not the pack name.
58#[derive(Debug, Deserialize, Serialize, Clone)]
59pub struct FtbVersionManifest {
60    pub id: u64,
61    pub name: String,
62    #[serde(default)]
63    pub targets: Vec<FtbTarget>,
64    #[serde(default)]
65    pub files: Vec<FtbFile>,
66}
67
68/// A file placed in `path`, a directory relative to the instance's `.minecraft`.
69#[derive(Debug, Deserialize, Serialize, Clone)]
70pub struct FtbFile {
71    pub path: String,
72    pub name: String,
73    #[serde(default)]
74    pub url: String,
75    #[serde(default)]
76    pub sha1: String,
77    #[serde(default)]
78    pub size: u64,
79    #[serde(default)]
80    pub clientonly: bool,
81    #[serde(default)]
82    pub serveronly: bool,
83    #[serde(default)]
84    pub optional: bool,
85    #[serde(default)]
86    pub curseforge: Option<FtbCurseForgeRef>,
87}
88
89/// CurseForge IDs used when a file has no direct download URL.
90#[derive(Debug, Deserialize, Serialize, Clone)]
91pub struct FtbCurseForgeRef {
92    #[serde(deserialize_with = "deserialize_id")]
93    pub project: u64,
94    #[serde(deserialize_with = "deserialize_id")]
95    pub file: u64,
96}
97
98/// Older FTB manifests encode CurseForge IDs as decimal strings.
99fn deserialize_id<'de, D: serde::Deserializer<'de>>(
100    deserializer: D,
101) -> std::result::Result<u64, D::Error> {
102    #[derive(Deserialize)]
103    #[serde(untagged)]
104    enum Id {
105        Number(u64),
106        Text(String),
107    }
108    match Id::deserialize(deserializer)? {
109        Id::Number(id) => Ok(id),
110        Id::Text(id) => id.parse().map_err(serde::de::Error::custom),
111    }
112}
113
114impl FtbVersionManifest {
115    /// Describe the Minecraft version and loader, using the separate pack name.
116    pub fn info(&self, pack_name: &str) -> Result<ModpackInfo> {
117        let mc = self
118            .targets
119            .iter()
120            .find(|t| t.name == "minecraft")
121            .filter(|t| !t.version.is_empty())
122            .ok_or_else(|| {
123                HexoError::Other("FTB manifest has no Minecraft target version".into())
124            })?;
125        if self.targets.iter().any(|t| t.name == "quilt") {
126            return Err(HexoError::UnsupportedLoader("quilt".into()));
127        }
128        let mut loader = LoaderType::Vanilla;
129        let mut loader_version = None;
130        for (name, kind) in [
131            ("neoforge", LoaderType::NeoForge),
132            ("forge", LoaderType::Forge),
133            ("fabric", LoaderType::Fabric),
134        ] {
135            if let Some(target) = self.targets.iter().find(|t| t.name == name) {
136                let version = if kind == LoaderType::Forge {
137                    target
138                        .version
139                        .strip_prefix(&format!("{}-", mc.version))
140                        .unwrap_or(&target.version)
141                } else {
142                    &target.version
143                };
144                if version.is_empty() {
145                    return Err(HexoError::Other(format!(
146                        "FTB {name} target has no version"
147                    )));
148                }
149                loader = kind;
150                loader_version = Some(version.to_string());
151                break;
152            }
153        }
154        ModpackInfo {
155            name: pack_name.to_string(),
156            version: Some(self.name.clone()),
157            mc_version: mc.version.clone(),
158            loader,
159            loader_version,
160        }.validated()
161    }
162
163    fn selected_files(&self, include_optional: bool) -> impl Iterator<Item = &FtbFile> {
164        self.files
165            .iter()
166            .filter(move |f| !f.serveronly && (!f.optional || include_optional))
167    }
168}
169
170fn parse_response<T: DeserializeOwned>(value: serde_json::Value, id: &str) -> Result<T> {
171    if value.get("status").and_then(|s| s.as_str()) != Some("success") {
172        return Err(HexoError::VersionNotFound(format!("FTB {id}")));
173    }
174    Ok(serde_json::from_value(value)?)
175}
176
177async fn fetch<T: DeserializeOwned>(id: &str) -> Result<T> {
178    let client = reqwest::Client::builder()
179        .user_agent(concat!("hexomc-lib/", env!("CARGO_PKG_VERSION")))
180        .build()?;
181    let response = client
182        .get(format!("{API_BASE}/modpack/{id}"))
183        .send()
184        .await?;
185    if response.status() == reqwest::StatusCode::NOT_FOUND {
186        return Err(HexoError::VersionNotFound(format!("FTB {id}")));
187    }
188    let http_error = response.error_for_status_ref().err();
189    let value = response.json::<serde_json::Value>().await;
190    if let Ok(ref value) = value {
191        if value.get("status").and_then(|s| s.as_str()) != Some("success") {
192            return Err(HexoError::VersionNotFound(format!("FTB {id}")));
193        }
194    }
195    if let Some(error) = http_error {
196        return Err(error.into());
197    }
198    parse_response(value?, id)
199}
200
201/// Fetch pack metadata, including its name and available versions.
202pub async fn get_ftb_pack(pack_id: u64) -> Result<FtbPack> {
203    fetch(&pack_id.to_string()).await
204}
205
206/// Fetch the files and targets for one pack version without installing it.
207pub async fn fetch_ftb_version_manifest(
208    pack_id: u64,
209    version_id: u64,
210) -> Result<FtbVersionManifest> {
211    fetch(&format!("{pack_id}/{version_id}")).await
212}
213
214fn download_task(
215    file: &FtbFile,
216    url: &str,
217    dest: &Path,
218    fallback_sha1: Option<&str>,
219) -> DownloadTask {
220    let mut task = DownloadTask::new(url, dest);
221    if !file.sha1.is_empty() {
222        task = task.with_sha1(&file.sha1);
223    } else if let Some(sha1) = fallback_sha1 {
224        task = task.with_sha1(sha1);
225    }
226    task
227}
228
229fn resolve_files(
230    wanted: Vec<(&FtbFile, PathBuf)>,
231    files: &[CfModFile],
232    projects: &[CfMod],
233    tasks: &mut Vec<DownloadTask>,
234    manual_downloads: &mut Vec<ManualDownload>,
235) {
236    let files: HashMap<_, _> = files.iter().map(|f| (f.id, f)).collect();
237    let projects: HashMap<_, _> = projects.iter().map(|p| (p.id, p)).collect();
238    for (entry, dest) in wanted {
239        let Some(reference) = &entry.curseforge else {
240            continue;
241        };
242        let file = files
243            .get(&reference.file)
244            .filter(|f| f.mod_id == reference.project);
245        let project = projects.get(&reference.project);
246        if let Some(url) = file
247            .and_then(|f| f.download_url.as_deref())
248            .filter(|u| !u.is_empty())
249        {
250            tasks.push(download_task(
251                entry,
252                url,
253                &dest,
254                file.and_then(|f| f.sha1()),
255            ));
256        } else {
257            manual_downloads.push(ManualDownload {
258                name: project.map_or_else(|| entry.name.clone(), |p| p.name.clone()),
259                file_name: entry.name.clone(),
260                website: project
261                    .and_then(|p| p.links.as_ref())
262                    .and_then(|l| l.website_url.as_ref())
263                    .map(|url| format!("{}/files/{}", url.trim_end_matches('/'), reference.file)),
264                dest,
265            });
266        }
267    }
268}
269
270/// Install an FTB pack version into `instance/{instance_name}`.
271///
272/// `curseforge` resolves files hosted on CurseForge; `None` returns them as manual
273/// downloads. `include_optional` also installs optional files. Server-only files
274/// are always excluded. Entries with neither a URL nor CurseForge IDs are skipped.
275#[allow(clippy::too_many_arguments)]
276pub async fn install_ftb_pack(
277    pack_id: u64,
278    version_id: u64,
279    instance_name: &str,
280    base_dir: &Path,
281    java_path: Option<&Path>,
282    curseforge: Option<&CurseForgeClient>,
283    include_optional: bool,
284    progress: ProgressFn,
285) -> Result<ModpackInstallResult> {
286    let pack = get_ftb_pack(pack_id).await?;
287    let manifest = fetch_ftb_version_manifest(pack_id, version_id).await?;
288    let info = manifest.info(&pack.name)?;
289    install_pack_loader(&info, instance_name, base_dir, java_path, progress.clone()).await?;
290
291    install_manifest_files(&manifest, &pack.name, instance_name, base_dir, curseforge, include_optional, progress).await
292}
293
294/// Download pack contents without installing Minecraft or its loader.
295/// `curseforge` and `include_optional` follow the same rules as [`install_ftb_pack`].
296/// No Java is required and `instance_config.json` is not created. Use the returned
297/// [`ModpackInfo`] to install Minecraft and the loader before the first launch.
298pub async fn install_ftb_pack_files(
299    pack_id: u64,
300    version_id: u64,
301    instance_name: &str,
302    base_dir: &Path,
303    curseforge: Option<&CurseForgeClient>,
304    include_optional: bool,
305    progress: ProgressFn,
306) -> Result<ModpackInstallResult> {
307    let pack = get_ftb_pack(pack_id).await?;
308    let manifest = fetch_ftb_version_manifest(pack_id, version_id).await?;
309    install_manifest_files(&manifest, &pack.name, instance_name, base_dir, curseforge, include_optional, progress).await
310}
311
312async fn install_manifest_files(
313    manifest: &FtbVersionManifest,
314    pack_name: &str,
315    instance_name: &str,
316    base_dir: &Path,
317    curseforge: Option<&CurseForgeClient>,
318    include_optional: bool,
319    progress: ProgressFn,
320) -> Result<ModpackInstallResult> {
321    let info = manifest.info(pack_name)?;
322
323    let game_dir = instance_game_dir(base_dir, instance_name);
324    tokio::fs::create_dir_all(&game_dir).await?;
325    let mut tasks = Vec::new();
326    let mut wanted = Vec::new();
327    let mut manual_downloads = Vec::new();
328    let mut skipped = Vec::new();
329    for file in manifest.selected_files(include_optional) {
330        let dest = safe_join(&safe_join(&game_dir, &file.path)?, &file.name)?;
331        if !file.url.is_empty() {
332            tasks.push(download_task(file, &file.url, &dest, None));
333        } else if file.curseforge.is_some() {
334            wanted.push((file, dest));
335        } else {
336            skipped.push(file.name.clone());
337        }
338    }
339
340    if !wanted.is_empty() {
341        let (files, projects) = if let Some(client) = curseforge {
342            progress(0, 0, "Resolving CurseForge files");
343            let references: Vec<_> = wanted
344                .iter()
345                .filter_map(|(f, _)| f.curseforge.as_ref())
346                .collect();
347            let mut file_ids: Vec<_> = references.iter().map(|r| r.file).collect();
348            let mut project_ids: Vec<_> = references.iter().map(|r| r.project).collect();
349            file_ids.sort_unstable();
350            file_ids.dedup();
351            project_ids.sort_unstable();
352            project_ids.dedup();
353            (
354                client.get_files(&file_ids).await?,
355                client.get_mods(&project_ids).await?,
356            )
357        } else {
358            (Vec::new(), Vec::new())
359        };
360        resolve_files(wanted, &files, &projects, &mut tasks, &mut manual_downloads);
361    }
362    download_batch(tasks, 8, move |d, t| {
363        progress(d, t, "Downloading pack files")
364    })
365    .await?;
366    Ok(ModpackInstallResult {
367        info,
368        manual_downloads,
369        skipped,
370    })
371}
372
373#[cfg(test)]
374mod tests {
375    use super::*;
376    use serde_json::json;
377
378    const SAMPLE: &str = r#"{
379        "status": "success", "id": 100422, "name": "1.0.1",
380        "type": "archived", "parent": 134,
381        "targets": [
382            {"name": "minecraft", "version": "1.21.1", "type": "game"},
383            {"name": "neoforge", "version": "21.1.235", "type": "modloader"},
384            {"name": "java", "version": "21.0.10+7-LTS", "type": "runtime"}
385        ],
386        "files": [{
387            "id": 3483569778, "path": "./config", "name": "dummmmmmy-common.toml",
388            "url": "https://files.feed-the-beast.com/blob/57/example.toml", "mirrors": [],
389            "sha1": "208500c1984ff314dde2e5ef1537c648e9e935af",
390            "hashes": {"sha1": "208500c1984ff314dde2e5ef1537c648e9e935af", "murmur": 2484968544},
391            "size": 1323, "clientonly": false, "serveronly": false, "optional": false,
392            "type": "config", "updated": 1784229181
393        }]
394    }"#;
395
396    fn manifest() -> FtbVersionManifest {
397        serde_json::from_str(SAMPLE).unwrap()
398    }
399
400    #[tokio::test]
401    async fn files_only_creates_game_dir_without_instance_config() {
402        let mut manifest = manifest();
403        manifest.files.clear();
404        let temp = tempfile::tempdir().unwrap();
405        let result = install_manifest_files(&manifest, "Test", "test", temp.path(), None, false, crate::no_progress()).await.unwrap();
406        assert_eq!(result.info.loader, LoaderType::NeoForge);
407        assert!(temp.path().join("instance/test/.minecraft").is_dir());
408        assert!(!temp.path().join("instance/test/instance_config.json").exists());
409        assert!(!temp.path().join("libraries").exists());
410        assert!(!temp.path().join("assets").exists());
411    }
412
413    #[test]
414    fn parse_manifest_and_pack() {
415        let manifest = manifest();
416        let info = manifest.info("FTB Skies 2: Aero").unwrap();
417        assert_eq!(info.name, "FTB Skies 2: Aero");
418        assert_eq!(info.version.as_deref(), Some("1.0.1"));
419        assert_eq!(info.mc_version, "1.21.1");
420        assert_eq!(info.loader, LoaderType::NeoForge);
421        assert_eq!(info.loader_version.as_deref(), Some("21.1.235"));
422        let file = &manifest.files[0];
423        let root = Path::new("game");
424        let dest = safe_join(&safe_join(root, &file.path).unwrap(), &file.name).unwrap();
425        assert_eq!(dest, root.join("config/dummmmmmy-common.toml"));
426        let task = download_task(file, &file.url, &dest, None);
427        assert_eq!(task.sha1.as_deref(), Some(file.sha1.as_str()));
428        assert_eq!(task.path, dest);
429        for (path, name) in [
430            ("../outside", "a.jar"),
431            ("./mods", "../../a.jar"),
432            ("/outside", "a.jar"),
433            ("./mods", "/a.jar"),
434        ] {
435            assert!(safe_join(root, path)
436                .and_then(|dir| safe_join(&dir, name))
437                .is_err());
438        }
439        let pack: FtbPack = parse_response(
440            json!({
441                "status": "success", "id": 134, "name": "FTB Skies 2: Aero",
442                "versions": [{"id": 100422, "name": "1.0.1", "type": "archived"}]
443            }),
444            "134",
445        )
446        .unwrap();
447        assert!(!pack.private);
448        assert_eq!(pack.versions[0].r#type, "archived");
449        assert!(pack.versions[0].targets.is_empty());
450    }
451
452    #[test]
453    fn filters_server_and_optional_files() {
454        let mut manifest = manifest();
455        manifest.files = (0..8)
456            .map(|flags| {
457                let mut file = manifest.files[0].clone();
458                file.serveronly = flags & 1 != 0;
459                file.optional = flags & 2 != 0;
460                file.clientonly = flags & 4 != 0;
461                file
462            })
463            .collect();
464        assert_eq!(manifest.selected_files(false).count(), 2);
465        assert_eq!(manifest.selected_files(true).count(), 4);
466        assert!(manifest.selected_files(true).all(|f| !f.serveronly));
467        assert!(manifest.selected_files(false).any(|f| f.clientonly));
468    }
469
470    #[test]
471    fn loader_selection_and_forge_prefix() {
472        let mut manifest = manifest();
473        manifest.targets.truncate(1);
474        assert_eq!(manifest.info("Test").unwrap().loader, LoaderType::Vanilla);
475        assert!(manifest.info("Test").unwrap().loader_version.is_none());
476        for (name, version, expected, bare) in [
477            ("fabric", "0.16.5", LoaderType::Fabric, "0.16.5"),
478            ("forge", "1.21.1-52.0.1", LoaderType::Forge, "52.0.1"),
479            ("neoforge", "21.1.235", LoaderType::NeoForge, "21.1.235"),
480        ] {
481            manifest.targets.insert(
482                1,
483                FtbTarget {
484                    name: name.into(),
485                    version: version.into(),
486                    r#type: "modloader".into(),
487                },
488            );
489            let info = manifest.info("Test").unwrap();
490            assert_eq!(info.loader, expected);
491            assert_eq!(info.loader_version.as_deref(), Some(bare));
492        }
493        manifest.targets.push(FtbTarget {
494            name: "quilt".into(),
495            version: "0.26.0".into(),
496            r#type: "modloader".into(),
497        });
498        assert!(matches!(
499            manifest.info("Test"),
500            Err(HexoError::UnsupportedLoader(_))
501        ));
502        manifest.targets.clear();
503        assert!(manifest.info("Test").is_err());
504    }
505
506    #[tokio::test]
507    async fn neoforge_1_20_1_is_rejected_before_installation() {
508        let mut manifest = manifest();
509        manifest.targets[0].version = "1.20.1".into();
510        let temp = tempfile::tempdir().unwrap();
511        let result = install_manifest_files(&manifest, "Test", "test", temp.path(), None, false, crate::no_progress()).await;
512        assert!(
513            matches!(result, Err(HexoError::UnsupportedLoader(message)) if message.contains("NeoForge for 1.20.1"))
514        );
515        assert!(!temp.path().join("instance").exists());
516    }
517
518    #[test]
519    fn error_status_does_not_require_manifest_fields() {
520        for value in [json!({"status": "error"}), json!({})] {
521            assert!(matches!(
522                parse_response::<FtbVersionManifest>(value, "134/0"),
523                Err(HexoError::VersionNotFound(_))
524            ));
525        }
526    }
527
528    #[test]
529    fn curseforge_ids_accept_legacy_strings_and_numbers() {
530        for ids in [
531            json!({"project": "273430", "file": "2459131"}),
532            json!({"project": 273430, "file": 2459131}),
533        ] {
534            let ids: FtbCurseForgeRef = serde_json::from_value(ids).unwrap();
535            assert_eq!(ids.project, 273430);
536            assert_eq!(ids.file, 2459131);
537        }
538        assert!(
539            serde_json::from_value::<FtbCurseForgeRef>(json!({"project": -1, "file": 2})).is_err()
540        );
541    }
542
543    #[test]
544    fn curseforge_resolution_keeps_paths_and_missing_files() {
545        let entry: FtbFile = serde_json::from_value(json!({
546            "path": "./custom", "name": "manifest-name.jar",
547            "curseforge": {"project": 123, "file": 456}
548        }))
549        .unwrap();
550        let dest = PathBuf::from("game/custom/manifest-name.jar");
551        let mut tasks = Vec::new();
552        let mut manual = Vec::new();
553        resolve_files(
554            vec![(&entry, dest.clone())],
555            &[],
556            &[],
557            &mut tasks,
558            &mut manual,
559        );
560        assert!(tasks.is_empty());
561        assert_eq!(manual.len(), 1);
562        assert_eq!(manual[0].dest, dest);
563        assert!(manual[0].website.is_none());
564
565        let project: CfMod = serde_json::from_value(json!({
566            "id": 123, "name": "Example", "slug": "example", "summary": "", "downloadCount": 0,
567            "links": {"websiteUrl": "https://www.curseforge.com/minecraft/mc-mods/example"}
568        }))
569        .unwrap();
570        let mut file: CfModFile = serde_json::from_value(json!({
571            "id": 456, "modId": 123, "displayName": "Example", "fileName": "api-name.jar",
572            "downloadUrl": null, "gameVersions": [], "fileDate": "",
573            "hashes": [{"algo": 1, "value": "abc"}]
574        }))
575        .unwrap();
576        for url in [
577            None,
578            Some("".to_string()),
579            Some("https://example.com/mod.jar".to_string()),
580        ] {
581            file.download_url = url;
582            tasks.clear();
583            manual.clear();
584            resolve_files(
585                vec![(&entry, dest.clone())],
586                std::slice::from_ref(&file),
587                std::slice::from_ref(&project),
588                &mut tasks,
589                &mut manual,
590            );
591            if file.download_url.as_ref().is_some_and(|u| !u.is_empty()) {
592                assert!(manual.is_empty());
593                assert_eq!(tasks[0].path, dest);
594                assert_eq!(tasks[0].sha1.as_deref(), Some("abc"));
595            } else {
596                assert!(tasks.is_empty());
597                assert_eq!(manual[0].file_name, entry.name);
598                assert_eq!(
599                    manual[0].website.as_deref(),
600                    Some("https://www.curseforge.com/minecraft/mc-mods/example/files/456")
601                );
602            }
603        }
604    }
605}