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>
This commit is contained in:
funman300
2026-07-15 21:23:34 -07:00
parent 6e26cc62aa
commit 5b24b21610
@@ -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/<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.