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
+4 -4
View File
@@ -283,13 +283,13 @@ if(BUILD_SDL3_APP)
src/services/impl/workflow/geometry/workflow_geometry_create_cube_step.cpp
src/services/impl/workflow/geometry/workflow_geometry_create_plane_step.cpp
src/services/impl/workflow/geometry/workflow_geometry_cube_generate_step.cpp
src/services/impl/workflow/rendering/workflow_bsp_load_step.cpp
src/services/impl/workflow/rendering/workflow_bsp_lightmap_atlas_step.cpp
src/services/impl/workflow/rendering/workflow_bsp_parse_spawn_step.cpp
src/services/impl/workflow/rendering/workflow_bsp_build_collision_step.cpp
src/services/impl/workflow/rendering/workflow_bsp_build_geometry_step.cpp
src/services/impl/workflow/rendering/workflow_bsp_extract_textures_step.cpp
src/services/impl/workflow/rendering/workflow_bsp_lightmap_atlas_step.cpp
src/services/impl/workflow/rendering/workflow_bsp_load_step.cpp
src/services/impl/workflow/rendering/workflow_bsp_parse_spawn_step.cpp
src/services/impl/workflow/rendering/workflow_bsp_upload_geometry_step.cpp
src/services/impl/workflow/rendering/workflow_bsp_build_collision_step.cpp
src/services/impl/workflow/rendering/workflow_draw_map_step.cpp
src/services/impl/workflow/rendering/workflow_draw_textured_box_step.cpp
src/services/impl/workflow/rendering/workflow_draw_textured_step.cpp
@@ -44,7 +44,7 @@
"parameters": { "inputs": { "image_path": "tex_walls_path" }, "outputs": { "texture": "walls_texture" } } },
{ "id": "physics_world", "type": "physics.world.create", "typeVersion": 1, "position": [0, 200] },
{ "id": "load_bsp", "name": "Load Q3 BSP", "type": "bsp.load", "typeVersion": 1, "position": [200, 200],
"parameters": { "pk3_path": "C:/baseq3/pak0.pk3", "map_name": "q3dm7", "scale": 0.03125 } },
"parameters": { "pk3_path": "${env:QUAKE3_PAK0}", "map_name": "q3dm7", "scale": 0.03125 } },
{ "id": "bsp_lightmap", "name": "BSP Lightmap Atlas", "type": "bsp.lightmap_atlas", "typeVersion": 1, "position": [400, 200] },
{ "id": "bsp_geometry", "name": "BSP Build Geometry", "type": "bsp.build_geometry", "typeVersion": 1, "position": [600, 200],
"parameters": { "patch_tess_level": 4 } },
+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}")
@@ -1,11 +1,44 @@
#include "services/interfaces/workflow/workflow_parameter_reader.hpp"
#include <cstdlib>
#include <stdexcept>
#include <string>
#include <utility>
#include <vector>
namespace sdl3cpp::services::impl {
// Expand ${env:VAR_NAME} placeholders in workflow string parameters.
// Resolved at JSON load time so every step reads already-substituted values.
// Unset variables expand to empty string (the step's missing-value handling
// then takes over with its own error message — keeps this layer policy-free).
namespace {
std::string ExpandEnvPlaceholders(const std::string& input) {
std::string out;
out.reserve(input.size());
size_t i = 0;
while (i < input.size()) {
const size_t open = input.find("${env:", i);
if (open == std::string::npos) {
out.append(input, i, std::string::npos);
break;
}
const size_t close = input.find('}', open);
if (close == std::string::npos) {
out.append(input, i, std::string::npos);
break;
}
out.append(input, i, open - i);
const std::string varName = input.substr(open + 6, close - (open + 6));
if (const char* envVal = std::getenv(varName.c_str())) {
out.append(envVal);
}
i = close + 1;
}
return out;
}
} // namespace
WorkflowParameterReader::WorkflowParameterReader(std::shared_ptr<ILogger> logger)
: logger_(std::move(logger)) {
if (logger_) {
@@ -73,7 +106,7 @@ std::unordered_map<std::string, WorkflowParameterValue> WorkflowParameterReader:
}
if (value.IsString()) {
result.emplace(key, WorkflowParameterValue::FromString(value.GetString()));
result.emplace(key, WorkflowParameterValue::FromString(ExpandEnvPlaceholders(value.GetString())));
continue;
}
if (value.IsBool()) {
@@ -90,7 +123,7 @@ std::unordered_map<std::string, WorkflowParameterValue> WorkflowParameterReader:
for (rapidjson::SizeType i = 0; i < value.Size(); ++i) {
const auto& entry = value[i];
if (entry.IsString()) {
stringItems.emplace_back(entry.GetString());
stringItems.emplace_back(ExpandEnvPlaceholders(entry.GetString()));
} else if (entry.IsNumber()) {
numberItems.emplace_back(entry.GetDouble());
} else {