Bläddra i källkod

feat(workshop): download Steam Workshop mods for Arma 3 and DayZ (#5008)

* feat(workshop): download Steam Workshop mods for Arma 3 and DayZ

Arma 3 and DayZ servers can't download Workshop content themselves.
Add workshopmods (item and collection IDs) and a workshop-update
command that downloads changed items with one SteamCMD login, installs
them to serverfiles/workshop/@<id>, lowercases them, copies BattlEye
keys and removes mods no longer listed. update and check-update include
Workshop mods, and start appends installed mods to -mod= in order.

* chore(workshop): make workshop_core.sh executable

* fix(workshop): download before stopping the server, and skip the restart when nothing changed
Daniel Gibbs 1 vecka sedan
förälder
incheckning
37fc36951b

+ 7 - 0
lgsm/config-default/config-lgsm/arma3server/_default.cfg

@@ -27,6 +27,13 @@ mods=""
 ## Server-side Mods
 servermods=""
 
+## Steam Workshop Mods | https://docs.linuxgsm.com/steamcmd/workshop
+# Workshop item or collection IDs, separated by spaces. Collections are expanded.
+# Installed to serverfiles/workshop and added to -mod= in the order listed.
+# Run ./arma3server workshop-update after changing, or let update apply it.
+# workshopmods="450814997"
+workshopmods=""
+
 ## Path to BattlEye
 # Leave empty for default
 bepath=""

+ 7 - 0
lgsm/config-default/config-lgsm/dayzserver/_default.cfg

@@ -27,6 +27,13 @@ mods=""
 ## Server-side Mods
 servermods=""
 
+## Steam Workshop Mods | https://docs.linuxgsm.com/steamcmd/workshop
+# Workshop item or collection IDs, separated by spaces. Collections are expanded.
+# Installed to serverfiles/workshop and added to -mod= in the order listed.
+# Run ./dayzserver workshop-update after changing, or let update apply it.
+# workshopmods="1559212036"
+workshopmods=""
+
 ## Path to BattlEye
 # Leave empty for default
 bepath=""

+ 14 - 4
lgsm/modules/check.sh

@@ -47,14 +47,14 @@ if [ "$(whoami)" != "root" ]; then
 	done
 fi
 
-allowed_commands_array=(BACKUP CONSOLE DEBUG DETAILS MAP-COMPRESSOR FASTDL MODS-INSTALL MODS-REMOVE MODS-UPDATE MONITOR POST-DETAILS RESTART START STOP TEST-ALERT CHANGE-PASSWORD UPDATE UPDATE-LGSM VALIDATE WIPE)
+allowed_commands_array=(BACKUP CONSOLE DEBUG DETAILS MAP-COMPRESSOR FASTDL MODS-INSTALL MODS-REMOVE MODS-UPDATE MONITOR POST-DETAILS RESTART START STOP TEST-ALERT CHANGE-PASSWORD UPDATE UPDATE-LGSM VALIDATE WIPE WORKSHOP-UPDATE)
 for allowed_command in "${allowed_commands_array[@]}"; do
 	if [ "${allowed_command}" == "${commandname}" ]; then
 		check_logs.sh
 	fi
 done
 
-allowed_commands_array=(BACKUP DEBUG DETAILS RESTART START UPDATE)
+allowed_commands_array=(BACKUP DEBUG DETAILS RESTART START UPDATE WORKSHOP-UPDATE)
 for allowed_command in "${allowed_commands_array[@]}"; do
 	if [ "${allowed_command}" == "${commandname}" ]; then
 		check_deps.sh
@@ -77,7 +77,7 @@ for allowed_command in "${allowed_commands_array[@]}"; do
 	fi
 done
 
-allowed_commands_array=(DEBUG RESTART START UPDATE VALIDATE CHECK-UPDATE)
+allowed_commands_array=(DEBUG RESTART START UPDATE VALIDATE CHECK-UPDATE WORKSHOP-UPDATE)
 for allowed_command in "${allowed_commands_array[@]}"; do
 	if [ "${allowed_command}" == "${commandname}" ]; then
 		if [ "${appid}" ]; then
@@ -86,7 +86,7 @@ for allowed_command in "${allowed_commands_array[@]}"; do
 	fi
 done
 
-allowed_commands_array=(CHANGE-PASSWORD DETAILS MONITOR RESTART START STOP UPDATE VALIDATE POST-DETAILS)
+allowed_commands_array=(CHANGE-PASSWORD DETAILS MONITOR RESTART START STOP UPDATE VALIDATE POST-DETAILS WORKSHOP-UPDATE)
 for allowed_command in "${allowed_commands_array[@]}"; do
 	if [ "${allowed_command}" == "${commandname}" ]; then
 		check_status.sh
@@ -106,3 +106,13 @@ for allowed_command in "${allowed_commands_array[@]}"; do
 		check_gamedig.sh
 	fi
 done
+
+if [ "${shortname}" == "arma3" ] || [ "${shortname}" == "dayz" ]; then
+	allowed_commands_array=(DEBUG DETAILS POST-DETAILS RESTART START)
+	for allowed_command in "${allowed_commands_array[@]}"; do
+		if [ "${allowed_command}" == "${commandname}" ] && { [ -n "${workshopmods}" ] || [ -d "${serverfiles}/workshop" ]; }; then
+			workshop_core.sh
+			fn_workshop_mods_startparameters
+		fi
+	done
+fi

+ 5 - 0
lgsm/modules/command_check_update.sh

@@ -44,4 +44,9 @@ else
 	update_steamcmd.sh
 fi
 
+if { [ "${shortname}" == "arma3" ] || [ "${shortname}" == "dayz" ]; } && { [ -n "${workshopmods}" ] || [ -d "${serverfiles}/workshop" ]; }; then
+	workshop_core.sh
+	fn_workshop_update
+fi
+
 core_exit.sh

+ 5 - 0
lgsm/modules/command_update.sh

@@ -45,6 +45,11 @@ else
 	update_steamcmd.sh
 fi
 
+if { [ "${shortname}" == "arma3" ] || [ "${shortname}" == "dayz" ]; } && { [ -n "${workshopmods}" ] || [ -d "${serverfiles}/workshop" ]; }; then
+	workshop_core.sh
+	fn_workshop_update
+fi
+
 # remove update lockfile.
 rm -f "${lockdir:?}/update.lock"
 

+ 28 - 0
lgsm/modules/command_workshop_update.sh

@@ -0,0 +1,28 @@
+#!/bin/bash
+# LinuxGSM command_workshop_update.sh module
+# Author: Daniel Gibbs
+# Contributors: https://linuxgsm.com/contrib
+# Website: https://linuxgsm.com
+# Description: Downloads, updates and removes Steam Workshop mods set in workshopmods.
+
+commandname="WORKSHOP-UPDATE"
+commandaction="Updating Workshop Mods"
+moduleselfname="$(basename "$(readlink -f "${BASH_SOURCE[0]}")")"
+fn_firstcommand_set
+
+fn_print_dots ""
+check.sh
+core_logs.sh
+
+if [ -z "${workshopmods}" ] && [ ! -d "${serverfiles}/workshop" ]; then
+	fn_print_info_nl "No Workshop mods set. Add Workshop IDs to workshopmods in ${configdirserver}"
+	fn_script_log_info "No Workshop mods set"
+	core_exit.sh
+fi
+
+workshop_core.sh
+fn_workshop_update
+
+rm -f "${lockdir:?}/update.lock"
+
+core_exit.sh

+ 7 - 0
lgsm/modules/core_getopt.sh

@@ -37,6 +37,8 @@ cmd_validate=("v;validate" "command_validate.sh" "Validate server files with Ste
 cmd_mods_install=("mi;mods-install" "command_mods_install.sh" "View and install available mods/addons.")
 cmd_mods_remove=("mr;mods-remove" "command_mods_remove.sh" "View and remove an installed mod/addon.")
 cmd_mods_update=("mu;mods-update" "command_mods_update.sh" "Update installed mods/addons.")
+# Server with Steam Workshop downloads.
+cmd_workshop_update=("wu;workshop-update" "command_workshop_update.sh" "Install and update Steam Workshop mods.")
 # Server specific.
 cmd_change_password=("pw;change-password" "command_ts3_server_pass.sh" "Change TS3 serveradmin password.")
 cmd_install_default_resources=("ir;install-default-resources" "command_install_resources_mta.sh" "Install the MTA default resources.")
@@ -141,6 +143,11 @@ if [ "${engine}" == "source" ] || [ "${shortname}" == "cs2" ] || [ "${shortname}
 	currentopt+=("${cmd_mods_install[@]}" "${cmd_mods_remove[@]}" "${cmd_mods_update[@]}")
 fi
 
+## Workshop commands.
+if [ "${shortname}" == "arma3" ] || [ "${shortname}" == "dayz" ]; then
+	currentopt+=("${cmd_workshop_update[@]}")
+fi
+
 ## Installer.
 currentopt+=("${cmd_install[@]}" "${cmd_auto_install[@]}")
 

+ 12 - 0
lgsm/modules/core_modules.sh

@@ -181,6 +181,11 @@ command_wipe.sh() {
 	fn_fetch_module
 }
 
+command_workshop_update.sh() {
+	modulefile="${FUNCNAME[0]}"
+	fn_fetch_module
+}
+
 install_squad_license.sh() {
 	modulefile="${FUNCNAME[0]}"
 	fn_fetch_module
@@ -292,6 +297,13 @@ mods_list.sh() {
 	fn_fetch_module
 }
 
+# Workshop
+
+workshop_core.sh() {
+	modulefile="${FUNCNAME[0]}"
+	fn_fetch_module
+}
+
 # Dev
 
 command_dev_clear_modules.sh() {

+ 458 - 0
lgsm/modules/workshop_core.sh

@@ -0,0 +1,458 @@
+#!/bin/bash
+# LinuxGSM workshop_core.sh module
+# Author: Daniel Gibbs
+# Contributors: https://linuxgsm.com/contrib
+# Website: https://linuxgsm.com
+# Description: Downloads and installs Steam Workshop mods for game servers that cannot download them.
+
+moduleselfname="$(basename "$(readlink -f "${BASH_SOURCE[0]}")")"
+
+workshopdir="${serverfiles}/workshop"
+workshopdldir="${tmpdir}/workshop"
+workshopmodlist="${datadir}/${selfname}-workshop.txt"
+workshopapi="https://api.steampowered.com/ISteamRemoteStorage"
+workshopapimax="100"
+workshopretries="5"
+
+# Converts workshopmods into an ordered list of IDs. Accepts space, comma or semicolon separators.
+fn_workshop_config_ids() {
+	workshopconfigids=()
+	local id
+	for id in ${workshopmods//[,;]/ }; do
+		if [[ "${id}" =~ ^[0-9]+$ ]]; then
+			workshopconfigids+=("${id}")
+		else
+			fn_print_warn_nl "Ignoring invalid Workshop ID: ${id}"
+			fn_script_log_warn "Ignoring invalid Workshop ID: ${id}"
+		fi
+	done
+}
+
+# Queries the Steam Web API. Usage: fn_workshop_api <GetCollectionDetails|GetPublishedFileDetails> <id>...
+fn_workshop_api() {
+	local method="$1"
+	shift
+	local countparam="itemcount"
+	if [ "${method}" == "GetCollectionDetails" ]; then
+		countparam="collectioncount"
+	fi
+	local postdata=("--data-urlencode" "${countparam}=$#")
+	local index=0
+	local id
+	for id in "$@"; do
+		postdata+=("--data-urlencode" "publishedfileids[${index}]=${id}")
+		index=$((index + 1))
+	done
+	curl -sSL --connect-timeout 10 --max-time 60 "${postdata[@]}" "${workshopapi}/${method}/v1/"
+}
+
+# Records which IDs are collections and their children, in batches.
+fn_workshop_get_collections() {
+	local ids=("$@")
+	local start response
+	for ((start = 0; start < ${#ids[@]}; start += workshopapimax)); do
+		if ! response="$(fn_workshop_api GetCollectionDetails "${ids[@]:start:workshopapimax}")" || ! jq -e '.response' <<< "${response}" > /dev/null 2>&1; then
+			fn_print_fail_nl "Checking for update: ${remotelocation}: Unable to get collection details from Steam"
+			fn_script_log_fail "Checking for update: ${remotelocation}: Unable to get collection details from Steam"
+			core_exit.sh
+		fi
+		local id result children
+		while IFS=$'\t' read -r id result children; do
+			workshopchecked["${id}"]=1
+			if [ "${result}" == "1" ]; then
+				workshopchildren["${id}"]="${children}"
+			fi
+		done < <(jq -r '.response.collectiondetails[] | [.publishedfileid, (.result | tostring), ([.children[]? | "\(.publishedfileid):\(.filetype)"] | join(" "))] | @tsv' <<< "${response}")
+	done
+}
+
+# Adds an ID to workshopitems, expanding collections in their sort order.
+fn_workshop_expand() {
+	local id="$1"
+	local depth="$2"
+	if [ -n "${workshopseen[${id}]}" ]; then
+		return
+	fi
+	workshopseen["${id}"]=1
+	if [ -n "${workshopchildren[${id}]+set}" ]; then
+		if [ "${depth}" -gt 3 ]; then
+			fn_print_warn_nl "Skipping Workshop collection ${id}: collections are nested too deeply"
+			fn_script_log_warn "Skipping Workshop collection ${id}: collections are nested too deeply"
+			return
+		fi
+		local child
+		for child in ${workshopchildren[${id}]}; do
+			fn_workshop_expand "${child%%:*}" $((depth + 1))
+		done
+	else
+		workshopitems+=("${id}")
+	fi
+}
+
+# Builds workshopitems: the ordered list of Workshop items to install, with collections expanded.
+fn_workshop_resolve() {
+	declare -gA workshopchecked=()
+	declare -gA workshopchildren=()
+	declare -gA workshopseen=()
+	workshopitems=()
+	fn_workshop_config_ids
+	if [ "${#workshopconfigids[@]}" -eq 0 ]; then
+		return
+	fi
+	local tocheck=("${workshopconfigids[@]}")
+	local round id child
+	for ((round = 0; round < 4 && ${#tocheck[@]} > 0; round++)); do
+		fn_workshop_get_collections "${tocheck[@]}"
+		local nested=()
+		for id in "${tocheck[@]}"; do
+			for child in ${workshopchildren[${id}]}; do
+				if [ "${child##*:}" == "2" ] && [ -z "${workshopchecked[${child%%:*}]}" ]; then
+					nested+=("${child%%:*}")
+				fi
+			done
+		done
+		tocheck=("${nested[@]}")
+	done
+	for id in "${workshopconfigids[@]}"; do
+		fn_workshop_expand "${id}" 0
+	done
+}
+
+# Gets title, game appid and last updated time for each item in workshopitems.
+fn_workshop_get_details() {
+	declare -gA workshoptitle=()
+	declare -gA workshopappid=()
+	declare -gA workshoptimeupdated=()
+	local validitems=()
+	local start response
+	for ((start = 0; start < ${#workshopitems[@]}; start += workshopapimax)); do
+		if ! response="$(fn_workshop_api GetPublishedFileDetails "${workshopitems[@]:start:workshopapimax}")" || ! jq -e '.response' <<< "${response}" > /dev/null 2>&1; then
+			fn_print_fail_nl "Checking for update: ${remotelocation}: Unable to get Workshop item details from Steam"
+			fn_script_log_fail "Checking for update: ${remotelocation}: Unable to get Workshop item details from Steam"
+			core_exit.sh
+		fi
+		local id result appid timeupdated title
+		while IFS=$'\t' read -r id result appid timeupdated title; do
+			if [ "${result}" != "1" ] || [ -z "${timeupdated}" ]; then
+				fn_print_warn_nl "Skipping Workshop item ${id}: not found, or not public"
+				fn_script_log_warn "Skipping Workshop item ${id}: not found, or not public"
+				continue
+			fi
+			workshoptitle["${id}"]="${title}"
+			workshopappid["${id}"]="${appid}"
+			workshoptimeupdated["${id}"]="${timeupdated}"
+		done < <(jq -r '.response.publishedfiledetails[] | [.publishedfileid, (.result | tostring), (.consumer_app_id // "" | tostring), (.time_updated // "" | tostring), (.title // "" | gsub("[\t\n\r]"; " "))] | @tsv' <<< "${response}")
+	done
+	local item
+	for item in "${workshopitems[@]}"; do
+		if [ -n "${workshoptimeupdated[${item}]}" ]; then
+			validitems+=("${item}")
+		fi
+	done
+	workshopitems=("${validitems[@]}")
+}
+
+# Reads a value from an installed mod's state file. Usage: fn_workshop_state <id> <key>
+fn_workshop_state() {
+	local statefile="${workshopdir}/@${1}/.lgsm-workshop"
+	if [ -f "${statefile}" ]; then
+		sed -n "s/^${2}=//p" "${statefile}"
+	fi
+}
+
+# Works out which mods need downloading and which installed mods are no longer used.
+fn_workshop_compare() {
+	workshopupdates=()
+	workshopremovals=()
+	local item
+	for item in "${workshopitems[@]}"; do
+		if [ "$(fn_workshop_state "${item}" timeupdated)" != "${workshoptimeupdated[${item}]}" ]; then
+			workshopupdates+=("${item}")
+		fi
+	done
+	if [ -d "${workshopdir}" ]; then
+		# Mods used by any instance sharing these serverfiles are kept.
+		local inuse=" ${workshopitems[*]} "
+		local listfile
+		for listfile in "${datadir}"/*-workshop.txt; do
+			if [ -f "${listfile}" ] && [ "${listfile}" != "${workshopmodlist}" ]; then
+				inuse+="$(grep -v '^#' "${listfile}" | tr '\n' ' ') "
+			fi
+		done
+		local moddir
+		for moddir in "${workshopdir}"/@*; do
+			item="${moddir##*/@}"
+			if [ -d "${moddir}" ] && [[ "${inuse}" != *" ${item} "* ]]; then
+				workshopremovals+=("${item}")
+			fi
+		done
+	fi
+}
+
+# Downloads Workshop items with a single SteamCMD login. Failed items are retried.
+fn_workshop_download() {
+	local pending=("$@")
+	local attempt=0
+	local item params
+	steamcmdlog="${lgsmlogdir}/${selfname}-steamcmd.log"
+	rm -rf "${workshopdldir:?}"
+	mkdir -p "${workshopdldir}"
+	if [ -d "${steamcmddir}" ]; then
+		cd "${steamcmddir}" || exit
+	fi
+	unbuffercommand=()
+	if [ "$(command -v unbuffer 2> /dev/null)" ]; then
+		unbuffercommand=("unbuffer")
+	fi
+	while [ "${#pending[@]}" -gt 0 ] && [ "${attempt}" -lt "${workshopretries}" ]; do
+		attempt=$((attempt + 1))
+		params=(+force_install_dir "${workshopdldir}" +login "${steamuser}" "${steampass}")
+		for item in "${pending[@]}"; do
+			params+=(+workshop_download_item "${workshopappid[${item}]}" "${item}")
+		done
+		params+=(+quit)
+		rm -f "${steamcmdlog:?}"
+		"${unbuffercommand[@]}" "${steamcmdcommand}" "${params[@]}" | uniq | tee -a "${lgsmlog}" "${steamcmdlog}"
+
+		if grep -q "FAILED (" "${steamcmdlog}" || grep -q "Two-factor code mismatch" "${steamcmdlog}"; then
+			fn_print_failure_nl "${commandaction} ${selfname}: ${remotelocation}: Steam login failed"
+			fn_print_nl "Check steamuser and steampass in ${configdirserver}"
+			fn_script_log_fail "${commandaction} ${selfname}: ${remotelocation}: Steam login failed"
+			core_exit.sh
+		fi
+
+		local failed=()
+		for item in "${pending[@]}"; do
+			if [ -z "$(find "${workshopdldir}/steamapps/workshop/content/${workshopappid[${item}]}/${item}" -mindepth 1 -print -quit 2> /dev/null)" ]; then
+				failed+=("${item}")
+			fi
+		done
+		pending=("${failed[@]}")
+		if [ "${#pending[@]}" -gt 0 ] && [ "${attempt}" -lt "${workshopretries}" ]; then
+			fn_print_error2_nl "${commandaction} ${selfname}: ${remotelocation}: ${#pending[@]} item(s) did not download, retrying"
+			fn_script_log_error "${commandaction} ${selfname}: ${remotelocation}: ${#pending[@]} item(s) did not download, retrying"
+		fi
+	done
+	workshopfailed=("${pending[@]}")
+	if [ "${#workshopfailed[@]}" -gt 0 ]; then
+		fn_print_failure_nl "${commandaction} ${selfname}: ${remotelocation}: ${#workshopfailed[@]} item(s) failed to download"
+		for item in "${workshopfailed[@]}"; do
+			fn_print_nl "* ${workshoptitle[${item}]} (${item})"
+			fn_script_log_fail "${commandaction} ${selfname}: ${remotelocation}: ${workshoptitle[${item}]} (${item}) failed to download"
+		done
+		fn_print_nl "The Steam account must own the game for its Workshop items to download"
+	fi
+}
+
+# Renames files and directories in a mod to lowercase, deepest first.
+fn_workshop_lowercase() {
+	local path name lower
+	while IFS= read -r -d '' path; do
+		name="${path##*/}"
+		lower="${name,,}"
+		if [ -e "${path%/*}/${lower}" ]; then
+			fn_script_log_warn "Lowercase name already exists, not renaming: ${path}"
+		else
+			mv "${path}" "${path%/*}/${lower}"
+		fi
+	done < <(find "$1" -mindepth 1 -depth -name '*[[:upper:]]*' -print0)
+}
+
+# Removes the BattlEye keys a mod installed, unless another installed mod also uses them.
+fn_workshop_remove_keys() {
+	local item="$1"
+	local key moddir
+	for key in $(fn_workshop_state "${item}" keys); do
+		local shared=""
+		for moddir in "${workshopdir}"/@*; do
+			if [ "${moddir}" != "${workshopdir}/@${item}" ] && [[ " $(fn_workshop_state "${moddir##*/@}" keys) " == *" ${key} "* ]]; then
+				shared=1
+			fi
+		done
+		if [ -z "${shared}" ]; then
+			rm -f "${serverfiles:?}/keys/${key}"
+		fi
+	done
+}
+
+# Moves a downloaded item into serverfiles/workshop/@<id> and prepares it for the game.
+fn_workshop_install() {
+	local item="$1"
+	local src="${workshopdldir}/steamapps/workshop/content/${workshopappid[${item}]}/${item}"
+	local dst="${workshopdir}/@${item}"
+	local keys=()
+	local key
+	fn_print_dots "Installing ${workshoptitle[${item}]} (${item})"
+	if [ -d "${dst}" ]; then
+		fn_workshop_remove_keys "${item}"
+		rm -rf "${dst:?}"
+	fi
+	mkdir -p "${workshopdir}"
+	if ! mv "${src}" "${dst}"; then
+		fn_print_fail_nl "Installing ${workshoptitle[${item}]} (${item})"
+		fn_script_log_fail "Installing ${workshoptitle[${item}]} (${item})"
+		core_exit.sh
+	fi
+	if [ "${shortname}" == "arma3" ] || [ "${shortname}" == "dayz" ]; then
+		fn_workshop_lowercase "${dst}"
+		mkdir -p "${serverfiles}/keys"
+		while IFS= read -r -d '' key; do
+			cp -f "${key}" "${serverfiles}/keys/"
+			keys+=("${key##*/}")
+		done < <(find "${dst}" -type f -name '*.bikey' -print0)
+	fi
+	{
+		echo "title=${workshoptitle[${item}]}"
+		echo "timeupdated=${workshoptimeupdated[${item}]}"
+		echo "keys=${keys[*]}"
+	} > "${dst}/.lgsm-workshop"
+	fn_print_ok_nl "Installing ${workshoptitle[${item}]} (${item})"
+	fn_script_log_pass "Installing ${workshoptitle[${item}]} (${item})"
+}
+
+# Removes an installed mod that is no longer in workshopmods.
+fn_workshop_remove() {
+	local item="$1"
+	local title
+	title="$(fn_workshop_state "${item}" title)"
+	fn_print_dots "Removing ${title} (${item})"
+	fn_workshop_remove_keys "${item}"
+	rm -rf "${workshopdir:?}/@${item}"
+	fn_print_ok_nl "Removing ${title} (${item})"
+	fn_script_log_pass "Removing ${title} (${item})"
+}
+
+# Installs downloaded items and removes unused ones. Downloads happen first, so the server only stops for this step.
+fn_workshop_apply() {
+	local item
+	for item in "${workshopinstalls[@]}"; do
+		fn_workshop_install "${item}"
+	done
+	for item in "${workshopremovals[@]}"; do
+		fn_workshop_remove "${item}"
+	done
+	rm -rf "${workshopdldir:?}"
+}
+
+# Saves the resolved mod order so start can build the mod list without querying Steam.
+fn_workshop_save_list() {
+	{
+		echo "# workshopmods=${workshopmods}"
+		printf '%s\n' "${workshopitems[@]}"
+	} > "${workshopmodlist}"
+}
+
+# Checks for Workshop mod updates and, for update commands, applies them.
+fn_workshop_update() {
+	remotelocation="Steam Workshop"
+	fn_print_dots "Checking for update: ${remotelocation}"
+	fn_workshop_resolve
+	if [ "${#workshopitems[@]}" -gt 0 ]; then
+		fn_workshop_get_details
+	fi
+	fn_workshop_compare
+	fn_print_ok_nl "Checking for update: ${remotelocation}"
+	fn_script_log_info "Checking for update: ${remotelocation}"
+
+	local item
+	if [ "${#workshopupdates[@]}" -eq 0 ] && [ "${#workshopremovals[@]}" -eq 0 ]; then
+		fn_print "\n"
+		fn_print_nl "${bold}${underline}No update${default} available"
+		fn_print_nl "* Workshop mods: ${#workshopitems[@]}"
+		fn_print "\n"
+		fn_script_log_info "No update available: ${remotelocation}"
+		if [ "${commandname}" != "CHECK-UPDATE" ]; then
+			fn_workshop_save_list
+		fi
+		return
+	fi
+
+	fn_print "\n"
+	fn_print_nl "${bold}${underline}Update${default} available"
+	for item in "${workshopupdates[@]}"; do
+		if [ -d "${workshopdir}/@${item}" ]; then
+			fn_print_nl "* Update: ${workshoptitle[${item}]} (${item})"
+		else
+			fn_print_nl "* Install: ${workshoptitle[${item}]} (${item})"
+		fi
+		fn_script_log_info "Update available: ${workshoptitle[${item}]} (${item})"
+	done
+	for item in "${workshopremovals[@]}"; do
+		fn_print_nl "* Remove: $(fn_workshop_state "${item}" title) (${item})"
+		fn_script_log_info "Remove: $(fn_workshop_state "${item}" title) (${item})"
+	done
+	fn_print "\n"
+
+	if [ "${commandname}" == "CHECK-UPDATE" ]; then
+		return
+	fi
+
+	fn_workshop_save_list
+	date '+%s' > "${lockdir:?}/update.lock"
+	workshopinstalls=()
+	workshopfailed=()
+	if [ "${#workshopupdates[@]}" -gt 0 ]; then
+		fn_print_start_nl "${remotelocation}"
+		fn_workshop_download "${workshopupdates[@]}"
+		for item in "${workshopupdates[@]}"; do
+			if [[ " ${workshopfailed[*]} " != *" ${item} "* ]]; then
+				workshopinstalls+=("${item}")
+			fi
+		done
+	fi
+	if [ "${#workshopinstalls[@]}" -eq 0 ] && [ "${#workshopremovals[@]}" -eq 0 ]; then
+		rm -rf "${workshopdldir:?}"
+		return
+	fi
+
+	check_status.sh
+	if [ "${status}" == "0" ]; then
+		fn_workshop_apply
+	else
+		fn_print_restart_warning
+		exitbypass=1
+		command_stop.sh
+		fn_firstcommand_reset
+		fn_workshop_apply
+		exitbypass=1
+		command_start.sh
+		fn_firstcommand_reset
+	fi
+	unset exitbypass
+}
+
+# Adds installed Workshop mods to the mods start parameter, in workshopmods order.
+fn_workshop_mods_startparameters() {
+	if [ -n "${workshopmodsadded}" ]; then
+		return
+	fi
+	workshopmodsadded=1
+	local items=()
+	if [ -f "${workshopmodlist}" ]; then
+		mapfile -t items < <(grep -v '^#' "${workshopmodlist}")
+		if [ "$(sed -n 's/^# workshopmods=//p' "${workshopmodlist}")" != "${workshopmods}" ]; then
+			fn_print_warn_nl "workshopmods has changed. Run ./${selfname} workshop-update to apply it"
+			fn_script_log_warn "workshopmods has changed. Run ./${selfname} workshop-update to apply it"
+		fi
+	else
+		fn_workshop_config_ids
+		items=("${workshopconfigids[@]}")
+	fi
+	local workshopmodsparam=""
+	local missing=0
+	local item
+	for item in "${items[@]}"; do
+		if [ -d "${workshopdir}/@${item}" ]; then
+			workshopmodsparam+="${workshopmodsparam:+\\;}workshop/@${item}"
+		else
+			missing=$((missing + 1))
+		fi
+	done
+	if [ "${missing}" -gt 0 ]; then
+		fn_print_warn_nl "${missing} Workshop mod(s) not installed. Run ./${selfname} workshop-update to install them"
+		fn_script_log_warn "${missing} Workshop mod(s) not installed"
+	fi
+	if [ -n "${workshopmodsparam}" ]; then
+		mods="${mods:+${mods}\\;}${workshopmodsparam}"
+	fi
+}