Modrinth Server modpacks in nix-minecraft

Date:
2023-05-21

Tags:

minecraft
nix

Building a nix expression for Modrinth modpacks.

Modrinth is a platform to download mods for minecraft. Mods are available individually, but often you want to grab a modpack, a predefined collection of mods maintained by someone in the community. These are also available in modrinth in a custom mrpack format.

Modpack format

The Modrinth Modpack Format is well documented and we can use it to define a nix derivation which will read the list of mods, their hashes and build the file structure with all the necessary resources to run the modpack in our server.

In its most simple form, a modpack is a zip file which contains its metadata in modrinth.index.json and optionally the folders overrides, overrides-server and overrides-client. These folders contain files will be copied into the minecraft folder, overriding the defaults. The -server and -client are functionally equivalent overrides which only apply to the server or client respectively.

All the mod downloads and other resources are specified in modrinth.index.json under the files key, which contains an array of files with the following structure:

{
    "path": "destination path of the download file"
    "hashes": {
        "sha1": "...",
        "sha512": "...",
    },
    "env": {
        "client": "required",
        "server": "unsupported"
    },
    "downloads": [
        "download-url",
        "alternative-mirror",
        "...",
    ],
}

Getting our hands dirty

The fields are self explanatory (details are in Modrinth Modpack Format reference), and they provide all we need to fetch the files into nix (download url and hash).

Let's start trying to parse and download all the files in some modpack. For instance, we'll grab Cobblemon Fabric 1.3.2 modpack. Since the file has a .mrpack extension, we must tell nix to ignore it and treat it as a zip file. The zip file does not have a root folder, so we must tell nix to not try stripping it:

cobblemon_modpack = pkgs.fetchzip {
    pname = "Cobblemon";
    version = "1.3.2";
    url = "https://cdn.modrinth.com/data/5FFgwNNP/versions/nvrqJg44/Cobblemon%20%5BFabric%5D%201.3.2.mrpack";
    sha256 = "sha256-F56AwHGoUN3HbDqvj+bFeFc9Z8jGJhGn5K73MzMsn8E=";
    extension = "zip";
    stripRoot = false;
};

In the nix repl, we can check the built derivation with :u cobblemon_modpack, which will build it and dumps us into a shell with the files. This special repl command is quite useful when trying to debug the file structure of derivations:

nix-repl> :u cobblemon_modpack

[nix-shell:~/modpack]$ ls
modrinth.index.json  overrides

[nix-shell:~/modpack]$ ls overrides/
config  fancymenu_data  global_packs  icon.png  instance.png  mods  options.txt  resourcepacks  shaderpacks

Nothing too exciting, this is simply the extracted zip file. We can exit out of the shell with ^D or exit.

Now, let's try reading the JSON file inside the modpack:

modrinth_index = builtins.fromJSON (builtins.readFile "${cobblemon_modpack}/modrinth.index.json");

If we now check modrinth_index in nix repl, we can see the following:

nix-repl> modrinth_index
{ dependencies = { ... }; files = [ ... ]; formatVersion = 1; game = "minecraft"; name = "Cobblemon [Fabric]"; summary = "Official Cobblemon Modpack for Fabric 1.19.2"; versionId = "1.3.2"; }

Awesome, now we have all we need in a nix object, we just need to run through the files attribute values and fetch the corresponding files.

files = builtins.filter (file: !(file ? env) || file.env.server != "unsupported") modrinth_index.files;

downloads = builtins.map (file: fetchurl {
    urls = file.downloads;
    inherit (file.hashes) sha512;
}) files;

There are a some points to address in the previous expressions, first, we need to check if the env attribute exists (file ? env), since if it doesn't we assume it is required. Additionally, I decided I wanted all optional mods, so my filter is file.env server != "unsupported, but that may not be ideal for certain modpacks. If that is the case, the filter expression can be changed.

