#!/bin/sh
# rikkiti-replay — Rikkiti Replay engine (docs/48, P1+P2): a rolling ring of the
# last N minutes of a chosen output, hardware-encoded, held as 2-second
# keyframe-aligned segments so a clip can be saved losslessly in ~0.2s at any
# moment — plus marks with LAZY rescue (a mark is metadata; footage is copied
# out only when the ring wrap actually threatens it → no disk doubling).
#
#   start [OUTPUT]  begin holding the ring (inert until Settings setup: see below)
#   stop            stop holding (buffer + marks kept until the next start)
#   save            save everything currently held to a clip (lossless concat)
#   mark            drop a mark — trim later; it can never be overwritten
#   rescue-check [all]  (internal: the watcher's lazy-rescue pass; all = flush)
#   status          show engine + ring state
#
# Pipeline (proven in spikes/replay, 2026-07-13): wf-recorder (dmabuf screencopy +
# VAAPI encode on the render GPU) → FIFO → ffmpeg -c copy -f segment -segment_wrap.
# ~4% of one core at 1440p60, ZERO measured game-FPS cost (Elden Ring, MangoHud).
# The panel Replay chip appears while the state file below exists (pid-checked,
# self-healing — same pattern as the REC chip). The panel also auto-starts/stops
# the engine on the comp's fullscreen-game events when autostart_games=true; the
# comp publishes the game's output+app_id in $XDG_RUNTIME_DIR/rikkiti-fs so the
# ring records the monitor the game is actually on.
#
# INERT UNTIL SET UP: requires enabled=true AND a folder in replay.conf (written by
# Settings ▸ Game Recording first-run setup). No conf → every verb no-ops politely,
# so the default keybinds can ship enabled without capturing anyone who never opted in.
set -u
CONF="$HOME/.config/rikkiti/replay.conf"
STATE="$HOME/.cache/rikkiti/replay.state"
RUN="${XDG_RUNTIME_DIR:-/tmp}"
FIFO="$RUN/rikkiti-replay.fifo"
SEG_SEC=2
SND=/usr/share/sounds/freedesktop/stereo
PAUSEF="$HOME/.cache/rikkiti/replay.paused"   # exists while paused; holds the pause epoch

# seg_index: the session's segments as "mtime path" lines, oldest→newest, one
# cheap `find` (no per-file forks — a 90-min ring is 2700 files). In-flight and
# empty segments included; callers filter.
seg_index() {
	find "$1" -maxdepth 1 -name 'seg-*.mkv' -size +0 -printf '%T@ %p\n' 2>/dev/null \
		| sort -n | sed 's/^\([0-9]*\)\.[0-9]* /\1 /'
}

# seg_geom <path>: a segment's encoded WxH, or "?" if it can't be determined.
# Used to decide whether two runs of the same game can be shown (and exported) as
# one: `cut`'s lossless path concatenates with `-c copy`, which CANNOT span a
# resolution change — it yields a broken file. "?" therefore never compares equal
# to anything, so an unprobeable segment fails safe (no merge) rather than
# silently producing a corrupt clip. Cached (keyed on name+mtime, so a segment the
# ring has wrapped over re-probes) because the Studio re-reads session-info often.
seg_geom() {
	[ -n "${1:-}" ] && [ -e "${1:-}" ] || { printf '?'; return 0; }
	command -v ffprobe >/dev/null 2>&1 || { printf '?'; return 0; }
	_k="$(basename "$1")@$(stat -c %Y "$1" 2>/dev/null)"
	_c="$(ring_dir)/.geom"
	_v=$(awk -v k="$_k" '$1 == k { print $2; exit }' "$_c" 2>/dev/null)
	if [ -z "$_v" ]; then
		_v=$(ffprobe -v error -select_streams v:0 -show_entries stream=width,height \
			-of csv=p=0:s=x "$1" 2>/dev/null | head -1)
		case "$_v" in ''|*[!0-9x]*) _v="?" ;; esac
		printf '%s %s\n' "$_k" "$_v" >>"$_c" 2>/dev/null
		# bounded: one line per live segment is plenty; a wrapped ring re-probes
		[ "$(wc -l <"$_c" 2>/dev/null || echo 0)" -gt 6000 ] && : >"$_c"
	fi
	printf '%s' "$_v"
}

# when_label <epoch>: "Today 11:04" / "Yesterday 14:32" / "Mon 09:12" / "3 Jul 14:00".
# Computed HERE rather than in the Studio because `date` resolves the user's local
# timezone and DST properly, which Odin's core:time does not.
when_label() {
	_t=$1
	case "$_t" in ''|*[!0-9]*) return 0 ;; esac
	_hm=$(date -d "@$_t" +%H:%M 2>/dev/null) || return 0
	_d=$(date -d "@$_t" +%F 2>/dev/null)
	if [ "$_d" = "$(date +%F)" ]; then printf 'Today %s' "$_hm"
	elif [ "$_d" = "$(date -d yesterday +%F 2>/dev/null)" ]; then printf 'Yesterday %s' "$_hm"
	elif [ $(( $(date +%s) - _t )) -lt 518400 ]; then printf '%s %s' "$(date -d "@$_t" +%a)" "$_hm"
	else printf '%s' "$(date -d "@$_t" '+%-d %b %H:%M')"
	fi
}

# conf value: last occurrence wins, '#' comments stripped (matches apps/conf).
ckey() {
	v=$(sed -n "s/#.*//; s/^[[:space:]]*$1[[:space:]]*=[[:space:]]*//p" "$CONF" 2>/dev/null \
		| tail -1 | sed 's/[[:space:]]*$//')
	[ -n "$v" ] && printf '%s' "$v" || printf '%s' "${2:-}"
}
# Export/probe work (durize sweep, concat, restamp) runs WHILE the ring keeps
# recording — and usually while a game renders. At normal priority that load
# visibly hitched the on-screen source mid-save (caught on the 5-min beep/flash
# ground-truth clip: freeze bursts only during saves/analysis, capture clock
# itself never off by 1ms). Idle priority makes a mid-game quick-clip polite:
# it only takes CPU/disk the live pipeline and game aren't using.
XNICE="nice -n 19"
command -v ionice >/dev/null 2>&1 && XNICE="ionice -c 3 nice -n 19"
# timeout: notify-send BLOCKS forever if the Notifications host stops pumping D-Bus
# (observed while a game session is up) — a toast must never wedge a save.
notify() {
	command -v notify-send >/dev/null 2>&1 || return 0
	# FIRE AND FORGET — this is a latency fix, not tidiness. notify-send BLOCKS on the
	# notification host's D-Bus reply: measured at 1.1 SECONDS on an IDLE desktop, and
	# the host stops pumping entirely while a fullscreen game is up. Every call site
	# ran notify BEFORE the osd/cue, so the pill that PIERCES a game and the audible
	# cue — the only feedback a player actually gets — were queued behind a toast they
	# cannot even see. $mod+F12 felt unregistered and Lee kept pressing it twice.
	# Nothing waits on a toast, so nothing should block on one. setsid: survives the
	# script exiting.
	setsid timeout 3 notify-send -a "Rikkiti Replay" "$1" "${2:-}" >/dev/null 2>&1 &
	return 0
}
# osd: flash the comp's overlay pill — the confirmation that PIERCES fullscreen
# games (toasts and the panel pill are suppressed / stacked under them).
osd() { command -v rikkiti-msg >/dev/null 2>&1 && timeout 2 rikkiti-msg osd "$1" >/dev/null 2>&1 || true; }
# short audio cue, audible over the game (marks/saves are confirmed by ear when
# the eyes never leave the fight). Fire-and-forget; honours sound_cues.
cue() { [ "$(ckey sound_cues true)" = "true" ] && [ -e "$SND/$1" ] && setsid paplay "$SND/$1" >/dev/null 2>&1 & }

folder() {
	f=$(ckey folder "")
	case "$f" in "~/"*) f="$HOME/${f#"~"/}" ;; esac
	printf '%s' "$f"
}
# The RING lives in the XDG state dir, not the user's media folder: thousands
# of 2s segments are machinery, not content — the folder the user chose holds
# only their clips + rescued moments. (chattr +C still applied on btrfs.)
ring_dir() { printf '%s' "${XDG_STATE_HOME:-$HOME/.local/state}/rikkiti/replay-ring"; }
# The compositor's wayland socket inode identifies THE SESSION: a new login
# recreates the socket (same name, new inode). setsid'd pipelines survive
# logout (logind doesn't kill leftover scope processes), so the ring compares
# this at start + on every watcher pass and stops itself when it changes —
# a recording must never outlive the session that started it (Lee's GPU-fans
# incident: a 30-min zombie ring across a logout).
wl_inode() { stat -c %i "$RUN/${WAYLAND_DISPLAY:-wayland-0}" 2>/dev/null || echo 0; }
state_inode() { sed -n 6p "$STATE" 2>/dev/null; }
first_output() {
	for c in /sys/class/drm/card*-*/status; do
		[ "$(cat "$c" 2>/dev/null)" = "connected" ] || continue
		d=${c%/status}; d=${d##*/}; echo "${d#card*-}"
	done | head -1
}
setup_ok() { [ "$(ckey enabled false)" = "true" ] && [ -n "$(folder)" ]; }

# trim_log <path> [max_lines] [keep_lines]: bound a log that is only ever appended to.
#
# Every log here is read from the END when something has gone wrong, so keeping the
# newest few hundred lines loses nothing and stops the file growing for the life of
# the machine. The watcher writes a heartbeat on every pass and the segmenters echo
# ffmpeg's chatter, so without this they only ever get bigger — slowly, which is what
# makes it easy to miss until a disk is full.
#
# `-f` is checked FIRST on purpose: `<"$f"` on a missing file is a REDIRECTION
# failure, which the shell reports itself before wc's 2>/dev/null can apply, and a
# first-ever run then printed "cannot open …: No such file" for no reason.
trim_log() {
	_tl="$1"; _tmax="${2:-400}"; _tkeep="${3:-100}"
	[ -f "$_tl" ] || return 0
	[ "$(wc -l <"$_tl" 2>/dev/null || echo 0)" -gt "$_tmax" ] || return 0
	tail -n "$_tkeep" "$_tl" >"$_tl.t" 2>/dev/null && mv -f "$_tl.t" "$_tl"
	return 0
}

# ---- which GPU, and therefore which encoder -----------------------------------
# render_node: the DRM render node the COMPOSITOR renders on. It publishes this at
# startup (RIK_RENDER_NODE in every child's environment, and the runtime file for
# anything spawned outside that tree — a terminal, ssh, a systemd unit).
#
# Guessing renderD128 was wrong on exactly the machines this matters most on. On a
# desktop with an NVIDIA card and an AMD/Intel iGPU, renderD128 is the IGPU while we
# composite on the discrete card, and the encoder then gets handed a dma-buf from the
# wrong GPU. On a single-NVIDIA box renderD128 IS the NVIDIA node, which has no VAAPI
# at all. The conf key still wins, as an escape hatch for a box we've guessed wrong on.
render_node() {
	_rn=$(ckey render_device "")
	[ -n "$_rn" ] && { printf '%s' "$_rn"; return 0; }
	[ -n "${RIK_RENDER_NODE:-}" ] && { printf '%s' "$RIK_RENDER_NODE"; return 0; }
	_rn=$(cat "$RUN/rikkiti-render-node" 2>/dev/null)
	[ -n "$_rn" ] && { printf '%s' "$_rn"; return 0; }
	printf '/dev/dri/renderD128' # last resort: the historical default
}

# node_driver <node>: the kernel driver behind a render node (amdgpu / i915 / xe /
# nvidia / …), or "" if it can't be determined.
node_driver() {
	_nd=$(readlink -f "/sys/class/drm/${1##*/}/device/driver" 2>/dev/null)
	printf '%s' "${_nd##*/}"
}

# enc_family <node>: which encoder family can actually encode OUR frames on $node.
#
#   vaapi  — the VAAPI VPP can take the compositor's dma-buf directly (AMD, Intel).
#   nvenc  — NVIDIA: no VAAPI, but NVENC encodes fine from system memory.
#   sw     — neither; libx264/libx265 on the CPU. Always works, costs cores.
#
# Deliberately probed rather than inferred from the driver name: "is there a VAAPI
# encoder on this node" is a question ffmpeg can answer in a fraction of a second,
# and a wrong guess here is a recording that silently never starts.
enc_family() {
	if ffmpeg -nostdin -hide_banner -v error -f lavfi -i testsrc=duration=0.1:size=320x240:rate=30 \
		-vaapi_device "$1" -vf 'format=nv12,hwupload' -c:v h264_vaapi -f null - \
		</dev/null >/dev/null 2>&1; then
		printf 'vaapi'; return 0
	fi
	if [ "$(node_driver "$1")" = nvidia ] \
		&& ffmpeg -hide_banner -encoders 2>/dev/null | grep -q h264_nvenc; then
		printf 'nvenc'; return 0
	fi
	printf 'sw'
}

# enc_name <family> <codec>: the ffmpeg encoder for this family+codec.
enc_name() {
	case "$1" in
	  vaapi) case "$2" in hevc) printf 'hevc_vaapi' ;; av1) printf 'av1_vaapi' ;; *) printf 'h264_vaapi' ;; esac ;;
	  nvenc) case "$2" in hevc) printf 'hevc_nvenc' ;; av1) printf 'av1_nvenc' ;; *) printf 'h264_nvenc' ;; esac ;;
	  *)     case "$2" in hevc) printf 'libx265' ;; *) printf 'libx264' ;; esac ;;
	esac
}

# enc_filter <family> <max_height> <fps>: the wf-recorder -F filter chain.
#
# VAAPI scales and converts on the GPU (scale_vaapi, zero-copy). The other two take
# CPU frames, so the same work is swscale's. Handing scale_vaapi a non-VAAPI frame is
# what broke recording on the NVIDIA ISO — wf-recorder died with "Failed to find AV
# format for 875710274" (XB24, the compositor's XBGR8888 dma-buf) before writing a
# single byte.
#
# out_range=limited is NOT decoration on either path. The segmenter's BSF declares the
# stream limited-range, so the pixels have to actually BE limited-range or every player
# stretches them and the clip comes back with crushed blacks and clipped whites.
# swscale does not default to it from an RGB source: measured on the NVIDIA ISO,
# without this the luma ran 0–255, with it 10–238 (docs/48).
enc_filter() {
	case "$2" in
	  ''|0|*[!0-9]*) _mh="" ;;
	  *) _mh="$2" ;;
	esac
	case "$1" in
	  vaapi) if [ -n "$_mh" ]; then
			printf 'scale_vaapi=w=-2:h=min(%s\\,ih):format=nv12:out_range=limited,fps=%s' "$_mh" "$3"
		 else printf 'scale_vaapi=format=nv12:out_range=limited'; fi ;;
	  *)     if [ -n "$_mh" ]; then
			printf 'scale=w=-2:h=min(%s\\,ih):out_range=limited,format=nv12,fps=%s' "$_mh" "$3"
		 else printf 'scale=out_range=limited,format=nv12'; fi ;;
	esac
}

# enc_pixfmt <family>: wf-recorder's -x (output pixel format).
#
# Required on the CPU paths. wf-recorder picks the encoder's format itself and ignores
# what the filter chain ends on, so NVENC — which accepts RGB — chose gbrp and emitted
# H.264 **High 4:4:4 Predictive**. That is a profile most players, browsers, phones and
# TV hardware decoders refuse outright: a clip that plays here and nowhere else. Naming
# yuv420p pins it to Main/High 4:2:0 like every other path. VAAPI needs no -x; its
# frames are already NV12 in GPU memory.
enc_pixfmt() { [ "$1" = vaapi ] || printf 'yuv420p'; }

# enc_devarg <family> <node>: wf-recorder's -d argument. Only VAAPI needs a device;
# NVENC picks its own CUDA device and libx264 has none, and passing -d there makes
# wf-recorder try (and fail) to set up a VAAPI device it will never use.
enc_devarg() { [ "$1" = vaapi ] && printf '%s' "$2"; }

# kill_hard <pids…>: TERM, then KILL what survives.
#
# Not belt-and-braces — required. A segmenter that is still blocked in open() on the
# FIFO (because its recorder died before ever writing) never reaches the main loop
# where ffmpeg acts on SIGTERM, so it ignores the TERM completely and keeps running
# after the start that spawned it has exited. That is how a failed start left a live
# ffmpeg behind on the NVIDIA ISO, with `status` reporting "not running".
kill_hard() {
	[ $# -gt 0 ] || return 0
	kill "$@" 2>/dev/null
	_kh=0
	while [ $_kh -lt 10 ]; do
		_alive=0
		for _p in "$@"; do [ -d "/proc/$_p" ] && _alive=1; done
		[ "$_alive" = 0 ] && return 0
		sleep 0.1; _kh=$((_kh + 1))
	done
	kill -KILL "$@" 2>/dev/null
	return 0
}

# running: state file exists AND the recorder pid is alive.
#
# It reports; it does NOT reap. Deleting the state here looked like tidy
# self-healing but made every READ-ONLY verb destructive — and `session-info` is
# one, which the Studio polls every few seconds. So seconds after a recorder died,
# whoever asked "is it running?" first silently deleted the session, and since the
# watcher's loop condition IS that file, the watcher exited too. The re-acquire and
# the teardown then never got a pass: Lee's GW2 resize (2026-07-15 10:08) stopped
# recording for good, and an earlier session left a 27-minute orphaned segmenter.
# The watcher owns this file's lifetime — it re-acquires if the game is still up,
# tears down properly if not — and `stop` removes it. A genuinely stale file is
# harmless: the chip hides itself on the dead pid, and the next `start` overwrites.
wf_pid()    { sed -n 1p "$STATE" 2>/dev/null; }
ff_pid()    { sed -n 2p "$STATE" 2>/dev/null; }
state_buf() { sed -n 3p "$STATE" 2>/dev/null; }

# kill_fifo_pipeline: kill ANY wf-recorder / segmenter ffmpeg bound to OUR fifo,
# found by matching the fifo path in the cmdline — NOT by the state pid, which can
# drift. This is a hard safety net: a pause/resume race once left a SECOND recorder
# writing to the fifo (two interleaved streams = corrupt segments), and when the
# state tracked only one pid, pause killed that one and the orphan kept recording
# the DESKTOP into the ring while the UI said "paused" (a privacy breach). Matching
# on the fifo path makes start/resume idempotent and pause/stop total.
# fifo_pids: pids bound to our fifo that are ACTUALLY the recorder/segmenter
# (comm = wf-recorder|ffmpeg) — never the script or anything else that merely
# mentions the path.
fifo_pids() {
	for p in $(pgrep -f "$FIFO" 2>/dev/null); do
		[ "$p" = "$$" ] && continue
		case "$(cat "/proc/$p/comm" 2>/dev/null)" in
			wf-recorder|ffmpeg) echo "$p" ;;
		esac
	done
}
kill_fifo_pipeline() {
	pids=$(fifo_pids | tr '\n' ' ')
	case $pids in *[0-9]*) ;; *) return 0 ;; esac   # nothing to kill (POSIX: no ${v//} in dash)
	# shellcheck disable=SC2086
	kill -INT $pids 2>/dev/null || true      # wf-recorder finalizes its segment on INT
	for _ in 1 2 3 4 5; do
		[ -z "$(fifo_pids)" ] && return 0
		sleep 0.3
	done
	pids=$(fifo_pids | tr '\n' ' ')
	# shellcheck disable=SC2086
	case $pids in *[0-9]*) kill -9 $pids 2>/dev/null || true ;; esac
	return 0
}
# pipeline_lock: serialize start/stop/pause/resume so the fullscreen watcher and
# the audio-watchdog's pause;resume can never overlap and double-spawn the recorder.
# Bounded wait (never deadlock) + fd 6 so it composes with start's own fd-8 lock;
# on timeout it proceeds anyway — kill_fifo_pipeline is the real safety net.
pipeline_lock() { exec 6>"$RUN/rikkiti-replay.pipeline.lock"; flock -w 10 6 || true; }

# ---- window capture: which toplevel IS the game? ----------------------------
# Games routinely front a launcher / splash / anti-cheat toplevel before their
# real one — Elden Ring's EAC "LOADING" splash (an 800x450 window that dies a
# few seconds later), Unity's resolution dialog, Unreal's shader-compile window.
# So "whichever window the compositor published at the instant we started" is
# regularly the WRONG window, and it disappears underneath us. Never trust one
# instant: re-ask, and prefer a fullscreen window, then the largest one — a game
# engine's window dwarfs its own launcher. Empty result = this game has no
# capturable window right now (still loading, or gone for good).
game_window() {
	{ command -v rikkiti-msg && command -v jq; } >/dev/null 2>&1 || return 0
	rikkiti-msg get-windows 2>/dev/null | jq -r --arg a "${1:-}" '
		[ .[] | select((.minimized | not) and (.skip | not))
		      | select($a == "" or .app_id == $a) ]
		| sort_by([(if .fullscreen then 1 else 0 end), (.rect.width * .rect.height)])
		| (last.id // empty)' 2>/dev/null
}

# fs_game_is <app>: does the compositor right now report THIS app as the
# fullscreen game? That — not which panel hook happened to fire — is what decides
# whether whole-screen capture is an acceptable stand-in while we wait for a
# window: for a fullscreen game the output is the game pixel for pixel (verified
# on Lee's ring 2026-07-15: the fallback frames are Elden Ring's own loading
# screen, not his desktop), and alt-tab fires autopause so the desktop cannot
# appear either. For a WINDOWED game the same fallback records the desktop, which
# is the one thing this feature promises never to capture.
fs_game_is() {
	[ -n "${1:-}" ] || return 1
	[ "$(sed -n 2p "$RUN/rikkiti-fs" 2>/dev/null)" = "$1" ]
}

# win_pipeline_up <wid> <segstart>: bring wincap → FIFO → segmenter up, and say
# whether it survived the settle window. Factored out because `start` and
# `resume` MUST behave identically here and had drifted into two copies — resume
# was the only path that ever got window capture onto Elden Ring, purely because
# it happened to run late enough to see the real window (Lee, 2026-07-15).
# On failure it leaves a clean, empty FIFO so the caller can simply try again.
win_pipeline_up() {
	# distinct names from acquire_window_capture's: POSIX sh has no `local`, so
	# every one of these is global and a shared name would clobber the caller.
	_pw=$1; _ps=$2
	_asrc=""; _amap=""; _aenc=""
	# Monitor-source audio reaches the segmenter BEFORE the same instant's video
	# does (pulse capture buffering vs the comp commit -> slot -> encode path).
	# Beep/flash ground-truth clips measured the lead dead-constant WITHIN a
	# session (zero drift over 5 minutes) but varying ~100-145ms BETWEEN sessions
	# (pulse stream negotiation), so 120 centres it: residual ±25ms sits inside
	# the acceptability window, biased audio-late (perceptually safer than early).
	# Tunable (whole ms, may be negative): av_sync_ms.
	_avs=$(ckey av_sync_ms 120)
	case "$_avs" in ''|*[!0-9-]*) _avs=120 ;; esac
	_avs=$(awk "BEGIN{printf \"%.3f\", ($_avs)/1000}")
	case "$AUDIO" in -a?*) _asrc="-thread_queue_size 1024 -itsoffset $_avs -f pulse -i ${AUDIO#-a}"; _amap="-map 1:a"; _aenc="-c:a aac -b:a 192k" ;; esac
	# -shortest is LOAD-BEARING, not a tidy-up. This segmenter has TWO inputs: video
	# from wincap's fifo and audio straight from pulse. Pulse NEVER ends — so when
	# wincap stops (a window resize, the game quitting), video EOFs but audio keeps
	# arriving, and ffmpeg keeps the in-flight segment OPEN, growing it on audio
	# alone. Two consequences, both seen on Lee's box 2026-07-15:
	#   1. the segment's duration becomes the length of the GAP (a 0.6s clip stamped
	#      338s), which poisons the Studio's EDL — mpv sums those headers, so a 1:12
	#      session reported 6:58 and every seek scaled wrong and snapped to the end;
	#   2. the segmenter outlives its producer forever, still holding a pulse capture
	#      stream (found once at 27 minutes) — the "orphan" had audio keeping it alive.
	# With -shortest the output finishes when the shortest input ends, so the
	# segmenter dies WITH wincap and the segment closes on its last real video frame.
	# (The output-capture path needs none of this: it has a single muxed input, so
	# the fifo's EOF ends it by itself.)
	# shellcheck disable=SC2086  # $_asrc/$_amap/$_aenc intentionally word-split (may be empty)
	setsid ffmpeg -nostdin -y -hide_banner -loglevel warning -thread_queue_size 1024 -i "$FIFO" $_asrc \
	  -map 0:v $_amap -c:v copy ${BSF:+-bsf:v "$BSF"} $_aenc ${_amap:+-shortest} \
	  -f segment -segment_time $SEG_SEC -segment_wrap "$WRAP" -segment_start_number "$_ps" -reset_timestamps 1 \
	  "$BUF/seg-%05d.mkv" >>"$HOME/.cache/rikkiti/replay-segwin.log" 2>&1 8>&- &
	FF=$!
	# APPEND, never truncate: each attempt used to clobber the previous one's log,
	# so a run that failed four times and fell back to whole-screen left only the
	# LAST attempt's output — the evidence for why it failed was destroyed every
	# time (2026-07-15: cost a whole test cycle). `start` truncates once per session.
	setsid rikkiti-wincap "$_pw" "$FIFO" 0 "$FPS" "$MAXH" "$CODEC" >>"$HOME/.cache/rikkiti/replay-wcwin.log" 2>&1 8>&- &
	WF=$!
	sleep 1
	[ -d "/proc/$WF" ] && [ -d "/proc/$FF" ] && return 0
	kill_hard "$WF" "$FF"
	kill_fifo_pipeline                       # never leave a half-dead writer on the fifo
	rm -f "$FIFO"; mkfifo "$FIFO" || return 1
	return 1
}

# acquire_window_capture <app> <pinned_wid> <segstart>: get window capture up,
# RE-ASKING the compositor which window is the game between attempts.
#
# The bug this replaces: we waited exactly ONE second and then treated a dead
# wincap as the verdict "this game's buffer is uncapturable", downgrading the
# WHOLE session to whole-screen capture with no re-check. One second cannot tell
# "uncapturable" apart from "the game hasn't presented its real window yet", and
# the fallback is the expensive way to be wrong: it puts the user's desktop in
# frame for a clip they might share. Retry across the launcher→game handover.
#
# A pinned wid (the $mod+F7 pick-a-window flow) is the user's explicit choice and
# is never re-resolved — we retry THAT window or fail.
acquire_window_capture() {
	_app=$1; _hint=$2; _pinned=$3; _segstart=$4; _tries=${5:-}
	# NOTHING IS RECORDED WHILE WE RETRY, so every attempt is a hole at the head of
	# the session — and the chip only appears once the state file exists, so a long
	# acquisition also reads to the user as "it isn't recording" (Lee, 2026-07-15).
	# Callers therefore choose the budget: a fullscreen game passes 1 (the output is
	# a pixel-perfect stand-in, so retrying buys nothing), while a picked/windowed
	# target spends the full budget because for IT the fallback records the desktop.
	[ -n "$_tries" ] || _tries=$(ckey window_acquire_tries 4)
	case "$_tries" in ''|*[!0-9]*) _tries=4 ;; esac
	_wid="$_hint"; _i=0
	while [ "$_i" -lt "$_tries" ]; do
		_i=$((_i + 1))
		[ -n "$_wid" ] || _wid=$(game_window "$_app")
		echo "$(date '+%F %T') acquire try $_i/$_tries app='$_app' wid='${_wid:-none}'" \
			>>"$HOME/.cache/rikkiti/replay-wcwin.log"
		if [ -n "$_wid" ] && win_pipeline_up "$_wid" "$_segstart"; then
			printf '%s' "$_wid" >"$RUN/rikkiti-replay.wid"
			return 0
		fi
		# Not capturable (yet). Unless the user pinned it, forget this window —
		# it may be a splash that has already died — and re-ask next pass.
		[ "$_pinned" = 1 ] || _wid=""
		[ "$_i" -lt "$_tries" ] && sleep 2
	done
	return 1
}

# ---- game-only audio (audio_scope = game) -----------------------------------
# OBS-style per-app capture on pipewire-pulse: a private null sink receives ONLY
# the game's streams (so the recording can't hear YouTube/Discord), and a
# loopback feeds it back to the real output so the player still hears the game.
# The game is matched by the session's app_id (recorded in buffer/session);
# streams are (re)adopted on every watcher pass — games often open audio late.
CAPSINK=rikkiti_replay_cap

# steam_name <appid>: the human game name from the LOCAL appmanifest (no
# network). Checks the usual Steam roots + any extra library folders.
steam_name() {
	for r in "$HOME/.local/share/Steam" "$HOME/.steam/steam" "$HOME/.steam/debian-installation" \
	         "$HOME/.var/app/com.valvesoftware.Steam/.local/share/Steam"; do
		mf="$r/steamapps/appmanifest_$1.acf"
		if [ -f "$mf" ]; then
			sed -n 's/^[[:space:]]*"name"[[:space:]]*"\(.*\)"/\1/p' "$mf" | head -1
			return
		fi
		vdf="$r/steamapps/libraryfolders.vdf"
		[ -f "$vdf" ] || continue
		sed -n 's/^[[:space:]]*"path"[[:space:]]*"\(.*\)"/\1/p' "$vdf" | while read -r lp; do
			mf2="$lp/steamapps/appmanifest_$1.acf"
			if [ -f "$mf2" ]; then
				sed -n 's/^[[:space:]]*"name"[[:space:]]*"\(.*\)"/\1/p' "$mf2" | head -1
				break
			fi
		done
	done | head -1
}

# game_audio_key: lowercase match key from the session's app_id. A Proton game's
# app_id is steam_app_<N> — useless against pulse stream props — so resolve the
# manifest name and squash it ("ELDEN RING" → "eldenring" matches the stream's
# application.process.binary "eldenring.exe"). Empty = can't identify the game.
game_audio_key() {
	app=$(sed -n 1p "$(ring_dir)/session" 2>/dev/null)
	case "$app" in
	  steam_app_*)
		nm=$(steam_name "${app#steam_app_}")
		if [ -n "$nm" ]; then
			printf '%s' "$nm" | tr 'A-Z' 'a-z' | tr -cd 'a-z0-9'
			return
		fi ;;
	esac
	printf '%s' "$app" | tr 'A-Z' 'a-z' | sed 's/\.exe$//; s/^steam_app_//' | tr -cd 'a-z0-9._-'
}

# game_audio_setup: load the capture sink + hear-through loopback. Module ids →
# state lines 7+8 (teardown uses them). Cleans any leftovers from a crash first.
game_audio_setup() {
	# never yank modules from under a LIVE recording — an out-of-band setup once
	# unloaded the sink mid-session and the rest of the footage was silent
	# (2026-07-13, the hand-adopted-orphan incident). Parked (resume) is fine.
	if running && [ ! -e "$PAUSEF" ]; then return 1; fi
	for m in $(pactl list modules short 2>/dev/null | grep "$CAPSINK" | cut -f1); do
		pactl unload-module "$m" 2>/dev/null || true
	done
	M1=$(pactl load-module module-null-sink "sink_name=$CAPSINK" \
		"sink_properties=device.description=RikkitiReplay" 2>/dev/null) || return 1
	M2=$(pactl load-module module-loopback "source=$CAPSINK.monitor" sink=@DEFAULT_SINK@ \
		latency_msec=40 2>/dev/null) || { pactl unload-module "$M1" 2>/dev/null; return 1; }
	return 0
}

# game_audio_adopt: move every sink-input belonging to the game onto the
# capture sink (idempotent; called at start and by the watcher — audio streams
# appear whenever the game feels like it). Identity is decided by PROCESS
# LINEAGE first, names second:
#  1. Steam titles: every process whose cmdline carries "AppId=<N> " (Steam's
#     reaper + wrappers) anchors the game's process tree; a stream whose
#     application.process.id has one of those anchors as an ancestor IS the
#     game. Immune to naming (application.name = "ELDEN RING™", binary =
#     wine64-preloader — names lie, ancestry doesn't).
#  2. Fallback: squashed-substring match of the app key against the stream's
#     name/binary/host properties (both sides squashed to [a-z0-9] — the ™
#     lesson of 2026-07-13). Covers native games where app_id ≈ binary name.
game_audio_adopt() {
	key=$(game_audio_key)
	app=$(sed -n 1p "$(ring_dir)/session" 2>/dev/null)
	anchors=""
	case "$app" in
	  steam_app_*) anchors=$(pgrep -f "AppId=${app#steam_app_} " 2>/dev/null | tr '\n' ' ') ;;
	esac
	[ -n "$key$anchors" ] || return 0
	pactl list sink-inputs 2>/dev/null | awk -v key="$key" '
		/^Sink Input #/ { if (idx != "") print idx, pid + 0, hit; idx = substr($3, 2); pid = 0; hit = 0 }
		/application\.process\.id/ { p = $0; gsub(/[^0-9]/, "", p); pid = p }
		/application\.process\.binary|application\.name|application\.process\.host/ {
			line = tolower($0)
			gsub(/[^a-z0-9]/, "", line)
			if (key != "" && index(line, key) > 0) hit = 1
		}
		END { if (idx != "") print idx, pid + 0, hit }
	' | while read -r si spid hit; do
		ok=$hit
		if [ "$ok" != 1 ] && [ -n "$anchors" ] && [ "$spid" -gt 1 ] 2>/dev/null; then
			# ancestry walk: /proc/<pid>/status PPid (stat field 4 breaks on
			# process names with spaces); anchors are few, hops capped
			p=$spid; i=0
			while [ "$i" -lt 25 ] && [ -n "$p" ] && [ "$p" -gt 1 ] 2>/dev/null; do
				case " $anchors " in *" $p "*) ok=1; break ;; esac
				p=$(awk '/^PPid:/{print $2}' "/proc/$p/status" 2>/dev/null)
				i=$((i + 1))
			done
		fi
		[ "$ok" = 1 ] && pactl move-sink-input "$si" "$CAPSINK" 2>/dev/null || true
	done
}

