Skip to content

Comments

feat(core): modpack import support#100

Open
Wsrsq wants to merge 1 commit intoHydroRoll-Team:mainfrom
Wsrsq:main
Open

feat(core): modpack import support#100
Wsrsq wants to merge 1 commit intoHydroRoll-Team:mainfrom
Wsrsq:main

Conversation

@Wsrsq
Copy link

@Wsrsq Wsrsq commented Feb 21, 2026

Add modpack parsing and extraction module supporting three major formats:

  • Modrinth
  • CurseForge
  • MultiMC / PrismLauncher

Details

  • modpack::detect() — parse a modpack zip and return metadata (name, Minecraft version, mod loader info)
  • modpack::import() — full parse with CurseForge API resolution for download URLs
  • modpack::extract_overrides() — extract config/resource override files to the game directory

Summary by Sourcery

Add a core modpack module to detect, import, and extract Minecraft modpacks from multiple formats.

New Features:

  • Introduce a unified modpack parsing API that detects and parses Modrinth, CurseForge, and MultiMC/PrismLauncher modpack archives.
  • Provide an import flow that resolves CurseForge mod files into concrete download URLs and target paths.
  • Add support for extracting modpack override files into a game directory with progress callbacks.

@vercel
Copy link

vercel bot commented Feb 21, 2026

@Wsrsq is attempting to deploy a commit to the retrofor Team on Vercel.

A member of the Team first needs to authorize it.

@sourcery-ai
Copy link
Contributor

sourcery-ai bot commented Feb 21, 2026

Reviewer's Guide

Adds a new core modpack module that can detect, parse, and import multiple modpack formats (Modrinth, CurseForge, MultiMC/PrismLauncher), including CurseForge API resolution and override file extraction, and wires it into the core module tree.

Sequence diagram for modpack import with CurseForge resolution

sequenceDiagram
    participant Caller
    participant Modpack as ModpackModule
    participant Zip as ZipArchive
    participant CurseForge as CurseForgeAPI

    Caller->>Modpack: import(path)
    activate Modpack
    Modpack->>Modpack: parse(path)
    activate Zip
    Modpack->>Zip: open(path)
    Zip-->>Modpack: Archive
    Modpack->>Modpack: parse_modrinth(archive)
    Modpack-->>Modpack: Err
    Modpack->>Modpack: parse_curseforge(archive)
    Modpack-->>Modpack: ParsedModpack(info, files, override_prefixes)
    deactivate Zip

    alt modpack_type == curseforge
        Modpack->>Modpack: resolve_curseforge_files(files)
        Modpack->>CurseForge: cf_post /v1/mods/files(fileIds)
        CurseForge-->>Modpack: file metadata (downloadUrl, modId, fileLength)
        Modpack->>CurseForge: cf_post /v1/mods(modIds)
        CurseForge-->>Modpack: mod metadata (classId)
        Modpack-->>Modpack: build ModpackFile list with URLs and paths
    end

    Modpack-->>Caller: ParsedModpack(info, resolved_files, override_prefixes)
    deactivate Modpack
Loading

Sequence diagram for override extraction from modpack archive

sequenceDiagram
    participant Caller
    participant Modpack as ModpackModule
    participant Zip as ZipArchive
    participant FS as FileSystem

    Caller->>Modpack: extract_overrides(path, game_dir, override_prefixes, on_progress)
    activate Modpack
    Modpack->>Zip: open(path)
    Zip-->>Modpack: Archive

    Modpack->>Zip: iterate entries(by_index_raw)
    Modpack-->>Modpack: all_names
    Modpack-->>Modpack: prefixes (existing override_prefixes)
    Modpack-->>Modpack: total = count(entries matching prefixes)

    loop for each archive entry
        Modpack->>Zip: by_index(i)
        Zip-->>Modpack: entry(name, is_dir)
        Modpack-->>Modpack: relative = strip(prefix, name)
        alt relative exists and within game_dir
            alt entry is directory
                Modpack->>FS: create_dir_all(outpath)
                FS-->>Modpack: ok
            else entry is file
                Modpack->>FS: create_dir_all(parent)
                FS-->>Modpack: ok
                Modpack->>FS: File::create(outpath)
                FS-->>Modpack: handle
                Modpack->>FS: copy(entry, file)
                FS-->>Modpack: ok
            end
            Modpack-->>Caller: on_progress(current, total, relative)
        else
            Modpack-->>Modpack: skip entry
        end
    end

    Modpack-->>Caller: Ok(())
    deactivate Modpack
Loading

Class diagram for new core modpack module types and API

