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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TkQMhvEyoFqKzeCC69N6o
This commit is contained in:
2026-08-29 17:10:22 +02:00
co-authored by Claude Fable 5
parent 68af02fb3f
commit 1001abf57a
7 changed files with 344 additions and 45 deletions
+50 -5
View File
@@ -96,26 +96,70 @@ No stat or number is changed. Only *when* radiators run.
``` ```
mod.rules manifest + patch actions mod.rules manifest + patch actions
logo.png Workshop preview / in-game mod logo
components/radiator_auto.rules measures network headroom, gates the radiator components/radiator_auto.rules measures network headroom, gates the radiator
tests/ offline validation suite 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/dotnet_enum.py reads component schemas out of Cosmoteer.dll
tools/ildump.py disassembles component methods to readable CIL 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 ## 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/<item id>/` 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\<SteamID>\Mods\` |
| Linux (Proton) | `<Steam library>/steamapps/compatdata/799600/pfx/drive_c/users/steamuser/Saved Games/Cosmoteer/<SteamID>/Mods/` |
### From this repo (Linux, for development)
```sh ```sh
./install.sh ./install.sh
``` ```
Then enable **Auto Radiator** in the game's Mods menu. `./install.sh --uninstall` Validates against the installed game first, then copies the same file set the
removes it. If the script cannot find your Mods folder, set `COSMOTEER_MODS_DIR`. 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 ## Tests
```sh ```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 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 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. 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. for the schema checks.
## How to tell it is working ## How to tell it is working
+11 -24
View File
@@ -2,10 +2,12 @@
# ABOUT ME # ABOUT ME
# Installs the Auto Radiator mod into Cosmoteer's user Mods folder. # Installs the Auto Radiator mod into Cosmoteer's user Mods folder.
# #
# Why it exists: Cosmoteer loads mods from the user profile directory, which on # Why it exists: this is the author's dev loop. Cosmoteer loads user mods from
# a Proton install lives deep inside the Steam compatdata prefix and is awkward # the profile directory, which on a Proton install lives deep inside the Steam
# to find by hand. This script locates it, validates the mod against the # compatdata prefix. This script locates it, validates the mod against the
# installed game data first, and copies only the files the game needs. # 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) # Usage: ./install.sh install (validates first)
# ./install.sh --uninstall remove the installed copy # ./install.sh --uninstall remove the installed copy
@@ -15,25 +17,10 @@ set -euo pipefail
MOD_ID="auto_radiator" MOD_ID="auto_radiator"
SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
find_mods_dir() { # Discovery reads Steam's own library list, so a second disk, Flatpak, Snap
# A Cosmoteer profile folder is named after the Steam ID, so glob for it. # or the Debian package all work. See tools/steam_paths.py.
local candidates=( if ! MODS_DIR="$(python3 "$SOURCE_DIR/tools/steam_paths.py" --mods)"; then
"$HOME"/.local/share/Steam/steamapps/compatdata/799600/pfx/drive_c/users/steamuser/"Saved Games"/Cosmoteer/*/Mods echo "Set COSMOTEER_MODS_DIR to the Mods folder and run again." >&2
/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
exit 1 exit 1
fi fi
@@ -52,7 +39,7 @@ echo
echo "Installing to $TARGET" echo "Installing to $TARGET"
rm -rf "$TARGET" rm -rf "$TARGET"
mkdir -p "$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/" cp -r "$SOURCE_DIR/components" "$TARGET/"
echo "Done. Enable \"Auto Radiator\" in the game's Mods menu." echo "Done. Enable \"Auto Radiator\" in the game's Mods menu."
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

+2 -1
View File
@@ -22,10 +22,11 @@
ID = ganter.auto_radiator ID = ganter.auto_radiator
Name = "Auto Radiator" Name = "Auto Radiator"
Version = 4.0.0 Version = 4.1.0
CompatibleGameVersions = ["0.30.4c", "0.30.4"] CompatibleGameVersions = ["0.30.4c", "0.30.4"]
ModifiesGameplay = true ModifiesGameplay = true
Author = "Tobias Ganter" 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"\ 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 "\ "Thermal Canister Missiles are built from your ship's own heat, and in vanilla "\
+25 -15
View File
@@ -8,23 +8,20 @@
# against the player's actual installed game data, before the game is launched. # against the player's actual installed game data, before the game is launched.
# #
# Run: python3 tests/test_mod.py # 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 os
import re import re
import sys 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.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.join(MOD_ROOT, "tools"))
import rules_parser 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 fields whose value names another component in the same part.
COMPONENT_REF_FIELDS = { COMPONENT_REF_FIELDS = {
@@ -56,13 +53,8 @@ def check(condition, message):
def find_data_dir(): def find_data_dir():
override = os.environ.get("COSMOTEER_DATA") """Locate the installed game's Data folder via tools/steam_paths.py."""
if override: return steam_paths.find_data_dir()
return override
for path in CANDIDATE_DATA_DIRS:
if os.path.isdir(path):
return path
return None
DATA = 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(): def main():
if DATA is None or not os.path.isdir(DATA): if DATA is None or not os.path.isdir(DATA):
print("SKIP: Cosmoteer Data folder not found. Set COSMOTEER_DATA to run these tests.") print("SKIP: Cosmoteer Data folder not found. Set COSMOTEER_DATA to run these tests.")
+137
View File
@@ -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())
+119
View File
@@ -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))