Automating fun with bash

zootr and bash

Published at

I really like The Legend of Zelda: Ocarina of Time Randomizer I used it as the base for collecting my notes on OCI artifacts1. I've watched races and show cases for years and find a lot of enjoyment in the puzzle solving aspect of the randomizer.

Installing the randomizer is a git clone of the code base, and using it is running a Python module. No pip install this or mkvirtualenv that. Just python Gui.py or if you like to live in the terminal python OoTRandomizer.py2. The two just run. I wanna just point out that this is a very, very non-trivial primarily Python project that just runs on Linux and Windows with no dependencies. Frankly, I am beyond amazed at that alone.

Anyways, after playing a handful of seeds and settling on some settings that fit with my skill level, I was kind of tired of booting up a GUI and clicking things. However, the CLI didn't provide all the options I needed to generate patched roms, or at least all the options I wanted to create patched roms. The primary missing piece is the CLI doesn't provide a way to declare a path to a compatible z64 file. But after digging through code and asking around on the community discord I learned that an uncompressed z64 file with a specific name inside the project directory also works.

I also wanted some quality of life features. The randomizer provides them on top of the base OOT game, why not provide my own over the randomizer itself? These were things like putting the files directly into my preferred directory, providing some kind of naming mechanism, and making use of the various versions and forks the community develops. OOTR is less a randomizer and more a foundation for a constellation of randomizers that offer a variety of features, including a randomizer for the randomizer!.

So I sat down and wrote some bash, as one does and a little bit of a Dockerfile.

There's three bash scripts, the first is responsible for acquiring a version of the randomizer and creating a container image from it:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
#!/usr/bin/env bash
set -ex
SCRIPT_DIR=$( cd -- "$( dirname -- $(realpath "${BASH_SOURCE[0]}") )" &> /dev/null && pwd )
REPO="git@github.com:OoTRandomizer/OoT-Randomizer.git"
REF="${ZOOTR_REF:-'v7.1.0'}"

if [ -d "zootr-source" ]; then
    rm -fr "zootr-source"
fi

TMPOUT=$(mktemp -d -p /var/tmp/linkspocket/)
pushd "${TMPOUT}"
if [ "$(pwd)" != "${TMPOUT}" ]; then
    echo "whoopps..."
    exit 99
fi
trap "rm ${TMPOUT}; popd" 9

git clone --quiet --progress --branch "${REF}" --single-branch --depth 1 "${REPO}" "zootr-source"
rm -rf ./zootr-source/.git
docker build -f "${SCRIPT_DIR}/Dockerfile" -t "zootr:${REF}" --build-context "base=${SCRIPT_DIR}" .

This I think is a really good example of why I unironically place bash in my top three favorite programming languages. A comparable script in another language3 would be much, much longer. The nastiest part is acquiring the actual placement of the script in the file system which is important because I may invoke this from anywhere and the script makes use a temporary directory to hold the cloned repository using pushd and popd to move to and from it4

You may ask yourself, "Where is that beautiful house Dockerfile?" Right here.

1
2
3
4
5
6
7
8
9
FROM python:3.11-slim-bookworm
ARG REF

COPY ./zootr-source/ /usr/lib/zootr
COPY ./zootr-source/bin/Decompress/Decompress /usr/bin/decompress
COPY --from="base" --chmod=777 entrypoint.sh /usr/bin/entrypoint

ENTRYPOINT ["/usr/bin/entrypoint"]
CMD ["-h"]

This is where the decompressed ROM comes into play. OOTR provides precompiled binaries for compressing and decompressing ROMs. I also want to bring attention to the COPY --from="base" line. There's an accompanying --build-context "base=${SCRIPT_DIR}" in the script that builds this image, these are the two halves of using multiple build contexts. Some of the container's content comes from one place and some comes from another place. Initially I had forgotten about this feature as I'd never used it before and the initial script was...hairy to say the least. Multiple contexts not only shortened and simplified the build script, it also felt very intuitive to use as well.