# game_audio_teardown: streams first (back to the default output), then modules.
game_audio_teardown() {
	m1=$(sed -n 7p "$STATE" 2>/dev/null); m2=$(sed -n 8p "$STATE" 2>/dev/null)
	# `short` lists the sink INDEX — resolve ours by name first
	csi=$(pactl list sinks short 2>/dev/null | awk -v s="$CAPSINK" '$2 == s {print $1}')
	if [ -n "$csi" ]; then
		pactl list sink-inputs short 2>/dev/null | awk -v s="$csi" '$2 == s {print $1}' \
			| while read -r si; do pactl move-sink-input "$si" @DEFAULT_SINK@ 2>/dev/null || true; done
	fi
	for m in "$m2" "$m1"; do
		case "$m" in ''|-|*[!0-9]*) continue ;; esac
		pactl unload-module "$m" 2>/dev/null || true
	done
	for m in $(pactl list modules short 2>/dev/null | grep "$CAPSINK" | cut -f1); do
		pactl unload-module "$m" 2>/dev/null || true
	done
}

# The lazy-rescue watcher: every 20s, rescue any mark the wrap is about to reach.
# It writes its OWN pid ($$ inside the child — `$!` after `setsid ... &` can be a
# corpse: setsid forks when it's already a group leader) and self-terminates when
# the state file disappears, so stop never needs to kill it. mark re-calls this,
# so a watcher that died still gets replaced the moment marks matter again.
WATCHPID="$RUN/rikkiti-replay.watch.pid"
ensure_watcher() {
	wp=$(cat "$WATCHPID" 2>/dev/null)
	[ -n "$wp" ] && [ -d "/proc/$wp" ] && return 0
	# fully detach: </dev/null >/dev/null so the watcher never pins the caller's
	# stdin/stdout open (piping `start` to a reader would otherwise hang on EOF).
	setsid sh -c 'echo $$ >"$4"; while [ -e "$1" ]; do sleep 20; "$2" rescue-check </dev/null >>"$3" 2>&1; done; rm -f "$4"' \
		rikkiti-replay-watch "$STATE" "$0" "$HOME/.cache/rikkiti/replay-watch.log" "$WATCHPID" </dev/null >/dev/null 2>&1 8>&- &
}
running() {
	[ -e "$STATE" ] || return 1
	[ -e "$PAUSEF" ] && return 0   # paused: pipeline parked, session alive
	p=$(wf_pid)
	[ -n "$p" ] && [ -d "/proc/$p" ] && return 0
	return 1
}

# durize_list <list> — pin each entry's concat offset to its measured VIDEO
# length (last two video packet PTS; no decode). Without this the concat
# demuxer offsets every next segment by the FILE duration, which the audio
# stream inflates ~20-60ms past the video (AAC priming + last-packet-duration
# bookkeeping) — punching a 40-58ms PTS hole into BOTH streams at every 2s
# join: a metronomic playback hitch (Lee saw it as "choppy"), and concat-time
# stretching ~1.25% past wall-time (Studio offsets landed late). The segments'
# packets are untouched — this only corrects where each file STARTS. Verified
# 2026-07-17 on live ER ring: plain = 40-56ms holes every join; durized = 0
# holes, byte-identical audio, A/V spans matched within 8ms. Do NOT "heal"
# joins with aresample instead — the audio content was never missing.
# The last entry gets no directive (nothing follows it); a file whose probe
# fails just falls back to the old offset for that one join.
durize_list() {
	dz_total=$(grep -c "^file " "$1" 2>/dev/null) || return 0
	[ "$dz_total" -gt 1 ] || return 0
	dz_tmp="$1.dur"; : >"$dz_tmp"; dz_n=0
	while IFS= read -r dz_line; do
		printf '%s\n' "$dz_line" >>"$dz_tmp"
		case "$dz_line" in "file '"*) ;; *) continue ;; esac
		dz_n=$((dz_n + 1))
		[ "$dz_n" -ge "$dz_total" ] && continue
		dz_f=${dz_line#file \'}; dz_f=${dz_f%\'}
		dz_d=$($XNICE ffprobe -v error -select_streams v -show_entries packet=pts_time \
			-of csv=p=0 "$dz_f" 2>/dev/null | tail -2 | \
			awk -F, 'NR==1{a=$1} NR==2 && $1>a {printf "%.6f", $1+($1-a)}')
		[ -n "$dz_d" ] && printf 'duration %s\n' "$dz_d" >>"$dz_tmp"
	done <"$1"
	mv "$dz_tmp" "$1"
}

# concat_out <list> <dest> — lossless concat, join-hole-free (see durize_list).
concat_out() {
	durize_list "$1"
	$XNICE ffmpeg -nostdin -y -v error -f concat -safe 0 -i "$1" -map 0 \
		-c copy "$2" </dev/null || return 1
	tag_mkv "$2"
	return 0
}
# tag_mkv <file> — colour metadata now lives IN the bitstream (the segmenter
# rewrites the VUI to limited BT.709), so no container stamping is needed.
# What remains: turn the encoder's 16-align pad rows (1080 coded as 1088, mkv
# display dims squishing them back in) into a REAL container crop so players
# cut the pad off instead of scaling it into the picture (the green line).
tag_mkv() {
	command -v mkvpropedit >/dev/null 2>&1 || return 0
	if command -v mkvmerge >/dev/null 2>&1; then
		dims=$($XNICE mkvmerge -J "$1" 2>/dev/null | tr ',{}' '\n' | sed -n 's/.*"\(pixel\|display\)_dimensions": *"[0-9]*x\([0-9]*\)".*/\1 \2/p' | head -2)
		ph=$(printf '%s\n' "$dims" | awk '$1 == "pixel" {print $2}')
		dh=$(printf '%s\n' "$dims" | awk '$1 == "display" {print $2}')
		if [ -n "$ph" ] && [ -n "$dh" ] && [ "$ph" -gt "$dh" ] 2>/dev/null; then
			mkvpropedit "$1" --edit track:v1 \
				--set pixel-crop-bottom=$((ph - dh)) --set display-height="$dh" \
				>/dev/null 2>&1 || true
		fi
	fi
	# The segmenter guesses the frame rate from mkv's ms-rounded timestamps
	# (first deltas 16ms → "62.5fps") and bakes DefaultDuration=16ms into every
	# ring segment; concat copies it into the export. Players that pace by it
	# run ~4% fast into a correction hitch — a constant speeds-up/slows-down
	# micro-stutter on a flawless stream. Restamp with the TRUE mean frame
	# interval measured from the packets themselves (rate-agnostic — works for
	# whatever fps this file was actually recorded at).
	dd_ns=$($XNICE ffprobe -v error -select_streams v -show_entries packet=pts_time \
		-of csv=p=0 "$1" 2>/dev/null | awk -F, '
		NR==1{f=$1} $1!=""{l=$1;n++}
		END{if(n>1){d=(l-f)/(n-1); if(d>0.001) printf "%.0f", d*1e9}}')
	if [ -n "$dd_ns" ]; then
		mkvpropedit "$1" --edit track:v1 \
			--set default-duration="$dd_ns" >/dev/null 2>&1 || true
	fi
}

# rescue_one <mark-epoch> <buf> — copy the padded window around a mark out of the
# ring to <folder>/rescued/ (lossless concat). Returns 1 if no covering segments
# remain. A segment's mtime is its END; it covers [mtime-SEG_SEC, mtime].
rescue_one() {
	M=$1; B=$2
	FOLDER=$(folder)
	s=$((M - $(ckey rescue_before_min 5) * 60))
	e=$((M + $(ckey rescue_after_min 2) * 60))
	INF=""
	running && INF=$(ls -t "$B"/seg-*.mkv 2>/dev/null | head -1)  # skip in-flight
	L="$RUN/rikkiti-replay.rescue.$$"
	: >"$L"; n=0
	OLDIFS=$IFS; IFS='
'
	for f in $(ls -tr "$B"/seg-*.mkv 2>/dev/null); do
		[ "$f" = "$INF" ] && continue
		[ -s "$f" ] || continue
		mt=$(stat -c %Y "$f" 2>/dev/null) || continue
		[ "$mt" -lt "$s" ] && continue
		[ $((mt - SEG_SEC)) -gt "$e" ] && continue
		printf "file '%s'\n" "$f" >>"$L"; n=$((n+1))
	done
	IFS=$OLDIFS
	[ "$n" -eq 0 ] && { rm -f "$L"; return 2; }   # nothing matched (2 ≠ transient failure)
	# The wrap keeps moving while we work: a file selected a moment ago may since
	# have been REWRITTEN as a brand-new segment (same name, new footage). Concat
	# then reads mid-overwrite garbage — seen 2026-07-17 as "Invalid data" demux
	# errors and rescued clips whose sidecar put the mark at -85min. Re-verify
	# every entry immediately before the copy and drop any whose mtime has left
	# the mark window: better a clip missing its first seconds than one made of
	# the wrong footage.
	L2="$L.v"; : >"$L2"; n2=0
	while IFS= read -r rl; do
		case "$rl" in "file '"*) ;; *) printf '%s\n' "$rl" >>"$L2"; continue ;; esac
		rf=${rl#file \'}; rf=${rf%\'}
		rmt=$(stat -c %Y "$rf" 2>/dev/null) || continue
		[ "$rmt" -lt "$s" ] && continue
		[ $((rmt - SEG_SEC)) -gt "$e" ] && continue
		printf '%s\n' "$rl" >>"$L2"; n2=$((n2+1))
	done <"$L"
	mv "$L2" "$L"
	[ "$n2" -eq 0 ] && { rm -f "$L"; return 2; }
	[ "$n2" -lt "$n" ] && echo "rescue: $((n - n2)) segment(s) overwritten before the copy — clip starts later than the full padding"
	mkdir -p "$FOLDER/rescued"
	OUTF="$FOLDER/rescued/Mark-$(date -d "@$M" +%Y%m%d-%H%M%S 2>/dev/null || date +%Y%m%d-%H%M%S).mkv"
	# -nostdin </dev/null: this runs INSIDE a `while read` loop — without it ffmpeg
	# eats the loop's stdin as interactive commands (it ate the leading '1' off the
	# next mark's epoch in testing, silently voiding every later mark).
	if concat_out "$L" "$OUTF"; then
		# Sidecar for Replay Studio's timeline: the mark's offset (secs) into this
		# clip. First listed segment is the oldest; its mtime is its END time.
		first=$(sed -n "1s/^file '\(.*\)'$/\1/p" "$L")
		fm=$(stat -c %Y "$first" 2>/dev/null || echo 0)
		[ "$fm" -gt 0 ] && echo $((M - fm + SEG_SEC)) >"$OUTF.marks"
		rm -f "$L"
		cue complete.oga
		osd "Marked moment saved"
		notify "Marked moment saved" "$OUTF"
		return 0
	fi
	rm -f "$L"; return 1
}