classDiagram
    class ModpackInfo {
        +String name
        +Option~String~ minecraft_version
        +Option~String~ mod_loader
        +Option~String~ mod_loader_version
        +String modpack_type
        +Option~String~ instance_id
    }

    class ModpackFile {
        +String url
        +String path
        +Option~u64~ size
        +Option~String~ sha1
    }

    class ParsedModpack {
        +ModpackInfo info
        +Vec~ModpackFile~ files
        +Vec~String~ override_prefixes
    }

    class ModpackModule {
        +detect(path: &Path) Result~ModpackInfo, String~
        +import(path: &Path) Result~ParsedModpack, String~
        +extract_overrides(path: &Path, game_dir: &Path, override_prefixes: &[String], on_progress: Fn(usize, usize, &str)) Result~(), String~
        -parse(path: &Path) Result~ParsedModpack, String~
        -parse_modrinth(archive: &mut Archive) Result~ParsedModpack, String~
        -parse_curseforge(archive: &mut Archive) Result~ParsedModpack, String~
        -parse_multimc(archive: &mut Archive) Result~ParsedModpack, String~
        -resolve_curseforge_files(files: &[ModpackFile]) Result~Vec~ModpackFile~, String~
        -cf_post(client: &reqwest::Client, endpoint: &str, body: &serde_json::Value) Result~serde_json::Value, String~
        -cf_class_ids(client: &reqwest::Client, mod_ids: &[u64]) HashMap~u64, u64~
        -read_entry(archive: &mut Archive, name: &str) Option~String~
        -read_json(archive: &mut Archive, name: &str) Result~serde_json::Value, String~
        -cfg_value(content: &str, key: &str) Option~String~
        -find_multimc_root(archive: &mut Archive) Option~String~
        -parse_modrinth_loader(deps: &serde_json::Value) (Option~String~, Option~String~)
        -parse_mmc_components(json: &serde_json::Value) (Option~String~, Option~String~, Option~String~)
    }

    ModpackModule ..> ModpackInfo : uses
    ModpackModule ..> ModpackFile : uses
    ModpackModule ..> ParsedModpack : returns
    ParsedModpack o-- ModpackInfo
    ParsedModpack o-- ModpackFile
Loading

File-Level Changes

Change Details Files
Introduce a unified modpack parsing/import API with metadata and file list abstraction.
  • Define ModpackInfo and ModpackFile data structures to represent modpack metadata and downloadable files.
  • Introduce ParsedModpack as a unified parse result containing info, files, and override prefixes.
  • Expose detect() for metadata-only parsing and import() for full parsing with format-specific handling and network resolution.
  • Implement a generic parse() dispatcher that tries format-specific parsers and falls back to a best-effort "unknown" pack.
src-tauri/src/core/modpack.rs
Implement format-specific parsers for Modrinth, CurseForge, and MultiMC/PrismLauncher modpacks.
  • Implement parse_modrinth() to read modrinth.index.json, infer mod loader from dependencies, and map files to ModpackFile entries with URLs and hashes, skipping unsupported or unsafe paths.
  • Implement parse_curseforge() to read manifest.json, validate manifestType, extract Minecraft and loader info, and create placeholder ModpackFile entries using a curseforge:// scheme along with override directory detection.
  • Implement parse_multimc() to detect MultiMC root, read instance.cfg and mmc-pack.json, derive Minecraft and loader components, and configure override prefixes to .minecraft or minecraft directories.
  • Add helper functions for reading entries/JSON from the archive and parsing Modrinth loader and MultiMC component metadata.
src-tauri/src/core/modpack.rs
Add override file extraction from modpack archives into a game directory.
  • Implement extract_overrides() to scan archive entries, filter those under configured override prefixes, and copy them into the target game directory.
  • Guard against path traversal by ensuring output paths remain under the game directory.
  • Provide progress callbacks with current/total counts and relative file names during extraction.
src-tauri/src/core/modpack.rs
Integrate CurseForge API resolution to turn logical file references into concrete download URLs and install paths.
  • Add resolve_curseforge_files() to batch-resolve mod files from curseforge:// pseudo-URLs using the CurseForge API and generate ModpackFile entries with download URLs, sizes, and target directories based on mod classId (mods/resourcepacks/shaderpacks).
  • Introduce cf_post() helper to perform authenticated POST requests to CurseForge endpoints using a compile-time CURSEFORGE_API_KEY.
  • Implement cf_class_ids() to map mod IDs to class IDs via a bulk API call for directory classification, falling back gracefully on failure.
