Custom Paintings

The way paintings work was changed in Minecraft 1.21.2 in order to enable players to be able to add custom paintings more easily. You can now add custom paintings by adding a small datapack with the description as well as a resourcepack with the actual picture.

In addition, the Minecraft Wiki kindly linked to an example on github: https://github.com/cassiancc/painting-variant-example/

Because I needed to figure out a process for it, I decided to write a bash -script that would generate some files and copy others in place. I called this script generate-paintings.sh.

#!/bin/bash

#
# Generate a MesSMPaint datapack and resource pack
#

# The pack description
PACK_DESCRIPTION="MesSMPaint Datapack"

# The filenames
PACK_FILENAME_PREFIX="MesSMPaint"

# Version
PACK_VERSION="0.3"
# UUID
PACK_UUID="5a426032-c816-403f-ac33-5a0de72e21e2"

# MesSMPaint base directory
BASE_DIR=MesSMPaint
# Directory containing the PNG files
PNG_DIR="${BASE_DIR}/paintings"
# Output directory for the datapack and resourcepack
OUTPUT_DIR="${BASE_DIR}/output"
# Predefined pack.png file
PACK_PNG="${BASE_DIR}/resources/pack.png"
# Server datapack path
SERVER_DP_PATH=world/datapacks
# Server resourcepack path
SERVER_RP_PATH=resourcepacks

# jq binary
JQ_PATH="env LD_LIBRARY_PATH=$(realpath ${BASE_DIR})/resources ${BASE_DIR}/resources/jq"

# gawk binary
GAWK_PATH="env LD_LIBRARY_PATH=$(realpath ${BASE_DIR})/resources ${BASE_DIR}/resources/gawk"

# Namespace
PACKSPACE=messmpaint

create_datapack_folders() {
    # Main output folder and data folder
    mkdir -p "${OUTPUT_DIR}/datapack/data"
    # Painting variant folder
    mkdir -p "${OUTPUT_DIR}/datapack/data/minecraft/tags/painting_variant"
    # Namespace and variant folder
    mkdir -p "${OUTPUT_DIR}/datapack/data/${PACKSPACE}/painting_variant"
}

create_resourcepack_folders() {
    # Main output folder and assets folder
    mkdir -p "${OUTPUT_DIR}/resourcepack/assets"
    # Painting painting folder
    mkdir -p "${OUTPUT_DIR}/resourcepack/assets/${PACKSPACE}/textures/painting"
}

pre_clean_up() {
    # Clean out any old versions of the packs
    
    local old_datapacks=""
    local old_resourcepacks=""

    # Find old datapacks..
    local old_datapacks=("${SERVER_DP_PATH}"/${PACK_FILENAME_PREFIX}-datapack-*.zip)
    for OLD_DP in ${old_datapacks}; do
        if [ -f "${OLD_DP}" ]; then
            # .. Remove them
            rm ${OLD_DP}; 
        fi
    done

    # Find old resourcepacks..
    local old_resourcepacks=("${SERVER_RP_PATH}"/${PACK_FILENAME_PREFIX}-resourcepack-*.zip)
    for OLD_RP in ${old_resourcepacks}; do
        if [ -f "${OLD_RP}" ]; then
            # .. Remove them
            rm ${OLD_RP}; 
        fi
    done
    
}

post_clean_up() {
    # Delete the datapack directory
    rm -rf ${OUTPUT_DIR}/datapack
    # Delete the resourcepack directory
    rm -rf ${OUTPUT_DIR}/resourcepack
}

# Function to pick datapack format
pick_datapack_format() {
    # 1.21.5 - 71
    echo 71
}

# Function to pick resourcepack format
pick_resourcepack_format() {
    # 1.21.5 - 55
    echo 55
}

populate_pack() {
    local packtype=$1

    # Create pack.mcmeta files
    create_pack_mcmeta ${packtype} ${PACKSPACE} $(pick_${packtype}_format) ${PACK_DESCRIPTION}

    # Copy pack.png file
    cp "$PACK_PNG" "${OUTPUT_DIR}/${packtype}/pack.png"

}

