#!/usr/bin/env bash # Extracts the game data Shockolate needs out of a purchased System Shock: # Enhanced Edition installer from gog.com, into ./ss_ee/{data,sound}. # # Usage: # 1. Buy System Shock: Enhanced Edition on gog.com and download the # offline installer (a Windows .exe, e.g. # setup_system_shock_enhanced_edition_1.2.16_(64bit)_(44378).exe). # 2. Drop it in this directory (res/assets/). # 3. Run this script: ./extract_assets.sh # # The installer is an Inno Setup package; the actual game data lives # inside it in sshock.kpf, which is just a zip file. Its res/pc/hd/ and # res/pc/cdrom/ trees together hold every classic-game data/sound file # Shockolate's res/ folder expects (res/pc/hd wins the couple of # filenames present in both: intro.res, objprop.dat), EXCEPT # mfdfrn.res/mfdger.res (French/German MFD art - see CONFIG.md/README.md), # which that installer doesn't include at all - see "Optional: French/ # German MFD art" below for where those instead come from. Needs # innoextract and unzip; if they're not installed locally, this script # does the extraction inside a throwaway Ubuntu container instead (needs # docker in that case, nothing else). # # Optional: French/German MFD art # The Enhanced Edition installer's data set doesn't include # mfdfrn.res/mfdger.res, so French/German are otherwise missing their # MFD (Multi-Function Display) art and the game refuses to switch to # them (see CONFIG.md). System Shock: Classic (also gog.com) does # include both files. If you also own it, drop its offline installer # (e.g. setup_system_shock_classic_2.0.0.3.exe) in this directory too - # entirely optional, this script still runs fine without it - and this # script pulls just those two files out of it into ss_ee/data/ as well. set -euo pipefail cd "$(dirname "${BASH_SOURCE[0]}")" ASSETS_DIR="$(pwd)" OUT_DIR="$ASSETS_DIR/ss_ee" fail() { echo "PREFLIGHT FAIL: $*" >&2; exit 1; } shopt -s nullglob installers=(setup_system_shock_enhanced_edition*.exe) classic_installers=(setup_system_shock_classic*.exe) shopt -u nullglob case "${#installers[@]}" in 0) fail "no setup_system_shock_enhanced_edition*.exe found in $ASSETS_DIR - buy System Shock: Enhanced Edition on gog.com, download the offline installer, and drop it here" ;; 1) ;; *) fail "multiple installer .exe files found in $ASSETS_DIR - keep only one: ${installers[*]}" ;; esac INSTALLER="${installers[0]}" # Optional - see "Optional: French/German MFD art" above. Empty (not # missing/failed) when absent; only a genuine ambiguity (more than one # candidate file) is a hard failure. case "${#classic_installers[@]}" in 0) CLASSIC_INSTALLER="" ;; 1) CLASSIC_INSTALLER="${classic_installers[0]}" ;; *) fail "multiple setup_system_shock_classic*.exe files found in $ASSETS_DIR - keep only one: ${classic_installers[*]}" ;; esac # Extracts sshock.kpf from $1 (an installer .exe) and assembles $2 (an # ss_ee output directory) from its res/pc/{hd,cdrom} trees. Defined as a # function so the exact same logic can run either natively or, via # `declare -f`, inside a throwaway container. run_extraction() { set -euo pipefail local installer="$1" out_dir="$2" # Deliberately not `local`: an EXIT trap set here fires when the whole # shell process exits, which is after this function - and this # function's own scope - has already returned, so a `local` var would # already be unset by then (unbound-variable error under `set -u`). work="$(mktemp -d)" trap 'rm -rf "${work:-}"' EXIT echo "== Extracting sshock.kpf from installer ==" innoextract --silent --include sshock.kpf -d "$work/installer" "$installer" echo "== Unpacking sshock.kpf (data/sound trees only) ==" mkdir -p "$work/kpf" unzip -q "$work/installer/sshock.kpf" \ 'res/pc/hd/data/*' 'res/pc/hd/sound/*' 'res/pc/cdrom/data/*' \ -d "$work/kpf" echo "== Assembling $out_dir ==" rm -rf "$out_dir" mkdir -p "$out_dir/data" "$out_dir/sound" # cdrom/data first (cutscenes, audio logs, and shared tables only it # has), then hd/data on top (wins the 2 overlapping filenames: # intro.res, objprop.dat - the hd install's own copies). cp -a "$work/kpf/res/pc/cdrom/data/." "$out_dir/data/" cp -a "$work/kpf/res/pc/hd/data/." "$out_dir/data/" cp -a "$work/kpf/res/pc/hd/sound/." "$out_dir/sound/" } # Extracts mfdfrn.res/mfdger.res from $1 (a System Shock: Classic installer # .exe) into $2 (an existing ss_ee output directory's data/ subfolder) - # see "Optional: French/German MFD art" above. Unlike the Enhanced Edition # installer, Classic's data files sit directly in the Inno Setup package # (no intermediate zip), so this is just a targeted innoextract --include, # no unzip step needed. Same declare -f-into-docker structure as # run_extraction() above. run_classic_mfd_extraction() { set -euo pipefail local installer="$1" out_dir="$2" work="$(mktemp -d)" trap 'rm -rf "${work:-}"' EXIT echo "== Extracting mfdfrn.res/mfdger.res from classic installer ==" innoextract --silent \ --include 'app/SSHOCK/DATA/MFDFRN.RES' --include 'app/SSHOCK/DATA/MFDGER.RES' \ -d "$work/classic" "$installer" # Original DOS-era filenames are uppercase; Shockolate's resource # loader expects the lowercase names already used for every other # res/data/*.res file (case-sensitive on Linux, unlike the DOS/Windows # filesystems these came from). cp "$work/classic/app/SSHOCK/DATA/MFDFRN.RES" "$out_dir/data/mfdfrn.res" cp "$work/classic/app/SSHOCK/DATA/MFDGER.RES" "$out_dir/data/mfdger.res" } if command -v innoextract >/dev/null 2>&1 && command -v unzip >/dev/null 2>&1; then echo "== Using local innoextract/unzip ==" run_extraction "$ASSETS_DIR/$INSTALLER" "$OUT_DIR" if [ -n "$CLASSIC_INSTALLER" ]; then run_classic_mfd_extraction "$ASSETS_DIR/$CLASSIC_INSTALLER" "$OUT_DIR" fi else echo "== innoextract/unzip not found locally - using a throwaway docker container ==" command -v docker >/dev/null 2>&1 \ || fail "docker not found in PATH (needed since innoextract/unzip aren't installed locally)" docker run --rm \ -v "$ASSETS_DIR:/assets" \ -e HOST_UID="$(id -u)" \ -e HOST_GID="$(id -g)" \ ubuntu:22.04 \ bash -c " set -euo pipefail apt-get update -qq >/dev/null apt-get install -y -qq innoextract unzip >/dev/null $(declare -f run_extraction) $(declare -f run_classic_mfd_extraction) run_extraction '/assets/$INSTALLER' '/assets/ss_ee' $([ -n "$CLASSIC_INSTALLER" ] && echo "run_classic_mfd_extraction '/assets/$CLASSIC_INSTALLER' '/assets/ss_ee'") chown -R \"\$HOST_UID:\$HOST_GID\" /assets/ss_ee " fi echo "== Done ==" echo "Assets extracted to $OUT_DIR" if [ -n "$CLASSIC_INSTALLER" ]; then echo "Also extracted mfdfrn.res/mfdger.res (French/German MFD art) from $CLASSIC_INSTALLER." else echo "Note: mfdfrn.res/mfdger.res (French/German MFD art) are not present - only needed for" echo "French/German (see CONFIG.md); drop a System Shock: Classic installer here too to get them." fi