src-tauri/src/core/modpack.rs
Wire the new modpack module into the core module system.
  • Export the new modpack module from the core mod.rs so it is available to the rest of the application.
src-tauri/src/core/mod.rs

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 2 issues, and left some high level feedback:

  • Using env!("CURSEFORGE_API_KEY") hard-fails at compile time and embeds the key into the binary; consider switching to a runtime configuration mechanism (e.g., reading from tauri.conf.json/env at startup) and passing the API key into resolve_curseforge_files instead of using a global constant.
  • In resolve_curseforge_files, entries whose url don't parse as curseforge://projectId:fileId are silently dropped; if mixed or malformed entries are possible it may be safer to either preserve them unchanged or surface an explicit error so they are not lost without notice.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- Using `env!("CURSEFORGE_API_KEY")` hard-fails at compile time and embeds the key into the binary; consider switching to a runtime configuration mechanism (e.g., reading from `tauri.conf.json`/env at startup) and passing the API key into `resolve_curseforge_files` instead of using a global constant.
- In `resolve_curseforge_files`, entries whose `url` don't parse as `curseforge://projectId:fileId` are silently dropped; if mixed or malformed entries are possible it may be safer to either preserve them unchanged or surface an explicit error so they are not lost without notice.

## Individual Comments

### Comment 1
<location> `src-tauri/src/core/modpack.rs:351-354` </location>
<code_context>
+                        id % 1000
+                    )
+                });
+            let dir = match f["modId"].as_u64().and_then(|mid| class_map.get(&mid)) {
+                Some(12) => "resourcepacks",
+                Some(6552) => "shaderpacks",
+                _ => "mods",
+            };
+            Some(ModpackFile {
</code_context>

<issue_to_address>
**issue (bug_risk):** Class ID matching is currently ineffective due to pattern matching on `Option<&u64>` instead of `Option<u64>`.

`class_map.get(&mid)` returns `Option<&u64>`, so the patterns need to match on a referenced `u64`, e.g.:

```rust
let dir = match f["modId"].as_u64().and_then(|mid| class_map.get(&mid)) {
    Some(&12) => "resourcepacks",
    Some(&6552) => "shaderpacks",
    _ => "mods",
};
```

Alternatively, bind the `&u64` to a local and match on that. This will correctly route resource and shader packs.
</issue_to_address>

### Comment 2
<location> `src-tauri/src/core/modpack.rs:310-314` </location>
<code_context>
+                .ok()
+        })
+        .collect();
+    if file_ids.is_empty() {
+        return Ok(vec![]);
+    }
+
</code_context>

<issue_to_address>
**suggestion (bug_risk):** Returning an empty list when no CurseForge IDs are parsed can silently drop files.

Since `file_ids` can be empty while `result.files` still contains valid entries (e.g. malformed or non-CurseForge ones), returning `Ok(vec![])` causes `import()` to overwrite `result.files` and drop those entries. Instead, either return the original `files` slice (e.g. via clone) when `file_ids` is empty, or short-circuit in `import()` to skip calling `resolve_curseforge_files` when there are no `curseforge://` URLs.

```suggestion
        .collect();
    if file_ids.is_empty() {
        return Ok(result.files.clone());
    }

```
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +351 to +354
let dir = match f["modId"].as_u64().and_then(|mid| class_map.get(&mid)) {
Some(12) => "resourcepacks",
Some(6552) => "shaderpacks",
_ => "mods",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): Class ID matching is currently ineffective due to pattern matching on Option<&u64> instead of Option<u64>.

class_map.get(&mid) returns Option<&u64>, so the patterns need to match on a referenced u64, e.g.:

let dir = match f["modId"].as_u64().and_then(|mid| class_map.get(&mid)) {
    Some(&12) => "resourcepacks",
    Some(&6552) => "shaderpacks",
    _ => "mods",
};

Alternatively, bind the &u64 to a local and match on that. This will correctly route resource and shader packs.

Comment on lines +310 to +314
.collect();
if file_ids.is_empty() {
return Ok(vec![]);
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (bug_risk): Returning an empty list when no CurseForge IDs are parsed can silently drop files.

Since file_ids can be empty while result.files still contains valid entries (e.g. malformed or non-CurseForge ones), returning Ok(vec![]) causes import() to overwrite result.files and drop those entries. Instead, either return the original files slice (e.g. via clone) when file_ids is empty, or short-circuit in import() to skip calling resolve_curseforge_files when there are no curseforge:// URLs.

Suggested change
.collect();
if file_ids.is_empty() {
return Ok(vec![]);
}
.collect();
if file_ids.is_empty() {
return Ok(result.files.clone());
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant