diff --git a/gameengine/CMakeLists.txt b/gameengine/CMakeLists.txt index 486e35f2b..a3f3c9c6e 100644 --- a/gameengine/CMakeLists.txt +++ b/gameengine/CMakeLists.txt @@ -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 diff --git a/gameengine/packages/quake3/workflows/q3_game.json b/gameengine/packages/quake3/workflows/q3_game.json index 665fbdfc6..59d203ac9 100644 --- a/gameengine/packages/quake3/workflows/q3_game.json +++ b/gameengine/packages/quake3/workflows/q3_game.json @@ -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 } }, diff --git a/gameengine/python/dev_commands.py b/gameengine/python/dev_commands.py index 840357614..f9cf66573 100755 --- a/gameengine/python/dev_commands.py +++ b/gameengine/python/dev_commands.py @@ -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) diff --git a/gameengine/python/steam_detector.py b/gameengine/python/steam_detector.py new file mode 100644 index 000000000..fa82a307a --- /dev/null +++ b/gameengine/python/steam_detector.py @@ -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}") diff --git a/gameengine/src/services/impl/workflow/workflow_parameter_reader.cpp b/gameengine/src/services/impl/workflow/workflow_parameter_reader.cpp index 1cb8b8daa..8a60e9c3e 100644 --- a/gameengine/src/services/impl/workflow/workflow_parameter_reader.cpp +++ b/gameengine/src/services/impl/workflow/workflow_parameter_reader.cpp @@ -1,11 +1,44 @@ #include "services/interfaces/workflow/workflow_parameter_reader.hpp" +#include #include +#include #include #include 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 logger) : logger_(std::move(logger)) { if (logger_) { @@ -73,7 +106,7 @@ std::unordered_map 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 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 {