#!/bin/bash # panel-native Empyrion entrypoint. # # Contract: # /game — SteamCMD-installed Windows build of Empyrion (volume, # populated by the panel's SteamCMD updater sidecar). # If missing, exit with EX_CONFIG and a helpful message. # /game-saves — dedicated.yaml + Saves/ tree (volume). # # Runs as root for its opening stage (fix up ownership after the SteamCMD # sidecar wrote as root, nuke any stale X lock from a previous crash), # then drops to the `panel` user for Wine + Xvfb + the game. Wine refuses # to run as root, so the drop is load-bearing. # # On every start we verify the binary is there, start Xvfb, boot a minimal # Wine prefix if needed, then exec EmpyrionDedicated.exe under Wine with # stdout/stderr line-buffered so docker logs → agent → panel console # delivers live output. set -euo pipefail log() { printf '[panel-empyrion] %s\n' "$*"; } # ----- stage 1: root-only bootstrap (runs once per container start) ----- if [[ "$(id -u)" = "0" ]]; then # SteamCMD writes as root in its sidecar; chown once so the panel user # (UID 1000) can read/write the game dir without fiddling with perms. chown -R panel:panel /game /game-saves /home/panel 2>/dev/null || true # Nuke any stale Xvfb state from a previous boot. The container's /tmp # persists across restarts (same writable layer), so /tmp/.X99-lock plus # /tmp/.X11-unix/X99 left by a SIGKILL'd Xvfb make the next start die # with "_XSERVTransMakeAllCOTSServerListeners: server already running". # Single rm wasn't catching every variant (verified live 2026-04-29); # nuking the whole dir + re-creating with the right mode is bulletproof. pkill -9 Xvfb 2>/dev/null || true rm -rf /tmp/.X11-unix /tmp/.X99-lock 2>/dev/null || true mkdir -p /tmp/.X11-unix && chmod 1777 /tmp/.X11-unix && chown root:root /tmp/.X11-unix 2>/dev/null || true # Empyrion writes build-stamped logs to Z:\Logs\ (= /Logs/... # because Wine maps Z:\ to /). Without this symlink panel gets an # UnauthorizedAccessException on first boot. Pointing it into the save # volume keeps logs persistent + visible in the Files tab. mkdir -p /game-saves/EmpyrionLogs chown -R panel:panel /game-saves/EmpyrionLogs if [[ ! -e /Logs || -L /Logs ]]; then ln -sfn /game-saves/EmpyrionLogs /Logs fi # Re-exec as `panel` user. setpriv comes with util-linux in debian-slim. log "dropping privileges to panel (UID 1000)" exec setpriv --reuid=panel --regid=panel --clear-groups \ --inh-caps=-all --bounding-set=-all \ env \ HOME=/home/panel \ WINEPREFIX=/home/panel/.wine \ WINEDEBUG=-all \ SRV_PORT="${SRV_PORT:-30000}" \ EAC_PORT="${EAC_PORT:-30003}" \ CSW_PORT="${CSW_PORT:-30004}" \ EAH_API_PORT="${EAH_API_PORT:-30007}" \ SCENARIO="${SCENARIO:-}" \ PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \ /entrypoint.sh "$@" fi GAME_DIR=/game SAVE_DIR=/game-saves DEDI_DIR="${GAME_DIR}/DedicatedServer" DEDI_EXE="${DEDI_DIR}/EmpyrionDedicated.exe" CONFIG="${SAVE_DIR}/dedicated.yaml" # Empyrion ships its default dedicated.yaml at the game root. SHIPPED_CONFIG="${GAME_DIR}/dedicated.yaml" # --- sanity: Windows build installed? --- if [[ ! -f "${DEDI_EXE}" ]]; then log "ERROR: ${DEDI_EXE} not found." log " Game files haven't been downloaded yet." log " Run 'Update' on this server in the panel — that launches a" log " SteamCMD sidecar which installs Empyrion (Windows build) into" log " this volume via +@sSteamCmdForcePlatformType windows." exit 78 # EX_CONFIG fi # --- dedicated.yaml: copy shipped default on first run --- if [[ ! -s "${CONFIG}" && -s "${SHIPPED_CONFIG}" ]]; then log "no panel config found — seeding ${CONFIG} from ${SHIPPED_CONFIG}" cp -f "${SHIPPED_CONFIG}" "${CONFIG}" fi if [[ ! -s "${CONFIG}" ]]; then log "WARN: no dedicated.yaml found in game install. Empyrion will create" log " one with defaults on first boot. Edit via the Files tab." fi # Symlink so the binary finds the config at its expected relative path. # Empyrion reads dedicated.yaml from the game root (same dir as the .exe's # -dedicated argument resolves). ln -sf "${CONFIG}" "${GAME_DIR}/dedicated.yaml" # --- patch dedicated.yaml ports from env on every boot --- # Empyrion reads Srv_Port / EAC_Port / Tel_Port from dedicated.yaml. Without # this, multiple instances on the same agent all bind 30000-30004 and only # one is reachable. SRV_PORT comes from the panel allocator via the # manifest's port→env mapping (`env: SRV_PORT` in module.yaml). yaml is # whitespace-sensitive but these keys are a flat top-level "Key: NNN" so # sed is safe. if [[ -s "${CONFIG}" ]]; then log "patching dedicated.yaml (Srv_Port=${SRV_PORT}, EAC_Port=${EAC_PORT})" sed -i -E \ -e "s|^([[:space:]]*Srv_Port:[[:space:]]*)[0-9]+|\1${SRV_PORT}|" \ -e "s|^([[:space:]]*EAC_Port:[[:space:]]*)[0-9]+|\1${EAC_PORT}|" \ "${CONFIG}" fi # --- scenario selection --- # SCENARIO env names a folder under /game/Content/Scenarios/. The empyrion- # shipped default is "Default Random". Reforged Eden / Reforged Eden 2 / # Project Eden need to be present in /game/Content/Scenarios// first # (drop via the panel's Files tab, or future workshop-subscribe feature). # This block keeps dedicated.yaml's CustomScenario in sync with the env. SCENARIO="${SCENARIO:-}" if [[ -n "${SCENARIO}" && -s "${CONFIG}" ]]; then if [[ ! -d "${GAME_DIR}/Content/Scenarios/${SCENARIO}" ]]; then log "WARN: scenario folder '${SCENARIO}' not found at ${GAME_DIR}/Content/Scenarios/${SCENARIO}" log " The panel UI Files tab can drop scenario contents there. For" log " Reforged Eden 2 (Workshop ID 3041847672), unzip into:" log " /game/Content/Scenarios/Reforged Eden 2/" log " Booting with Default for now." else log "selecting scenario: ${SCENARIO}" if grep -qE '^[[:space:]]*CustomScenario:' "${CONFIG}"; then sed -i -E "s|^([[:space:]]*CustomScenario:[[:space:]]*).*|\1${SCENARIO}|" "${CONFIG}" else # Insert under GameConfig: at the top, ahead of dedicated game block. printf '\nGameConfig:\n CustomScenario: %s\n' "${SCENARIO}" >> "${CONFIG}" fi fi fi # --- stage EPM mod (TCP relay for Eleon Mod API) on first boot --- # Pre-staged at /opt/panel-epm in the image. Copy into the live game # Content/Mods/EPM/ if it isn't there yet. This makes the panel-empyrion- # bridge sidecar work out-of-the-box: every new empyrion instance comes # with EPM listening on EAH_API_PORT. EPM_SRC=/opt/panel-epm EPM_DST="${GAME_DIR}/Content/Mods/EPM" if [[ -d "${EPM_SRC}" && ! -f "${EPM_DST}/EmpyrionNetworkRelayMod.dll" ]]; then log "staging EPM mod into ${EPM_DST}" mkdir -p "${EPM_DST}" cp -a "${EPM_SRC}/." "${EPM_DST}/" # If a panel-allocated EAH_API_PORT is set, patch EPM's Settings.yaml # so its TCP listener binds the right port for this instance. if [[ -n "${EAH_API_PORT:-}" && -f "${EPM_DST}/Settings.yaml" ]]; then sed -i -E "s|^GameServerApiPort:[[:space:]]*[0-9]+|GameServerApiPort: ${EAH_API_PORT}|" \ "${EPM_DST}/Settings.yaml" fi fi # Re-patch port even if EPM was already staged, in case panel reallocated. if [[ -f "${EPM_DST}/Settings.yaml" && -n "${EAH_API_PORT:-}" ]]; then sed -i -E "s|^GameServerApiPort:[[:space:]]*[0-9]+|GameServerApiPort: ${EAH_API_PORT}|" \ "${EPM_DST}/Settings.yaml" fi # --- prep saves dir (Empyrion writes under /game/Saves by default) --- # Redirect Saves into the save volume via a symlink so steam validate # can't clobber world data. mkdir -p "${SAVE_DIR}/Saves" if [[ -d "${GAME_DIR}/Saves" && ! -L "${GAME_DIR}/Saves" ]]; then # Steam shipped an empty Saves dir; replace with symlink. rm -rf "${GAME_DIR}/Saves" fi ln -sfn "${SAVE_DIR}/Saves" "${GAME_DIR}/Saves" # --- Wine + Xvfb via xvfb-run (auto-allocates a free display) --- # # We previously hand-started Xvfb on :99. That broke once the host had # its OWN Xvfb on :99 — the empyrion container uses network_mode: host, # which shares the network namespace, and X server abstract Unix sockets # (`@/tmp/.X11-unix/X99`) are namespaced by network ns. Result: container # Xvfb fails with "_XSERVTransMakeAllCOTSServerListeners: server already # running" and Wine then dies "Authorization required, but no auth # protocol specified" → Unity exits "Failed to create batch mode window: # Success." (verified 2026-04-29). # # `xvfb-run -a` finds a free display number, starts Xvfb with proper # xauth set up, exports DISPLAY to its child, and cleans up on exit. # Bulletproof against host's existing Xvfb instances. # --- Wine prefix bootstrap (first boot only). xvfb-run handles the X # server for the actual run; for prefix init we don't strictly need # X but wineboot complains less when one is available. --- if [[ ! -d "${WINEPREFIX}" || ! -f "${WINEPREFIX}/system.reg" ]]; then log "initializing Wine prefix at ${WINEPREFIX}" xvfb-run -a wineboot --init 2>&1 | sed 's/^/ /' || true wineserver -w || true fi # --- go --- cd "${GAME_DIR}" log "starting Empyrion Dedicated Server" log " game dir: ${GAME_DIR}" log " save dir: ${SAVE_DIR}" log " config: ${CONFIG}" log " binary: ${DEDI_EXE}" # Shutdown handler: forward SIGTERM (xvfb-run forwards to wine; wine's # Unity hook persists state on quit). trap 'log "SIGTERM received; stopping server"; wineserver -k 2>/dev/null || true; exit 0' TERM INT # -batchmode / -nographics make Unity skip the renderer; -logFile - pipes # the Unity log to our stdout so the panel console sees everything. # `xvfb-run -a` picks a free display, sets up xauth, and exports DISPLAY. # `--server-args="-screen 0 1024x768x24"` matches our previous Xvfb args. exec stdbuf -oL -eL xvfb-run -a --server-args="-screen 0 1024x768x24" \ wine "${DEDI_EXE}" \ -batchmode \ -nographics \ -logFile - \ -dedicated dedicated.yaml \ -startDedicated