Let's look at the entrypoint next:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
#!/usr/bin/env bash
set -e

usage() {
    echo "Usage $0 [-r <path to rom>] [-- [additional ootr options]]" 1>&2;
}

ROMDEST="/usr/lib/zootr/ZOOTDEC.z64"
ROM=$ROMDEST

echo "Args ${@}"

while getopts ":r:" o; do
    case "${o}" in
        r)
            ROM=${OPTARG}
            ;;
    esac
done

shift $((OPTIND-1))

echo "Remaining args ${@}"

if [ ! -f "${ROM}" ]; then 
    echo "${ROM} does not exist!" 1>&2
    usage
    exit 2
fi

if [ "${ROM}" != "${ROMDEST}" ]; then
    echo "Poking rom ${ROM}"
    ROMSHA=$(sha256sum "${ROM}")
    if echo "$ROMSHA" | grep "c916ab315fbe82a22169bff13d6b866e9fddc907461eb6b0a227b82acdf5b506" > /dev/null; then
        echo "Decompressing rom..."
        decompress "${ROM}" "${ROMDEST}"
    elif echo "$ROMSHA" | grep "01193bef8785775e3bc026288c6b4ecb8bd13cfdc0595afd0007c6206da1f3b2" > /dev/null; then
        echo "Using decompressed rom..."
        cp "${ROM}" "${ROMDEST}"
    else
        echo "Invalid rom provided" 1>&2
        usage
        exit 3
    fi 
fi

exec python3 /usr/lib/zootr/OoTRandomizer.py "${@}" --output_settings

Here there's some CLI parsing, SHA256 hash comparisons to figure out if we already have a decompressed ROM already or not and finally actually invoking the randomizer, which this script instructs to always generate a settings file, primarily for my archival purposes but it also aids in automatic tagging.

So we have a build script, the entrypoint script and the Dockerfile. How the heck do these all fit together? There's a last file I've named linkspocket.sh, after the Randomizer's name for the "area" we find starting items in.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
#!/usr/bin/env bash
set -e
SCRIPT_DIR=$( cd -- "$( dirname -- $(realpath "${BASH_SOURCE[0]}") )" &> /dev/null && pwd )
ROM=""
OUT=""
VERSION="v7.1.0"
TAG=""

usage() {
    cat <<EOF
Usage $0 -r <path to oot rom> -o <directory to put output into> 

    -h Show this message and exit
    -o Base output directory, may be absolute or relative to current directory
       This directory will be created if necessary
    -r Path to rom, may be absolute or relative to current directory
    -t Output files are placed in a directory with this name
       By default a tag is derived from the generated settings file
    -v Version of the randomizer to generate with, default is 'v7.1.0'
       If the version is not present, this script will attempt to build it first
       May be any 'commit-ish' (as defined by git) present in the OOTR repository

OOTR specific CLI options can be passed by providing -- and then the options.
A settings file is always generated.

Example usage:

    $0 -r ./my-rom.z64 -o ~/zootr-seeds/ -t my-seed -- --settings_string [omitted]

This would create a directory ~/zootr-seeds/my-seed from the specified rom and
settings string.
EOF
}

while getopts "hr:o:v:t:" o; do 
    case "$o" in
        h)
            usage
            exit 0
            ;;
        r)
            ROM="${OPTARG}"
            ;;
        o)
            OUT="${OPTARG}"
            ;;
        v)
            VERSION="${OPTARG}"
            ;;
        t)
            TAG="${OPTARG}"
            ;;
        *)
            usage
            exit 1
            ;;
    esac
done

shift $((OPTIND-1))

if [ -z "${ROM}" ];  then
    echo "-r <path to rom> must be provided!"
    usage
    exit 2
fi

if [ ! -f "${ROM}" ]; then
    echo "${ROM} does not exist!"
    usage
    exit 3
fi