case "${1:-}" in
  start)
	setup_ok || { echo "rikkiti-replay: not set up (Settings ▸ Game Recording)"; exit 0; }
	# serialize: both panel processes fire autostart on the same fullscreen event.
	# -w: a stuck holder must never pile up blocked starts (the fd-8 deadlock).
	exec 8>"$RUN/rikkiti-replay.start.lock"
	flock -w 10 8 || { echo "another start is holding the lock — giving up"; exit 1; }
	pipeline_lock          # also exclude a concurrent pause/resume/stop
	kill_fifo_pipeline     # never inherit a stray recorder from a prior race
	# A ring left over from a PREVIOUS login (logout/shutdown while recording or
	# parked) must be RETIRED, not resumed: stop tears the audio routing down, then
	# this start begins fresh — and the wipe branch below flushes any outstanding
	# marks to rescued/ as a last resort before deleting the previous login's footage.
	if [ -e "$STATE" ] && [ "$(state_inode)" != "$(wl_inode)" ]; then
		echo "note: retiring a ring from a previous session (any marks flushed to rescued/ on wipe)"
		"$0" stop >/dev/null 2>&1 || true
	fi
	running && { echo "already holding a ring ($(state_buf))"; exit 0; }
	FOLDER=$(folder); BUF=$(ring_dir)
	MINUTES=$(ckey buffer_minutes 15)
	FPS=$(ckey max_fps 60)
	DEV=$(render_node)
	# CODEC (the short name) is passed to rikkiti-wincap so it encodes the SAME codec
	# the BSF below expects — else wincap's hardcoded-HEVC output met an h264 BSF on a
	# default install and the segmenter wrote ZERO segments. Normalise the *) fallthrough
	# to h264 so wincap's arg can never disagree with ENC/BSF.
	CODEC=$(ckey codec h264)
	case "$CODEC" in hevc|av1) ;; *) CODEC=h264 ;; esac
	FAM=$(enc_family "$DEV")
	ENC=$(enc_name "$FAM" "$CODEC")
	# Output: CLI arg > conf > the comp's fullscreen-game publication (records the
	# monitor the game is ON) > the single connected connector > first connected.
	OUT="${2:-$(ckey output "")}"
	[ -z "$OUT" ] && OUT=$(sed -n 1p "$RUN/rikkiti-fs" 2>/dev/null)
	if [ -z "$OUT" ]; then
		set -- $(for c in /sys/class/drm/card*-*/status; do
			[ "$(cat "$c" 2>/dev/null)" = "connected" ] || continue
			d=${c%/status}; d=${d##*/}; echo "${d#card*-}"
		done)
		OUT="${1:-}"
		[ $# -gt 1 ] && echo "note: multiple outputs ($*), recording $OUT (pass one, or set output= in replay.conf)"
	fi
	[ -z "$OUT" ] && { echo "rikkiti-replay: no output found"; exit 1; }

	# The buffer subdir is always created BY US so nodatacow can be applied the
	# moment it exists (chattr +C only sticks on new dirs) — incl. after the user
	# moves the folder. Constant segment rewrites on btrfs fragment badly otherwise.
	if [ ! -d "$BUF" ]; then
		mkdir -p "$BUF" || { echo "rikkiti-replay: cannot create $BUF"; exit 1; }
		[ "$(stat -f -c %T "$BUF" 2>/dev/null)" = "btrfs" ] && chattr +C "$BUF" 2>/dev/null || true
	fi
	# ---- Multi-game rolling buffer (chronological, docs/48) -------------------
	# Keep recording ACROSS games within a login: a NEW game CONTINUES the same
	# ring (Replay Studio splits it into per-game entries via the boundary log
	# below) instead of wiping the previous game's footage. Only a NEW LOGIN starts
	# a fresh buffer — footage must never outlive its session ([[gpu-harness-safety]]
	# / the 30-min-zombie-ring incident). CONTINUE = the login marker matches this
	# wayland session AND segments still exist; else FRESH (wipe).
	NOW=$(date +%s); SEGSTART=0
	if [ "$(cat "$BUF/login" 2>/dev/null)" = "$(wl_inode)" ] && ls "$BUF"/seg-*.mkv >/dev/null 2>&1; then
		# continue: keep segments + marks + boundary log; CONTINUE the numbering right
		# after the NEWEST segment (by mtime, not max index — they diverge once the
		# ring has wrapped) so the new game overwrites OLDEST-first, never the current
		# game's most recent footage. ffmpeg wraps the start number modulo $WRAP.
		SEGSTART=$(( $(seg_index "$BUF" | tail -1 | sed 's/.*seg-0*\([0-9][0-9]*\)\.mkv/\1/' | grep . || echo -1) + 1 ))
	else
		# fresh buffer (new login / empty ring) — wipe everything incl. boundaries.
		# LAST-RESORT mark flush: this wipe is the ONE moment a mark is genuinely about
		# to be destroyed forever (a previous login's ring, never rescued). Honour the
		# "a mark can never be overwritten" promise by copying each out first.
		# rescue_one reads the buffer + conf directly (no live session needed); a mark
		# with no covering footage just returns non-zero and dies with the wipe. Gated
		# on a configured folder so an un-set-up manual start can't mkdir "/rescued".
		if [ -s "$BUF/marks" ] && [ -n "$(folder)" ]; then
			while read -r _m; do
				case "$_m" in ''|*[!0-9]*) continue ;; esac
				rescue_one "$_m" "$BUF" >/dev/null 2>&1 || true
			done <"$BUF/marks"
		fi
		rm -f "$BUF"/seg-*.mkv "$BUF"/marks* "$BUF"/session "$BUF"/sessions
		printf '%s' "$(wl_inode)" >"$BUF/login"
	fi
	rm -f "$FIFO" "$PAUSEF"
	# fresh-start hygiene: drop stale watchdog counters/rate-limits so a new game
	# doesn't inherit a previous session's empty-cap count or fallback timer
	rm -f "$RUN/rikkiti-replay.emptycap" "$RUN/rikkiti-replay.silence" "$RUN/rikkiti-replay.audiofix" \
	      "$RUN/rikkiti-replay.upgrades" "$RUN/rikkiti-replay.reacq"
	# Do NOT truncate the wincap log here. One game launch spans SEVERAL starts
	# (Elden Ring: launcher window → destroyed → game window), so truncating per
	# start destroyed the evidence for every run but the last — which is exactly
	# what hid the "could not fetch the window's dma-buf" cause for two rounds.
	# Keep the history, bounded.
	WCL="$HOME/.cache/rikkiti/replay-wcwin.log"
	mkdir -p "$HOME/.cache/rikkiti"   # before ANY log redirect below touches it
	# Bound every log this engine appends to. wcwin has always been trimmed here;
	# segwin (the window-capture segmenter's ffmpeg output) and watch (a heartbeat
	# per pass) were not, and grew without limit for the life of the install.
	trim_log "$WCL"
	trim_log "$HOME/.cache/rikkiti/replay-segwin.log"
	trim_log "$HOME/.cache/rikkiti/replay-watch.log" 800 200
	trim_log "$HOME/.cache/rikkiti/replay-ff.log"
	trim_log "$HOME/.cache/rikkiti/replay-wf.log"
	mkfifo "$FIFO" || exit 1
	# CURRENT game identity for audio isolation + the Studio's live game. A WINDOWED
	# game (pick / windowed-auto) isn't in rikkiti-fs (the fullscreen publication) —
	# its app_id rides in on RIK_TARGET_APP so game_audio_key can still identify it
	# (else it can't match the game's streams and records ALL system sound).
	APPID="${RIK_TARGET_APP:-$(sed -n 2p "$RUN/rikkiti-fs" 2>/dev/null)}"
	printf '%s\n%s\n%s\n' "$APPID" "$NOW" "$OUT" >"$BUF/session"
	# Append this game's boundary (epoch<TAB>app_id<TAB>output): the Studio maps
	# each segment to a game by its mtime vs these boundaries → the per-game list.
	printf '%s\t%s\t%s\n' "$NOW" "$APPID" "$OUT" >>"$BUF/sessions"

	WRAP=$((MINUTES * 60 / SEG_SEC))
	# If the user SHRANK the buffer in Settings, the previous ring may hold
	# segments numbered >= the new wrap — ffmpeg wraps modulo the new value, so
	# those files would never be rewritten: stale footage pinned on disk forever
	# (and surfacing in the Studio). Reap them before the pipeline starts.
	for _sf in "$BUF"/seg-*.mkv; do
		[ -e "$_sf" ] || break
		_sn=${_sf##*/seg-}; _sn=${_sn%.mkv}
		case "$_sn" in *[!0-9]*) continue ;; esac
		while [ "${_sn#0}" != "$_sn" ] && [ "${#_sn}" -gt 1 ]; do _sn=${_sn#0}; done
		[ "$_sn" -ge "$WRAP" ] && rm -f "$_sf"
	done
	GOP=$((FPS * SEG_SEC))
	# Optional recording height cap (Steam parity): GPU-side scale in the same
	# VAAPI filter chain wf-recorder builds itself (min() = never upscale).
	MAXH=$(ckey max_height 0)
	# LIMITED range on purpose: it's what every player's default path assumes.
	# Full-range HEVC renders oversaturated in VLC even with an EXPLICIT VUI
	# full flag (verified against a live side-by-side) — encode what the world
	# expects instead of what's theoretically richer.
	FILTER=$(enc_filter "$FAM" "$MAXH" "$FPS")
	DEVARG=$(enc_devarg "$FAM" "$DEV")
	PIXFMT=$(enc_pixfmt "$FAM")
	# the recorder writes full_range=1 into the SPS regardless of the frames —
	# rewrite the VUI losslessly at the segmenter (+ declare BT.709, which the
	# VAAPI conversion genuinely uses: measured) so players stop guessing
	# Keyed on the CODEC, not the encoder: h264 is h264 whether VAAPI, NVENC or x264
	# produced it, and every one of them writes a full-range SPS we have to correct.
	case "$CODEC" in
		h264) BSF="h264_metadata=video_full_range_flag=0:colour_primaries=1:transfer_characteristics=1:matrix_coefficients=1" ;;
		hevc) BSF="hevc_metadata=video_full_range_flag=0:colour_primaries=1:transfer_characteristics=1:matrix_coefficients=1" ;;
		*) BSF="" ;;
	esac
	AUDIO=""
	AM1="-"; AM2="-"
	# Default (pulse) audio backend on purpose: the pipewire backend ignores
	# .monitor names and can fall back to the MIC (spike finding). Resolve the
	# monitor at every start — the default sink can change between sessions.
	if [ "$(ckey audio_system true)" = "true" ]; then
		AUDIO="-a$(pactl get-default-sink).monitor"
		case "$(ckey audio_scope system)" in
		  game|game-only)
			# Route ONLY the game's streams into a private capture sink (the clip
			# can't hear YouTube/Discord); a loopback keeps the player hearing it.
			if [ -n "$(game_audio_key)" ] && game_audio_setup; then
				AM1=$M1; AM2=$M2
				AUDIO="-a$CAPSINK.monitor"
				game_audio_adopt
			else
				notify "Recording everything you hear" "Couldn't isolate this game's audio — using normal system audio for this session."
			fi
			;;
		esac
	fi

	# Capture SOURCE (docs/49): "window" = rikkiti-wincap encodes just the game's
	# toplevel (the desktop is structurally never captured; windowed games work);
	# "output" = wf-recorder captures the whole monitor (the legacy path). Window
	# mode needs the toplevel id the comp publishes on line 3 of rikkiti-fs.
	CAPTURE=$(ckey capture output)
	# A specific target window — the pick-a-window flow ($mod+F7) or windowed
	# auto-record — comes in via RIK_TARGET_WID and forces WINDOW capture, whatever
	# the Settings default. Otherwise the fullscreen game the comp published (line 3).
	WID="${RIK_TARGET_WID:-$(sed -n 3p "$RUN/rikkiti-fs" 2>/dev/null)}"
	case "$WID" in ''|*[!0-9]*) WID="" ;; esac
	[ -n "${RIK_TARGET_WID:-}" ] && [ -n "$WID" ] && CAPTURE=window
	rm -f "$RUN/rikkiti-replay.nostart"                 # a deliberate start un-suppresses auto-record
	[ -n "$WID" ] && printf '%s' "$WID" >"$RUN/rikkiti-replay.wid"   # session window, for a manual stop's suppress
	# setsid: children must outlive a closing terminal / the panel's spawn shell.
	WINOK=0
	# Window capture is VAAPI-only: rikkiti-wincap imports the compositor's dma-buf
	# straight into a VAAPI encoder, and there is no equivalent import on a GPU without
	# VAAPI (ffmpeg cannot derive a CUDA device from a DRM one — ENOSYS — so NVIDIA has
	# no zero-copy route). Don't let it fail per-attempt on such a box; take the screen
	# path deliberately and say so once.
	if [ "$CAPTURE" = window ] && [ "$FAM" != vaapi ]; then
		echo "rikkiti-replay: window capture needs a VAAPI GPU (this one encodes via $FAM) — recording the whole screen" >&2
		CAPTURE=output
	fi
	if [ "$CAPTURE" = window ] && command -v rikkiti-wincap >/dev/null 2>&1; then
		# $WID is only a HINT: at the instant a game goes fullscreen the comp may
		# still be naming its launcher/anti-cheat splash, which dies seconds later.
		# acquire re-resolves it (see game_window) rather than reading that death
		# as "this game can't be window-captured". Only $mod+F7's explicit pick is
		# pinned — that window is the user's stated choice.
		# PIN = the user picked this exact window ($mod+F7). ONLY that pins. Windowed
		# auto-record also passes a RIK_TARGET_WID, but treating that as a pin was a
		# real bug: Elden Ring's launcher (window 118) is what the panel sees first,
		# ER then DESTROYS it and opens window 122 for the game — and a pinned
		# acquire re-tried the dead 118 four times over 17s instead of re-asking
		# which window is the game (Lee's log, 2026-07-15 09:03).
		PIN=0; [ -n "${RIK_PIN:-}" ] && PIN=1
		# How long is it worth stalling to get window capture? That depends
		# entirely on what the fallback would record.
		#
		#   pinned (pick-a-window / windowed auto-record): the fallback records the
		#     whole SCREEN — the user's desktop, which is the one thing this feature
		#     promises never to capture. Never an acceptable stand-in: spend the
		#     full retry budget and only fall back (loudly) if it truly can't work.
		#
		#   fullscreen autostart: the output IS the game, pixel for pixel, and
		#     alt-tab fires autopause so the desktop can't appear either. The
		#     fallback costs nothing, and stalling costs real footage plus a chip
		#     that doesn't show up for ~17s. So try ONCE and get recording.
		#
		# This ordering matters for a real game, not just in theory: Elden Ring
		# churns its window buffer for ~30s (an 800x450 EAC splash, then loading,
		# then the engine's real surface), so retrying at start both recorded the
		# splash AND still ended on the screen fallback. Recording the output
		# immediately and letting the watcher upgrade gets identical pixels from
		# second zero, with no splash chunk and no dead chip.
		# Budget: full by default (the fallback would record the desktop), but ONE
		# try when the comp says this app is the fullscreen game — there the
		# fallback is the game itself, so stalling only buys a footage hole and a
		# chip that doesn't appear (which read to Lee as "it isn't recording").
		TRY=""
		[ "$PIN" != 1 ] && fs_game_is "$APPID" && TRY=1
		# Remember which kind of session this is: `resume` (alt-tab back, the
		# watcher's upgrade, a re-acquire after a resize) has to make the same
		# call, and by then RIK_TARGET_WID is long gone.
		if [ "$PIN" = 1 ]; then : >"$RUN/rikkiti-replay.pinned"; else rm -f "$RUN/rikkiti-replay.pinned"; fi
		if acquire_window_capture "$APPID" "$WID" "$PIN" "$SEGSTART" "$TRY"; then
			WINOK=1
		else
			echo "rikkiti-replay: game window not capturable yet — recording its screen for now" >&2
			tail -3 "$HOME/.cache/rikkiti/replay-wcwin.log" 2>/dev/null >&2
		fi
	fi
	if [ "$WINOK" = 0 ]; then
		# Try the hardware encoder, then — if it didn't survive its first second —
		# once more on the CPU. Encoder selection reasons about the GPU, and a GPU can
		# always surprise us (an unpublished render node, a driver whose VPP won't take
		# our pixel format, a codec the silicon lists but won't accept). Software x264
		# has no such failure mode: it costs cores, and it always records. Losing the
		# footage because we guessed the encoder wrong is the one outcome this feature
		# cannot have.
		for _try in hw sw; do
			if [ "$_try" = sw ]; then
				[ "$FAM" = sw ] && break   # already software — nothing left to fall back to
				echo "rikkiti-replay: $ENC failed to start — falling back to software encoding" >&2
				tail -3 "$HOME/.cache/rikkiti/replay-wf.log" 2>/dev/null >&2
				FAM=sw
				ENC=$(enc_name sw "$CODEC")
				FILTER=$(enc_filter sw "$MAXH" "$FPS")
				DEVARG=$(enc_devarg sw "$DEV")
				PIXFMT=$(enc_pixfmt sw)
				rm -f "$FIFO"; mkfifo "$FIFO" || break
			fi
			setsid ffmpeg -nostdin -y -hide_banner -loglevel warning -i "$FIFO" \
			  -map 0 -c copy ${BSF:+-bsf:v "$BSF"} -f segment -segment_time $SEG_SEC -segment_wrap "$WRAP" -segment_start_number "$SEGSTART" \
			  -reset_timestamps 1 "$BUF/seg-%05d.mkv" >"$HOME/.cache/rikkiti/replay-ff.log" 2>&1 8>&- &
			FF=$!
			# shellcheck disable=SC2086  # $AUDIO intentionally word-splits (may be empty)
			PULSE_LATENCY_MSEC=$(ckey audio_latency_ms 100) \
			setsid wf-recorder -o "$OUT" -c "$ENC" ${DEVARG:+-d "$DEVARG"} ${PIXFMT:+-x "$PIXFMT"} -r "$FPS" -p g=$GOP $AUDIO \
			  ${FILTER:+-F "$FILTER"} \
			  -m matroska -f "$FIFO" -y >>"$HOME/.cache/rikkiti/replay-wf.log" 2>&1 8>&- &
			WF=$!
			sleep 1
			[ -d "/proc/$WF" ] && [ -d "/proc/$FF" ] && break
			kill_hard "$WF" "$FF"
		done
	fi
	if ! [ -d "/proc/$WF" ] || ! [ -d "/proc/$FF" ]; then
		kill_hard "$WF" "$FF"; rm -f "$FIFO"
		echo "rikkiti-replay: pipeline failed to start:"
		tail -3 "$HOME/.cache/rikkiti/replay-wf.log" 2>/dev/null
		exit 1
	fi
	mkdir -p "$HOME/.cache/rikkiti"
	printf '%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n' "$WF" "$FF" "$BUF" "$(date +%s)" "$MINUTES" "$(wl_inode)" "$AM1" "$AM2" >"$STATE.new" \
		&& mv "$STATE.new" "$STATE"
	# Capture MODE for the panel chip: "window" = only the game's window; "output" =
	# whole-screen fallback → the chip warns that your desktop can end up in a clip.
	{ [ "$WINOK" = 1 ] && echo window || echo output; } >"$RUN/rikkiti-replay.capmode"
	ensure_watcher   # after the state write — its loop condition is the state file
	echo "holding the last $MINUTES min of $OUT ($ENC @ ${FPS}fps) → $BUF"
	;;
  pause)
	# Park the session (alt-tab away / game quit): the pipeline is fully STOPPED
	# — a SIGSTOPped wayland client stops reading its socket, overflows, and the
	# compositor disconnects it (the resume-then-die bug). The session (state,
	# ring, marks, audio routing) stays; resume relaunches the pipeline into the
	# SAME ring, so reach-back survives and nothing off-game is ever captured.
	pipeline_lock
	running || { echo "not running"; exit 0; }
	[ -e "$PAUSEF" ] && { echo "already paused"; exit 0; }
	date +%s >"$PAUSEF"
	WFP=$(wf_pid); FFP=$(ff_pid)
	kill -INT "$WFP" 2>/dev/null || true    # finalize the in-flight segment
	i=0; while [ $i -lt 5 ] && [ -n "$FFP" ] && [ -d "/proc/$FFP" ]; do sleep 1; i=$((i+1)); done
	kill "$FFP" 2>/dev/null || true
	kill_fifo_pipeline   # TOTAL: also reap any recorder the state pid missed (no desktop leak)
	rm -f "$FIFO"
	{ printf -- '-
-
'; sed -n 3,8p "$STATE"; } >"$STATE.new" && mv "$STATE.new" "$STATE"
	osd "Recording paused"
	echo "paused"
	;;
  resume)
	pipeline_lock
	running || { echo "not running"; exit 0; }
	[ -e "$PAUSEF" ] || { echo "not paused"; exit 0; }
	# idempotency: a stray recorder from a prior race must not survive INTO the new
	# pipeline (two writers on one fifo = corruption). Reap anything on the fifo first.
	kill_fifo_pipeline
	BUF=$(state_buf)
	# Relaunch the pipeline into the SAME ring: segment numbering continues
	# (everything downstream orders by mtime, but reused NAMES would overwrite
	# kept footage), and the session start shifts by the paused wall-time so
	# held-arithmetic keeps matching actual footage.
	MINUTES=$(sed -n 5p "$STATE")
	FPS=$(ckey max_fps 60)
	DEV=$(render_node)
	# CODEC (the short name) is passed to rikkiti-wincap so it encodes the SAME codec
	# the BSF below expects — else wincap's hardcoded-HEVC output met an h264 BSF on a
	# default install and the segmenter wrote ZERO segments. Normalise the *) fallthrough
	# to h264 so wincap's arg can never disagree with ENC/BSF.
	CODEC=$(ckey codec h264)
	case "$CODEC" in hevc|av1) ;; *) CODEC=h264 ;; esac
	FAM=$(enc_family "$DEV")
	ENC=$(enc_name "$FAM" "$CODEC")
	OUT="$(ckey output "")"
	[ -z "$OUT" ] && OUT=$(sed -n 1p "$RUN/rikkiti-fs" 2>/dev/null)
	[ -z "$OUT" ] && OUT=$(first_output)
	[ -z "$OUT" ] && { echo "no output"; exit 1; }
	WRAP=$((MINUTES * 60 / SEG_SEC))
	GOP=$((FPS * SEG_SEC))
	MAXH=$(ckey max_height 0)
	# LIMITED range on purpose: it's what every player's default path assumes.
	# Full-range HEVC renders oversaturated in VLC even with an EXPLICIT VUI
	# full flag (verified against a live side-by-side) — encode what the world
	# expects instead of what's theoretically richer.
	FILTER=$(enc_filter "$FAM" "$MAXH" "$FPS")
	DEVARG=$(enc_devarg "$FAM" "$DEV")
	PIXFMT=$(enc_pixfmt "$FAM")
	# the recorder writes full_range=1 into the SPS regardless of the frames —
	# rewrite the VUI losslessly at the segmenter (+ declare BT.709, which the
	# VAAPI conversion genuinely uses: measured) so players stop guessing
	# Keyed on the CODEC, not the encoder: h264 is h264 whether VAAPI, NVENC or x264
	# produced it, and every one of them writes a full-range SPS we have to correct.
	case "$CODEC" in
		h264) BSF="h264_metadata=video_full_range_flag=0:colour_primaries=1:transfer_characteristics=1:matrix_coefficients=1" ;;
		hevc) BSF="hevc_metadata=video_full_range_flag=0:colour_primaries=1:transfer_characteristics=1:matrix_coefficients=1" ;;
		*) BSF="" ;;
	esac
	AUDIO=""
	if [ "$(ckey audio_system true)" = "true" ]; then
		if [ "$(sed -n 7p "$STATE")" != "-" ]; then
			# game-only routing survives the pause — but VERIFY the sink still
			# exists before pointing the recorder at it (a vanished capsink =
			# a silent session, discovered the hard way 2026-07-13)
			if ! pactl list sinks short 2>/dev/null | awk -v s="$CAPSINK" '$2 == s {f=1} END {exit !f}'; then
				if game_audio_setup; then
					{ sed -n 1,6p "$STATE"; printf '%s\n%s\n' "$M1" "$M2"; } >"$STATE.aud" && mv "$STATE.aud" "$STATE"
					game_audio_adopt
				else
					notify "Recording everything you hear" "The game-audio capture was lost and couldn't be rebuilt — using normal system audio."
					{ sed -n 1,6p "$STATE"; printf -- '-\n-\n'; } >"$STATE.aud" && mv "$STATE.aud" "$STATE"
				fi
			fi
			if [ "$(sed -n 7p "$STATE")" != "-" ]; then
				AUDIO="-a$CAPSINK.monitor"
			else
				AUDIO="-a$(pactl get-default-sink).monitor"
			fi
		else
			AUDIO="-a$(pactl get-default-sink).monitor"
		fi
	fi
	NEXT=$(( $(ls "$BUF"/seg-*.mkv 2>/dev/null | sed 's/.*seg-0*\([0-9][0-9]*\)\.mkv/\1/' | sort -n | tail -1 | grep . || echo -1) + 1 ))
	rm -f "$FIFO"; mkfifo "$FIFO" || exit 1
	# Same capture-source split as `start` (docs/49): window mode re-reads the
	# comp's toplevel id (still the live fullscreen game — that's why we resume)
	# and hands wincap the fresh FIFO; the segmenter continues the ring numbering.
	CAPTURE=$(ckey capture output)
	WID=$(sed -n 3p "$RUN/rikkiti-fs" 2>/dev/null)
	case "$WID" in ''|*[!0-9]*) WID="" ;; esac
	RAPP=$(sed -n 1p "$BUF/session" 2>/dev/null)
	# One boundary per PIPELINE RUN, not per game. Each run has exactly ONE capture
	# geometry (wincap sizes its encoder once at spawn; wf-recorder refuses to
	# change frame properties on the fly), so chunking on runs makes every chunk
	# resolution-consistent BY CONSTRUCTION — which is what keeps `cut`'s lossless
	# `-c copy` concat, which cannot span a resolution change, from ever emitting a
	# broken clip. Before this, a screen→window upgrade inside one session mixed two
	# resolutions into one chunk. session-info merges adjacent same-game/same-size
	# runs back together, so a pause/resume still reads as one game in the Studio.
	# A run that then fails to start simply leaves a 0-segment boundary, which the
	# chunker drops.
	printf '%s\t%s\t%s\n' "$(date +%s)" "$RAPP" "$(sed -n 3p "$BUF/session" 2>/dev/null)" >>"$BUF/sessions"
	WINOK=0
	# Window capture is VAAPI-only: rikkiti-wincap imports the compositor's dma-buf
	# straight into a VAAPI encoder, and there is no equivalent import on a GPU without
	# VAAPI (ffmpeg cannot derive a CUDA device from a DRM one — ENOSYS — so NVIDIA has
	# no zero-copy route). Don't let it fail per-attempt on such a box; take the screen
	# path deliberately and say so once.
	if [ "$CAPTURE" = window ] && [ "$FAM" != vaapi ]; then
		echo "rikkiti-replay: window capture needs a VAAPI GPU (this one encodes via $FAM) — recording the whole screen" >&2
		CAPTURE=output
	fi
	if [ "$CAPTURE" = window ] && command -v rikkiti-wincap >/dev/null 2>&1; then
		# Identical acquisition to `start` — these two had drifted into separate
		# copies, and the difference was invisible until it mattered: resume was
		# the ONLY path that ever got window capture onto Elden Ring, purely
		# because a relaunch ran late enough to see past the EAC splash.
		# Same budget rule as `start`, and it matters MOST here: the watcher's
		# whole-screen→window upgrade runs through resume, so a long acquisition
		# would trade working output capture for a silent hole — strictly worse
		# than the thing it is trying to improve. Try once; if the game still
		# isn't ready the next upgrade pass tries again 20s later, at no cost.
		RPIN=0; RTRY=""
		[ -e "$RUN/rikkiti-replay.pinned" ] && RPIN=1
		[ "$RPIN" != 1 ] && fs_game_is "$RAPP" && RTRY=1
		if acquire_window_capture "$RAPP" "$WID" "$RPIN" "$NEXT" "$RTRY"; then
			WINOK=1
		else
			echo "rikkiti-replay: game window not capturable yet — recording its screen for now" >&2
			tail -3 "$HOME/.cache/rikkiti/replay-wcwin.log" 2>/dev/null >&2
		fi
	fi
	if [ "$WINOK" = 0 ]; then
		setsid ffmpeg -nostdin -y -hide_banner -loglevel warning -i "$FIFO" \
		  -map 0 -c copy ${BSF:+-bsf:v "$BSF"} -f segment -segment_time $SEG_SEC -segment_wrap "$WRAP" \
		  -segment_start_number "$NEXT" \
		  -reset_timestamps 1 "$BUF/seg-%05d.mkv" >>"$HOME/.cache/rikkiti/replay-ff.log" 2>&1 &
		FF=$!
		# shellcheck disable=SC2086
		PULSE_LATENCY_MSEC=$(ckey audio_latency_ms 100) \
		setsid wf-recorder -o "$OUT" -c "$ENC" ${DEVARG:+-d "$DEVARG"} ${PIXFMT:+-x "$PIXFMT"} -r "$FPS" -p g=$GOP $AUDIO \
		  ${FILTER:+-F "$FILTER"} \
		  -m matroska -f "$FIFO" -y >>"$HOME/.cache/rikkiti/replay-wf.log" 2>&1 &
		WF=$!
		sleep 1
	fi
	if ! [ -d "/proc/$WF" ] || ! [ -d "/proc/$FF" ]; then
		kill_hard "$WF" "$FF"; rm -f "$FIFO"
		echo "resume failed:"; tail -3 "$HOME/.cache/rikkiti/replay-wf.log" 2>/dev/null
		exit 1
	fi
	pep=$(cat "$PAUSEF" 2>/dev/null || date +%s)
	gap=$(( $(date +%s) - pep ))
	st=$(sed -n 4p "$STATE"); newst=$((st + gap))
	{ printf '%s\n%s\n' "$WF" "$FF"; sed -n 3p "$STATE"; echo "$newst"; sed -n 5p "$STATE"; wl_inode; sed -n 7,8p "$STATE"; } >"$STATE.new" \
		&& mv "$STATE.new" "$STATE"
	rm -f "$PAUSEF"
	{ [ "$WINOK" = 1 ] && echo window || echo output; } >"$RUN/rikkiti-replay.capmode"
	ensure_watcher
	osd "Recording"
	echo "resumed (paused ${gap}s, continuing at seg $NEXT)"
	;;
  autostart)
	# The panel's hook: a fullscreen game appeared (rikkiti-fs), OR a registered
	# game's window opened windowed (RIK_TARGET_WID). Resume a paused ring for the
	# SAME game, replace a paused ring for a DIFFERENT one, no-op when already live,
	# else start fresh.
	setup_ok || exit 0
	# Did the user manually stop THIS window's session? Then don't auto-restart it
	# (alt-tab won't sneak it back). A relaunch is a NEW window id → clear + record.
	TWID="${RIK_TARGET_WID:-$(sed -n 3p "$RUN/rikkiti-fs" 2>/dev/null)}"
	case "$TWID" in ''|*[!0-9]*) TWID="" ;; esac
	NOSTART=$(cat "$RUN/rikkiti-replay.nostart" 2>/dev/null)
	[ -n "$TWID" ] && [ "$TWID" = "$NOSTART" ] && exit 0
	[ -n "$NOSTART" ] && [ "$TWID" != "$NOSTART" ] && rm -f "$RUN/rikkiti-replay.nostart"
	# windowed-auto passes the game's app_id in RIK_TARGET_APP (it isn't in
	# rikkiti-fs); it flows through to start's env for the session id + audio.
	APP="${RIK_TARGET_APP:-$(sed -n 2p "$RUN/rikkiti-fs" 2>/dev/null)}"
	if running && [ "$(state_inode)" != "$(wl_inode)" ]; then
		"$0" stop >/dev/null 2>&1 || true   # previous-login leftovers: retire (wipe flushes marks)
	fi
	if running; then
		CUR=$(sed -n 1p "$(state_buf)/session" 2>/dev/null)
		if [ -e "$PAUSEF" ]; then
			if [ "$APP" = "$CUR" ]; then exec "$0" resume
			else "$0" stop >/dev/null 2>&1; exec "$0" start; fi
		fi
		exit 0
	fi
	exec "$0" start
	;;
  autopause)
	# The compositor says nothing is FULLSCREEN any more. What that means depends
	# entirely on what we are capturing:
	#
	#   output capture — critical. The screen now shows the DESKTOP, and recording
	#     it would put the user's desktop into a clip. Pause. (The watcher turns a
	#     long pause into a real stop, so a quit game winds down by itself while an
	#     alt-tab costs nothing.)
	#
	#   window capture — meaningless. We record the GAME'S WINDOW; fullscreen was
	#     never part of that, and the desktop structurally cannot be in frame. A
	#     game that goes windowed — or merely changes resolution, which drops
	#     fullscreen for a moment — is still right there and still being played.
	#     Pausing on this edge parked the session FOREVER, because nothing re-fires
	#     autostart for a non-fullscreen window: Lee, 2026-07-15, "it seems to have
	#     just stayed paused when i changed resolution and when i turned it into
	#     window mode too". A window-capture session's life belongs to the WINDOW —
	#     wincap stops when it goes, and the watcher then re-acquires or tears down.
	#
	# Trade-off worth knowing: alt-tabbing away from a window-captured game now
	# keeps recording that window rather than parking. That is what the feature
	# promises (record the game, never the desktop), but a very long alt-tab will
	# roll real gameplay out of the ring. If that bites, the fix is to pause on the
	# game losing FOCUS — not on it losing fullscreen.
	running || exit 0
	[ "$(cat "$RUN/rikkiti-replay.capmode" 2>/dev/null)" = window ] && exit 0
	exec "$0" pause
	;;
  toggle)
	# F7: recording → stop (a USER stop, so auto-record won't sneak this game back on
	# until it's relaunched); paused → resume; idle → pick a window to record.
	if running && [ ! -e "$PAUSEF" ]; then exec "$0" stop suppress; fi
	if running; then exec "$0" resume; fi
	exec "$0" pick
	;;
  pick)
	# Interactive "click a window to record" (docs/49) — works for windowed AND
	# fullscreen games. Feeds each window's box to slurp; the box you click maps back
	# to its window id and we record THAT window (the desktop can never be in frame).
	setup_ok || { echo "rikkiti-replay: not set up (Settings ▸ Game Recording)"; exit 0; }
	running && exec "$0" toggle
	{ command -v slurp && command -v jq && command -v rikkiti-msg; } >/dev/null 2>&1 \
		|| { osd "Window picker needs slurp + jq"; exit 1; }
	# Offer ONLY windows you can actually see. get-windows is MRU order (the comp
	# moves a toplevel to the front of its list on focus), so every window EARLIER
	# in the list is above this one: a window is hidden when those cover ~all of it.
	# Offering hidden windows meant you could pick something stacked BEHIND the game
	# you meant — and nearly always by accident, because its box is invisible but
	# still snappable (Lee, 2026-07-15: "users will deffo do this and think it is a
	# bug"). Bring a window to the front if you want to record it.
	#
	# AREA, not centre: the obvious "is the centre covered?" test hides a fullscreen
	# game the moment any small window sits over its middle. 85% covered = hidden;
	# anything with a real sliver showing stays pickable, and you can point at it.
	VISIBLE='[ .[] | select((.minimized|not) and (.skip|not)) ] as $w
	  | [ range(0; $w|length) as $i
	      | $w[$i] as $x
	      | ($x.rect.width * $x.rect.height) as $area
	      | ([ $w[0:$i][] as $o
	           | (([$x.rect.x+$x.rect.width, $o.rect.x+$o.rect.width] | min) - ([$x.rect.x, $o.rect.x] | max)) as $ow
	           | (([$x.rect.y+$x.rect.height, $o.rect.y+$o.rect.height] | min) - ([$x.rect.y, $o.rect.y] | max)) as $oh
	           | if $ow > 0 and $oh > 0 then $ow * $oh else 0 end ] | add // 0) as $cov
	      | if $area > 0 and ($cov / $area) >= 0.85 then empty else $x end ]'
	wins=$(rikkiti-msg get-windows 2>/dev/null | jq -c "$VISIBLE" 2>/dev/null)
	boxes=$(printf '%s' "$wins" | jq -r '.[] | "\(.rect.x),\(.rect.y) \(.rect.width)x\(.rect.height)"' 2>/dev/null)
	[ -z "$boxes" ] && { osd "No windows to record"; exit 0; }
	osd "Click the game window to record"
	sel=$(printf '%s\n' "$boxes" | slurp 2>/dev/null) || exit 0
	[ -z "$sel" ] && exit 0                              # Esc / cancelled
	xy=${sel%% *}; wh=${sel#* }; px=${xy%,*}; py=${xy#*,}; pw=${wh%x*}; ph=${wh#*x}
	cx=$((px + pw / 2)); cy=$((py + ph / 2))
	wid=$(printf '%s' "$wins" | jq -r --argjson x "$px" --argjson y "$py" --argjson w "$pw" --argjson h "$ph" --argjson cx "$cx" --argjson cy "$cy" \
		'([.[]|select(.rect.x==$x and .rect.y==$y and .rect.width==$w and .rect.height==$h)] + [.[]|select($cx>=.rect.x and $cx<(.rect.x+.rect.width) and $cy>=.rect.y and $cy<(.rect.y+.rect.height))]) | (.[0].id // empty)' 2>/dev/null)
	[ -z "$wid" ] && { osd "Couldn't identify that window"; exit 1; }
	# Remember its app so Settings ▸ Game Recording can offer it for auto-record.
	app=$(printf '%s' "$wins" | jq -r --argjson id "$wid" 'map(select(.id==$id))|(.[0].app_id // empty)' 2>/dev/null)
	if [ -n "$app" ]; then p="$HOME/.cache/rikkiti/fullscreen-seen"; mkdir -p "$HOME/.cache/rikkiti"; grep -qxF "$app" "$p" 2>/dev/null || printf '%s\n' "$app" >>"$p"; fi
	# carry the app_id so start records the right game for audio isolation (windowed).
	# RIK_PIN marks this as the USER'S OWN choice of window — the one case where the
	# target must never be re-resolved to something else. Auto-record also passes a
	# RIK_TARGET_WID, but that is only the panel's guess and must stay re-resolvable.
	RIK_TARGET_WID="$wid" RIK_TARGET_APP="$app" RIK_PIN=1 exec "$0" start
	;;
  stop)
	pipeline_lock
	running || { echo "not running"; exit 0; }
	# "$2" = suppress: a USER stop (F7/menu). Remember this game's window so
	# auto-record won't restart the SAME session — alt-tabbing out and back won't
	# sneak it back on. A relaunch (a new window id) clears it and records again.
	if [ "${2:-}" = suppress ] && [ -s "$RUN/rikkiti-replay.wid" ]; then
		cp -f "$RUN/rikkiti-replay.wid" "$RUN/rikkiti-replay.nostart" 2>/dev/null
	fi
	BUF=$(state_buf)
	# SIGINT/teardown need live processes — wake a paused pipeline first
	kill -CONT "$(wf_pid)" "$(ff_pid)" 2>/dev/null || true
	rm -f "$PAUSEF"
	# Outstanding marks are NOT flushed here (Lee, 2026-07-24): stopping the game must
	# not manufacture a clip you never asked for. The footage is KEPT below, so a mark
	# stays actionable in Replay Studio, and if the SAME login relaunches, the ring
	# CONTINUEs and the lazy watcher still rescues a mark the wrap creeps up on
	# (honouring rescue_mode). The one moment a mark is truly about to be destroyed is
	# a FRESH start wiping a previous login's ring — that branch flushes as a last
	# resort. See `start`.
	WF=$(wf_pid); FF=$(ff_pid)
	game_audio_teardown                      # streams back to the speakers, modules gone
	kill -INT "$WF" 2>/dev/null || true      # wf-recorder finalizes; EOF ends ffmpeg
	# (the watcher isn't killed: removing the state file below ends its loop)
	i=0; while [ $i -lt 5 ] && [ -d "/proc/$FF" ]; do sleep 1; i=$((i+1)); done
	kill "$FF" 2>/dev/null || true
	kill_fifo_pipeline   # TOTAL: reap anything still on the fifo (stale/orphan pid)
	rm -f "$STATE" "$FIFO" "$RUN/rikkiti-replay.capmode" "$RUN/rikkiti-replay.wid" \
	      "$RUN/rikkiti-replay.pinned"
	echo "stopped (buffer + marks kept — 'rikkiti-replay save' and Replay Studio still work until the next start)"
	;;
  save)
	setup_ok || { echo "rikkiti-replay: not set up (Settings ▸ Game Recording)"; exit 0; }
	# The clip cluster only acts on a LIVE session (running or paused) — after a
	# session ends, $mod+F11 saving stale footage surprised more than it helped
	# (the Studio still opens everything the ring holds, any time).
	running || { osd "Not recording"; echo "not recording"; exit 1; }
	FOLDER=$(folder); BUF=$(ring_dir)
	exec 9>"$RUN/rikkiti-replay.save.lock"
	flock -n 9 || { echo "a save is already running"; exit 1; }
	newest=$(ls -t "$BUF"/seg-*.mkv 2>/dev/null | head -1)
	[ -z "$newest" ] && { notify "Nothing to save" "Replay isn't holding any footage."; echo "no footage held"; exit 1; }
	INFLIGHT=""
	if running; then
		# Wait (≤3s) for the in-flight segment to roll so the freshest seconds —
		# usually THE moment — make it into the clip; then skip the new in-flight.
		i=0; while [ $i -lt 30 ]; do
			n2=$(ls -t "$BUF"/seg-*.mkv 2>/dev/null | head -1)
			[ "$n2" != "$newest" ] && break
			sleep 0.1; i=$((i+1))
		done
		INFLIGHT=$(ls -t "$BUF"/seg-*.mkv 2>/dev/null | head -1)
	fi
	LIST="$RUN/rikkiti-replay.concat.$$"
	: >"$LIST"
	n=0
	# The hotkey grabs the LAST save_last_min minutes only (ShadowPlay model): the
	# ring is how far back you can reach; whole-session cutting is Replay Studio's
	# job (it cuts from the segments directly — no giant dumps; docs/48 v2).
	cutoff=$(( $(date +%s) - $(ckey save_last_min 5) * 60 ))
	T0=$(date +%s)   # list-build instant — a segment whose mtime moves past this was REWRITTEN
	# newline-IFS: the folder is user-chosen and may contain spaces ("Rikkiti Replay")
	OLDIFS=$IFS; IFS='
