#!/usr/bin/env python3 """Gaming: what the machine is doing, and what it should do while you play. The interesting part is not reporting gamemode's state -- it is reacting to it. gamemode can run a script when a game asks for it and another when the game exits, so `hook start` and `hook end` are what let Panama switch the power profile and silence notifications for exactly the duration of a game, and put both back afterwards. Everything else here is honest reporting. panama-gaming snapshot panama-gaming set-overlay true|false panama-gaming set-overlay-preset fps|detailed panama-gaming install-hooks | remove-hooks panama-gaming hook start|end (called by gamemode, not by a person) """ from __future__ import annotations import json import os import re import shutil import subprocess import sys from pathlib import Path MANGOHUD_CONFIG = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "MangoHud" / "MangoHud.conf" GAMEMODE_CONFIG = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "gamemode.ini" ENVIRONMENT_CONFIG = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "environment.d" / "panama-mangohud.conf" # What the overlay shows. Deliberately two presets rather than exposing every # MangoHud key: this is a settings page, not a config file with a nicer font. PRESETS = { "fps": ["fps", "frametime=0", "no_display=0", "position=top-left", "font_size=22", "background_alpha=0.4", "toggle_hud=Shift_R+F12"], "detailed": ["fps", "frametime", "gpu_stats", "gpu_temp", "gpu_power", "cpu_stats", "cpu_temp", "ram", "vram", "position=top-left", "font_size=20", "background_alpha=0.4", "toggle_hud=Shift_R+F12"], } class BoundaryError(RuntimeError): """A user-visible failure.""" def run(command: list[str], timeout: float = 20.0) -> subprocess.CompletedProcess: try: return subprocess.run(command, capture_output=True, text=True, timeout=timeout, check=False) except (OSError, subprocess.TimeoutExpired) as error: raise BoundaryError(f"{command[0]} did not answer.") from error def read_int(path: Path) -> int | None: try: return int(path.read_text().strip()) except (OSError, ValueError): return None def gpus() -> list[dict]: """Every AMD/Intel GPU with a hwmon node, warmest first. Read from sysfs rather than a tool: it costs nothing, needs no daemon, and a page that polls while it is open should not be spawning processes. """ found = [] for hwmon in sorted(Path("/sys/class/hwmon").glob("hwmon*")): try: name = (hwmon / "name").read_text().strip() except OSError: continue if name not in ("amdgpu", "i915", "xe", "nouveau"): continue device = (hwmon / "device").resolve() temperature = read_int(hwmon / "temp1_input") power = read_int(hwmon / "power1_average") used = read_int(device / "mem_info_vram_used") total = read_int(device / "mem_info_vram_total") model = "" try: model = (device / "product_name").read_text().strip() except OSError: model = "" found.append({ "driver": name, "model": model, "temperatureC": round(temperature / 1000, 1) if temperature else None, "watts": round(power / 1000000, 1) if power else None, "vramUsedBytes": used or 0, "vramTotalBytes": total or 0, # A card with no VRAM reported is the integrated one sharing system # memory; saying "0 of 0 GB" would look broken. "discrete": bool(total and total > 1073741824), }) found.sort(key=lambda entry: (not entry["discrete"], entry["driver"])) return found def governor() -> str: try: return Path("/sys/devices/system/cpu/cpu0/cpufreq/scaling_governor").read_text().strip() except OSError: return "" def game_mode() -> dict: available = bool(shutil.which("gamemoded")) state = {"available": available, "active": False, "daemonRunning": False, "governorWhileGaming": "", "governorNow": governor()} if not available: return state status = run(["gamemoded", "-s"]).stdout.strip() state["active"] = "is active" in status state["daemonRunning"] = "gamemode is" in status # What it would switch the governor to, from its own configuration. for path in (GAMEMODE_CONFIG, Path("/usr/share/gamemode/gamemode.ini")): try: text = path.read_text() except OSError: continue found = re.search(r"^\s*desiredgov\s*=\s*(\S+)", text, re.M) if found: state["governorWhileGaming"] = found.group(1) break return state def hooks_installed() -> bool: try: text = GAMEMODE_CONFIG.read_text() except OSError: return False return "panama-gaming hook start" in text def overlay() -> dict: installed = bool(shutil.which("mangohud")) preset = "" if MANGOHUD_CONFIG.is_file(): try: body = MANGOHUD_CONFIG.read_text() preset = "detailed" if "gpu_stats" in body else "fps" except OSError: preset = "" return { "installed": installed, "configured": MANGOHUD_CONFIG.is_file(), "preset": preset or "fps", # Global enablement is an environment variable read at session start, so # a change here does not affect anything already running. "globallyEnabled": ENVIRONMENT_CONFIG.is_file(), "toggleKey": "Shift_R+F12", } def library() -> dict: root = Path.home() / ".local/share/Steam" games = len(list((root / "steamapps").glob("*.acf"))) if (root / "steamapps").is_dir() else 0 tools = [] for directory in (root / "compatibilitytools.d", root / "steamapps/common"): if not directory.is_dir(): continue for entry in sorted(directory.iterdir()): if entry.is_dir() and entry.name.lower().startswith("proton"): tools.append({ "name": entry.name, "community": directory.name == "compatibilitytools.d", }) return { "path": str(root), "games": games, "protonBuilds": tools, "gamescope": bool(shutil.which("gamescope")), "steam": bool(shutil.which("steam")), } def snapshot() -> dict: return { "gameMode": {**game_mode(), "hooksInstalled": hooks_installed()}, "gpus": gpus(), "overlay": overlay(), "library": library(), "error": "", } def set_overlay_preset(preset: str) -> None: if preset not in PRESETS: raise BoundaryError("That is not an overlay preset.") MANGOHUD_CONFIG.parent.mkdir(parents=True, exist_ok=True) header = ("# Written by Panama's Gaming settings.\n" "# Edits here are replaced when the preset changes.\n") MANGOHUD_CONFIG.write_text(header + "\n".join(PRESETS[preset]) + "\n", encoding="utf-8") def set_overlay(enabled: bool) -> None: if not shutil.which("mangohud"): raise BoundaryError("MangoHud is not installed.") if enabled: if not MANGOHUD_CONFIG.is_file(): set_overlay_preset("fps") ENVIRONMENT_CONFIG.parent.mkdir(parents=True, exist_ok=True) ENVIRONMENT_CONFIG.write_text( "# Written by Panama's Gaming settings.\n" "# Read when the session starts, so this reaches applications\n" "# launched afterwards rather than ones already running.\n" "MANGOHUD=1\n", encoding="utf-8") else: try: ENVIRONMENT_CONFIG.unlink() except OSError: pass def install_hooks() -> None: """Point gamemode's start and end hooks at this script. Written by editing rather than replacing: gamemode.ini is the user's file and may hold settings this page does not manage. """ script = str(Path(__file__).resolve()) lines = [] if GAMEMODE_CONFIG.is_file(): lines = [line for line in GAMEMODE_CONFIG.read_text().splitlines() if "panama-gaming hook" not in line] text = "\n".join(lines) if "[custom]" not in text: text += "\n\n[custom]\n" text = re.sub(r"\[custom\]\n", f"[custom]\nstart={script} hook start\nend={script} hook end\n", text, count=1) GAMEMODE_CONFIG.parent.mkdir(parents=True, exist_ok=True) GAMEMODE_CONFIG.write_text(text.strip() + "\n", encoding="utf-8") def remove_hooks() -> None: if not GAMEMODE_CONFIG.is_file(): return lines = [line for line in GAMEMODE_CONFIG.read_text().splitlines() if "panama-gaming hook" not in line] GAMEMODE_CONFIG.write_text("\n".join(lines).strip() + "\n", encoding="utf-8") def hook(phase: str) -> None: """Called by gamemode when a game starts and when it stops. Reads what the user asked for from the settings file directly: the shell may not be running, and a hook that depends on a running shell would silently do nothing for someone who restarted it mid-session. """ if phase not in ("start", "end"): raise BoundaryError("That is not a hook phase.") settings_path = (Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "panama" / "settings.json") try: settings = json.loads(settings_path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): settings = {} scripts = Path(__file__).resolve().parent # What was true before the game started, so ending it restores that rather # than imposing a default. Without this, finishing a game would silently # undo a power profile or a Do Not Disturb the user had chosen themselves. state_path = Path(os.environ.get("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}")) / "panama-gaming.json" if phase == "start": before = {} if settings.get("gamingPerformanceProfile", True): current = run([str(scripts / "panama-power-profile"), "list"], timeout=15) try: before["profile"] = json.loads(current.stdout or "{}").get("active", "") except json.JSONDecodeError: before["profile"] = "" run([str(scripts / "panama-power-profile"), "set", "performance"], timeout=15) # Reported, not acted on. Silencing is a focus mode's job now, and two # things writing Do Not Disturb would each restore whatever the other # happened to leave behind. The hook knows a game started; what that # should mean is decided in one place. if shutil.which("qs"): run(["qs", "ipc", "call", "focus", "gameStarted"], timeout=10) try: state_path.write_text(json.dumps(before), encoding="utf-8") except OSError: pass if settings.get("gamingNotifyOnStart", False) and shutil.which("notify-send"): run(["notify-send", "-a", "Panama", "Game Mode", "Performance profile engaged."], timeout=10) return # end try: before = json.loads(state_path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): before = {} if before.get("profile"): run([str(scripts / "panama-power-profile"), "set", before["profile"]], timeout=15) if shutil.which("qs"): run(["qs", "ipc", "call", "focus", "gameEnded"], timeout=10) try: state_path.unlink() except OSError: pass def main(arguments: list[str]) -> int: try: if arguments == ["snapshot"]: print(json.dumps(snapshot(), separators=(",", ":"))) return 0 if len(arguments) == 2 and arguments[0] == "hook": hook(arguments[1]) return 0 if len(arguments) == 2 and arguments[0] == "set-overlay": set_overlay(arguments[1] == "true") elif len(arguments) == 2 and arguments[0] == "set-overlay-preset": set_overlay_preset(arguments[1]) elif arguments == ["install-hooks"]: install_hooks() elif arguments == ["remove-hooks"]: remove_hooks() else: raise BoundaryError( "Usage: panama-gaming snapshot | set-overlay true|false | " "set-overlay-preset fps|detailed | install-hooks | remove-hooks | " "hook start|end") except BoundaryError as error: state = snapshot() state["error"] = str(error) print(json.dumps(state, separators=(",", ":"))) return 0 print(json.dumps(snapshot(), separators=(",", ":"))) return 0 if __name__ == "__main__": raise SystemExit(main(sys.argv[1:]))