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.