'
	for f in $(ls -tr "$BUF"/seg-*.mkv 2>/dev/null); do
		[ "$f" = "$INFLIGHT" ] && continue
		[ -s "$f" ] || continue          # skip 0-byte/truncated (crash leftovers)
		mt=$(stat -c %Y "$f" 2>/dev/null) || continue
		[ "$mt" -le "$cutoff" ] && continue   # ends before the grab window
		printf "file '%s'\n" "$f" >>"$LIST"
		n=$((n+1))
	done
	IFS=$OLDIFS
	[ "$n" -eq 0 ] && { rm -f "$LIST"; echo "no complete segments yet"; exit 1; }
	# When the grab window spans the whole buffer (grab >= keep, a valid config)
	# the list INCLUDES the wrap's next victims, and a segment rewritten during
	# the save still passes the one-sided cutoff filter — with FRESH footage in
	# an OLD slot. Re-verify just before the copy: anything whose mtime moved
	# past the list-build instant was rewritten — drop it (clip starts a few
	# seconds later instead of containing the wrong moment).
	LV="$LIST.v"; : >"$LV"; nv=0
	while IFS= read -r rl; do
		rf=${rl#file \'}; rf=${rf%\'}
		rmt=$(stat -c %Y "$rf" 2>/dev/null) || continue
		[ "$rmt" -le "$cutoff" ] && continue
		[ "$rmt" -gt $((T0 + 1)) ] && continue
		printf '%s\n' "$rl" >>"$LV"; nv=$((nv+1))
	done <"$LIST"
	mv "$LV" "$LIST"; n=$nv
	[ "$n" -eq 0 ] && { rm -f "$LIST"; echo "no complete segments yet"; exit 1; }
	mkdir -p "$FOLDER" 2>/dev/null   # user renamed/deleted it mid-session — recreate, don't fail
	CLIP="$FOLDER/Replay-$(date +%Y%m%d-%H%M%S).mkv"
	if concat_out "$LIST" "$CLIP"; then
		secs=$((n * SEG_SEC))
		# Sidecar for Replay Studio: every outstanding mark that falls inside this
		# clip, as offsets (secs) from its start (= oldest segment's END - SEG_SEC).
		if [ -s "$BUF/marks" ]; then
			first=$(sed -n "1s/^file '\(.*\)'$/\1/p" "$LIST")
			fm=$(stat -c %Y "$first" 2>/dev/null || echo 0)
			if [ "$fm" -gt 0 ]; then
				start=$((fm - SEG_SEC))
				while read -r M; do
					case "$M" in ''|*[!0-9]*) continue ;; esac
					off=$((M - start))
					[ "$off" -ge 0 ] && [ "$off" -le "$secs" ] && echo "$off" >>"$CLIP.marks"
				done <"$BUF/marks"
			fi
		fi
		cue complete.oga
		osd "$(printf 'Replay saved (%d:%02d)' $((secs/60)) $((secs%60)))"
		notify "Replay saved" "$(printf '%d:%02d — %s' $((secs/60)) $((secs%60)) "$CLIP")"
		echo "$CLIP ($((secs/60))m$((secs%60))s)"
	else
		rm -f "$LIST"; notify "Replay save FAILED" "see ~/.cache/rikkiti/replay-ff.log"; exit 1
	fi
	rm -f "$LIST"
	;;
  mark)
	setup_ok || { echo "rikkiti-replay: not set up (Settings ▸ Game Recording)"; exit 0; }
	running || { notify "Not recording" "Rikkiti Replay isn't holding a ring."; echo "not running"; exit 1; }
	BUF=$(state_buf)
	date +%s >>"$BUF/marks"
	ensure_watcher   # self-heal: a marked moment must never depend on a dead watcher
	N=$(wc -l <"$BUF/marks")
	# ORDER MATTERS: cue + osd are the feedback a player actually receives (the pill
	# pierces fullscreen; the toast is stacked under the game and unseen). Fire them
	# FIRST — notify is fire-and-forget now, but keep the order honest anyway.
	cue message.oga
	osd "Marked (${N})"
	notify "Marked (×$N)" "Safe forever — trim it whenever you like."
	echo "mark $N dropped"
	;;
  rescue-check)
	# Bound the watcher's own log HERE, not just at `start`. The watcher loop runs
	# this every 20s for as long as the session lives and appends whatever it prints,
	# so a long recording session would otherwise grow the file without ever passing
	# through the trim at start. wc -l on an already-bounded file is nothing.
	trim_log "$HOME/.cache/rikkiti/replay-watch.log" 800 200
	# The LAZY-rescue pass (docs/48): a mark is pure metadata until the ring wrap
	# gets within warn_lead of eating it — only then is the padded window copied
	# out ('all' = flush every outstanding mark, used by stop). rescue_mode=warn
	# swaps the copy for a warning so the user can trim it themselves.
	#
	# --- producer died? re-acquire before writing the session off --------------
	# MUST come before running(), which reaps the state file for a dead recorder —
	# and the watcher's own loop condition is that file, so by then the session is
	# unrecoverable. wincap stops whenever the window changes shape (it is sized
	# from its first frame): a game leaving its launcher for its real resolution,
	# an in-game resolution change, alt-enter. The game is still on screen and the
	# ring must come back — as a NEW run, so it becomes its own chunk at the new
	# size. Parking a PAUSEF first is what lets `resume` do the work: running()
	# treats a paused session as alive, so the state survives the handover.
	#
	# Gated on the game still HAVING a window: if it has actually quit, wincap died
	# for the honest reason and resume would fall back to whole-screen capture —
	# i.e. we would silently start recording the user's desktop. Let it die instead.
	if [ -e "$STATE" ] && [ ! -e "$PAUSEF" ] && [ "${2:-}" != all ]; then
		wp=$(wf_pid); sg=$(sed -n 2p "$STATE" 2>/dev/null)
		# Re-acquire if EITHER half of the pipeline has died — the producer
		# (wf_pid: wincap / wf-recorder) OR the segmenter (line 2). -shortest ties
		# the window segmenter to its shortest input, so when a game rebuilds its
		# audio at launch the pulse capture EOFs and the segmenter EXITS while
		# wincap keeps blasting the fifo: footage stalls with a LIVE producer.
		# Checking only the producer missed that, and the stall watchdog below
		# then STOPPED a live session (Lee, 2026-07-27: a whole Elden Ring run
		# recorded 8s then died at the EAC launcher hand-off).
		if [ -z "$wp" ] || [ ! -d "/proc/$wp" ] || [ -z "$sg" ] || [ ! -d "/proc/$sg" ]; then
			RQ="$RUN/rikkiti-replay.reacq"
			nrq=$(cat "$RQ" 2>/dev/null || echo 0)
			case "$nrq" in ''|*[!0-9]*) nrq=0 ;; esac
			if [ "$nrq" -lt 10 ] && [ -n "$(game_window "$(sed -n 1p "$(state_buf)/session" 2>/dev/null)")" ]; then
				echo "$((nrq + 1))" >"$RQ"
				echo "$(date '+%F %T') recorder stopped but the game is still up (window resize?) — re-acquiring"
				kill_fifo_pipeline          # sweep the dead producer's partner off the fifo
				date +%s >"$PAUSEF"         # park: keeps the session alive across the handover
				"$0" resume >/dev/null 2>&1 || rm -f "$PAUSEF"
			else
				# The game really is gone. Nothing else cleans this up: running()
				# only checks the RECORDER pid and then deletes the state, and
				# `stop` refuses to run once running() is false — so the segmenter,
				# the capture sink and the loopback were simply abandoned. Observed
				# on Lee's box 2026-07-15: a 27-minute orphaned ffmpeg still holding
				# a pulse capture stream, plus a leaked null sink + module-loopback,
				# one pair per session. Do what stop would have done.
				echo "$(date '+%F %T') recorder stopped and the game is gone — closing the session down"
				# Marks are NOT flushed here (same policy as `stop`, Lee 2026-07-24): a
				# session ending must never manufacture a clip. The buffer + marks stay
				# on disk; a FRESH start flushes them as a last resort before wiping, and
				# a same-login relaunch keeps them lazy (rescued only if the wrap nears).
				game_audio_teardown          # streams back to the speakers, modules unloaded
				kill_fifo_pipeline           # reap the orphaned segmenter
				rm -f "$STATE" "$FIFO" "$RUN/rikkiti-replay.capmode" "$RUN/rikkiti-replay.wid"
				exit 0
			fi
		fi
	fi
	running || exit 0
	BUF=$(state_buf); MINUTES=$(sed -n 5p "$STATE")
	# --- liveness watchdog (every watcher pass; skipped for stop's own flush) ---
	if [ "${2:-}" != "all" ]; then
		if [ "$(state_inode)" != "$(wl_inode)" ]; then
			echo "$(date '+%F %T') session changed (logout?) — stopping the stale ring"
			"$0" stop >/dev/null 2>&1   # flushes marks first; footage needs no compositor
			exit 0
		fi
		if [ -e "$PAUSEF" ]; then
			# Paused (alt-tab). A LONG pause means the game actually quit — wind
			# the session down. COUNT WATCHER PASSES, not wall-clock elapsed: the
			# watcher only ticks while the machine is AWAKE (20s loop below), so a
			# suspend/resume — or an NTP/DST clock step — can't fake a long pause
			# and tear down a LIVE session whose game is still running (the classic
			# laptop bug: alt-tab, suspend, resume 10min later → recording lost).
			# The sibling reacq/emptycap watchdogs count passes for the same reason.
			PP="$RUN/rikkiti-replay.pausepass"
			pp=$(( $(cat "$PP" 2>/dev/null || echo 0) + 1 )); echo "$pp" >"$PP"
			need=$(( ( $(ckey pause_stop_min 10) * 60 + 19 ) / 20 ))   # 20s = the watch loop's sleep
			if [ "$pp" -ge "$need" ]; then
				echo "$(date '+%F %T') paused ${pp} awake passes (~$((pp * 20))s) — game gone, stopping"
				"$0" stop >/dev/null 2>&1
				exit 0
			fi
		else
			rm -f "$RUN/rikkiti-replay.pausepass"   # unpaused → reset the awake-pass count
			newest=$(ls -t "$BUF"/seg-*.mkv 2>/dev/null | head -1)
			if [ -n "$newest" ]; then
				age=$(( $(date +%s) - $(stat -c %Y "$newest" 2>/dev/null || date +%s) ))
				if [ "$age" -le 40 ]; then
					# footage is flowing again — a rough patch recovered, so hand the
					# rebuild budget back for any LATER wedge in this same session.
					rm -f "$RUN/rikkiti-replay.reacq"
				elif [ "$age" -gt 60 ]; then
					# Footage has stalled but BOTH halves are still alive (a dead one is
					# handled above): wincap can outlive the window it captured — a game
					# destroys+recreates its surface at launch / alt-enter and the old
					# wincap sits idle on a fifo nobody drains. Don't write the session
					# off — REBUILD against whatever window the game has NOW, exactly as
					# the producer-died path does. Only stop if the game is truly gone or
					# the rebuild budget is spent, so an uncapturable pipeline can't churn.
					RQ="$RUN/rikkiti-replay.reacq"
					nrq=$(cat "$RQ" 2>/dev/null || echo 0)
					case "$nrq" in ''|*[!0-9]*) nrq=0 ;; esac
					if [ "$nrq" -lt 10 ] && [ -n "$(game_window "$(sed -n 1p "$BUF/session" 2>/dev/null)")" ]; then
						echo "$((nrq + 1))" >"$RQ"
						echo "$(date '+%F %T') no new footage for ${age}s but the game is still up — rebuilding the pipeline"
						kill_fifo_pipeline
						date +%s >"$PAUSEF"
						"$0" resume >/dev/null 2>&1 || rm -f "$PAUSEF"
					else
						echo "$(date '+%F %T') no new footage for ${age}s — pipeline wedged, stopping"
						notify "Recording stopped" "The capture pipeline stalled — see ~/.cache/rikkiti/replay-wf.log"
						"$0" stop >/dev/null 2>&1
						exit 0
					fi
				fi
			fi
		fi
		# --- capture reconcile: whole-screen is a fallback, never a verdict -----
		# A game with no capturable window at start (still on its launcher /
		# anti-cheat splash) almost always has one seconds later. Left alone the
		# session stayed on whole-screen capture for its entire life, so the
		# user's desktop could land in a clip they share — Lee, 2026-07-15: 1:08
		# of screen capture out of a FULLSCREEN Elden Ring, because the EAC splash
		# died during the one-second health check. So keep asking, and upgrade the
		# moment the game's real window shows up. Bounded to a few attempts per
		# session: if a game's buffer genuinely can't be imported, each attempt
		# costs a full acquisition cycle, and we must not churn pause/resume for
		# the whole session.
		if [ "$(ckey capture output)" = window ] && [ ! -e "$PAUSEF" ] \
		   && [ "$(cat "$RUN/rikkiti-replay.capmode" 2>/dev/null)" = output ] \
		   && command -v rikkiti-wincap >/dev/null 2>&1; then
			UPG="$RUN/rikkiti-replay.upgrades"
			nup=$(cat "$UPG" 2>/dev/null || echo 0)
			case "$nup" in ''|*[!0-9]*) nup=0 ;; esac
			# Each attempt is now cheap (resume tries window ONCE, ~1s, then keeps
			# the output pipeline), so spend more of them: Elden Ring needs ~30s to
			# settle and these passes are 20s apart.
			if [ "$nup" -lt 6 ] && [ -n "$(game_window "$(sed -n 1p "$BUF/session" 2>/dev/null)")" ]; then
				echo "$((nup + 1))" >"$UPG"
				echo "$(date '+%F %T') the game's window is capturable now — upgrading from whole-screen to window capture"
				"$0" pause >/dev/null 2>&1; "$0" resume >/dev/null 2>&1
				if [ "$(cat "$RUN/rikkiti-replay.capmode" 2>/dev/null)" = window ]; then
					notify "Now recording just the game" "Its window wasn't ready yet when recording started, so the whole screen was captured for a moment."
				fi
			fi
		fi
	fi
	# game-only audio: (re)adopt the game's streams — audio often opens late,
	# and games recreate streams on device switches / level loads. PLUS the
	# audio WATCHDOG (2026-07-13: a whole session recorded silence and nobody
	# noticed for 33 minutes — a broken capture must repair itself or shout):
	# a vanished capsink, or ~3 min of digital silence, triggers ONE bounded
	# pipeline rebuild (pause/resume re-verifies + re-routes audio) + a toast.
	if [ "$(sed -n 7p "$STATE" 2>/dev/null)" != "-" ] && [ ! -e "$PAUSEF" ]; then
		RB="$RUN/rikkiti-replay.audiofix"
		if ! pactl list sinks short 2>/dev/null | awk -v s="$CAPSINK" '$2 == s {f=1} END {exit !f}'; then
			last=$(cat "$RB" 2>/dev/null || echo 0)
			el=$(( $(date +%s) - last ))
			# el < 0 = the wall clock stepped BACKWARD since the last repair — never
			# let that suppress a needed rebuild, or we keep RECORDING SILENCE (the
			# exact failure this watchdog exists to prevent). Treat a backward step
			# as "long enough elapsed" and repair.
			if [ "$el" -gt 600 ] || [ "$el" -lt 0 ]; then
				date +%s >"$RB"
				echo "$(date '+%F %T') capture sink vanished — restarting pipeline to restore sound"
				notify "Replay sound repaired" "The game-audio capture vanished mid-recording — the ring was restarted to bring sound back."
				"$0" pause >/dev/null 2>&1; "$0" resume >/dev/null 2>&1
			fi
		else
			game_audio_adopt
			# NEVER RECORD SILENCE. Decide on ROUTING, not on segment volume: a
			# routed-but-quiet game (menu, exploration) is NOT a fault — checking
			# segment volume there fired a false "can't hear the game" alarm and a
			# needless rebuild during quiet play. Instead: if the capsink has a
			# game stream routed, we're good (quiet = quiet). If NOTHING is routed
			# after a grace period, adoption genuinely can't match this game (a
			# non-Steam title, an odd stream name) — fall back to SYSTEM audio via
			# the line-7 sentinel + rebuild, so the clip captures everything you
			# hear instead of silence. (Universal process-lineage adoption — which
			# closes this gap for non-Steam games too — comes with window capture,
			# docs/49: the comp will hand us the game's PID.)
			csi=$(pactl list sinks short 2>/dev/null | awk -v s="$CAPSINK" '$2 == s {print $1}')
			routed=$(pactl list sink-inputs short 2>/dev/null | awk -v s="$csi" '$2 == s {c++} END {print c+0}')
			EC="$RUN/rikkiti-replay.emptycap"
			if [ "${routed:-0}" -gt 0 ]; then
				rm -f "$EC"   # the game's audio IS captured — a quiet stretch is fine
			else
				ec=$(( $(cat "$EC" 2>/dev/null || echo 0) + 1 )); echo "$ec" >"$EC"
				if [ "$ec" -ge 6 ]; then    # ~120s (6 watcher passes) of nothing routed
					rm -f "$EC"
					echo "$(date '+%F %T') can't isolate this game's audio after 120s — switching to system audio"
					notify "Recording everything you hear" "Couldn't pick out this game's own audio — capturing all sound so your clips are never silent."
					{ sed -n 1,6p "$STATE"; printf -- '-\n-\n'; } >"$STATE.aud" && mv "$STATE.aud" "$STATE"
					"$0" pause >/dev/null 2>&1; "$0" resume >/dev/null 2>&1
				fi
			fi
		fi
	fi
	[ -s "$BUF/marks" ] || exit 0
	echo "$(date '+%F %T') pass: $(wc -l <"$BUF/marks") mark(s) pending${2:+ [$2]}"  # heartbeat (watcher logs this)
	exec 7>"$RUN/rikkiti-replay.rescue.lock"
	if [ "${2:-}" = "all" ]; then flock 7          # stop-flush must never be skipped
	else flock -n 7 || exit 0; fi                  # watcher passes may simply yield
	BUFLEN=$((MINUTES * 60)); LEAD=$(( $(ckey warn_lead_min 5) * 60 ))
	# The deadline must protect the PADDED window, not the mark itself: the
	# rescued clip starts rescue_before_min BEFORE the mark, and with the old
	# `M + BUFLEN - LEAD` trigger that padding cancelled the lead exactly
	# (5min pad vs 5min lead) — rescue fired the very second the wrap started
	# eating the clip's oldest segments, and concat read files mid-overwrite
	# (the "Invalid data" + -85min sidecars of 2026-07-17).
	PADB=$(( $(ckey rescue_before_min 5) * 60 ))
	AFTB=$(( $(ckey rescue_after_min 2) * 60 ))
	MODE=$(ckey rescue_mode auto)
	now=$(date +%s)
	KEEP="$BUF/marks.keep.$$"; : >"$KEEP"
	while read -r M; do
		case "$M" in ''|*[!0-9]*) continue ;; esac
		due=$((M - PADB + BUFLEN - LEAD))
		# Small buffers make that deadline land in the past (5min keep - 5min
		# lead - 5min pad): never rescue before the AFTER-padding has actually
		# been recorded, or every mark fires instantly with no tail footage.
		[ "$due" -lt $((M + AFTB)) ] && due=$((M + AFTB))
		# now < M means the wall clock has stepped back to BEFORE this mark was
		# even made — the wall-clock deadline projection is meaningless and would
		# rescue late (ring wraps over the mark first). Rescue defensively; the
		# pre-copy mtime re-verify in rescue_one drops any wrong/evicted footage.
		if [ "${2:-}" = "all" ] || [ "$now" -ge "$due" ] || [ "$now" -lt "$M" ]; then
			if [ "$MODE" = "warn" ] && [ "${2:-}" != "all" ]; then
				notify "A marked moment is about to be overwritten" "Save or trim it now — auto-rescue is off (rescue_mode=warn)."
				cue dialog-warning.oga
				echo "$M" >>"$BUF/marks.warned"   # warned once, not spammed
			else
				rescue_one "$M" "$BUF"; rrc=$?
				if [ "$rrc" -eq 2 ] && [ "$now" -ge $((M + AFTB + SEG_SEC * 2)) ]; then
					# Window closed and NO footage ever matched — the mark was
					# made during a pause/dead air. Segment mtimes only move
					# forward, so this can never succeed: dropping (with a
					# heads-up) beats a zombie mark retried every 20s forever.
					echo "$(date '+%F %T') mark $M unrescuable (no footage in its window) — dropped"
					notify "Marked moment lost" "Nothing was being recorded around that mark (paused?)."
				elif [ "$rrc" -ne 0 ]; then
					echo "$M" >>"$KEEP"  # footage still arriving / transient failure — retry next pass
				fi
			fi
		else
			echo "$M" >>"$KEEP"
		fi
	done <"$BUF/marks"
	mv "$KEEP" "$BUF/marks"
	;;
  session-info)
	# Replay Studio's view of the ring as a LIST OF GAMES (docs/48 v2 multi-game).
	# The ring is ONE continuous rolling buffer; games are split out chronologically
	# by the boundary log ($BUF/sessions). Emits repeated `game` blocks, newest last:
	#   game / app= / start= / segs= / dur= / from= / to= / [live=1] / [paused=1] / mark=…
	# `from`/`to` are segment-mtime bounds so the Studio can pick THIS game's segments
	# for its EDL + pass them to `cut`/`unmark`. Marks are FOOTAGE offsets into the
	# GAME's own footage (pause/resume gaps mean only the footage is a true timeline).
	setup_ok || { echo "segs=0"; exit 0; }
	BUF=$(ring_dir)
	IDX="$RUN/rikkiti-replay.si.$$"
	seg_index "$BUF" >"$IDX"                       # <mtime> <path>, mtime-sorted
	n=$(wc -l <"$IDX")
	[ "$n" -eq 0 ] && { rm -f "$IDX"; echo "segs=0"; exit 0; }
	# Boundaries (epoch<TAB>app<TAB>output), epoch-sorted. Fallback for a pre-upgrade
	# ring with no boundary log: one boundary spanning the whole buffer, from session.
	BND="$RUN/rikkiti-replay.bnd.$$"
	if [ -s "$BUF/sessions" ]; then
		sort -n "$BUF/sessions" >"$BND"
	else
		printf '%s\t%s\n' "$(sed -n 2p "$BUF/session" 2>/dev/null || echo 0)" "$(sed -n 1p "$BUF/session" 2>/dev/null)" >"$BND"
	fi
	# Chunk mtime-sorted segments by boundary: each seg → the latest boundary whose
	# epoch <= the seg's START (mtime-SEG_SEC). Consecutive segs sharing a boundary =
	# one chronological chunk (a re-visited game = a new chunk). Overwritten segments
	# carry a fresh mtime so they re-attribute to the game that overwrote them → the
	# rolling buffer stays correct and aged-out games (0 segs) simply drop off.
	CHK="$RUN/rikkiti-replay.chk.$$"
	awk -v ss="$SEG_SEC" '
		FNR==NR { bep[NR]=$1; bapp[NR]=$2; nb=NR; next }
		{
			m=$1; st=m-ss; bi=1
			for (i=1;i<=nb;i++) { if (bep[i]<=st) bi=i; else break }
			if (bi!=cur) { if (cnt>0) { a=bapp[cur]; if(a=="")a="-"; print a, cfrom, cto, cnt } cur=bi; cfrom=m; cnt=0 }
			cto=m; cnt++
		}
		END { if (cnt>0) { a=bapp[cur]; if(a=="")a="-"; print a, cfrom, cto, cnt } }
	' "$BND" "$IDX" >"$CHK"
	# Merge adjacent RUNS of the same game at the same capture size back into one
	# entry. A pause/resume (alt-tab, the watcher's audio repair, a screen→window
	# upgrade) ends one run and starts another, but to the player that is still one
	# game. Runs at DIFFERENT sizes deliberately stay apart — that split is exactly
	# what stops a lossless `-c copy` export from spanning a resolution change and
	# handing the user a broken file. An unprobeable size ("?") never merges: fail
	# safe, at worst an extra row in the list.
	CHK2="$RUN/rikkiti-replay.chk2.$$"
	: >"$CHK2"
	pa=""; pg=""; pfrom=""; pto=""; pcnt=0
	while read -r app cfrom cto cnt; do
		g=$(seg_geom "$(awk -v f="$cfrom" '$1 == f { print $2; exit }' "$IDX")")
		if [ -n "$pfrom" ] && [ "$app" = "$pa" ] && [ "$g" = "$pg" ] && [ "$g" != "?" ]; then
			pto=$cto; pcnt=$((pcnt + cnt))
		else
			[ -n "$pfrom" ] && printf '%s %s %s %s\n' "$pa" "$pfrom" "$pto" "$pcnt" >>"$CHK2"
			pa=$app; pg=$g; pfrom=$cfrom; pto=$cto; pcnt=$cnt
		fi
	done <"$CHK"
	[ -n "$pfrom" ] && printf '%s %s %s %s\n' "$pa" "$pfrom" "$pto" "$pcnt" >>"$CHK2"
	mv -f "$CHK2" "$CHK"
	nc=$(wc -l <"$CHK"); gi=0
	while read -r app cfrom cto cnt; do
		gi=$((gi + 1))
		[ "$app" = "-" ] && app=""
		# Drop a single-segment dead chunk: 2 seconds is not a clip, and these are
		# an artefact of a game's launcher window being captured for an instant
		# before the game destroys it (Elden Ring's 800x450 EAC splash produced one
		# "ELDEN RING — 0:02" row per launch). Never drop the LIVE chunk — a session
		# that just started legitimately has one segment.
		if [ "$cnt" -lt 2 ] && ! { [ "$gi" -eq "$nc" ] && running; }; then
			continue
		fi
		echo "game"
		echo "app=$app"
		echo "segs=$cnt"
		echo "dur=$((cnt * SEG_SEC))"
		echo "from=$cfrom"
		echo "to=$cto"
		echo "start=$((cfrom - SEG_SEC))"
		echo "when=$(when_label "$((cfrom - SEG_SEC))")"   # human label for the Studio's list
		if [ "$gi" -eq "$nc" ]; then
			running && echo "live=1"
			[ -e "$PAUSEF" ] && echo "paused=1"
		fi
		if [ -s "$BUF/marks" ]; then
			SUB="$RUN/rikkiti-replay.sub.$$"
			awk -v f="$cfrom" -v t="$cto" '$1 >= f && $1 <= t' "$IDX" >"$SUB"
			while read -r M; do
				case "$M" in ''|*[!0-9]*) continue ;; esac
				awk -v m="$M" -v ss="$SEG_SEC" '$1 > m && $1 - ss <= m { printf "mark=%d\n", (NR - 1) * ss + m - $1 + ss; exit }' "$SUB"
			done <"$BUF/marks"
			rm -f "$SUB"
		fi
	done <"$CHK"
	rm -f "$IDX" "$BND" "$CHK"
	;;
  unmark)
	# unmark <in_off> <out_off> — drop marks covered by an exported window.
	# The export IS the rescue: once the Studio has cut a clip over a mark, a
	# later auto-rescue of the same moment would just be a duplicate file.
	# Same lock as the watcher's rescue loop so the two can't race the file.
	setup_ok || { echo 0; exit 0; }
	BUF=$(ring_dir)
	[ -s "$BUF/marks" ] || { echo 0; exit 0; }
	# [from to] (5th/6th args) scope the footage index to ONE game's segments so the
	# offsets match the Studio's per-game timeline (multi-game buffer).
	INO=${2:?in_off}; OUTO=${3:?out_off}; FROM=${4:-}; TO=${5:-}
	exec 7>"$RUN/rikkiti-replay.rescue.lock"; flock 7
	IDX="$RUN/rikkiti-replay.um.$$"
	if [ -n "$FROM" ]; then seg_index "$BUF" | awk -v f="$FROM" -v t="$TO" '$1 >= f && $1 <= t' >"$IDX"
	else seg_index "$BUF" >"$IDX"; fi
	# Same live-ring re-anchor as `cut`: each mark's `off` below is measured in the
	# CURRENT (export-time) footage frame, but INO/OUTO come from the Studio's FROZEN
	# timeline. Shift INO/OUTO into the current frame by the footage evicted since
	# the timeline froze (current-oldest mtime − FROM) so we clear exactly the marks
	# the clip actually covered — not ones slid out from under the stale offsets.
	if [ -n "$FROM" ] && [ "$FROM" -gt 0 ] 2>/dev/null; then
		cur_old=$(awk 'NR==1{print $1; exit}' "$IDX")
		if [ -n "$cur_old" ] && [ "$cur_old" -gt "$FROM" ] 2>/dev/null; then
			cs=$((cur_old - FROM))
			INO=$((INO - cs)); [ "$INO" -lt 0 ] && INO=0
			OUTO=$((OUTO - cs)); [ "$OUTO" -lt 0 ] && OUTO=0
		fi
	fi
	KEEP="$BUF/marks.keep.$$"; : >"$KEEP"; n=0
	while read -r M; do
		case "$M" in ''|*[!0-9]*) continue ;; esac
		off=$(awk -v m="$M" -v ss="$SEG_SEC" \
			'$1 > m && $1 - ss <= m { printf "%d", (NR - 1) * ss + m - $1 + ss; exit }' "$IDX")
		if [ -n "$off" ] && [ "$off" -ge "$INO" ] && [ "$off" -le "$OUTO" ]; then
			n=$((n + 1))   # covered by the export — the clip preserves the moment
		else
			echo "$M" >>"$KEEP"
		fi
	done <"$BUF/marks"
	mv "$KEEP" "$BUF/marks"
	rm -f "$IDX"
	echo "$n"
	;;
  cut)
	# cut <in_off> <out_off> [height] [dest] [from to] — export a FOOTAGE window of
	# the session ring (Replay Studio): offsets in seconds into the concatenated
	# segments (gap-proof under pause/resume). Only the covering segments are
	# touched (never a whole-session dump). height 0/absent = lossless mkv copy;
	# 1080/720 = share re-encode mp4. dest set = scratch cut (no toast/cue). The
	# optional [from to] segment-mtime bounds scope the index to ONE game's segments
	# (multi-game buffer) so the offsets are that game's own timeline + a lossless
	# `-c copy` never spans a resolution change between games.
	setup_ok || { echo "not set up"; exit 1; }
	INO=${2:?in_off}; OUTO=${3:?out_off}; H=${4:-0}; DEST=${5:-}; FROM=${6:-}; TO=${7:-}
	FOLDER=$(folder); BUF=$(ring_dir)
	INF=""
	running && [ ! -e "$PAUSEF" ] && INF=$(ls -t "$BUF"/seg-*.mkv 2>/dev/null | head -1)
	L="$RUN/rikkiti-replay.cut.$$"
	FL="$RUN/rikkiti-replay.cutf.$$"
	seg_index "$BUF" | { if [ -n "$FROM" ]; then awk -v f="$FROM" -v t="$TO" '$1 >= f && $1 <= t'; else cat; fi; } >"$FL"
	# LIVE-RING RE-ANCHOR (Lee 2026-07-18: "clipped while playing → exported later
	# content; stopped recording first → correct"). INO/OUTO are footage-seconds
	# from the game's oldest segment AT THE MOMENT the Studio froze its timeline
	# (that segment's mtime == FROM; the Studio never moves the pegs afterwards).
	# On a FULL ring the wrap keeps evicting the oldest footage while the editor is
	# open, so footage-offset 0 slides FORWARD and the stale offsets land further
	# along. Shift them back by the footage evicted since the timeline was frozen =
	# (current-oldest mtime − FROM). Exact in the common case; a pause sitting in
	# the already-evicted oldest region makes it over-shift by at most that pause
	# (vs. the old bug's unbounded error). No-op when nothing wrapped (cur_old==FROM
	# → the stop-then-clip path that already worked) or on a pre-upgrade whole ring.
	if [ -n "$FROM" ] && [ "$FROM" -gt 0 ] 2>/dev/null; then
		cur_old=$(awk 'NR==1{print $1; exit}' "$FL")
		if [ -n "$cur_old" ] && [ "$cur_old" -gt "$FROM" ] 2>/dev/null; then
			cs=$((cur_old - FROM))
			echo "$(date '+%F %T') cut: ring wrapped ${cs}s since the timeline froze — re-anchoring offsets by -${cs}s" >&2
			INO=$((INO - cs)); [ "$INO" -lt 0 ] && INO=0
			OUTO=$((OUTO - cs)); [ "$OUTO" -lt 0 ] && OUTO=0
		fi
	fi
	awk -v ino="$INO" -v outo="$OUTO" -v ss="$SEG_SEC" -v inf="$INF" '
		{
			i = NR - 1
			path = $0; sub(/^[0-9]* /, "", path)
			if (path == inf) next
			if ((i + 1) * ss <= ino) next   # segment ends before the window
			if (i * ss >= outo) next        # segment starts after it
			if (!lo_set) { lo = i; lo_set = 1 }
			printf "file '\''%s'\''\n", path
		}
		END { if (lo_set) printf "lo=%d\n", lo > "/dev/stderr" }
	' "$FL" >"$L" 2>"$L.lo"
	rm -f "$FL"
	n=$(grep -c "^file" "$L" 2>/dev/null || echo 0)
	[ "$n" -eq 0 ] && { rm -f "$L" "$L.lo"; echo "no footage left in that range"; exit 1; }
	lo=$(sed -n 's/^lo=//p' "$L.lo"); rm -f "$L.lo"
	relin=$((INO - lo * SEG_SEC)); [ "$relin" -lt 0 ] && relin=0
	relout=$((OUTO - lo * SEG_SEC))
	exec 9>"$RUN/rikkiti-replay.save.lock"
	flock -n 9 || { rm -f "$L"; echo "an export is already running"; exit 1; }
	# Pin concat offsets to video length: kills the 40-58ms join holes in the
	# lossless copy AND makes concat-time == wall-time, so -ss/-to (computed on
	# the wall-clock segment grid) land where the user's handles actually are.
	durize_list "$L"
	PROG="$RUN/rikkiti-replay.cut.progress"   # Studio polls this for its % display
	if [ "$H" -gt 0 ] 2>/dev/null; then
		# share re-encode. The VAAPI encoder codes 16-aligned heights (1080 →
		# 1088 with 8 green pad rows) and only the mkv DISPLAY dims say 1080 —
		# the decoder hands filters the naked 1088, so scale=-2 computed a
		# 1906x1080 squish and the pad rows leaked in. Crop the pad first
		# (capture height cap is known), then scale; setsar kills the leftover
		# rounding SAR; the range conversion + tags stop players guessing the
		# colours (the dark/oversaturated look).
		CAPH=$(ckey max_height 0)
		FPS=$(ckey max_fps 60)
		CROP=""
		case "$CAPH" in ''|0|*[!0-9]*) ;; *) CROP="crop=iw:min(ih\,$CAPH),";; esac
		CLIP="${DEST:-$FOLDER/Clip-$(date +%Y%m%d-%H%M%S).mp4}"
		# The 2s-join PTS holes this fps filter used to paper over are now fixed
		# at source (durize_list above pins the concat offsets), but keep it:
		# it guarantees CFR output for share targets whatever the ring carries.
		$XNICE ffmpeg -nostdin -y -v error -progress "$PROG" -f concat -safe 0 -i "$L" -ss "$relin" -to "$relout" -map 0 \
			-vf "${CROP}setsar=1,scale=-2:$H:out_range=tv,fps=$FPS,format=yuv420p" \
			-colorspace bt709 -color_primaries bt709 -color_trc bt709 -color_range tv \
			-c:v libx264 -preset fast -crf 21 \
			-c:a aac -b:a 192k -af aresample=async=1 \
			-movflags +faststart "$CLIP" </dev/null || { rm -f "$L" "$PROG"; exit 1; }
	else
		CLIP="${DEST:-$FOLDER/Clip-$(date +%Y%m%d-%H%M%S).mkv}"
		# ★ NO -ss HERE. This is an A/V SYNC fix, not a tidy-up.
		#
		# There is exactly ONE keyframe per segment (wincap sets gop_size = fps*2 =
		# the 2s segment), so a `-c copy` cut CANNOT start mid-segment: the video has
		# nowhere to begin. `-ss $relin` therefore cut the AUDIO precisely at relin
		# while the VIDEO could only resume at the next keyframe — i.e. the start of
		# the next segment. The two streams began at different points and nothing
		# corrected it, so every lossless clip carried a CONSTANT offset of up to 2s
		# with the sound running ahead of the picture.
		#
		# Measured with a flash+beep source through the real pipeline (2026-07-15):
		#   the capture itself     +57 ms   (pulse monitor latency — imperceptible)
		#   with -ss $relin       +1078 ms  ← the bug; matches Lee's exported clip
		#   -ss BEFORE -i         +2094 ms  ← worse: snaps to the PREVIOUS keyframe
		#   no -ss (this)           +70 ms  ← back to the capture's own figure
		#
		# The concat list already contains only the covering segments, and a segment
		# boundary IS a keyframe, so starting at 0 begins BOTH streams together. The
		# cost is honest and already promised in the UI: "Lossless cuts land on the
		# recording's 2-second keyframe grid" — a clip can start up to 2s before your
		# handle. That is the deal with a lossless cut; the re-encode path above keeps
		# its exact -ss because re-encoding CAN cut anywhere.
		$XNICE ffmpeg -nostdin -y -v error -progress "$PROG" -f concat -safe 0 -i "$L" -to "$relout" -map 0 \
			-c copy "$CLIP" </dev/null || { rm -f "$L" "$PROG"; exit 1; }
		tag_mkv "$CLIP"
	fi
	rm -f "$PROG"
	rm -f "$L"
	# DEST set = a preview/scratch cut (Replay Studio) — no toast, no cue
	if [ -z "$DEST" ]; then
		notify "Clip exported" "$CLIP"
		cue complete.oga
	fi
	echo "$CLIP"
	;;
  probe)
	# Which codecs can this GPU hardware-encode? (fast testsrc encodes; used for the
	# Settings pane's badges + recommended pick). No conf needed.
	#
	# Probes whichever encoder family this GPU actually uses — on NVIDIA the VAAPI
	# names are all "no" and always will be, which told the Settings pane there was no
	# hardware encoding on a box with a perfectly good NVENC block.
	DEV=$(render_node)
	FAM=$(enc_family "$DEV")
	printf 'device=%s family=%s ' "$DEV" "$FAM"
	for c in h264 hevc av1; do
		e=$(enc_name "$FAM" "$c")
		case "$FAM" in
		  vaapi) _ok="ffmpeg -nostdin -hide_banner -v error -f lavfi -i testsrc=duration=0.2:size=1280x720:rate=30 -vaapi_device $DEV -vf format=nv12,hwupload -c:v $e -f null -" ;;
		  *)     _ok="ffmpeg -nostdin -hide_banner -v error -f lavfi -i testsrc=duration=0.2:size=1280x720:rate=30 -vf format=nv12 -c:v $e -f null -" ;;
		esac
		if $_ok </dev/null >/dev/null 2>&1; then printf '%s=ok ' "$e"; else printf '%s=no ' "$e"; fi
	done
	echo
	;;
  selftest)
	# Trust, but verify (docs/48): run the REAL pipeline for ~3s (actual screen,
	# actual encoder), then decode it back. Settings runs this on first-run setup
	# and on codec change, so picking a codec is never a leap of faith. Works
	# before setup (defaults apply; no folder needed).
	CODEC="${2:-$(ckey codec h264)}"
	case "$CODEC" in hevc|av1) ;; *) CODEC=h264 ;; esac
	DEV=$(render_node)
	FAM=$(enc_family "$DEV")
	ENC=$(enc_name "$FAM" "$CODEC")
	OUT="${3:-$(ckey output "")}"
	[ -z "$OUT" ] && OUT=$(first_output)
	[ -z "$OUT" ] && { echo "FAIL no output"; exit 1; }
	# Run the SAME filter + device arguments `start` will. Omitting them is what made
	# this test pass on a box where recording could not work: wf-recorder without -F
	# builds its own conversion and copes, while start's -F scale_vaapi got handed the
	# compositor's XBGR8888 dma-buf and died ("Failed to find AV format for 875710274")
	# before writing a byte. A selftest that skips the flag under test is worse than no
	# selftest — it converts "broken" into "verified working".
	FILTER=$(enc_filter "$FAM" "$(ckey max_height 0)" 30)
	DEVARG=$(enc_devarg "$FAM" "$DEV")
	PIXFMT=$(enc_pixfmt "$FAM")
	mkdir -p "$HOME/.cache/rikkiti"
	T="$RUN/rikkiti-replay.selftest.mkv"
	rm -f "$T"
	timeout -s INT 4 wf-recorder -o "$OUT" -c "$ENC" ${DEVARG:+-d "$DEVARG"} ${PIXFMT:+-x "$PIXFMT"} -r 30 -p g=30 \
		${FILTER:+-F "$FILTER"} \
		-m matroska -f "$T" -y >"$HOME/.cache/rikkiti/replay-selftest.log" 2>&1
	if [ -s "$T" ] && ffmpeg -nostdin -v error -i "$T" -f null - </dev/null >/dev/null 2>&1; then
		rm -f "$T"; echo "OK $ENC encoded and played back"; exit 0
	fi
	rm -f "$T"; echo "FAIL $ENC (see ~/.cache/rikkiti/replay-selftest.log)"; exit 1
	;;
  screenshot)
	# $mod+F9: instant SILENT screenshot of the game's output, auto-saved into
	# the game-screenshots folder (per-game subfolder when the game is known).
	# Gated on a live session (running or paused) with the rest of the replay
	# cluster (Lee 2026-07-17) — for a no-recording screenshot the shot tool
	# ($mod+Print) is the front door.
	running || { osd "Not recording"; echo "not recording"; exit 1; }
	OUT=$(sed -n 1p "$RUN/rikkiti-fs" 2>/dev/null)
	[ -z "$OUT" ] && OUT=$(first_output)
	[ -z "$OUT" ] && exit 1
	D=$(ckey screenshots_game_folder "$HOME/Pictures/Game Screenshots")
	case "$D" in "~/"*) D="$HOME/${D#"~"/}" ;; esac
	app=$(sed -n 2p "$RUN/rikkiti-fs" 2>/dev/null)
	name=""
	case "$app" in
	  steam_app_*) name=$(steam_name "${app#steam_app_}") ;;
	  *) name="$app" ;;
	esac
	[ -n "$name" ] && D="$D/$name"
	mkdir -p "$D"
	F="$D/Shot-$(date +%Y%m%d-%H%M%S).png"
	# -l 1, not grim's default -l 6: measured at 1440p, -l 6 costs 789ms and -l 1 costs
	# 197ms for 16% more file (3.1M → 3.6M). A game screenshot is worth 500KB to feel
	# instant — grim spends nearly all of that time squeezing the PNG, not capturing.
	if grim -l 1 -o "$OUT" "$F" 2>/dev/null; then
		cue camera-shutter.oga
		osd "Screenshot saved"
		notify "Screenshot saved" "$F"
		echo "$F"
	else
		osd "Screenshot failed"
		exit 1
	fi
	;;
  osd-status)
	# $mod+F9: flash the recording state over whatever is on screen.
	if running; then
		st=$(sed -n 4p "$STATE"); now=$(date +%s)
		[ -e "$PAUSEF" ] && now=$(cat "$PAUSEF" 2>/dev/null || echo "$now")
		held=$((now - st)); [ "$held" -lt 0 ] && held=0
		m=0; [ -s "$(state_buf)/marks" ] && m=$(wc -l <"$(state_buf)/marks")
		txt=$(printf 'Recording %d:%02d' $((held/60)) $((held%60)))
		[ "$m" -gt 0 ] && txt="$txt · $m mark(s)"
		[ -e "$PAUSEF" ] && txt="$txt · paused"
		osd "$txt"
	else
		osd "Not recording"
	fi
	;;
  status)
	if running; then
		echo "holding ($(sed -n 5p "$STATE") min ring) → $(state_buf)"
		m=0; [ -s "$(state_buf)/marks" ] && m=$(wc -l <"$(state_buf)/marks")
		echo "outstanding marks: $m"
		ps -o pid,%cpu,rss,comm -p "$(wf_pid)" -p "$(ff_pid)" 2>/dev/null || true
	else
		echo "not running"
	fi
	B=$(ring_dir)
	[ -d "$B" ] && du -sh "$B" 2>/dev/null && ls "$B" 2>/dev/null | tail -3
	;;
  *)
	echo "usage: rikkiti-replay {start [OUTPUT]|stop|pause|resume|toggle|save|mark|screenshot|status|session-info|cut IN_OFF OUT_OFF [H] [DEST]|selftest [codec]|probe}" >&2; exit 2 ;;
esac
exit 0
