Files
dotfiles/docs/superpowers/specs/2026-07-15-niri-gamemode-design.md
T
funman300 5b24b21610 Spec: niri gamemode (idle inhibit, DND, perf profile, hide waybar)
Hybrid trigger (Feral gamemode hooks + Mod+G toggle), reference-counted
so overlapping sources apply once and revert once. Also removes three
dead VRR window-rules (panel has no VRR support).

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

204 lines
9.4 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 **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/<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.
## 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.