0ab08e8561
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>
237 lines
11 KiB
Markdown
237 lines
11 KiB
Markdown
# 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.
|