feat(gameengine): auto-detect Steam game data via ${env:VAR} placeholders

Workflow JSON params can now reference environment variables (e.g.
${env:QUAKE3_PAK0}) which are expanded at JSON load time. A new Python
detector reads the Windows registry and parses libraryfolders.vdf to
locate Steam libraries, then resolves known-game files into env vars
that dev_commands.py exports before launching the engine. Lets users
with legitimate Steam-owned game data run packages like quake3 without
hardcoded paths.

Also fixes os.execv on Windows (Invalid argument with spaced paths) by
falling back to subprocess.call.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-30 14:58:29 +01:00
parent 2d3715c5ff
commit d9af1fb5bf
5 changed files with 249 additions and 8 deletions
+21 -1
View File
@@ -640,6 +640,22 @@ def run_demo(args: argparse.Namespace) -> None:
if not args.no_sync:
_sync_assets(build_dir, args.dry_run)
# Detect Steam-owned game data and export as env vars for ${env:VAR}
# substitution in workflow JSON. Silent no-op if Steam isn't installed.
try:
from steam_detector import detect_and_export
detected = detect_and_export()
if detected:
print("=== Detected game data ===")
for k, v in detected.items():
print(f" {k}={v}")
os.environ[k] = v
print()
except NotImplementedError as e:
print(f"[steam_detector] {e}")
except Exception as e:
print(f"[steam_detector] detection skipped: {e}")
exe_name = args.target or ("sdl3_app.exe" if IS_WINDOWS else "sdl3_app")
binary = str(Path(build_dir).resolve() / exe_name)
run_args = _strip_leading_double_dash(args.args)
@@ -647,8 +663,12 @@ def run_demo(args: argparse.Namespace) -> None:
if run_args:
cmd.extend(run_args)
_print_cmd(cmd)
import os
os.chdir(build_dir)
if IS_WINDOWS:
# os.execv is buggy on Windows with paths containing spaces;
# use subprocess and propagate the exit code instead.
import subprocess
sys.exit(subprocess.call(cmd))
os.execv(binary, cmd)
+188
View File
@@ -0,0 +1,188 @@
"""
Steam install detection for legitimate access to game data the user owns.
Two-phase discovery:
1. Find Steam's install root (Windows registry / standard *nix paths).
2. Parse libraryfolders.vdf to enumerate all Steam libraries and which
app IDs each contains.
Then resolve_game_files() walks a known-games registry and exports paths
for any owned games that are present.
This module is platform-aware but keeps Windows-specific code optional —
Linux/macOS Steam users have ~/.steam/steam and ~/Library/Application Support/Steam.
"""
from __future__ import annotations
import os
import re
import sys
from pathlib import Path
# ─── Phase 1: Find Steam ─────────────────────────────────────────────────────
def find_steam_root() -> Path | None:
"""Locate Steam's install directory across platforms."""
if sys.platform == "win32":
try:
import winreg
for hive, key in [
(winreg.HKEY_CURRENT_USER, r"Software\Valve\Steam"),
(winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\WOW6432Node\Valve\Steam"),
(winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\Valve\Steam"),
]:
try:
with winreg.OpenKey(hive, key) as k:
value_name = "SteamPath" if hive == winreg.HKEY_CURRENT_USER else "InstallPath"
path, _ = winreg.QueryValueEx(k, value_name)
p = Path(path)
if p.exists():
return p
except OSError:
continue
except ImportError:
pass
# Fallback: standard install location
for candidate in [r"C:\Program Files (x86)\Steam", r"C:\Program Files\Steam"]:
p = Path(candidate)
if p.exists():
return p
elif sys.platform == "darwin":
p = Path.home() / "Library/Application Support/Steam"
if p.exists():
return p
else: # linux
for candidate in [Path.home() / ".steam/steam", Path.home() / ".local/share/Steam"]:
if candidate.exists():
return candidate
return None
# ─── Phase 2: Parse libraryfolders.vdf ───────────────────────────────────────
def parse_library_folders(steam_root: Path) -> list[dict]:
"""
Parse libraryfolders.vdf into a list of {path, apps: {appid: size}} dicts.
The VDF format is a recursive KeyValues structure; we only need the
flat list of "path" + "apps" per library, so a regex pass is sufficient
rather than pulling in a full vdf parser.
"""
vdf_path = steam_root / "steamapps" / "libraryfolders.vdf"
if not vdf_path.exists():
return []
text = vdf_path.read_text(encoding="utf-8", errors="replace")
libraries = []
# Each library block: "0" { "path" "..." ... "apps" { "appid" "size" ... } }
# Split on top-level numeric keys. Robust enough for our purposes.
lib_pattern = re.compile(r'"\d+"\s*\{(.*?)\n\t\}', re.DOTALL)
for block in lib_pattern.finditer(text):
body = block.group(1)
path_match = re.search(r'"path"\s*"([^"]+)"', body)
if not path_match:
continue
# Steam writes Windows paths with doubled backslashes in vdf
lib_path = Path(path_match.group(1).replace("\\\\", "\\"))
apps = {}
apps_match = re.search(r'"apps"\s*\{(.*?)\n\t\t\}', body, re.DOTALL)
if apps_match:
for app in re.finditer(r'"(\d+)"\s*"(\d+)"', apps_match.group(1)):
apps[app.group(1)] = int(app.group(2))
libraries.append({"path": lib_path, "apps": apps})
return libraries
# ─── Known-games registry ────────────────────────────────────────────────────
#
# Maps Steam app ID → metadata for resolving the game's data files.
# - install_dir: folder under steamapps/common/
# - files: {env_var_name: relative_path_to_required_file}
#
# Adding a new game here is the integration point — no code changes needed.
KNOWN_GAMES: dict[str, dict] = {
"2200": { # Quake 3 Arena
"name": "Quake 3 Arena",
"install_dir": "Quake 3 Arena",
"files": {
"QUAKE3_PAK0": "baseq3/pak0.pk3",
},
},
# Future: Quake (id 2310), Doom 3 (208200), etc.
}
# ─── Phase 3: Resolve game files (THE PIECE FOR YOU TO WRITE) ────────────────
def resolve_game_files(libraries: list[dict]) -> dict[str, str]:
"""
Walk the libraries and KNOWN_GAMES registry; return a dict of
{env_var_name: absolute_path_string} for every required file we found.
TODO (your turn — see the prompt below).
Args:
libraries: list of {"path": Path, "apps": {appid: size}} dicts
from parse_library_folders()
Returns:
dict mapping env var name (e.g., "QUAKE3_PAK0") to absolute path string.
Skip games whose required files don't actually exist on disk.
"""
resolved: dict[str, str] = {}
for appid, meta in KNOWN_GAMES.items():
for lib in libraries:
install_root = lib["path"] / "steamapps" / "common" / meta["install_dir"]
if not install_root.exists():
continue
for env_var, rel_path in meta["files"].items():
if env_var in resolved:
continue # first-match-wins across libraries
full_path = install_root / rel_path
if full_path.is_file():
resolved[env_var] = full_path.as_posix()
break # found this game in this library; don't keep scanning
return resolved
# ─── Public entry point ──────────────────────────────────────────────────────
def detect_and_export() -> dict[str, str]:
"""
Top-level: find Steam, enumerate libraries, resolve known-game files.
Returns a dict of env vars ready to be merged into os.environ.
Empty dict if Steam isn't installed (silent — not an error).
"""
root = find_steam_root()
if not root:
return {}
libs = parse_library_folders(root)
if not libs:
return {}
return resolve_game_files(libs)
if __name__ == "__main__":
# Run as a script to see what got detected.
root = find_steam_root()
print(f"Steam root: {root}")
if not root:
sys.exit(0)
libs = parse_library_folders(root)
print(f"Libraries found: {len(libs)}")
for lib in libs:
print(f" {lib['path']} ({len(lib['apps'])} apps)")
try:
resolved = resolve_game_files(libs)
print(f"\nResolved env vars:")
for k, v in resolved.items():
print(f" {k}={v}")
except NotImplementedError as e:
print(f"\n[!] {e}")