Also, we use urls instead of url which takes a list of possible download locations.

We can check that the first download works:

nix-repl> :b builtins.head downloads

This derivation produced the following outputs:
  out -> /nix/store/qnvaj0c5wsh9ira6bgbk6jyswgk2gr31-advancementinfo-1.19.1-fabric0.58.5-1.3.1.jar

Now, we just need to fetch all the files and symlink them into their folders. However, first we need to know were each file goes, for that we have the file.path attribute. We can run a simple regex to split the folder and the filename and then just create and copy the file where it's needed:

paths = builtins.map (builtins.getAttr "path") files;

derivations = lib.zipListsWith (path: download:
    let folder_name = builtins.match "(.*)/(.*$)" path;
        folder = builtins.head folder_name;
        name = builtins.head (builtins.tail folder_name);
    in
        pkgs.runCommand name { } ''
          mkdir -p "$out/${folder}"
          cp ${download} "$out/${path}"
        ''
    ) paths downloads;

Putting it all together

Now, we can take all the derivations and symlink them together:

pkgs.symlinkJoin {
    name = with modrinth_index; "${name}-${versionId}";
    paths = derivations ++ [ "${modpack}/overrides" ];
};

We can now put it all into a single default.nix:

{ lib
, fetchurl
, runCommand
, symlinkJoin
, modpack ? null
, extra_mods ? []
}:
let
  modrinth_index = builtins.fromJSON (builtins.readFile "${modpack}/modrinth.index.json");

  files = builtins.filter (file: !(file ? env) || file.env.server != "unsupported") modrinth_index.files;

  downloads = builtins.map (file:
    fetchurl {
      urls = file.downloads;
      inherit (file.hashes) sha512;
    }
  ) files;

  paths = builtins.map (builtins.getAttr "path") files;

  derivations = lib.zipListsWith (path: download:
    let folder_name = builtins.match "(.*)/(.*$)" path;
      folder = builtins.head folder_name;
      name = builtins.head (builtins.tail folder_name);
    in
    runCommand name { } ''
      mkdir -p "$out/${folder}"
      cp ${download} "$out/${path}"
    ''
    ) paths downloads;

in symlinkJoin {
  inherit (modrinth_index) name;
  paths = derivations ++ [ "${modpack}/overrides" ] ++ extra_mods;
}

I've added an extra_mods parameter with which you can add additional mods to complement the modpack.

An important issue with all this is that often, the config files are expected to be modifiable by some mods, so we cannot use it directly as is in some cases.

Using it with nix-minecraft

We can integrate this with nix-minecraft to have a fully nix minecraft server. As stated above, there are some folders that need to be modifiable for the server to function properly (mainly config/). Thankfully, nix-minecraft can help with that, we just need to specify which folders should be symlinked and which ones should be copied into a modifiable folder on the first creation of the server:

services.minecraft-servers = {
  enable = true;
  eula = true;

  servers = {
    cobblemon = {
      enable = true;
      autoStart = false;
      package = pkgs.fabricServers.fabric-1_19_2;
      serverProperties.difficulty = "hard";
      symlinks = lib.genAttrs ["mods" "fancymenu_data" "global_packs" "icon.png" "instance.png" "resourcepacks" "shaderpacks"] (name: "${cobblemon_files}/${name}");
      files = let conf_files = lib.filesystem.listFilesRecursive "${cobblemon_files}/config";
        in builtins.listToAttrs (builtins.map (value: {
          name = builtins.unsafeDiscardStringContext (lib.strings.removePrefix "${cobblemon_files}/" value);
          inherit value;
        }) conf_files);
    };
  };
};

Here, we specify all the folders we want to symlink (in symlinks) and then, all the files in config which we want to copy. To get the list of all the files, we need to cheat a little and use unsafeDiscardStringContext so that we can use the values of the files as attributes. This is safe because the values of the attributes contain the nix store path, so there is no issue with paths not existing.