og-cod-plutainer/scripts/lib/cod.sh
Amos f9a7953652
Some checks failed
Build and Push Docker Image / build (amd64, Dockerfile, false, linux/amd64, ubuntu-latest) (push) Failing after 2s
Build and Push Docker Image / build (arm64, Dockerfile.arm64, true, linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Image / merge (push) Has been cancelled
SteamCMD family: generalise 7DTD support, add CS2/L4D2/HL2:DM, unify the CoD side (#9)
* feat: add 7 Days to Die server support

* refactor(scripts): two table-driven families behind one entry script each

Plutainer had five "families" that were really four Call of Duty engines
plus a platform, and a separate entry script per engine. Adding 7 Days to
Die made the mismatch obvious: a SteamCMD game shares nothing with a
Quake-derived one, while a Plutonium T6 and a CoD4x server differ far
less from each other than either does from a Steam install.

There are now two families, and they are platforms:

  cod    servers Plutainer installs and runs from game files you supply
  steam  servers SteamCMD installs

Engine (plutonium/iw4x/alterware/cod4x, unity/srcds) drops to a field in
that family's game table, where it belongs. Each family gets one entry
script driven by that table, so the four CoD entry scripts collapse into
games/codentry.sh at 92 lines, and adding a game is a table row rather
than a new script. entrypoint.sh has two branches and gains none per game.

Per-engine behaviour is expressed as hooks resolved most-specific-first:

  cod_<hook>_<game> -> cod_<hook>_<base game> -> cod_<hook>_<engine>

so a game inherits its engine and overrides only what genuinely differs.
The same mechanism serves both families, which removed fifteen one-line
pass-through functions on the Steam side.

Hook names are strings, so plutainer_require_hooks verifies the mandatory
ones resolve at startup and prints what it tried. That is not theoretical:
during this refactor a comma-separated suffix list was passed where an
array was wanted, and every server died with no output whatsoever until
plutainer_hook was made to say what it could not find.

The library splits along the same line — core/fs/cod/steam — so a CoD
change never requires reading the Steam helpers. Audited: zero
cross-family calls in either direction. Anything both needed (the symlink
helpers) moved to fs.sh. Renamed from *-config.sh, which was inaccurate
once the files held install logic and launch arguments.

Verified on all ten CoD games (T4/T5/T6 MP+ZM, IW5, IW4x, T7x MP+ZM,
CoD4x): launch commands byte-identical to the originals, correct config
symlinks per engine, health and RCON working.

* feat(protocols): A2S, Source RCON and telnet, split by protocol

pyquake3.py was the only wire protocol Plutainer spoke, which was fine
while every game was Quake-derived. The SteamCMD games are not: 7DTD has
a telnet console and no RCON at all, Source games speak Valve's RCON over
TCP, and none of them answer getstatus.

Protocols now live in scripts/protocols/, split by protocol rather than
by game, because the mapping is many-to-many — Quake3 covers seven CoD
titles, A2S covers four Steam games across three engines, and querying is
a different concern from administering.

healthcheck.sh picks its probe from the family table (STEAM_QUERY) rather
than a hardcoded branch, and keeps the same bar for every game: the server
must name a map it is running. A2S reports one, so "healthy" does not
degrade to "a TCP connect succeeded" for the new family.

rcon-cli keeps its name because that is what people search for, but is now
one small class per protocol behind a dispatch table. It also distinguishes
"this game has no remote console" from "it has one and you have it switched
off", and tells you which setting to change in the second case.

Both try loopback and then the container's own address. Source 1 servers
answer only on the latter: an identical A2S query times out on 127.0.0.1
and replies immediately on the container IP, with the server healthy
throughout. Rather than encode which engines behave which way, try both.

pyquake3.py moves to protocols/quake3.py with its GPL attribution intact.

* fix(logs): let a family opt out of rotation, and skip huge install trees

Rotation is copy-truncate, which is only valid against a writer that
opened its log with O_APPEND — true of every CoD engine, and the reason
the existing implementation is safe. A Unity dedicated server's -logfile
writer keeps its own offset instead, so truncating would leave a sparse
hole and the file's apparent size would snap straight back over the limit,
re-triggering rotation on every poll.

That has not been measured against a running 7DTD, so PLUTAINER_LOG_ROTATE
lets the SteamCMD family turn rotation off. Not rotating is the safe
failure — an unbounded log, which is what the game does unmanaged — rather
than a rotation loop copying gigabytes every two seconds.

PLUTAINER_LOG_PRUNE_DIRS keeps the poller out of a SteamCMD install, which
is tens of thousands of files that would otherwise be walked every two
seconds for one log that does not live there.

* build: add SteamCMD, keep STOPSIGNAL SIGKILL

SteamCMD is fetched but deliberately not bootstrapped at build time.
Running `steamcmd.sh +quit` in the image pulls a few hundred MB of Steam
client into $HOME that every Call of Duty user would then carry forever,
and 340 MB of Xvfb was removed for exactly that reason. It also does not
reliably prevent the first-contact failure it appears to fix, which is
handled with a retry instead.

Placed after everything the CoD families need, so adding or bumping it
never invalidates their layers.

STOPSIGNAL stays SIGKILL. A game with world state to flush cannot be
served by a signal that cannot be trapped, but changing the image default
would alter shutdown for seven servers already running in production to
benefit one new family. Those games ask for SIGTERM per service instead:

    stop_signal: SIGTERM
    stop_grace_period: 90s

Measured: 7DTD forwards the signal, saves, and exits 0 in 3.5s; the same
image with no stop_signal stops in 254ms as before. The hang case needs no
code — Docker sends SIGKILL itself when the grace period expires.

chmod now covers every *.sh by find rather than a hand-maintained list
that silently rots as scripts move between directories.

* feat(steam): add CS2, L4D2 and Half-Life 2: Deathmatch

Three more games through the SteamCMD family, chosen to stress the table
in different directions rather than to pad the list. Each needed a table
row and, at most, one hook.

HL2:DM is the plain srcds case. L4D2 shares its hooks entirely. CS2 shares
seed, configure, stage and admin with them and overrides only its launch
arguments, because Source 2 has no srcds_run wrapper.

L4D2 needs a two-phase install and it is not optional. Valve restricted
anonymous Linux installation of app 222860: every depot including the
9.5 GB content one is flagged windows, so a plain app_update on Linux
fails with "Invalid platform". This is an open upstream issue
(ValveSoftware/steam-for-linux#11522) that takes LinuxGSM down with it, so
"this used to work" is true and not a local fault. Pulling the content as
Windows and re-running as Linux overlays the native binaries depot on top.
STEAM_INSTALL_PLATFORMS expresses that generically. The result is a genuine
native server — srcds_linux, no Wine, reports "os: Linux Dedicated".

CS2 exposed a real bug in the config handling. link_configs refuses to
replace a real file at the engine path, because for the CoD families that
file is the strongest signal of user intent. Inside a SteamCMD install the
same signal means the opposite: the directory is Steam's, so a real file is
a depot default that the next update restores anyway. CS2 ships a 33-byte
game/csgo/cfg/server.cfg reading "// Defaults in server_default.cfg", so
the fan-out skipped it with a warning and the server ran on stock settings
while app/configs/server.cfg sat there looking correct. Measured before
and after: hostname went from "Counter-Strike 2" to the configured name,
and the name a server browser shows changed with it.

SteamCMD's first contact in a fresh container is also unreliable — it
downloads its own client, re-execs, and an app_update issued before that
settles fails with "Missing configuration". Measured on both 7DTD and
HL2:DM, with the next attempt succeeding, so installs are retried.

Seed configs are hand-written rather than vendored: Valve ships no
server.cfg for any of these. All three ship empty rcon_password and
sv_password, like every other seed here.

* docs: rewrite for two families and eleven games

The docs described five families and a script-per-game layout that no
longer exists, and the 7DTD material from the original PR assumed it was
a one-off rather than the first member of a family.

Reworked around the two-platform model, with per-game specifics where a
reader looks for them: disk footprints (CS2 is 67 GB and will exceed the
health grace period on first start), the GSLT and masterserver-token
equivalence, why clean shutdown is opt-in per service, and what the
depot-config takeover does.

Audited rather than assumed: all fourteen game tags now appear in the
README table, the games reference, the configuration reference and a
working compose example; every environment variable the code reads is in
the reference table; every script is described in CLAUDE.md; no stale
references to the deleted entry scripts remain; all internal doc links
resolve. That audit found two undocumented variables, now added.

* chore: remove the EXAMPLE-docker-compose.yml signpost

The file had already been reduced to a stub pointing at examples/, kept on
the theory that older guides and forum posts link to it. Nothing in the
repo references it any more: every doc that shows a compose file points at
examples/ directly, so the stub was carrying its own rationale and nothing
else.

Also drops the two places that still named it — its .dockerignore entry
(examples/ and *.md already cover everything it excluded) and its
paths-ignore entry in the publish workflow, which was suppressing builds
for a file that no longer exists.

---------

Co-authored-by: Keyboard Sped <93077330+CoreyUK@users.noreply.github.com>
2026-08-16 17:12:31 +01:00

800 lines
30 KiB
Bash

#!/bin/bash
#
# The `cod` family: Quake-derived servers Plutainer installs and runs itself —
# Plutonium (T4/T5/T6/IW5), IW4x, Alterware (T7x) and CoD4x.
#
# One entry script (games/codentry.sh) serves all ten games; what differs
# between them lives in the table below and in the hook functions beneath it.
# Adding a game should mean a table row and a couple of hooks, not a new entry
# script.
#
# What the family has in common:
# - the user supplies the game files, mounted read-only at
# /home/plutainer/gamefiles, and Plutainer mirrors them into the volume
# - plain-text *.cfg files the engine `+exec`s from a fixed directory, with
# app/configs/ as the source of truth and symlinks fanned out to the engine
# - an `rcon_password` cvar, and the Quake3 connectionless query protocol
# - Wine, with CoD4x the sole exception (native 32-bit Linux binary)
#
# None of that applies to the steam family, which is why the two live in
# separate files.
#
# Decide the config source-of-truth. Default: configs/ (symlink fan-out to
# engine dir). With PLUTAINER_USE_RAW_CONFIGS=true: engine dir IS the SOT
# (no symlinks, cfgs live where the game reads them).
# Requires ENGINE_CONFIG_DIR, which cod_resolve_game sets from the table.
# Sets CONFIG_SOT_DIR, ALT_CONFIG_DIR, CONFIG_PATH.
resolve_config_layout() {
if [[ "${PLUTAINER_USE_RAW_CONFIGS:-}" == "true" ]]; then
CONFIG_SOT_DIR="$ENGINE_CONFIG_DIR"
ALT_CONFIG_DIR="$PLUTAINER_CONFIGS_DIR"
else
CONFIG_SOT_DIR="$PLUTAINER_CONFIGS_DIR"
ALT_CONFIG_DIR="$ENGINE_CONFIG_DIR"
fi
CONFIG_PATH="$CONFIG_SOT_DIR/$CONFIG_FILE"
}
# Convenience for callers (healthcheck.sh, rcon-cli) that only need
# CONFIG_PATH set. Runs the chain end-to-end. Reached through the family
# dispatcher resolve_config_path() in lib/core.sh.
cod_resolve_config_path() {
cod_resolve_game || return 1
resolve_config_layout
}
# Copy bundled community seed configs into the volume on first run.
# Strategy:
# - Top-level *.cfg files inside the seed's "config root" subdir
# (cfg_root_rel within the seed bundle) land in CONFIG_SOT_DIR.
# - Everything else (assets, mod scripts, nested cfgs, etc) lands under
# asset_root, preserving the seed's relative path.
# Always idempotent: never overwrites a file that already exists.
# Args: <game-key> <asset_root> <cfg_root_rel>
seed_configs() {
local game="$1" asset_root="$2" cfg_root_rel="${3:-}"
local src="/home/plutainer/.plutainer/seed-configs/${game}"
[[ -d "$src" ]] || return 0
mkdir -p "$asset_root" "$CONFIG_SOT_DIR"
local rel parent dest
while IFS= read -r -d '' relpath; do
rel="${relpath#./}"
# Provenance metadata for the vendored seed (upstream repo + commit). It
# belongs to the image, not the user's volume.
[[ "$rel" == "SOURCE" ]] && continue
parent="$(dirname "$rel")"
[[ "$parent" == "." ]] && parent=""
if [[ "$rel" == *.cfg && "$parent" == "$cfg_root_rel" ]]; then
dest="$CONFIG_SOT_DIR/$(basename "$rel")"
else
dest="$asset_root/$rel"
fi
mkdir -p "$(dirname "$dest")"
[[ -e "$dest" ]] || cp "$src/$rel" "$dest"
done < <(cd "$src" && find . -type f -print0)
}
# If the user has placed a REAL (non-symlink) cfg file at the engine path,
# treat that as authoritative and move it into the SOT location. Engine-path
# real file is the strongest signal of user intent: they manually wrote it
# where the game reads from. Overrides anything already in SOT.
# Runs BEFORE seed_configs so seed (cp -n) doesn't paper over user intent.
# Requires CONFIG_SOT_DIR, ALT_CONFIG_DIR, CONFIG_FILE set.
auto_lift_user_config() {
local cfg="${CONFIG_FILE:-}"
[[ -z "$cfg" ]] && return 0
[[ "${PLUTAINER_USE_RAW_CONFIGS:-}" == "true" ]] && return 0
local alt_path="$ALT_CONFIG_DIR/$cfg"
local sot_path="$CONFIG_SOT_DIR/$cfg"
if [[ -f "$alt_path" && ! -L "$alt_path" ]]; then
echo "[INFO] Auto-lift: real file at $alt_path — moving to $sot_path (v2 SOT)."
mkdir -p "$CONFIG_SOT_DIR"
mv -f "$alt_path" "$sot_path"
fi
}
# Verify $CONFIG_FILE exists at the SOT location. Returns 1 with a
# case-insensitive find hint if absent. Run AFTER seed_configs + link_configs
# so any gap-fill has had its chance.
# Requires CONFIG_SOT_DIR, ALT_CONFIG_DIR, CONFIG_FILE set.
ensure_config_present() {
local cfg="$CONFIG_FILE"
local sot_path="$CONFIG_SOT_DIR/$cfg"
if [[ -e "$sot_path" ]]; then
return 0
fi
echo "[ERROR] PLUTAINER_CONFIG_FILE='$cfg' but no such file exists." >&2
echo " Looked at:" >&2
echo " $sot_path" >&2
echo " $ALT_CONFIG_DIR/$cfg" >&2
local match
match=$(find "$CONFIG_SOT_DIR" "$ALT_CONFIG_DIR" -maxdepth 1 -type f -iname "$cfg" 2>/dev/null | head -1)
if [[ -n "$match" ]]; then
echo " Did you mean: $(basename "$match") ? (filenames are case-sensitive on Linux)" >&2
else
echo " Available cfgs in $CONFIG_SOT_DIR/:" >&2
if compgen -G "$CONFIG_SOT_DIR/*.cfg" > /dev/null; then
(cd "$CONFIG_SOT_DIR" && ls -1 *.cfg) | sed 's/^/ /' >&2
else
echo " (none)" >&2
fi
fi
return 1
}
_rcon_missing_warning() {
echo "[WARN] Could not parse rcon_password from ${CONFIG_PATH}." >&2
echo " Healthcheck and rcon-cli will not work until this is set." >&2
echo " Expected format in your cfg file:" >&2
echo " set rcon_password \"your_password_here\"" >&2
echo " Single quotes and unquoted values are accepted; commented (//) lines are ignored." >&2
echo " Do NOT set rcon_password via PLUTAINER_EXTRA_ARGS — Plutainer cannot read it back." >&2
}
# Extract the RCON password from the user's config file. Handles:
# set rcon_password "value" (double-quoted, default)
# seta rcon_password 'value' (single-quoted)
# set rcon_password value (unquoted; value is first token)
# rcon_password "value" (bare; T6/T7 community cfgs do this)
# Strips line comments (//...) before searching. Picks the last uncommented
# match, in case the cfg overrides itself.
# Sets RCON_PASSWORD on success; returns 1 + warning on failure.
extract_rcon_password() {
RCON_PASSWORD=""
if [[ ! -f "${CONFIG_PATH}" ]]; then
echo "[WARN] Config file not found at ${CONFIG_PATH} — cannot extract rcon_password." >&2
return 1
fi
local line
line=$(sed -e 's|//.*$||' "${CONFIG_PATH}" \
| grep -iE '^[[:space:]]*(set[a]?[[:space:]]+)?rcon_password[[:space:]]+' \
| tail -1)
if [[ -z "$line" ]]; then
_rcon_missing_warning
return 1
fi
local dq_pat='"([^"]*)"'
local sq_pat="'([^']*)'"
if [[ "$line" =~ $dq_pat ]]; then
RCON_PASSWORD="${BASH_REMATCH[1]}"
elif [[ "$line" =~ $sq_pat ]]; then
RCON_PASSWORD="${BASH_REMATCH[1]}"
else
# Unquoted: value is the token immediately after 'rcon_password'.
# Strip optional 'set'/'seta' prefix so $2 is always the value.
RCON_PASSWORD=$(echo "$line" | awk '{ if ($1 ~ /^[Ss][Ee][Tt][Aa]?$/) print $3; else print $2 }')
fi
if [[ -z "$RCON_PASSWORD" ]]; then
_rcon_missing_warning
return 1
fi
}
# Build the `+rconWhitelistAdd` launch args that let an admin tool in another
# container reach RCON, and populate RCON_WHITELIST_ARGS with them.
#
# T5 and T6 gate *unauthenticated* queries — `getinfo` and `getstatus` — on the
# RCON whitelist, not just RCON commands, and an empty whitelist does NOT mean
# "everyone" for those (it does for RCON commands). Since IW4MAdmin's T5/T6
# parsers open with `getinfo`, a sidecar admin container can complete the RCON
# handshake and still fail to attach. Measured on T6 zombies, off-loopback:
#
# whitelist state RCON getinfo
# upstream placeholders blocked blocked
# empty works blocked
# gateway whitelisted works works
#
# Traffic from a container on another Docker network arrives from this
# container's own bridge gateway, whose address is assigned at run time and so
# cannot be baked into a config file — hence detecting it here. Passed as a
# launch arg rather than written into the user's cfg, so nothing is mutated and
# the value tracks the network on every start.
#
# T4 and IW5 do not gate queries this way and are left alone: adding an entry
# makes their whitelist non-empty, which would newly restrict RCON to the
# listed addresses for no benefit. iw4x and t7x have no such command at all.
#
# Adding entries does mean T5/T6 RCON becomes "whitelisted + loopback only",
# which is the same posture upstream's placeholder entries intended (and what
# a working IW4MAdmin deployment ends up doing by hand). An admin tool on
# another machine needs its address in PLUTAINER_RCON_WHITELIST.
resolve_rcon_whitelist_args() {
RCON_WHITELIST_ARGS=()
case "${BASE_GAME:-}" in
t5|t6) ;;
*) return 0 ;;
esac
local -a addresses=()
if [[ "${PLUTAINER_RCON_WHITELIST_GATEWAY:-true}" != "false" ]]; then
local gateway
gateway=$(ip route 2>/dev/null | awk '/^default/ {print $3; exit}')
if [[ -n "$gateway" ]]; then
addresses+=("$gateway")
else
echo "[WARN] Could not determine the container's default gateway; RCON from a sidecar admin tool may be refused." >&2
fi
fi
# Extra hosts (an admin tool on another machine, say). Comma or space separated.
if [[ -n "${PLUTAINER_RCON_WHITELIST:-}" ]]; then
local entry
for entry in ${PLUTAINER_RCON_WHITELIST//,/ }; do
[[ -n "$entry" ]] && addresses+=("$entry")
done
fi
[[ ${#addresses[@]} -gt 0 ]] || return 0
local address
for address in "${addresses[@]}"; do
RCON_WHITELIST_ARGS+=(+rconWhitelistAdd "$address")
done
echo "[INFO] RCON whitelist: ${addresses[*]} (loopback is always permitted)."
}
# Write PLUTAINER_RCON_PASSWORD into the source-of-truth config file.
#
# Opt-in only. Unset (or set to the empty string, which is what an unfilled
# compose variable looks like) means "leave the config alone" — it never nulls
# out a password the user already put there by hand. There is deliberately no
# default value: a shipped placeholder would be a known credential on a UDP
# port that anyone can find by scanning for `getstatus` responders.
#
# Rewrites the value in place, keeping the rest of the line (upstream seeds put
# an explanatory // comment after it), or appends a line if the config has none.
apply_rcon_password() {
local desired="${PLUTAINER_RCON_PASSWORD:-}"
[[ -n "$desired" ]] || return 0
local target="${CONFIG_SOT_DIR}/${CONFIG_FILE}"
if [[ ! -f "$target" ]]; then
echo "[WARN] PLUTAINER_RCON_PASSWORD is set but ${target} does not exist — skipping." >&2
return 0
fi
# Python rather than sed: the password is arbitrary user input and would need
# escaping against sed's replacement metacharacters (& \ and the delimiter).
local result
result=$(RCON_TARGET="$target" RCON_DESIRED="$desired" python3 - <<'PY'
import os
import re
import sys
path = os.environ['RCON_TARGET']
desired = os.environ['RCON_DESIRED']
with open(path, 'r', encoding='utf-8', errors='surrogateescape') as fh:
lines = fh.read().split('\n')
# Same forms extract_rcon_password() reads back, so a value written here is
# always one it can find again.
pattern = re.compile(
r'^(?P<lead>\s*(?:set[a]?\s+)?rcon_password\s+)'
r'(?P<value>"[^"]*"|\'[^\']*\'|\S+)'
r'(?P<rest>.*)$',
re.IGNORECASE,
)
replacement = '"%s"' % desired.replace('"', '')
changed = False
matched = False
for i, line in enumerate(lines):
# Ignore fully commented lines; a commented-out password is not in effect.
if line.lstrip().startswith('//'):
continue
m = pattern.match(line)
if not m:
continue
matched = True
if m.group('value') == replacement:
continue
lines[i] = m.group('lead') + replacement + m.group('rest')
changed = True
if not matched:
if lines and lines[-1] == '':
lines.pop()
lines.extend([
'',
'// Added by Plutainer from PLUTAINER_RCON_PASSWORD.',
'set rcon_password %s' % replacement,
'',
])
changed = True
if changed:
with open(path, 'w', encoding='utf-8', errors='surrogateescape') as fh:
fh.write('\n'.join(lines))
print('changed' if changed else 'unchanged')
PY
) || {
echo "[WARN] Could not apply PLUTAINER_RCON_PASSWORD to ${target}." >&2
return 0
}
if [[ "$result" == "changed" ]]; then
echo "[INFO] rcon_password set from PLUTAINER_RCON_PASSWORD (${#desired} chars) in ${CONFIG_FILE}."
else
echo "[INFO] rcon_password in ${CONFIG_FILE} already matches PLUTAINER_RCON_PASSWORD."
fi
if [[ "$desired" == *'"'* ]]; then
echo "[WARN] PLUTAINER_RCON_PASSWORD contained double quotes; they were stripped before writing." >&2
fi
}
# Every Quake-derived engine here answers RCON on its own game port, over the
# same connectionless UDP protocol used for status queries.
cod_resolve_admin_endpoint() {
ADMIN_PROTOCOL="quake3"
if [[ -n "${CUSTOM_PORT:-}" ]]; then
ADMIN_PORT="$CUSTOM_PORT"
else
resolve_default_port || return 1
ADMIN_PORT="$DEFAULT_PORT"
fi
cod_resolve_config_path || return 1
# A missing or unreadable password is not fatal here: rcon-cli reports it in
# terms the user can act on, and extract_rcon_password has already explained
# the accepted forms.
extract_rcon_password || true
ADMIN_PASSWORD="${RCON_PASSWORD:-}"
}
# ---------------------------------------------------------------------------
# Game table
# ---------------------------------------------------------------------------
#
# Fields set by cod_resolve_game:
#
# COD_ENGINE plutonium | iw4x | alterware | cod4x
# BASE_GAME t4 | t5 | t6 | iw5 | iw4x | t7x | cod4x
# COD_DEFAULT_PORT port when PLUTAINER_PORT is unset
# COD_SEED_KEY seed-configs/<key>, empty for no seed bundle
# COD_SEED_ASSET_ROOT where non-cfg seed files land
# COD_SEED_CFG_ROOT subdir within the bundle whose top-level *.cfg lift
# COD_LABEL name used in log lines
#
# ENGINE_CONFIG_DIR and MOD_CONFIG_DIR are set here too, since they are per-game
# facts rather than logic.
#
# Games this family serves. derive_family() consults this, so adding a game is
# a change to this file and nothing else.
COD_GAMES=(t4mp t4sp t5mp t5sp t6mp t6zm iw5mp iw4x t7x cod4x)
cod_is_known_game() {
local candidate="$1" game
for game in "${COD_GAMES[@]}"; do
[[ "$game" == "$candidate" ]] && return 0
done
return 1
}
cod_resolve_game() {
local game="${1:-$GAME_NAME}"
COD_SEED_KEY=""
COD_SEED_ASSET_ROOT=""
COD_SEED_CFG_ROOT=""
case "$game" in
t4mp|t4sp)
COD_ENGINE="plutonium"; BASE_GAME="t4"; COD_DEFAULT_PORT=28960
COD_LABEL="Plutonium T4 (World at War)"
COD_SEED_KEY="t4"; COD_SEED_ASSET_ROOT="$PLUTAINER_GAMEFILES_DIR/main"
ENGINE_CONFIG_DIR="$PLUTAINER_GAMEFILES_DIR/main"
;;
t5mp|t5sp)
COD_ENGINE="plutonium"; BASE_GAME="t5"; COD_DEFAULT_PORT=28960
COD_LABEL="Plutonium T5 (Black Ops)"
COD_SEED_KEY="t5"; COD_SEED_ASSET_ROOT="$PLUTAINER_PLUTONIUM_DIR/storage/t5"
ENGINE_CONFIG_DIR="$PLUTAINER_PLUTONIUM_DIR/storage/t5"
;;
t6mp|t6zm)
COD_ENGINE="plutonium"; BASE_GAME="t6"; COD_DEFAULT_PORT=4976
COD_LABEL="Plutonium T6 (Black Ops II)"
COD_SEED_KEY="t6"; COD_SEED_ASSET_ROOT="$PLUTAINER_PLUTONIUM_DIR/storage/t6"
ENGINE_CONFIG_DIR="$PLUTAINER_PLUTONIUM_DIR/storage/t6"
;;
iw5mp)
COD_ENGINE="plutonium"; BASE_GAME="iw5"; COD_DEFAULT_PORT=27016
COD_LABEL="Plutonium IW5 (Modern Warfare 3)"
COD_SEED_KEY="iw5"; COD_SEED_ASSET_ROOT="$PLUTAINER_GAMEFILES_DIR/admin"
ENGINE_CONFIG_DIR="$PLUTAINER_GAMEFILES_DIR/admin"
;;
iw4x)
COD_ENGINE="iw4x"; BASE_GAME="iw4x"; COD_DEFAULT_PORT=28960
COD_LABEL="IW4x (Modern Warfare 2)"
COD_SEED_KEY="iw4x"; COD_SEED_ASSET_ROOT="$PLUTAINER_GAMEFILES_DIR"
COD_SEED_CFG_ROOT="userraw"
ENGINE_CONFIG_DIR="$PLUTAINER_GAMEFILES_DIR/userraw"
;;
t7x)
COD_ENGINE="alterware"; BASE_GAME="t7x"; COD_DEFAULT_PORT=27017
COD_LABEL="Alterware T7x (Black Ops III)"
COD_SEED_KEY="t7x"; COD_SEED_ASSET_ROOT="$PLUTAINER_GAMEFILES_DIR"
COD_SEED_CFG_ROOT="zone"
ENGINE_CONFIG_DIR="$PLUTAINER_GAMEFILES_DIR/zone"
;;
cod4x)
COD_ENGINE="cod4x"; BASE_GAME="cod4x"; COD_DEFAULT_PORT=28960
COD_LABEL="CoD4x (Modern Warfare)"
COD_SEED_KEY="cod4x"; COD_SEED_ASSET_ROOT="$PLUTAINER_GAMEFILES_DIR"
COD_SEED_CFG_ROOT="main"
ENGINE_CONFIG_DIR="$PLUTAINER_GAMEFILES_DIR/main"
;;
*)
echo "[ERROR] '${game}' is not a known Call of Duty game." >&2
return 1
;;
esac
cod_resolve_mod_config_dir
}
# Where the game looks for cfg files inside the active mod (fs_game), if
# PLUTAINER_MOD is set. Empty when no mod, or when the family has no such
# concept — Alterware's MOD is a Steam Workshop ID, not a path.
cod_resolve_mod_config_dir() {
MOD_CONFIG_DIR=""
[[ -z "${PLUTAINER_MOD:-}" ]] && return 0
case "$COD_ENGINE" in
plutonium)
case "$BASE_GAME" in
t4|iw5) MOD_CONFIG_DIR="$PLUTAINER_GAMEFILES_DIR/$PLUTAINER_MOD" ;;
*) MOD_CONFIG_DIR="$PLUTAINER_PLUTONIUM_DIR/storage/$BASE_GAME/$PLUTAINER_MOD" ;;
esac
;;
iw4x) MOD_CONFIG_DIR="$PLUTAINER_GAMEFILES_DIR/$PLUTAINER_MOD" ;;
cod4x) MOD_CONFIG_DIR="$PLUTAINER_GAMEFILES_DIR/$PLUTAINER_MOD" ;;
esac
}
# ---------------------------------------------------------------------------
# Hooks
#
# Resolved most-specific-first by games/codentry.sh:
# cod_<hook>_<game> -> cod_<hook>_<base game> -> cod_<hook>_<engine>
#
# stage put the game files where the engine expects them
# update fetch/refresh the server binaries
# launch populate COD_LAUNCH_CMD with the command and its arguments
# validate optional; extra per-engine environment checks
# ---------------------------------------------------------------------------
# --- Plutonium -------------------------------------------------------------
# Which files each base game needs out of the read-only mount. Everything else
# in the mount is client-side and ignored.
cod_stage_t4() {
link_files "$PLUTAINER_SOURCE_DIR" "$PLUTAINER_GAMEFILES_DIR" \
zone binkw32.dll localization.txt cod.bmp codlogo.bmp
# main/ must stay a real writable dir: cfg symlinks and game logs land there.
mkdir -p "$PLUTAINER_GAMEFILES_DIR/main"
link_files "$PLUTAINER_SOURCE_DIR/main" "$PLUTAINER_GAMEFILES_DIR/main" \
iw_00.iwd iw_14.iwd iw_21.iwd iw_22.iwd iw_24.iwd iw_26.iwd \
localized_english_iw00.iwd localized_english_iw04.iwd
}
cod_stage_t5() {
link_files "$PLUTAINER_SOURCE_DIR" "$PLUTAINER_GAMEFILES_DIR" \
main zone binkw32.dll localization.txt
}
cod_stage_t6() {
link_files "$PLUTAINER_SOURCE_DIR" "$PLUTAINER_GAMEFILES_DIR" \
zone binkw32.dll codlogo.bmp
}
cod_stage_iw5() {
link_files "$PLUTAINER_SOURCE_DIR" "$PLUTAINER_GAMEFILES_DIR" \
main miles zone binkw32.dll localization.txt mss32.dll
}
cod_update_plutonium() {
mkdir -p "$PLUTAINER_PLUTONIUM_DIR"
local marker="$PLUTAINER_PLUTONIUM_DIR/cdn_info.json"
if [[ -f "$marker" && "${PLUTAINER_AUTO_UPDATE:-}" == "false" ]]; then
echo "Skipping Plutonium update because PLUTAINER_AUTO_UPDATE is set to 'false'."
return 0
fi
if [[ -f "$marker" ]]; then
echo "Checking for Plutonium updates..."
else
echo "First container run detected. Downloading Plutonium initial files..."
fi
/home/plutainer/.plutainer/plutonium-updater --directory "$PLUTAINER_PLUTONIUM_DIR"
}
cod_validate_plutonium() {
[[ -n "${PLUTO_SERVER_KEY:-}" ]] || hold_indefinitely \
"PLUTO_SERVER_KEY is not set. Get a server key from https://platform.plutonium.pw/serverkeys"
}
cod_launch_plutonium() {
COD_WORKDIR="$PLUTAINER_PLUTONIUM_DIR"
COD_LAUNCH_CMD=(
wine bin/plutonium-bootstrapper-win32.exe
"$GAME_NAME"
"$PLUTAINER_GAMEFILES_DIR"
-dedicated
+set key "${PLUTO_SERVER_KEY}"
+set net_port "$ACTIVE_PORT"
)
# IW5 spells both of these differently to the rest of the family.
if [[ "$BASE_GAME" == "iw5" ]]; then
COD_LAUNCH_CMD+=(+set sv_config "$CONFIG_FILE")
else
COD_LAUNCH_CMD+=(+exec "$CONFIG_FILE")
fi
[[ -n "${PLUTAINER_MOD:-}" ]] && COD_LAUNCH_CMD+=(+set fs_game "$PLUTAINER_MOD")
[[ -n "${PLUTO_MAX_CLIENTS:-}" ]] && COD_LAUNCH_CMD+=(+set sv_maxclients "$PLUTO_MAX_CLIENTS")
# Must come after +exec: the cfg's own rconWhitelistAdd lines are additive,
# and these entries are what let an admin tool in another container query
# T5/T6 at all. No-op for the other base games.
resolve_rcon_whitelist_args
(( ${#RCON_WHITELIST_ARGS[@]} )) && COD_LAUNCH_CMD+=("${RCON_WHITELIST_ARGS[@]}")
cod_append_extra_args
cod_append_map_rotate "$([[ "$BASE_GAME" == "iw5" ]] && echo '+start_map_rotate' || echo '+map_rotate')"
}
# --- IW4x ------------------------------------------------------------------
cod_stage_iw4x() {
link_files "$PLUTAINER_SOURCE_DIR" "$PLUTAINER_GAMEFILES_DIR" \
main usermaps binkw32.dll localization.txt mss32.dll
# userraw/ is the engine config dir, so it must be a real writable directory
# for the cfg fan-out to land in.
link_dir_contents "$PLUTAINER_SOURCE_DIR" "$PLUTAINER_GAMEFILES_DIR" userraw
# zone/ is split by how the launcher writes, not by who owns the content.
#
# Its rawfiles component unpacks release.zip by writing straight through each
# destination path, so a symlink there — at the directory OR the leaf —
# resolves into the read-only mount and aborts the whole run:
# [E] failed to extract file: zone/patch/iw4_credits_load.ff
# That also skips sync_dlc and sync_helper and leaves rawfiles unstamped, so
# every later start fails identically and client updates never apply. The zip
# covers all of zone/patch/ and zone/zonebuilder/, so neither may be
# pre-populated. Nothing is lost: its 56 zone/patch entries are a superset of
# a full MW2 install's 39, and zone/zonebuilder is the same lone file.
#
# Every other component stages downloads and renames into place, which
# replaces a symlink rather than writing through it — so mirroring these two
# is safe, and useful, since the reconciler hash-validates existing host
# fastfiles and skips re-downloading matches.
link_dir_contents "$PLUTAINER_SOURCE_DIR" "$PLUTAINER_GAMEFILES_DIR" zone/english
link_dir_contents "$PLUTAINER_SOURCE_DIR" "$PLUTAINER_GAMEFILES_DIR" zone/dlc
}
cod_update_iw4x() {
# The launcher has no --path flag: it canonicalises /proc/self/exe and uses
# its own directory as the installation root, ignoring cwd. So run a copy from
# inside the volume — that way the ~800 MB it fetches lands in the bind mount
# and survives container recreation instead of filling an image layer. It must
# be a real copy: a symlink canonicalises straight back to .plutainer and
# reinstates the ephemeral install root.
local src="/home/plutainer/.plutainer/iw4x-launcher"
local launcher="$PLUTAINER_GAMEFILES_DIR/iw4x-launcher"
# Capability check, deliberately not an architecture check, so this clears
# itself the moment an image ships a working binary again.
if [[ ! -x "$src" ]]; then
hold_indefinitely "iw4x-launcher is missing from this image, so PLUTAINER_GAME=iw4x cannot start.
This image was built for an architecture upstream does not publish a launcher
binary for, and building it from source failed. Tracked as iw4x/launcher#76.
Options: run iw4x on an amd64 host, or use a different PLUTAINER_GAME —
Plutonium (t4/t5/t6/iw5) and Alterware (t7x) are unaffected."
fi
if [[ ! -f "$launcher" || "$src" -nt "$launcher" ]]; then
echo "Staging iw4x-launcher into the game directory..."
cp -f "$src" "$launcher"
chmod +x "$launcher"
fi
local marker="$PLUTAINER_GAMEFILES_DIR/cache/iw4x.db"
if [[ -f "$marker" && "${PLUTAINER_AUTO_UPDATE:-}" == "false" ]]; then
echo "Skipping iw4x update because PLUTAINER_AUTO_UPDATE is set to 'false'."
return 0
fi
if [[ -f "$marker" ]]; then
echo "Checking for iw4x updates..."
else
echo "First container run detected. Downloading iw4x initial files..."
fi
# Don't let a GitHub/CDN outage take a working server down: only a first-run
# failure (no iw4x.exe yet) is fatal.
if ! "$launcher" --skip-launch --no-self-update; then
if [[ -f "$PLUTAINER_GAMEFILES_DIR/iw4x.exe" ]]; then
echo "[WARN] iw4x-launcher failed — starting with the existing install." >&2
else
hold_indefinitely "iw4x-launcher failed and no iw4x.exe is present. Check network access to github.com and cdn.iw4x.io, and that the app volume is not mounted 'noexec' (the launcher runs from $PLUTAINER_GAMEFILES_DIR)."
fi
fi
}
cod_launch_iw4x() {
COD_WORKDIR="$PLUTAINER_GAMEFILES_DIR"
COD_LAUNCH_CMD=(
wine iw4x.exe
-dedicated
-stdout
+set sv_lanonly "0"
+set net_port "$ACTIVE_PORT"
+exec "$CONFIG_FILE"
+set logfile "1"
+set party_enable "0"
)
[[ -n "${PLUTAINER_MOD:-}" ]] && COD_LAUNCH_CMD+=(+set fs_game "$PLUTAINER_MOD")
[[ -n "${IW4X_NET_LOG_IP:-}" ]] && COD_LAUNCH_CMD+=(+set g_log_add "$IW4X_NET_LOG_IP")
cod_append_extra_args
cod_append_map_rotate +map_rotate
}
# --- Alterware (T7x) -------------------------------------------------------
cod_stage_t7x() {
link_files "$PLUTAINER_SOURCE_DIR" "$PLUTAINER_GAMEFILES_DIR" \
codlogo.bmp machinecfg steam_api64.dll steamclient64.dll tier0_s64.dll vstdlib_s64.dll
# T7x decides it is a dedicated server by checking which executables exist:
# is_server = has_flag("dedicated") || (!has_client && has_server)
# Under Wine, flag detection via GetCommandLineW() can be unreliable, so only
# the server binary is linked, guaranteeing the fallback path fires.
if [[ -f "$PLUTAINER_SOURCE_DIR/BlackOps3_UnrankedDedicatedServer.exe" ]]; then
ln -sf "$PLUTAINER_SOURCE_DIR/BlackOps3_UnrankedDedicatedServer.exe" "$PLUTAINER_GAMEFILES_DIR"/
elif [[ -f "$PLUTAINER_SOURCE_DIR/BlackOps3.exe" ]]; then
echo "[WARN] BlackOps3_UnrankedDedicatedServer.exe not found, falling back to BlackOps3.exe" >&2
ln -sf "$PLUTAINER_SOURCE_DIR/BlackOps3.exe" "$PLUTAINER_GAMEFILES_DIR"/
fi
# zone/ is the engine config dir, so real dir + symlinked contents.
link_dir_contents "$PLUTAINER_SOURCE_DIR" "$PLUTAINER_GAMEFILES_DIR" zone
}
cod_update_alterware() {
local exe="$PLUTAINER_GAMEFILES_DIR/t7x.exe"
if [[ -f "$exe" && "${PLUTAINER_AUTO_UPDATE:-}" == "false" ]]; then
echo "Skipping T7x update because PLUTAINER_AUTO_UPDATE is set to 'false'."
return 0
fi
if [[ -f "$exe" ]]; then
echo "Checking for T7x updates..."
else
echo "First container run detected. Downloading T7x... This may take a moment."
fi
# wget -N is timestamping: it only downloads when upstream is newer.
wget -q -N -P "$PLUTAINER_GAMEFILES_DIR" https://master.bo3.eu/t7x/t7x.exe
}
cod_launch_alterware() {
COD_WORKDIR="$PLUTAINER_GAMEFILES_DIR"
# `-headless` makes t7x skip Sys_CreateConsole and print to stdout instead of
# building a Win32 console window, so no X display is needed. Without it the
# server hangs on window creation ("nodrv_CreateWindow") and never binds its
# port — which is why this image used to ship Xvfb.
COD_LAUNCH_CMD=(
wine t7x.exe
-headless
-dedicated
+set fs_game "${PLUTAINER_MOD:-}"
+set net_port "$ACTIVE_PORT"
+set logfile "2"
+exec "$CONFIG_FILE"
)
cod_append_extra_args
# T7x has never had a map-rotate argument.
}
# --- CoD4x -----------------------------------------------------------------
COD4X_ASSET_DIR="/home/plutainer/.plutainer/cod4x"
COD4X_BINARY="cod4x18_dedrun"
cod_stage_cod4x() {
# main/ and zone/english/ must stay writable real directories: cfg symlinks
# and games_mp.log land in main/, and CoD4x's patch fastfiles are placed
# alongside the stock ones in zone/english/.
link_dir_contents "$PLUTAINER_SOURCE_DIR" "$PLUTAINER_GAMEFILES_DIR" main
link_dir_contents "$PLUTAINER_SOURCE_DIR" "$PLUTAINER_GAMEFILES_DIR" zone/english
}
cod_update_cod4x() {
[[ -x "$COD4X_ASSET_DIR/$COD4X_BINARY" ]] || hold_indefinitely \
"CoD4x is not available in this image (no ${COD4X_BINARY}).
Upstream publishes a 32-bit x86 Linux binary only, so CoD4x cannot run on this
architecture. Plutonium, IW4x and Alterware titles are unaffected."
local dest="$PLUTAINER_GAMEFILES_DIR/$COD4X_BINARY"
# Copied, not symlinked: CoD4x ships a self-updater that rewrites the binary
# in place, which would fail against a read-only image layer. Living in the
# volume also means an updated build survives container recreation.
if [[ ! -f "$dest" ]]; then
echo "First container run detected. Staging CoD4x server binary..."
cp "$COD4X_ASSET_DIR/$COD4X_BINARY" "$dest"
chmod +x "$dest"
elif [[ "${PLUTAINER_AUTO_UPDATE:-}" != "false" ]]; then
# Only refresh when the image ships something newer; never clobber a build
# the self-updater fetched.
if [[ "$COD4X_ASSET_DIR/$COD4X_BINARY" -nt "$dest" ]]; then
echo "Image ships a newer CoD4x binary — updating."
cp "$COD4X_ASSET_DIR/$COD4X_BINARY" "$dest"
chmod +x "$dest"
fi
fi
# CoD4x's own fastfiles and iwd, which a stock CoD4 install lacks. The server
# refuses to load a map without cod4x_patchv2. cp -n: a user-supplied or
# self-updated copy always wins.
cp -n "$COD4X_ASSET_DIR"/zone/english/*.ff "$PLUTAINER_GAMEFILES_DIR/zone/english/" 2>/dev/null || true
cp -n "$COD4X_ASSET_DIR"/main/*.iwd "$PLUTAINER_GAMEFILES_DIR/main/" 2>/dev/null || true
}
cod_launch_cod4x() {
COD_WORKDIR="$PLUTAINER_GAMEFILES_DIR"
# `dedicated 2` is a public (master-listed) server; `1` is LAN.
COD_LAUNCH_CMD=(
"./$COD4X_BINARY"
+set dedicated "${PLUTAINER_DEDICATED:-2}"
+set net_port "$ACTIVE_PORT"
+set fs_homepath "$PLUTAINER_GAMEFILES_DIR"
)
# CoD4x authorises servers against its master. Without a token from cod4x.ovh,
# -1 disables the check so the server still boots; a real token gets it listed.
if [[ -n "${PLUTAINER_COD4X_AUTH_TOKEN:-}" ]]; then
COD_LAUNCH_CMD+=(+set sv_authtoken "$PLUTAINER_COD4X_AUTH_TOKEN")
else
COD_LAUNCH_CMD+=(+set sv_authorizemode "${PLUTAINER_COD4X_AUTHORIZE_MODE:--1}")
fi
[[ -n "${PLUTAINER_MOD:-}" ]] && COD_LAUNCH_CMD+=(+set fs_game "$PLUTAINER_MOD")
COD_LAUNCH_CMD+=(+exec "$CONFIG_FILE")
cod_append_extra_args
cod_append_map_rotate +map_rotate
}
# --- shared launch-argument helpers ----------------------------------------
cod_append_extra_args() {
# Intentionally word-split: users pass several flags in one variable.
# shellcheck disable=SC2206
[[ -n "${PLUTAINER_EXTRA_ARGS:-}" ]] && COD_LAUNCH_CMD+=( ${PLUTAINER_EXTRA_ARGS} )
return 0
}
# Opt-out. An unconditional map-rotate argument overrides playlist- or
# cfg-driven map selection, which some setups want.
# Args: <the argument this engine uses>
cod_append_map_rotate() {
[[ "${PLUTAINER_MAP_ROTATE:-true}" == "false" ]] && return 0
COD_LAUNCH_CMD+=("$1")
}