diff --git a/docs/superpowers/specs/2026-07-15-niri-gamemode-design.md b/docs/superpowers/specs/2026-07-15-niri-gamemode-design.md new file mode 100644 index 0000000..0db040a --- /dev/null +++ b/docs/superpowers/specs/2026-07-15-niri-gamemode-design.md @@ -0,0 +1,203 @@ +# Niri Gamemode Design + +**Date:** 2026-07-15 +**Status:** Approved + +## Summary + +Add a compositor-level "gamemode": while a game is running, inhibit idle/blanking, +silence notifications, switch to the `performance` power profile (and bump the fan), +and hide waybar — then revert everything cleanly when the game ends. One script, +`scripts/gamemode-session.sh`, is the whole engine. It is driven by **two independent +sources** and reference-counted, so the two triggers can overlap safely: + +- **Automatic** — Feral `gamemoded` custom start/end hooks (any game launched with + `gamemoderun`). +- **Manual** — a `Mod+G` niri keybind, for games that don't call gamemode. + +Effects apply on the first activation (0→1 active sources) and revert on the last +deactivation (1→0). Also removes three dead VRR window-rules found during review. + +## Current Behaviour + +- **Compositor:** niri 26.04, single internal panel `eDP-1` (BOE, 2256×1504@60, + **VRR not supported**). Config at `niri/config.kdl`. +- **Idle:** `hypridle` blanks the panel at 5 min, locks at 10 min, suspend-then- + hibernate at 30 min. It honors idle inhibitors (`ignore_dbus_inhibit = false`, + `ignore_systemd_inhibit = false`). A controller/turn-based game with no kbd/mouse + input therefore goes dark at 5 minutes. +- **Notifications:** `mako`, currently with no config file (running on defaults) — so + toasts pop over fullscreen games and there is no mode to suppress them. +- **Power:** `power-profiles-daemon` (AMD pstate); active profile read/set via + `powerprofilesctl`, waybar module refreshed with `pkill -RTMIN+8 waybar` (and + `-RTMIN+9`). Fan via `fw-fanctrl` with a virtual `auto` mode that maps + profile→strategy (`performance` → `agile`) when + `$XDG_STATE_HOME/fan-profile-auto` exists. +- **waybar:** launched directly (`spawn-at-startup "waybar"`); `waybar-restart` + script exists. +- **Feral gamemode:** `gamemoded` installed, **no `gamemode.ini`** → no custom hooks. +- **Script convention:** `scripts/.sh` is symlinked to `~/.local/bin/` + (no `.sh` in the bin name), e.g. `power-profile`, `fan-profile`, `screenshot`. + +### Review findings (delivered separately, cleanup folded into this spec) + +- 🔴 **Three VRR rules are dead code.** The panel doesn't support VRR, so all three + `variable-refresh-rate true` rules are no-ops. The `match is-focused=true` one is + also conceptually wrong (it targets every focused window, not games). +- 🟠 **Idle blanks mid-game** (the 5-min issue above) — the main problem gamemode fixes. +- 🟡 **Notifications interrupt fullscreen.** +- 🟢 `match app-id="steam_app_"` is an unanchored regex (looser than the other + anchored rules) — noted, left as-is. + +## Target Behaviour + +### Sources and reference counting + +State lives in a runtime dir `${XDG_RUNTIME_DIR}/gamemode/`. A "source" is either +`feral` or `manual`, represented by a marker file (`src.feral`, `src.manual`). +Gamemode is **active iff at least one source marker exists**. + +``` +add : + was_active = (any src.* exists) + touch src. + if not was_active: snapshot baseline + apply effects + +del : + rm -f src. + if no src.* remain: read baseline + revert effects, clear state dir + +toggle: # what Mod+G calls + if src.manual exists → del manual else → add manual + +status: # for a future waybar module; prints active|inactive, exit 0/1 +``` + +Applying only on 0→1 and reverting only on 1→0 makes overlap safe: launch a Steam +game (feral add) *and* hit `Mod+G` (manual add), and effects apply once; effects +revert only when both are gone. Idempotent and crash-tolerant — stale markers are +harmless, and baseline is re-snapshotted only when transitioning from zero sources. + +### Baseline snapshot (captured on 0→1) + +Written into the state dir so revert restores exactly what was there: + +- `baseline.profile` — `powerprofilesctl get` at entry. +- `baseline.waybar` — `1` if waybar was running at entry, else `0`. +- `baseline.fanauto` — `1` if `$XDG_STATE_HOME/fan-profile-auto` exists at entry. +- `idle.pid` — PID of the idle-inhibit process (written when it launches). + +### Effects + +| Effect | Enter | Revert | +|---|---|---| +| **Idle inhibit** | `systemd-inhibit --what=idle --who=gamemode --why="gaming session" --mode=block sleep infinity &`, record PID in `idle.pid`. hypridle honors systemd idle inhibitors, so its blank/lock/hibernate timers stop. | `kill` the recorded PID; remove `idle.pid`. | +| **Do-not-disturb** | `makoctl mode -a do-not-disturb` (requires a `[mode=do-not-disturb]` block — see mako config below). | `makoctl mode -r do-not-disturb`. | +| **Performance profile** | if `baseline.profile` != `performance`: `powerprofilesctl set performance`, then `pkill -RTMIN+8 waybar`. If `baseline.fanauto`=1: `fw-fanctrl use agile`. | restore `baseline.profile` (only if we changed it), `pkill -RTMIN+8 waybar`; if `baseline.fanauto`=1 remap fan to match the restored profile (`fan-profile`'s existing auto poll also reconciles within 5 s, so this is belt-and-suspenders). | +| **Hide waybar** | if waybar running: `pkill -x waybar`. | if `baseline.waybar`=1: relaunch `waybar` detached (`setsid waybar >/dev/null 2>&1 &`). | + +All effect steps are individually guarded (`command -v`, `2>/dev/null`) so a missing +tool degrades to a no-op rather than aborting the rest — gamemode must never wedge a +session. Revert always runs every restore step regardless of individual failures. + +### Failure / edge cases + +- **Stale markers after a crash:** next `add` sees markers, treats itself as + already-active, and won't re-snapshot — but a manual `gamemode-session reset` + (clears the state dir and force-reverts) is provided as an escape hatch. `toggle` + from a stale-active state will `del manual` and, if that empties the sources, + revert — self-healing in the common case. +- **Idle PID already dead:** `kill` failure is ignored. +- **profile unchanged at entry** (already `performance`): we don't touch it and don't + restore it, so we never clobber a deliberate user choice. + +## Implementation + +### 1. `scripts/gamemode-session.sh` (new) + +`bash`, mirrors the house style of the other scripts. Structure: + +- Constants: `STATE_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/gamemode"`, + `FAN_AUTO="${XDG_STATE_HOME:-$HOME/.local/state}/fan-profile-auto"`. +- Helpers: `is_active`, `snapshot_baseline`, `apply_effects`, `revert_effects`, + each effect as a small function. +- Dispatch on `$1`: `add`/`del` (with `$2` = source), `toggle`, `status`, `reset`. +- Symlinked to `~/.local/bin/gamemode-session` (same as every other script; add the + symlink line to `install.sh` if it enumerates scripts, else create it manually to + match the existing ones). + +Named `gamemode-session` (not `gamemode`) to avoid any collision with Feral's +`gamemoderun`/`gamemoded` namespace. + +### 2. `mako/config` (new file in dotfiles → `~/.config/mako/config`) + +mako has no config today; DND needs a mode block. Fold in the current visible +settings so nothing changes visually, then add the mode: + +```ini +background-color=#1d1f21 +text-color=#c5c8c6 +border-size=2 +border-color=#81a2be +default-timeout=4000 + +[mode=do-not-disturb] +invisible=1 +``` + +`invisible=1` hides new notifications while the mode is active; removing the mode +restores normal display. (History is retained — nothing is lost, just not shown.) + +### 3. `gamemode.ini` (new file in dotfiles → `~/.config/gamemode.ini`) + +```ini +[custom] +start=/home/alex/.local/bin/gamemode-session add feral +end=/home/alex/.local/bin/gamemode-session del feral +``` + +Absolute path because gamemoded's exec environment is minimal. No other gamemode +tuning (Feral's defaults already set the CPU governor). + +### 4. `niri/config.kdl` (edited) + +**Add** the keybind (near the other `spawn` binds): + +``` +Mod+G { spawn "gamemode-session" "toggle"; } +``` + +**Remove** the three dead VRR rules: +- the `variable-refresh-rate true` line inside the `steam_app_` rule, +- the entire `match is-focused=true { variable-refresh-rate true }` rule. + +Leave a one-line comment on the `steam_app_` rule noting to re-add +`variable-refresh-rate true` if a VRR-capable external monitor is ever attached. + +## Files Touched + +- `scripts/gamemode-session.sh` — **new**, the engine. +- `mako/config` — **new**, adds the `do-not-disturb` mode (plus current settings). +- `gamemode.ini` — **new**, Feral start/end hooks. +- `niri/config.kdl` — add `Mod+G` bind; remove 3 dead VRR rules; add one comment. +- `install.sh` — add the `gamemode-session` symlink + `gamemode.ini`/`mako/config` + placement, matching how existing scripts/configs are linked (or create the + symlink manually if install.sh doesn't enumerate). + +## Usage + +- **Steam:** set launch options to `gamemoderun %command%` (per-game or as a global + default) → fully automatic enter/revert. +- **Other games / anything gamemode doesn't catch:** `Mod+G` to toggle. +- Both converge on `gamemode-session`; overlapping is safe. + +## Out of Scope (YAGNI) + +- **gamescope wrapper / upscaling / frame limiting** — separate concern; can be + layered into a game's launch command independently. +- **Per-game profiles** — one uniform gamemode for now. +- **waybar gamemode indicator** — the `status` subcommand is provided so a + `custom/gamemode` module can be added later, but no module ships now. +- **Cross-machine sync of gamemode state** — state is per-boot runtime only, by design. +- **Touching VRR behaviour for external monitors** — left as a comment breadcrumb; + no rule added until such a monitor exists.