Tags:
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.