if [ -z "${OUT}" ]; then
    echo "-o <output directory> must be provided!"
    usage
    exit 4
fi

REALROM=$(realpath -e "${ROM}")
ROMNAME=$(basename "${ROM}")
mkdir -p /var/tmp/linkspocket
TMPOUT=$(mktemp -d -p /var/tmp/linkspocket/)

if [ "$(docker images -q "zootr:${VERSION}" 2> /dev/null)" == "" ]; then
    echo "Version ${VERSION} not found locally, building..."
    ZOOTR_REF="${VERSION}" "${SCRIPT_DIR}/zootr/build.sh"
fi

CONTID=$(docker create \
    -v "${REALROM}:/etc/zootr/${ROMNAME}:ro" \
    "zootr:${VERSION}" \
    -r "/etc/zootr/${ROMNAME}" -- "${@}" )

set +e
echo "Launching container ${CONTID}"
docker start -a "${CONTID}"
EC=$?
set -e

docker cp "${CONTID}:/lib/zootr/Output" "${TMPOUT}"

if [ -z "${TAG}" ]; then
    SETTINGSFILE=$(ls -1 "${TMPOUT}/Output" | grep -i 'settings')

    TAG=$(jq -jr '.["file_hash"] | join(" ")' "${TMPOUT}/Output/${SETTINGSFILE}" |\
          tr -sc '[:alnum:]' '-' | tr '[:upper:]' '[:lower:]')
    if [ -z "${TAG}" ]; then 
        echo "Unable to determine tag automatically" 1>&2
        exit 4
    fi
fi

ACTUALOUT="$(realpath "${OUT}")/${TAG}"
mkdir -p "${ACTUALOUT}"
mv -v "${TMPOUT}/Output/"* "${ACTUALOUT}/"

docker rm ${CONTID}

I hear it already. "ALEC! That's more than hundred lines of bash, what the fuck." And my answer is (: But honestly, it's not that much and mostly its formatting quality of life and CLI argument parsing. Again, unironically a top three language for me and admittedly Python probably puts up a much better argument this time around5

There's a lot going on here but the primary points are:

  1. This script allows switching between versions of the randomizer6 and ensures that version is available as a container.
  2. Ensures the ROM is available inside the container
  3. Copies, rathere than uses a volume mount, to extract the generated output

Why copy instead of a volume mount? Simply I didn't really feel like fighting file permissions when I could be playing a seed instead. Note above the Dockerfile lazily and dangerously sets file permissions to 777 as well. Also the container runs as root, it's a real "do as I say, not as I do" moment. :shrug:

The script also names the ultimate destination of all of the output after either the tag provided, or, if not provided, after the textual representation of the visual representation of the seed hash.

Finally, I added a symlink from this script to ~/.local/bin/linkspocket as a favor to myself.

What's next?

I've gotten a lot of use outta these scripts already. But I've been heavily interested in OCI artifacts recently so that seems a logical next step to me. I won't follow the examples I laid out in the previous post, those were complex to explore and illustrate what the OCI image and distribution specifications allows. Instead, I'll likely, initially, flatten the output to a single manifest with each file stored in its own blob. And eventually storing settings separately so they could be referenced from the CLI instead of providing the settings string.

The randomizer also supports quite a few cosmetic options, including models and music which would interesting to include in a similar fashion to how the ROM is included currently


  1. mostly because reading SBOM and signature again and again and again makes my eyes glaze over, it's important but it isn't interesting reading to me 

  2. It even has a hashbang if you wanna make that file executable! 

  3. setting aside other shell languages 

  4. These are very handy utilities to build a stack of directories to navigate, they have more functionality than this and I'd recommend reaching from them occasion 

  5. Spoilers, that's also the next iteration of this and why I'm posting raw bash instead of linking to a repository or even some kinda paste sharing site, these are the first iterations 

  6. I only play on the primary randomizer so this script doesn't support forks currently