diff --git a/README.md b/README.md index f097e17..e33d6cc 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,7 @@ No stat or number is changed. Only *when* radiators run. mod.rules manifest + patch actions components/radiator_auto.rules measures network headroom, gates the radiator tests/ offline validation suite +tests/steam_paths.py finds the game via Steam's libraryfolders.vdf tools/dotnet_enum.py reads component schemas out of Cosmoteer.dll tools/ildump.py disassembles component methods to readable CIL install.sh validate + copy into the Mods folder @@ -110,12 +111,18 @@ install.sh validate + copy into the Mods folder ``` Then enable **Auto Radiator** in the game's Mods menu. `./install.sh --uninstall` -removes it. If the script cannot find your Mods folder, set `COSMOTEER_MODS_DIR`. +removes it. + +The script finds the game by reading Steam's `libraryfolders.vdf`, so a library +on a second disk and a Proton prefix are both handled. If it still comes up +empty it prints the libraries it searched; set `COSMOTEER_MODS_DIR` to override. ## Tests ```sh -python3 tests/test_mod.py +python3 tests/test_mod.py # the mod, against installed game data +python3 tests/test_steam_paths.py # game discovery +./tests/test_install.sh # Mods folder discovery ``` Cosmoteer applies mod actions to vanilla data at load time. Some mistakes crash diff --git a/install.sh b/install.sh index 653ed39..928fe22 100755 --- a/install.sh +++ b/install.sh @@ -3,56 +3,110 @@ # Installs the Auto Radiator mod into Cosmoteer's user Mods folder. # # Why it exists: Cosmoteer loads mods from the user profile directory, which on -# a Proton install lives deep inside the Steam compatdata prefix and is awkward -# to find by hand. This script locates it, validates the mod against the -# installed game data first, and copies only the files the game needs. +# a Proton install lives deep inside the Steam compatdata prefix of whichever +# library the game was installed to. That library can sit on any disk, so this +# script reads Steam's own libraryfolders.vdf to find it, validates the mod +# against the installed game data, and copies only the files the game needs. # # Usage: ./install.sh install (validates first) # ./install.sh --uninstall remove the installed copy - -set -euo pipefail +# +# Sourcing this file defines the discovery functions without installing, and +# leaves the sourcing shell's options untouched. MOD_ID="auto_radiator" +COSMOTEER_APPID=799600 SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +steam_roots() { + # The Steam client directories, one per install flavour: the modern default, + # the two legacy symlinks, and Flatpak's sandboxed home. + printf '%s\n' \ + "$HOME/.local/share/Steam" \ + "$HOME/.steam/steam" \ + "$HOME/.steam/root" \ + "$HOME/.var/app/com.valvesoftware.Steam/data/Steam" +} + +steam_libraries() { + # Every library folder Steam knows about. libraryfolders.vdf lists one + # "path" per library; the app data sits in /steamapps. Steam's own + # root is listed there too, but emit it regardless so a missing or + # unreadable manifest still leaves the default library searchable. + local root vdf + steam_roots + while read -r root; do + vdf="$root/steamapps/libraryfolders.vdf" + [ -f "$vdf" ] || continue + sed -n 's/^[[:space:]]*"path"[[:space:]]*"\(.*\)"[[:space:]]*$/\1/p' "$vdf" + done < <(steam_roots) +} + find_mods_dir() { - # A Cosmoteer profile folder is named after the Steam ID, so glob for it. - local candidates=( - "$HOME"/.local/share/Steam/steamapps/compatdata/799600/pfx/drive_c/users/steamuser/"Saved Games"/Cosmoteer/*/Mods - /mnt/*/SteamLibrary/steamapps/compatdata/799600/pfx/drive_c/users/steamuser/"Saved Games"/Cosmoteer/*/Mods - "$HOME"/"Saved Games"/Cosmoteer/*/Mods - "$HOME"/Documents/"My Games"/Cosmoteer/*/Mods - ) - for path in "${candidates[@]}"; do + local lib path + local -a libraries + readarray -t libraries < <(steam_libraries) + + # Proton keeps the profile inside the game's compat prefix. The profile + # folder is named after the Steam ID, so glob for it. + for lib in "${libraries[@]}"; do + for path in "$lib/steamapps/compatdata/$COSMOTEER_APPID/pfx/drive_c/users/steamuser/Saved Games/Cosmoteer"/*/Mods; do + [ -d "$path" ] && printf '%s\n' "$path" && return 0 + done + done + + # A native or Windows install keeps the profile in the user's home. + for path in "$HOME/Saved Games/Cosmoteer"/*/Mods "$HOME/Documents/My Games/Cosmoteer"/*/Mods; do [ -d "$path" ] && printf '%s\n' "$path" && return 0 done + return 1 } -MODS_DIR="${COSMOTEER_MODS_DIR:-$(find_mods_dir || true)}" +resolve_mods_dir() { + # An explicit COSMOTEER_MODS_DIR wins, so an unusual install can be pointed + # at by hand when discovery comes up empty. + if [ -n "${COSMOTEER_MODS_DIR:-}" ]; then + printf '%s\n' "$COSMOTEER_MODS_DIR" + return 0 + fi + find_mods_dir +} -if [ -z "$MODS_DIR" ]; then - echo "Could not find the Cosmoteer Mods folder." >&2 - echo "Set COSMOTEER_MODS_DIR to it and run again." >&2 - exit 1 +main() { + set -euo pipefail + local mods_dir target + mods_dir="$(resolve_mods_dir || true)" + + if [ -z "$mods_dir" ]; then + echo "Could not find the Cosmoteer Mods folder." >&2 + echo "Searched these Steam libraries:" >&2 + steam_libraries | sort -u | sed 's/^/ /' >&2 + echo "Set COSMOTEER_MODS_DIR to it and run again." >&2 + return 1 + fi + + target="$mods_dir/$MOD_ID" + + if [ "${1:-}" = "--uninstall" ]; then + rm -rf "$target" + echo "Removed $target" + return 0 + fi + + echo "Validating against installed game data..." + python3 "$SOURCE_DIR/tests/test_mod.py" + + echo + echo "Installing to $target" + rm -rf "$target" + mkdir -p "$target" + cp "$SOURCE_DIR/mod.rules" "$target/" + cp -r "$SOURCE_DIR/components" "$target/" + + echo "Done. Enable \"Auto Radiator\" in the game's Mods menu." +} + +if [ "${BASH_SOURCE[0]}" = "$0" ]; then + main "$@" fi - -TARGET="$MODS_DIR/$MOD_ID" - -if [ "${1:-}" = "--uninstall" ]; then - rm -rf "$TARGET" - echo "Removed $TARGET" - exit 0 -fi - -echo "Validating against installed game data..." -python3 "$SOURCE_DIR/tests/test_mod.py" - -echo -echo "Installing to $TARGET" -rm -rf "$TARGET" -mkdir -p "$TARGET" -cp "$SOURCE_DIR/mod.rules" "$TARGET/" -cp -r "$SOURCE_DIR/components" "$TARGET/" - -echo "Done. Enable \"Auto Radiator\" in the game's Mods menu." diff --git a/tests/steam_paths.py b/tests/steam_paths.py new file mode 100644 index 0000000..477e212 --- /dev/null +++ b/tests/steam_paths.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +# ABOUT ME +# Locates Cosmoteer's installed Data folder by asking Steam where its libraries +# are. +# +# Why this exists: the mod tests validate against vanilla game data, and the +# game can be installed to any Steam library on any disk. A fixed list of +# guessed paths misses real installs, so this reads the library roots out of +# Steam's own libraryfolders.vdf and looks for the game under each one. +# +# install.sh carries the same logic in bash for finding the Mods folder. + +import os +import re + +COSMOTEER_APPID = 799600 + +# One "path" entry per library root; the app data sits in /steamapps. +_LIBRARY_PATH_RE = re.compile(r'^\s*"path"\s*"(.*)"\s*$') + + +def steam_roots(home): + """The Steam client directories, one per install flavour.""" + return [ + os.path.join(home, ".local/share/Steam"), + os.path.join(home, ".steam/steam"), + os.path.join(home, ".steam/root"), + os.path.join(home, ".var/app/com.valvesoftware.Steam/data/Steam"), + ] + + +def steam_libraries(home): + """Every Steam library root, in search order and without duplicates. + + Steam's own roots come first so the default library stays searchable even + when libraryfolders.vdf is missing or unreadable. + """ + libraries = list(steam_roots(home)) + for root in steam_roots(home): + vdf = os.path.join(root, "steamapps", "libraryfolders.vdf") + try: + with open(vdf, encoding="utf-8", errors="replace") as handle: + lines = handle.readlines() + except OSError: + continue + for line in lines: + match = _LIBRARY_PATH_RE.match(line) + if match: + libraries.append(match.group(1).replace("\\\\", "\\")) + + seen = set() + unique = [] + for library in libraries: + if library not in seen: + seen.add(library) + unique.append(library) + return unique + + +def find_data_dir(home=None, environ=None): + """The Cosmoteer Data folder, or None when the game is not installed. + + COSMOTEER_DATA overrides discovery so an unusual install can be pointed at + by hand. + """ + environ = os.environ if environ is None else environ + override = environ.get("COSMOTEER_DATA") + if override: + return override + + home = os.path.expanduser("~") if home is None else home + for library in steam_libraries(home): + data = os.path.join(library, "steamapps", "common", "Cosmoteer", "Data") + if os.path.isdir(data): + return data + return None + + +def find_bin_dir(home=None, environ=None): + """The Cosmoteer Bin folder, which sits beside Data.""" + data = find_data_dir(home=home, environ=environ) + if not data: + return None + return os.path.join(os.path.dirname(data), "Bin") diff --git a/tests/test_install.sh b/tests/test_install.sh new file mode 100755 index 0000000..546a19e --- /dev/null +++ b/tests/test_install.sh @@ -0,0 +1,122 @@ +#!/usr/bin/env bash +# ABOUT ME +# Tests install.sh's discovery of the Cosmoteer Mods folder. +# +# Why it exists: the Mods folder sits inside a Proton prefix under whichever +# Steam library the game was installed to, and that library can live anywhere. +# Guessing paths misses real installs, so discovery reads Steam's own +# libraryfolders.vdf. These tests build fake Steam trees and check that the +# discovery finds the Mods folder in each layout. +# +# Usage: ./tests/test_install.sh + +set -uo pipefail + +REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +APPID=799600 +PROFILE=76561198000495479 + +# Sourcing install.sh must define the functions without installing anything. +source "$REPO_DIR/install.sh" + +failures=0 +tmp_root="$(mktemp -d)" +trap 'rm -rf "$tmp_root"' EXIT + +check() { + # check + if [ "$2" = "$3" ]; then + echo "PASS: $1" + else + echo "FAIL: $1" + echo " expected: $2" + echo " actual: $3" + failures=$((failures + 1)) + fi +} + +# Builds a Steam client dir at /.local/share/Steam whose +# libraryfolders.vdf lists every library path passed after the root. +make_steam_home() { + local home="$1" + shift + local vdf="$home/.local/share/Steam/steamapps/libraryfolders.vdf" + mkdir -p "$(dirname "$vdf")" + { + echo '"libraryfolders"' + echo '{' + local i=0 + for lib in "$@"; do + printf '\t"%s"\n\t{\n\t\t"path"\t\t"%s"\n\t}\n' "$i" "$lib" + i=$((i + 1)) + done + echo '}' + } >"$vdf" +} + +# Creates the Proton-prefix Mods folder for Cosmoteer inside a library root. +make_proton_mods() { + local lib="$1" + local mods="$lib/steamapps/compatdata/$APPID/pfx/drive_c/users/steamuser/Saved Games/Cosmoteer/$PROFILE/Mods" + mkdir -p "$mods" + printf '%s\n' "$mods" +} + +# 1. The game lives in a secondary library on another disk, which is the case +# whenever Steam is told to install to a second drive. +case1="$tmp_root/case1" +mkdir -p "$case1/home" +lib="$case1/disk2/steamapps" +make_steam_home "$case1/home" "$case1/home/.local/share/Steam" "$lib" +expected="$(make_proton_mods "$lib")" +actual="$(HOME="$case1/home" find_mods_dir)" +check "finds Mods in a secondary library from libraryfolders.vdf" "$expected" "$actual" + +# 2. The game lives in the default library inside the Steam client dir. +case2="$tmp_root/case2" +mkdir -p "$case2/home" +make_steam_home "$case2/home" "$case2/home/.local/share/Steam" +expected="$(make_proton_mods "$case2/home/.local/share/Steam")" +actual="$(HOME="$case2/home" find_mods_dir)" +check "finds Mods in the default Steam library" "$expected" "$actual" + +# 3. Library paths may contain spaces, so discovery must not word-split them. +case3="$tmp_root/case3" +mkdir -p "$case3/home" +lib="$case3/my games/steamapps" +make_steam_home "$case3/home" "$case3/home/.local/share/Steam" "$lib" +expected="$(make_proton_mods "$lib")" +actual="$(HOME="$case3/home" find_mods_dir)" +check "finds Mods under a library path containing spaces" "$expected" "$actual" + +# 4. A Windows-style profile in the user's home, for a non-Proton install. +case4="$tmp_root/case4" +mkdir -p "$case4/home/Saved Games/Cosmoteer/$PROFILE/Mods" +expected="$case4/home/Saved Games/Cosmoteer/$PROFILE/Mods" +actual="$(HOME="$case4/home" find_mods_dir)" +check "finds Mods in the home Saved Games folder" "$expected" "$actual" + +# 5. Nothing installed at all: discovery reports failure instead of guessing. +case5="$tmp_root/case5" +mkdir -p "$case5/home" +actual="$(HOME="$case5/home" find_mods_dir)" +status=$? +check "reports no Mods folder when there is no install" "" "$actual" +check "exits non-zero when there is no install" "1" "$status" + +# 6. COSMOTEER_MODS_DIR overrides discovery, so an unusual install can be +# pointed at by hand. +case6="$tmp_root/case6" +mkdir -p "$case6/elsewhere/Mods" +make_steam_home "$case6/home" "$case6/home/.local/share/Steam" +make_proton_mods "$case6/home/.local/share/Steam" >/dev/null +actual="$(HOME="$case6/home" COSMOTEER_MODS_DIR="$case6/elsewhere/Mods" resolve_mods_dir)" +check "COSMOTEER_MODS_DIR overrides discovery" "$case6/elsewhere/Mods" "$actual" + +echo +if [ "$failures" -eq 0 ]; then + echo "All install.sh discovery tests passed." +else + echo "$failures test(s) failed." + exit 1 +fi diff --git a/tests/test_mod.py b/tests/test_mod.py index bcaccc5..3810824 100644 --- a/tests/test_mod.py +++ b/tests/test_mod.py @@ -17,15 +17,10 @@ import sys sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import rules_parser +import steam_paths MOD_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -CANDIDATE_DATA_DIRS = [ - "/mnt/SSD4TB/SteamLibrary/steamapps/common/Cosmoteer/Data", - os.path.expanduser("~/.local/share/Steam/steamapps/common/Cosmoteer/Data"), - os.path.expanduser("~/.steam/steam/steamapps/common/Cosmoteer/Data"), -] - # Component fields whose value names another component in the same part. COMPONENT_REF_FIELDS = { "OperationalToggle", @@ -55,17 +50,7 @@ def check(condition, message): _failures.append(message) -def find_data_dir(): - override = os.environ.get("COSMOTEER_DATA") - if override: - return override - for path in CANDIDATE_DATA_DIRS: - if os.path.isdir(path): - return path - return None - - -DATA = find_data_dir() +DATA = steam_paths.find_data_dir() _vanilla_cache = {} diff --git a/tests/test_steam_paths.py b/tests/test_steam_paths.py new file mode 100644 index 0000000..0c395ac --- /dev/null +++ b/tests/test_steam_paths.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +# ABOUT ME +# Tests steam_paths.find_data_dir, which locates Cosmoteer's Data folder. +# +# Why this exists: the game can be installed to any Steam library on any disk, +# so the Data folder cannot be guessed from a fixed list of paths. Discovery +# reads Steam's own libraryfolders.vdf instead. These tests build fake Steam +# trees and check that each realistic layout is found. +# +# Usage: python3 tests/test_steam_paths.py + +import os +import shutil +import sys +import tempfile + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +import steam_paths + +_failures = [] + + +def check(name, expected, actual): + if expected == actual: + print(f"PASS: {name}") + else: + print(f"FAIL: {name}") + print(f" expected: {expected}") + print(f" actual: {actual}") + _failures.append(name) + + +def make_steam_home(home, libraries): + """Write a libraryfolders.vdf under `home` listing the given library roots.""" + vdf = os.path.join(home, ".local/share/Steam/steamapps/libraryfolders.vdf") + os.makedirs(os.path.dirname(vdf), exist_ok=True) + with open(vdf, "w", encoding="utf-8") as handle: + handle.write('"libraryfolders"\n{\n') + for index, library in enumerate(libraries): + handle.write(f'\t"{index}"\n\t{{\n\t\t"path"\t\t"{library}"\n\t}}\n') + handle.write("}\n") + + +def make_data_dir(library): + """Create the Cosmoteer Data folder inside a library root.""" + data = os.path.join(library, "steamapps/common/Cosmoteer/Data") + os.makedirs(data, exist_ok=True) + return data + + +def main(): + tmp_root = tempfile.mkdtemp() + try: + # 1. The game lives in a secondary library on another disk. + case = os.path.join(tmp_root, "case1") + home = os.path.join(case, "home") + library = os.path.join(case, "disk2/steamapps") + make_steam_home(home, [os.path.join(home, ".local/share/Steam"), library]) + expected = make_data_dir(library) + check( + "finds Data in a secondary library from libraryfolders.vdf", + expected, + steam_paths.find_data_dir(home=home, environ={}), + ) + + # 2. The game lives in the default library inside the Steam client dir. + case = os.path.join(tmp_root, "case2") + home = os.path.join(case, "home") + steam = os.path.join(home, ".local/share/Steam") + make_steam_home(home, [steam]) + expected = make_data_dir(steam) + check( + "finds Data in the default Steam library", + expected, + steam_paths.find_data_dir(home=home, environ={}), + ) + + # 3. Library paths may contain spaces. + case = os.path.join(tmp_root, "case3") + home = os.path.join(case, "home") + library = os.path.join(case, "my games/steamapps") + make_steam_home(home, [os.path.join(home, ".local/share/Steam"), library]) + expected = make_data_dir(library) + check( + "finds Data under a library path containing spaces", + expected, + steam_paths.find_data_dir(home=home, environ={}), + ) + + # 4. Nothing installed: discovery reports None instead of guessing. + case = os.path.join(tmp_root, "case4") + home = os.path.join(case, "home") + os.makedirs(home, exist_ok=True) + check( + "returns None when there is no install", + None, + steam_paths.find_data_dir(home=home, environ={}), + ) + + # 5. COSMOTEER_DATA overrides discovery. + case = os.path.join(tmp_root, "case5") + home = os.path.join(case, "home") + steam = os.path.join(home, ".local/share/Steam") + make_steam_home(home, [steam]) + make_data_dir(steam) + override = os.path.join(case, "elsewhere/Data") + os.makedirs(override, exist_ok=True) + check( + "COSMOTEER_DATA overrides discovery", + override, + steam_paths.find_data_dir(home=home, environ={"COSMOTEER_DATA": override}), + ) + finally: + shutil.rmtree(tmp_root, ignore_errors=True) + + print() + if _failures: + print(f"{len(_failures)} test(s) failed.") + return 1 + print("All Steam path discovery tests passed.") + return 0 + + +if __name__ == "__main__": + sys.exit(main())