# Function to create pack.mcmeta file
create_pack_mcmeta() {
    local pack_type=$1
    local pack_name=$2
    local pack_format=$3
    local description=$4

    cat <<EOF > "${OUTPUT_DIR}/$pack_type/pack.mcmeta"
{
  "pack": {
    "pack_format": $pack_format,
    "description": "$description"
  }
}
EOF
}

# Function to create variant file
create_variant_json() {
    local paintings=("$@")
    local json_array=()

    for painting in "${paintings[@]}"; do
        local id=$(basename "${painting}" | cut -d '-' -f 1)
        local sizex=$(basename "${painting}" | cut -d '-' -f 2 | cut -d x -f 1)
        local sizey=$(basename "${painting}" | cut -d '-' -f 2 | cut -d x -f 2)
        local artist=$(basename "${painting}" | cut -d '-' -f 3 )

        # Capitalize the title
        local title="$(tr '[:lower:]' '[:upper:]' <<< ${id:0:1})${id:1}"

        json_array=("{
            \"asset_id\": \"${PACKSPACE}:${id}\",
            \"author\": \"${artist%.*}\",
            \"title:\": \"${title}\",
            \"height\": ${sizey},
            \"width\": ${sizex}
        }")

        # Build the json
        local json_string=$(IFS=,; echo "${json_array[*]}")
        
        # Save it in an asset json
        echo ${json_string} | $JQ_PATH > "${OUTPUT_DIR}/datapack/data/${PACKSPACE}/painting_variant/${id}.json"

    done

}

# Function to create placeable.json file
create_placeable_json() {
    local json_path=${OUTPUT_DIR}/datapack/data/minecraft/tags/painting_variant/placeable.json
    local paintings=("$@")
    local json_array=()

    for painting in ${paintings}; do
        local asset_id=$(basename "${painting}" | cut -d '-' -f 1)
        json_array+=("\"${PACKSPACE}:${asset_id}\"")
    done

    local json_string=$(IFS=,; echo "${json_array[*]}")
    echo "{ \"values\": [ ${json_string} ] }" | $JQ_PATH > ${json_path}
}

# Function to copy PNG files to the resourcepack
copy_png_files() {
    local png_files=("$@")
    local resource_dir="${OUTPUT_DIR}/resourcepack/assets/${PACKSPACE}/textures/painting/"

    for painting in "${png_files[@]}"; do
        local id=$(basename "${painting}" | cut -d '-' -f 1)
        cp "${painting}" "${resource_dir}/${id}.png"
    done
}

