Files
dotfiles/docs/superpowers/specs/2026-07-15-niri-gamemode-design.md
T
funman300 0ab08e8561 gamemode: global auto-trigger via niri window watch
Add scripts/gamemode-watch.py, launched at niri startup, which subscribes
to niri's event stream and toggles gamemode whenever a game window
(steam_app_*, RuneLite, gamescope) is open — so Steam games trigger
gamemode with zero per-game setup instead of needing gamemoderun %command%.

'winwatch' is a third reference-counted source alongside feral + manual.
Pure Watcher class is unit-tested; SIGTERM/finally guarantee cleanup so
the watcher stopping never leaves gamemode stuck on.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 21:51:52 -07:00

237 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 **three
independent sources** and reference-counted, so the triggers can overlap safely:
- **Window watch (`winwatch`)** — `scripts/gamemode-watch.py`, launched at niri
startup, subscribes to niri's event stream and toggles gamemode whenever a game
window (app_id matching `steam_app_*`, RuneLite, or gamescope) is open. This makes
Steam games automatic with **zero per-game setup** — the primary trigger.
- **Feral (`feral`)** — Feral `gamemoded` custom start/end hooks, for
gamemode-aware launchers run with `gamemoderun` that the window watch may not match.
- **Manual (`manual`)** — a `Mod+G` niri keybind, for anything else.
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.
> **Addendum (2026-07-15):** The window-watch source was added after the initial
> design so Steam games trigger gamemode globally instead of needing
> `gamemoderun %command%` per game. See "Window watch" below.
## 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/<name>.sh` is symlinked to `~/.local/bin/<name>`
(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 <source>:
was_active = (any src.* exists)
touch src.<source>
if not was_active: snapshot baseline + apply effects
del <source>:
rm -f src.<source>
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.
### 5. `scripts/gamemode-watch.py` (new) — window watch
A Python daemon (Python already used by the theme generator) launched via
`spawn-at-startup "gamemode-watch"`. It runs `niri msg --json event-stream` and
maintains the set of open **game** windows:
- `is_game(app_id)` matches `steam_app_` (substring), the RuneLite ids
(`net-runelite-client-RuneLite|runelite|RuneLite`), and `gamescope`
(case-insensitive) — mirrors the niri window rules. Bolt's *launcher* window is
deliberately excluded (it would trigger before you're in-game).
- `WindowsChanged` (connect snapshot) rebuilds the set; `WindowOpenedOrChanged`
adds/removes by app_id; `WindowClosed` (id only) discards by id.
- On empty→non-empty it calls `gamemode-session add winwatch`; non-empty→empty,
`del winwatch`.
- Event logic is a pure `Watcher` class (injected `emit`) so it unit-tests without
niri or subprocesses (`scripts/tests/gamemode-watch.test.py`).
- Robustness: reconnects if the stream drops while niri is alive; a SIGTERM handler
and a `finally` guarantee `del winwatch` on exit, so the watcher stopping can
never leave gamemode stuck on.
## Files Touched
- `scripts/gamemode-session.sh`**new**, the engine.
- `scripts/gamemode-watch.py`**new**, niri-event-stream window watch (primary
auto-trigger). Tested by `scripts/tests/gamemode-watch.test.py`.
- `mako/config`**new**, adds the `do-not-disturb` mode (plus current settings);
also un-ignored in `.gitignore` (was previously untracked by choice).
- `gamemode.ini`**new**, Feral start/end hooks.
- `niri/config.kdl` — add `Mod+G` bind; `spawn-at-startup "gamemode-watch"`; remove
3 dead VRR rules; add one comment.
- `install.sh` — link `gamemode-session`, `gamemode-watch`, `gamemode.ini`, and
`mako/config` (the last was never linked before).
## Usage
- **Steam / RuneLite / gamescope games:** nothing to do — the window watch turns
gamemode on when a game window opens and off when the last one closes.
- **Gamemode-aware launchers** (Lutris/Heroic via `gamemoderun`): covered by the
Feral hooks even if their window app_id isn't matched.
- **Anything else / manual override:** `Mod+G` to toggle.
- All three converge on `gamemode-session`; overlapping is safe (reference-counted).
## 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.