From 1001abf57aa624bc2e9f0354b5d006288caaa686 Mon Sep 17 00:00:00 2001 From: Tobias Ganter Date: Sat, 29 Aug 2026 17:10:22 +0200 Subject: [PATCH] feat: v4.1.0 -- workshop-ready (logo, path discovery, docs) Players install from Steam Workshop instead of install.sh, which failed on Windows (bash) and a Manjaro box (hard-coded Mods globs). Adds Logo + logo.png the uploader ships, tools/steam_paths.py reading Steam's libraryfolders.vdf (Flatpak/Snap/second disk/Windows), and README install tiers plus in-game publishing steps. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014TkQMhvEyoFqKzeCC69N6o --- README.md | 55 +++++++++++++-- install.sh | 35 +++------- logo.png | Bin 0 -> 9055 bytes mod.rules | 3 +- tests/test_mod.py | 40 ++++++----- tests/test_steam_paths.py | 137 ++++++++++++++++++++++++++++++++++++++ tools/steam_paths.py | 119 +++++++++++++++++++++++++++++++++ 7 files changed, 344 insertions(+), 45 deletions(-) create mode 100644 logo.png create mode 100644 tests/test_steam_paths.py create mode 100755 tools/steam_paths.py diff --git a/README.md b/README.md index f097e17..59fbcb9 100644 --- a/README.md +++ b/README.md @@ -96,26 +96,70 @@ No stat or number is changed. Only *when* radiators run. ``` mod.rules manifest + patch actions +logo.png Workshop preview / in-game mod logo components/radiator_auto.rules measures network headroom, gates the radiator tests/ offline validation suite +tools/steam_paths.py finds the game and the Mods folder via Steam's library list 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 +install.sh author's dev loop: validate + copy into the Mods folder ``` ## Install +### Steam Workshop (any OS) + +Subscribe to **Auto Radiator** on the Steam Workshop and enable it in the +game's Mods menu. Steam places it under +`steamapps/workshop/content/799600//` and the game loads it from +there; no files to copy. + +*Workshop link: pending first upload.* + +### Manual (any OS) + +Copy `mod.rules`, `logo.png` and the `components/` folder into a folder named +`auto_radiator` inside your user Mods folder, then enable it in the Mods menu: + +| | Mods folder | +|---|---| +| Windows | `%USERPROFILE%\Saved Games\Cosmoteer\\Mods\` | +| Linux (Proton) | `/steamapps/compatdata/799600/pfx/drive_c/users/steamuser/Saved Games/Cosmoteer//Mods/` | + +### From this repo (Linux, for development) + ```sh ./install.sh ``` -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`. +Validates against the installed game first, then copies the same file set the +Workshop uploader ships. `./install.sh --uninstall` removes it. Discovery reads +Steam's `libraryfolders.vdf`, so a second disk, Flatpak, Snap or the Debian +package all work; if it still cannot find your Mods folder, set +`COSMOTEER_MODS_DIR` (and `COSMOTEER_DATA` for the game data). + +## Publishing to the Workshop + +The uploader is built into the game; no `steamcmd` needed. + +1. `./install.sh` so the current files sit in the user Mods folder. +2. In game: **Mods** → find *Auto Radiator* under *User Mods* → right-click → + **Upload to Steam Workshop**. +3. Fill name, description, preview image (defaults to `logo.png`), visibility + and game version range → **Start Upload**. +4. Updates use the same menu item; it asks for change notes. Bump `Version` + in `mod.rules` first — multiplayer and save syncing match on it. + +The **entire** mod folder is uploaded, which is why `install.sh` copies only +`mod.rules`, `logo.png` and `components/`. The Workshop item is bound to the +mod `ID`; keep `ganter.auto_radiator` stable. Steam does not refresh the +preview image on later uploads, and rejects previews over 500 KB. ## Tests ```sh -python3 tests/test_mod.py +python3 tests/test_mod.py # mod against installed game data +python3 tests/test_steam_paths.py # Steam library discovery, on fake layouts ``` Cosmoteer applies mod actions to vanilla data at load time. Some mistakes crash @@ -127,7 +171,8 @@ silently do nothing; see **Status** below for the four this project hit and the guard for each. The suite also self-checks its own parser against known vanilla facts first, so a broken parser fails loudly instead of passing everything. -Set `COSMOTEER_DATA` to point at a different install, and `COSMOTEER_ASSEMBLY` +The game is found through Steam's library list (`tools/steam_paths.py`). Set +`COSMOTEER_DATA` to point at a different install, and `COSMOTEER_ASSEMBLY` for the schema checks. ## How to tell it is working diff --git a/install.sh b/install.sh index 653ed39..24d005f 100755 --- a/install.sh +++ b/install.sh @@ -2,10 +2,12 @@ # ABOUT ME # 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. +# Why it exists: this is the author's dev loop. Cosmoteer loads user mods from +# the profile directory, which on a Proton install lives deep inside the Steam +# compatdata prefix. This script locates it, validates the mod against the +# installed game data first, and copies only the files the game needs -- the +# exact set that "Upload to Steam Workshop" later ships. Players install from +# the Workshop instead; see README.md. # # Usage: ./install.sh install (validates first) # ./install.sh --uninstall remove the installed copy @@ -15,25 +17,10 @@ set -euo pipefail MOD_ID="auto_radiator" SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -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 - [ -d "$path" ] && printf '%s\n' "$path" && return 0 - done - return 1 -} - -MODS_DIR="${COSMOTEER_MODS_DIR:-$(find_mods_dir || true)}" - -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 +# Discovery reads Steam's own library list, so a second disk, Flatpak, Snap +# or the Debian package all work. See tools/steam_paths.py. +if ! MODS_DIR="$(python3 "$SOURCE_DIR/tools/steam_paths.py" --mods)"; then + echo "Set COSMOTEER_MODS_DIR to the Mods folder and run again." >&2 exit 1 fi @@ -52,7 +39,7 @@ echo echo "Installing to $TARGET" rm -rf "$TARGET" mkdir -p "$TARGET" -cp "$SOURCE_DIR/mod.rules" "$TARGET/" +cp "$SOURCE_DIR/mod.rules" "$SOURCE_DIR/logo.png" "$TARGET/" cp -r "$SOURCE_DIR/components" "$TARGET/" echo "Done. Enable \"Auto Radiator\" in the game's Mods menu." diff --git a/logo.png b/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..9fac65f5787c330c5a89023a53772adc79bf4848 GIT binary patch literal 9055 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 JCD-kr{tJ!x2Uq|A literal 0 HcmV?d00001 diff --git a/mod.rules b/mod.rules index 12d92b6..420eddf 100644 --- a/mod.rules +++ b/mod.rules @@ -22,10 +22,11 @@ ID = ganter.auto_radiator Name = "Auto Radiator" -Version = 4.0.0 +Version = 4.1.0 CompatibleGameVersions = ["0.30.4c", "0.30.4"] ModifiesGameplay = true Author = "Tobias Ganter" +Logo = "logo.png" Description = "Radiators switched OFF bank heat and vent only when the ship's heat storage is nearly full.\n\n"\ "Thermal Canister Missiles are built from your ship's own heat, and in vanilla "\ diff --git a/tests/test_mod.py b/tests/test_mod.py index bcaccc5..e629685 100644 --- a/tests/test_mod.py +++ b/tests/test_mod.py @@ -8,23 +8,20 @@ # against the player's actual installed game data, before the game is launched. # # Run: python3 tests/test_mod.py -# The game install is found automatically, or set COSMOTEER_DATA to override. +# The game install is found through Steam's own library list (tools/steam_paths.py), +# or set COSMOTEER_DATA to override. import os import re import sys +MOD_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, os.path.join(MOD_ROOT, "tools")) 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 = { @@ -56,13 +53,8 @@ def check(condition, 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 + """Locate the installed game's Data folder via tools/steam_paths.py.""" + return steam_paths.find_data_dir() DATA = find_data_dir() @@ -745,6 +737,24 @@ def test_manifest_is_well_formed(): ) +def test_manifest_declares_existing_logo(): + """Steam Workshop shows the mod's Logo; the file must ship inside the mod + folder, be a real PNG, and stay under Steam's 500 KB preview limit.""" + mod = rules_parser.parse_file(os.path.join(MOD_ROOT, "mod.rules")) + logo = mod.get("Logo") + check(logo is not None, "mod.rules is missing the 'Logo' field") + if logo is None: + return + path = os.path.join(MOD_ROOT, logo.value.strip('"')) + check(os.path.isfile(path), f"Logo file does not exist: {path}") + if not os.path.isfile(path): + return + with open(path, "rb") as f: + magic = f.read(8) + check(magic == b"\x89PNG\r\n\x1a\n", f"Logo is not a PNG: {path}") + check(os.path.getsize(path) < 500_000, "Logo must be under 500 KB for Workshop upload") + + def main(): if DATA is None or not os.path.isdir(DATA): print("SKIP: Cosmoteer Data folder not found. Set COSMOTEER_DATA to run these tests.") diff --git a/tests/test_steam_paths.py b/tests/test_steam_paths.py new file mode 100644 index 0000000..992fc8e --- /dev/null +++ b/tests/test_steam_paths.py @@ -0,0 +1,137 @@ +# ABOUT ME +# Tests for tools/steam_paths.py, the Steam library discovery shared by +# install.sh and tests/test_mod.py. +# +# Why this exists: the discovery must work on machines the author does not +# own -- Flatpak Steam, a second library on another disk, Windows profiles. +# Each test builds a fake filesystem under a temporary HOME, so no real Steam +# install is needed and every layout is exercised on every run. +# +# Run: python3 tests/test_steam_paths.py + +import os +import sys +import tempfile + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "tools")) + +import steam_paths + +_failures = [] +_checks = 0 + + +def check(condition, message): + global _checks + _checks += 1 + if not condition: + _failures.append(message) + + +PROTON_MODS = "steamapps/compatdata/799600/pfx/drive_c/users/steamuser/Saved Games/Cosmoteer/76561190000000000/Mods" + + +def make_library(root, with_game=True, with_mods=True): + os.makedirs(os.path.join(root, "steamapps"), exist_ok=True) + if with_game: + os.makedirs(os.path.join(root, "steamapps/common/Cosmoteer/Data")) + if with_mods: + os.makedirs(os.path.join(root, PROTON_MODS)) + + +def write_vdf(steam_root, library_paths): + entries = "".join( + f'\t"{i}"\n\t{{\n\t\t"path"\t\t"{p}"\n\t\t"label"\t\t""\n\t}}\n' + for i, p in enumerate(library_paths) + ) + os.makedirs(os.path.join(steam_root, "steamapps"), exist_ok=True) + with open(os.path.join(steam_root, "steamapps/libraryfolders.vdf"), "w") as f: + f.write('"libraryfolders"\n{\n' + entries + "}\n") + + +def test_second_library_from_vdf(): + with tempfile.TemporaryDirectory() as home: + steam = os.path.join(home, ".local/share/Steam") + second = os.path.join(home, "disk2/SteamLibrary") + make_library(steam, with_game=False, with_mods=False) + make_library(second) + write_vdf(steam, [steam, second]) + env = {"HOME": home} + check( + steam_paths.find_data_dir(env) == os.path.join(second, "steamapps/common/Cosmoteer/Data"), + "Data dir in a second library listed in libraryfolders.vdf was not found", + ) + check( + steam_paths.find_mods_dir(env) == os.path.join(second, PROTON_MODS), + "Proton Mods dir in a second library was not found", + ) + + +def test_flatpak_steam_root(): + with tempfile.TemporaryDirectory() as home: + steam = os.path.join(home, ".var/app/com.valvesoftware.Steam/.local/share/Steam") + make_library(steam) + env = {"HOME": home} + check( + steam_paths.find_data_dir(env) == os.path.join(steam, "steamapps/common/Cosmoteer/Data"), + "Flatpak Steam root was not searched for the Data dir", + ) + check( + steam_paths.find_mods_dir(env) == os.path.join(steam, PROTON_MODS), + "Flatpak Steam root was not searched for the Mods dir", + ) + + +def test_steam_root_without_vdf_still_works(): + with tempfile.TemporaryDirectory() as home: + steam = os.path.join(home, ".steam/debian-installation") + make_library(steam) + env = {"HOME": home} + check( + steam_paths.find_data_dir(env) == os.path.join(steam, "steamapps/common/Cosmoteer/Data"), + "A Steam root with no libraryfolders.vdf should still be searched itself", + ) + + +def test_windows_profile_mods_dir(): + with tempfile.TemporaryDirectory() as profile: + mods = os.path.join(profile, "Saved Games/Cosmoteer/76561190000000000/Mods") + os.makedirs(mods) + env = {"USERPROFILE": profile} + check(steam_paths.find_mods_dir(env) == mods, "Windows Saved Games Mods dir was not found") + + +def test_env_overrides_win(): + with tempfile.TemporaryDirectory() as home: + env = {"HOME": home, "COSMOTEER_DATA": "/override/Data", "COSMOTEER_MODS_DIR": "/override/Mods"} + check(steam_paths.find_data_dir(env) == "/override/Data", "COSMOTEER_DATA override ignored") + check(steam_paths.find_mods_dir(env) == "/override/Mods", "COSMOTEER_MODS_DIR override ignored") + + +def test_nothing_found_returns_none_and_lists_roots(): + with tempfile.TemporaryDirectory() as home: + env = {"HOME": home} + check(steam_paths.find_data_dir(env) is None, "Empty HOME must yield None for Data") + check(steam_paths.find_mods_dir(env) is None, "Empty HOME must yield None for Mods") + roots = steam_paths.steam_roots(env) + check(any(r.startswith(home) for r in roots), "steam_roots must report the candidates it would try") + + +def main(): + for name, test in sorted(globals().items()): + if name.startswith("test_") and callable(test): + before = len(_failures) + test() + print(f" [{'PASS' if len(_failures) == before else 'FAIL'}] {name}") + print(f"\n{_checks} checks run.") + if _failures: + print(f"{len(_failures)} FAILED:\n") + for failure in _failures: + print(f" - {failure}") + return 1 + print("All checks passed.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/steam_paths.py b/tools/steam_paths.py new file mode 100755 index 0000000..965e6d6 --- /dev/null +++ b/tools/steam_paths.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +# ABOUT ME +# Finds the installed Cosmoteer game data and the user Mods folder, wherever +# Steam put them. Shared by install.sh and tests/test_mod.py. +# +# Why it exists: Steam lives in different places (native, Flatpak, Snap, +# Debian package, Windows) and a game can sit in any library listed in +# steamapps/libraryfolders.vdf, often on another disk. Hard-coded globs miss +# those; this reads Steam's own library list instead. +# +# Environment overrides always win: COSMOTEER_DATA for the Data folder, +# COSMOTEER_MODS_DIR for the Mods folder. +# +# Usage: python3 tools/steam_paths.py --data print the Data folder +# python3 tools/steam_paths.py --mods print the Mods folder +# Exit 1 with the roots tried on stderr when nothing is found. + +import glob +import os +import re +import sys + +APP_ID = "799600" +PROTON_PROFILE = "compatdata/" + APP_ID + "/pfx/drive_c/users/steamuser/Saved Games/Cosmoteer" + +# Steam roots relative to HOME, in the order they are tried. +STEAM_ROOTS_UNDER_HOME = [ + ".local/share/Steam", + ".steam/steam", + ".steam/debian-installation", + ".var/app/com.valvesoftware.Steam/.local/share/Steam", + ".snap/steam/common/.local/share/Steam", + "snap/steam/common/.local/share/Steam", +] + +_VDF_PATH = re.compile(r'"path"\s+"([^"]+)"') + + +def steam_roots(env=None): + """Candidate Steam install roots for this environment (existing or not).""" + env = os.environ if env is None else env + home = env.get("HOME") + roots = [os.path.join(home, rel) for rel in STEAM_ROOTS_UNDER_HOME] if home else [] + program_files = env.get("PROGRAMFILES(X86)") or env.get("ProgramFiles(x86)") + if program_files: + roots.append(os.path.join(program_files, "Steam")) + return roots + + +def libraries(env=None): + """Every Steam library folder: each root plus what its libraryfolders.vdf lists.""" + seen = [] + for root in steam_roots(env): + for candidate in [root] + _vdf_libraries(root): + candidate = os.path.realpath(candidate) + if candidate not in seen and os.path.isdir(os.path.join(candidate, "steamapps")): + seen.append(candidate) + return seen + + +def _vdf_libraries(root): + vdf = os.path.join(root, "steamapps", "libraryfolders.vdf") + if not os.path.isfile(vdf): + return [] + with open(vdf, encoding="utf-8", errors="replace") as f: + return [p.replace("\\\\", "\\") for p in _VDF_PATH.findall(f.read())] + + +def find_data_dir(env=None): + env = os.environ if env is None else env + override = env.get("COSMOTEER_DATA") + if override: + return override + for library in libraries(env): + data = os.path.join(library, "steamapps", "common", "Cosmoteer", "Data") + if os.path.isdir(data): + return data + return None + + +def find_mods_dir(env=None): + env = os.environ if env is None else env + override = env.get("COSMOTEER_MODS_DIR") + if override: + return override + patterns = [ + os.path.join(library, "steamapps", PROTON_PROFILE, "*", "Mods") for library in libraries(env) + ] + profile = env.get("USERPROFILE") + if profile: + patterns.append(os.path.join(profile, "Saved Games", "Cosmoteer", "*", "Mods")) + for pattern in patterns: + for match in sorted(glob.glob(pattern)): + if os.path.isdir(match): + return match + return None + + +def main(argv): + if argv[1:] == ["--data"]: + found = find_data_dir() + what = "Cosmoteer Data folder" + elif argv[1:] == ["--mods"]: + found = find_mods_dir() + what = "Cosmoteer Mods folder" + else: + print(__doc__ or "usage: steam_paths.py --data | --mods", file=sys.stderr) + return 2 + if found: + print(found) + return 0 + print(f"Could not find the {what}. Steam roots tried:", file=sys.stderr) + for root in steam_roots(): + print(f" {root}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main(sys.argv))