wrap_up_pack() {
    local pwd=$(pwd)
    cd ${OUTPUT_DIR}/$1

    # Find all the files/directories for the pack
    local packfiles=(./*)

    local packname="${PACK_FILENAME_PREFIX}-$1-$PACK_VERSION".zip

    # Wrap them
    zip -qr ${packname} ${packfiles[@]}

    # Get back
    cd ${pwd}

    # Move the packs into place
    case $1 in
    datapack)
        mv ${OUTPUT_DIR}/$1/${packname} ${SERVER_DP_PATH}/
        ;;
    resourcepack)
        mv ${OUTPUT_DIR}/$1/${packname} ${SERVER_RP_PATH}/
        ;;
    esac

}

update_server_properties() {
    local packname="${PACK_FILENAME_PREFIX}-resourcepack-$PACK_VERSION".zip

    local pack_sha1=$(sha1sum ${SERVER_RP_PATH}/${packname} | $GAWK_PATH '{ print $1 }' )
    local server_config="server.properties"
    local resource_pack_url="https\://sorbens.musca.fi/resourcepacks/${packname}"

    sed -i "s#^\(resource-pack=\).*#\\1${resource_pack_url}#" ${server_config}
    sed -i "s#^\(resource-pack-id=\).*#\\1${PACK_UUID}#" ${server_config}
    sed -i "s#^\(resource-pack-sha1=\).*#\\1${pack_sha1}#" ${server_config}
    
}

# Main script
main() {
    # Get list of PNG files
    png_files=("$PNG_DIR"/*.png)

    # Pre clean up
    pre_clean_up

    # Create folders
    create_datapack_folders
    create_resourcepack_folders

    # Populate the folders
    populate_pack datapack
    populate_pack resourcepack

    # DATAPACK - Create tiles.json file
    create_variant_json "${png_files[@]}"

    # DATAPACK - Create placeable.json file
    create_placeable_json "${png_files[@]}"

    # Copy PNG files to the resourcepack
    copy_png_files "${png_files[@]}"

    # Wrap up the datapack
    wrap_up_pack datapack
    wrap_up_pack resourcepack

    # Clean up
    post_clean_up

    # Print the Sha1 for the resource pack
    update_server_properties
}

# Run the main function
main

Note: This script uses the commands jq and gawk, which turned out to be a bit of problem. But more on that later.

In short, this script:

  1. Gets a list of images in the paintings/ -folder
  2. Creates a Datapack with the information from them
  3. Creates a Resourcepack with the actual images
  4. Moves the packs into the correct folders
  5. Apply the necessary changes to the server.properties -file

In order to use the script I needed to get the pterodactyl panel to run it in conjunction with starting the server.

Replacing the command

I started off by changing the starting command to a custom script, called start-the-server.sh and added a few lines in there:

#/bin/bash

echo "### GENERATING MESSMP PAINTINGS ###"
./generate_paintings.sh

echo "### SERVER STARTING###"
java -Xms13312M -Xmx13312M --add-modules=jdk.incubator.vector -XX:+UseG1GC -XX:+ParallelRefProcEnabled -XX:MaxGCPauseMillis=200 -XX:+UnlockExperimentalVMOptions -XX:+DisableExplicitGC -XX:+AlwaysPreTouch -XX:G1HeapWastePercent=5 -XX:G1MixedGCCountTarget=4 -XX:InitiatingHeapOccupancyPercent=15 -XX:G1MixedGCLiveThresholdPercent=90 -XX:G1RSetUpdatingPauseTimePercent=5 -XX:SurvivorRatio=32 -XX:+PerfDisableSharedMem -XX:MaxTenuringThreshold=1 -XX:G1NewSizePercent=40 -XX:G1MaxNewSizePercent=50 -XX:G1HeapRegionSize=16M -XX:G1ReservePercent=15 -jar server.jar --nogui

Building the structure

In order to get everything to work I needed to create a bit of a structure. So I made a folder named MesSMPaint with a small structure:

The folders are used for a temporary works space (“output”), the collection of images that end up on the server (“paintings”) and the files needed for the process (“resources”).

The images have names with three parts, divided by a dash (-).

  1. A name for the picture
  2. The size, in minecraft blocks; height and width, divided by an ‘x’
  3. The name of the author/artist

The Annoying Bits – The Resources

Now, the resources folder initially only had the pack.png -thumbnail for the packs. (The resource- and datapack use the same thumbnail.) But I also used the commands jq and gawk in the script before discovering that the docker container does not have them.

So that means I had to find out which dynamic libraries are needed by the commands and then include them in the resources -folder.

This is also why the JQ_PATH and GAWK_PATH -commands are so complicated:

JQ_PATH="env LD_LIBRARY_PATH=$(realpath ${BASE_DIR})/resources ${BASE_DIR}/resources/jq"

Because the commands need to define the LD_LIBRARY_PATH -environment variable in order for the commands to work.

That said, if you’re able to get those commands into the docker, then you don’t need any other resources – and you can simplify the paths to the commands.

How does it work?

So you can now just throw in .png pictures into the paintings -folder and whenever the server reboots it creates new pack -files.

Also, the resource-pack folder is symlinked and shared by nginx, so the server.properties can just link straight to that. This way the client downloads and verifies it automatically when a player joins.

Note 1: Even if the pictures are .png -format, the game ignores the alpha channel.

Note 2: The datapack does have a maximum size. (Note to self: look up the limit, it’s about 250MB.)