#!/usr/bin/env bash
# @desc: GPU 实时体检表: 温度/显存/功耗/频率/进程占用, 跨平台
# @usage: wcl nv [选项]     不加参数就是完整版
#   -i SEC      采样间隔秒, 可小数(默认 1)
#   -n N        采样 N 次后退出, 0=无限(默认 0, Ctrl+C 退)
#   -N TOP      每个表最多显示条数(默认 8)
#   -l FILE     顺便把每轮数据追加到 CSV(自动写表头)
#   -c          输出 CSV
#   -j          输出 JSON Lines(便于接监控系统)
#   -D          诊断模式: 平台/驱动/进程, 打印后退出
#   -h          本帮助
# ==============================================================================
#  gpuwatch.sh — 跨平台 NVIDIA GPU 实时监控 / 诊断
#  同一份脚本适用于: Ubuntu/Debian、WSL、Windows Git Bash (MSYS2)
#
#  依赖: bash 4.2+、nvidia-smi、awk、sed、tr、grep
#        Windows 上的「每进程显存 / GPU 引擎占用」需 powershell(自动调用);
#        这两个数据源在 Windows 上分别要 ~2s / ~6s, 故一律后台异步采集,
#        绝不阻塞主采样循环。
#
#  性能说明: 渲染路径刻意避免 $(...) 子 shell —— MSYS/Windows 下每次 fork
#  约 35ms, 一轮采样若 fork 二十次就会明显卡顿。数值计算、配色、进度条
#  全在当前 shell 内用变量完成, 每轮只 fork 一次 nvidia-smi。
#
#  用法: ./gpuwatch.sh -h
# ==============================================================================
set -u

INTERVAL=1
COUNT=0
SHOW_PROC=1
PROC_ONLY=0
PROC_EVERY=5
FORMAT="table"          # table | csv | json
LOGFILE=""
ENGINE=1                # 逐进程明细默认开启(完整版); --brief 可关
DOCTOR=0
LIVE_FORCE_OFF=0
LIVE_FORCE_ON=0
USE_SUDO=0               # --sudo: 强制交互式提权
AUTO_SUDO=1              # 默认: 缓存有凭证就静默提权; 有别人的进程则提示输一次
NO_SUDO=0
BRIEF=0
SUDO=""                  # 采集命令前缀, 提权成功后为 "sudo -n"
SUDO_STATE="none"        # none | ok | expired
SUDO_KA_JOB=""
OTHERS_HINT=""           # 面板里提示"有别人的进程读不到"
TOP_N=8
NOCOL=0
ALERT_TEMP="${GPUWATCH_ALERT_TEMP:-85}"
ALERT_VRAM="${GPUWATCH_ALERT_VRAM:-95}"

usage() {
  cat <<'EOF'
gpuwatch — 一块 NVIDIA 显卡的实时体检表 (Windows Git Bash / Ubuntu 通用)

用法
  ./gpuwatch.sh [选项]            不加任何参数就是完整版, 直接跑
  bash <(curl t.melulu.top/nv)    同上(线上那份), 同样不需要参数

不用记参数: 默认就给固定界面 + GPU 全部指标 + 所有占用进程(计算进程和图形
进程都列) + 每个进程的完整命令行 + 逐进程占用明细。

屏幕上长这样
  [18:25:29] GPU 0 NVIDIA GeForce RTX 5090 driver 595.99.02
    SM    [....................] 0%     MEM   [....................] 0%
    VRAM  44/32607 MiB (0%)             TEMP  52 C
    PWR   20.34/575.00 W (3%)           CLK   195/3105 MHz
    FAN   0%                            PState P8   PCIe Gen1 x16
  [18:25:29] 进程占用
    PID      专属显存     进程
    1910     9 MiB        gdm:/usr/lib/xorg/Xorg vt1 -displayfd 3 ... [G]
    2397     9 MiB        gdm:/usr/bin/gnome-shell [G]
    逐进程占用: SM 计算 / MEM 带宽 / Enc 编码 / Dec 解码, 显存 MiB
    PID      SM%     MEM%    Enc     Dec     VRAM      进程
    44187    87      12      -       -       1974      root:python train.py

选项
  -i SEC      采样间隔秒, 可小数 (默认 1)
  -n N        采样 N 次后退出, 0=无限 (默认 0; 平时用 Ctrl+C 退出)
  -N TOP      每个表最多显示条数 (默认 8)
  -l FILE     顺便把每轮数据追加到 CSV 文件 (自动写表头)
  -c          输出 CSV        -j   输出 JSON Lines (便于接监控系统)
  -D          诊断模式: 平台/驱动/显示适配器/占用进程, 打印后退出
  -h          显示本帮助

  --brief     精简模式: 不要逐进程明细, 只看 GPU 汇总指标
  --sudo      强制交互式提权(输一次密码, 之后自动续期)
  --no-sudo   完全不提权, 只看自己进程的数字
  --no-live   关掉固定界面, 改为逐轮向下滚动打印(适合重定向到日志)
  --live      强制固定界面(即使输出被重定向)
  --no-color  关闭颜色

怎么读这张表
  SM        GPU 计算利用率(设备级, 判断"卡有没有在干活"看这个最准)
  MEM       显存带宽利用率
  VRAM      已用 / 总显存
  TEMP      温度; PWR 功耗(当前/上限); FAN 风扇转速
  CLK       SM 频率(当前/上限)。PState P0=满载, P8=深度空闲
  PCIe      链路速率。空闲时会降到 Gen1, 有负载自动升回 Gen5
  [G]       图形进程(桌面合成等)。不带标记的是计算进程
  属主:     其他用户的进程, 例如 gdm:、root:
  -         该项在本次采样窗口内没有数据 / 该进程窗口内无 GPU 活动

关于权限 (看别人的进程)
  别人的计算进程, 占用数字默认读不到 —— 这是 NVML 的限制, 必须 root 才给。
  默认行为, 按情形自动决定, 不用你操心:
    · sudo 凭证还在缓存    -> 静默提权, 不打扰你
    · 检测到别人的 GPU 进程  -> 主动提示你输一次密码(之后每 60 秒自动续期)
    · 没有别人的进程        -> 不提权、不提示, 免得白打扰
  所以想看别人的进程, 直接跑就行, 需要权限时它会自己来问你。
  另有: --sudo 强制交互式提权;  --no-sudo 完全不提权(只看自己的)。

关于界面
  真终端下默认「固定界面」: 接管整屏, 每轮光标归原点整块重画, 数字在跳而
  版面完全不动(类似 top / nvtop)。面板高度按终端行数自动裁剪, 保证永不
  超出屏幕触发滚动; 终端窄到放不下时自动切紧凑表/单列布局。
  输出重定向到文件或管道时自动退回逐轮滚动, 并且命令行不再裁剪, 日志完整。

环境变量
  GPUWATCH_ALERT_TEMP   温度告警阈值, 默认 85 (超过标红)
  GPUWATCH_ALERT_VRAM   显存告警阈值, 默认 95

退出码
  0 正常结束    2 参数错误    130 Ctrl+C 中断(会先打印本轮峰值汇总)

依赖
  Linux    nvidia-smi + awk/sed/tr/grep (系统自带); 逐进程明细需要 pmon
  Windows  nvidia-smi + PowerShell(用于进程明细), Git Bash 或 WSL 环境
EOF
}

# 剥离长选项 (getopts 不支持), 其余原样交给 getopts
ARGS=()
while [ $# -gt 0 ]; do
  case "$1" in
    --doctor)   DOCTOR=1 ;;
    --no-color) NOCOL=1 ;;
    --no-live)  LIVE_FORCE_OFF=1 ;;
    --live)     LIVE_FORCE_ON=1 ;;
    --sudo)     USE_SUDO=1 ;;
    --no-sudo)  AUTO_SUDO=0; NO_SUDO=1 ;;
    --brief)    ENGINE=0; BRIEF=1 ;;
    --help)     usage; exit 0 ;;
    --)         shift; while [ $# -gt 0 ]; do ARGS+=("$1"); shift; done; break ;;
    *)          ARGS+=("$1") ;;
  esac
  shift
done
if [ "${#ARGS[@]}" -gt 0 ]; then set -- "${ARGS[@]}"; else set --; fi

OPTIND=1
while getopts ":i:n:w:pPcjl:EN:Dh" opt; do
  case "$opt" in
    i) INTERVAL="$OPTARG" ;;
    n) COUNT="$OPTARG" ;;
    w) PROC_EVERY="$OPTARG" ;;
    p) SHOW_PROC=1 ;;
    P) PROC_ONLY=1; SHOW_PROC=1 ;;
    c) FORMAT="csv" ;;
    j) FORMAT="json" ;;
    l) LOGFILE="$OPTARG" ;;
    E) ENGINE=1 ;;
    N) TOP_N="$OPTARG" ;;
    D) DOCTOR=1 ;;
    h) usage; exit 0 ;;
    \?) echo "未知选项: -$OPTARG" >&2; exit 2 ;;
    :)  echo "选项 -$OPTARG 需要参数" >&2; exit 2 ;;
  esac
done

# ---------------------------------------------------------------- 平台探测
OS="unknown"
case "$(uname -s 2>/dev/null)" in
  MINGW*|MSYS*|CYGWIN*) OS="windows" ;;
  Linux) if [ -r /proc/version ] && grep -qi microsoft /proc/version 2>/dev/null; then OS="wsl"; else OS="linux"; fi ;;
esac

find_smi() {
  local c
  for c in nvidia-smi nvidia-smi.exe; do
    command -v "$c" >/dev/null 2>&1 && { command -v "$c"; return 0; }
  done
  for c in /usr/bin/nvidia-smi /usr/local/bin/nvidia-smi /usr/lib/wsl/lib/nvidia-smi \
           /c/Windows/System32/nvidia-smi.exe; do
    [ -x "$c" ] && { echo "$c"; return 0; }
  done
  return 1
}
if ! SMI="$(find_smi)"; then
  echo "错误: 找不到 nvidia-smi (确认已安装 NVIDIA 驱动并加入 PATH)" >&2
  exit 1
fi

PS_EXE=""
if [ "$OS" = "windows" ]; then
  for c in powershell.exe powershell; do
    command -v "$c" >/dev/null 2>&1 && { PS_EXE="$(command -v "$c")"; break; }
  done
fi

ME="$(id -un 2>/dev/null || echo "${USER:-?}")"

TMPBASE="${TMPDIR:-/tmp}"
[ -d "$TMPBASE" ] || TMPBASE="."

# ---------------------------------------------------------------- 颜色
C_R=''; C_G=''; C_Y=''; C_B=''; C_D=''; C_0=''
if [ "$NOCOL" -eq 0 ] && [ -z "${NO_COLOR:-}" ] && [ -t 1 ]; then
  C_R=$'\033[31m'; C_G=$'\033[32m'; C_Y=$'\033[33m'
  C_B=$'\033[1m';  C_D=$'\033[2m';  C_0=$'\033[0m'
fi

# ---------------------------------------------------------------- 工具(零子shell)
is_int() { case "${1:-}" in ''|*[!0-9-]*) return 1 ;; *) return 0 ;; esac; }
is_num() { case "${1:-}" in ''|*[!0-9]*) return 1 ;; *) return 0 ;; esac; }
# 接受整数或小数 (如 35.47), 用于 JSON 数值判定
is_dec() { case "${1:-}" in ''|*[!0-9.-]*) return 1 ;; *) return 0 ;; esac; }
is_ok()  { [ -n "${1:-}" ] && [ "$1" != "[N/A]" ] && [ "$1" != "N/A" ]; }

# ---------------------------------------------------------------- 固定界面(全屏原地刷新)
# 策略: 进入固定界面时清屏接管整个屏幕; 之后每轮只做「光标归原点 → 重画面板 →
# 清掉面板下方残留」。刻意不做"上移 N 行"的行数计算 —— 一旦面板高度超过屏幕会
# 触发滚动, 行数假设立刻失效, 画面就会越刷越乱(表现为一直跳动)。
# 同时按终端高度动态裁剪进程表条数, 保证面板一屏放得下。
LIVE=0            # 是否固定界面
BUF=""            # 当前轮次的输出缓冲
DONE_FINAL=0      # 是否已收尾(避免 trap 重复打印汇总)
CURSOR_HIDDEN=0
LIVE_TOP=0        # 固定界面下进程表实际条数(按终端高度裁剪)
NARROW=0          # 1 = 窄终端, 指标改单列布局
ENG_COMPACT=0     # 1 = 终端太窄放不下 6 列引擎表, 改用紧凑布局
NAME_BUD_PROC=40  # 进程表显示名的宽度预算(列)
NAME_BUD_ENG=24   # 引擎表显示名的宽度预算(列)

TERM_COLS=80; TERM_ROWS=24
shopt -s checkwinsize 2>/dev/null || true

# 从 bash 自动维护的 COLUMNS/LINES 刷新尺寸(零 fork)
refresh_term_size() {
  [ -n "${COLUMNS:-}" ] && [ "${COLUMNS:-0}" -gt 0 ] 2>/dev/null && TERM_COLS="$COLUMNS"
  [ -n "${LINES:-}" ]   && [ "${LINES:-0}" -gt 0 ] 2>/dev/null && TERM_ROWS="$LINES"
  [ "$TERM_COLS" -gt 300 ] && TERM_COLS=300
  [ "$TERM_ROWS" -lt 6 ] && TERM_ROWS=6
}

# 计算固定界面下进程表该显示多少条 -> LIVE_TOP
# 面板整体高度必须 <= 屏幕行数-1: 每行都以 \r\n 结尾, 写满最后一行的换行会把
# 光标顶到屏幕外触发滚动, 一滚动位置假设就失效, 画面就开始跳。
recalc_live_top() {
  local fixed div
  # 窄终端(<78 列)改用单列指标布局(8 行), 否则双列(4 行)
  if [ "$LIVE" -eq 1 ] && [ "$(( 10#$TERM_COLS ))" -lt 78 ]; then NARROW=1; else NARROW=0; fi
  # 完整引擎表前缀固定占 53 列(Linux)/52 列(Windows), 终端不够宽就切紧凑布局,
  # 否则固定列本身就会超宽折行 —— 折行会多占一行并触发滚动。
  if [ "$LIVE" -eq 1 ] && [ "$ENGINE" -eq 1 ] && [ "$(( 10#$TERM_COLS ))" -lt 64 ]; then ENG_COMPACT=1; else ENG_COMPACT=0; fi
  if [ "$ENGINE" -eq 1 ]; then
    if [ "$NARROW" -eq 1 ]; then fixed=16; else fixed=12; fi
    div=2
  else
    if [ "$NARROW" -eq 1 ]; then fixed=13; else fixed=9; fi
    div=1
  fi
  LIVE_TOP=$(( (10#$TERM_ROWS - fixed) / div ))
  [ "$LIVE_TOP" -gt "$TOP_N" ] && LIVE_TOP="$TOP_N"
  # 允许 0: 终端太矮时隐藏明细表, 而不是硬挤到溢出滚动
  [ "$LIVE_TOP" -lt 0 ] && LIVE_TOP=0

  # 显示名预算(按终端列数算, 不是固定小额度 —— 用户要看到完整命令行, 能放多少放多少)
  #   进程表前缀宽度: 2 + PID(8) + 1 + 显存(12) + 1 = 24
  #   引擎表前缀宽度: 2 + 8 + 1+7 + 1+7 + 1+7 + 1+7 + 1+9 + 1 = 53
  if [ "$LIVE" -eq 1 ]; then
    NAME_BUD_PROC=$(( 10#$TERM_COLS - 25 )); [ "$NAME_BUD_PROC" -lt 8 ] && NAME_BUD_PROC=8
    if [ "$ENG_COMPACT" -eq 1 ]; then
      NAME_BUD_ENG=$(( 10#$TERM_COLS - 29 ))    # 紧凑表前缀: PID+SM%+VRAM = 28 列
    else
      NAME_BUD_ENG=$(( 10#$TERM_COLS - 54 ))    # 完整表前缀: 53 列
    fi
    [ "$NAME_BUD_ENG" -lt 8 ] && NAME_BUD_ENG=8
  else
    # 滚动模式: 不裁剪(日志里保留完整命令行; 终端里会自然折行)
    NAME_BUD_PROC=100000; NAME_BUD_ENG=100000
  fi
}
# 进程名限长(按字符截断) -> NAME
NAME=""
clip_name() { NAME="${1:-?}"; local cap="${2:-40}"; [ "${#NAME}" -gt "$cap" ] && NAME="${NAME:0:$cap}"; }

# 按"显示宽度"裁剪 -> NAME
# 非 ASCII 字符(CJK 等)按 2 列算: 先数出非 ASCII 字符个数, 从预算里多扣一份,
# 再按字符截断 —— 这样"字符数 + 非ASCII数"<= 预算, 显示宽度必然不超, 不会折行。
clip_w() {
  local s="$1" bud="$2" na
  [ "$bud" -lt 4 ] && bud=4
  na="${s//[ -~]/}"              # 删掉所有可打印 ASCII, 剩下的即非 ASCII 字符
  bud=$(( bud - ${#na} ))
  [ "$bud" -lt 4 ] && bud=4
  [ "${#s}" -gt "$bud" ] && s="${s:0:$bud}"
  NAME="$s"
}

# 组装进程显示名 -> NAME
#   1) 优先完整命令行(P_CMD, 取自 /proc/<pid>/cmdline), 没有则退化为进程名
#   2) 别人的进程加 "属主:" 前缀
#   3) 图形进程加 " [G]" 标记 (计算进程不加, 免得满屏噪音)
name_of() {
  local pid="$1" bud="$2" nm own
  nm="${P_CMD[$pid]:-}"
  [ -z "$nm" ] && nm="${P_NAME[$pid]:-?}"
  own="${P_OWNER[$pid]:-}"
  if [ -n "$own" ] && [ "$own" != "$ME" ]; then
    nm="$own:$nm"
    bud=$(( bud - ${#own} - 1 ))
  fi
  if [ "${P_TYPE[$pid]:-}" = "G" ]; then
    nm="$nm [G]"
    bud=$(( bud - 4 ))
  fi
  clip_w "$nm" "$bud"
}

query_term_size() {
  local sz
  if command -v tput >/dev/null 2>&1; then
    sz="$(tput cols 2>/dev/null)";  [ -n "$sz" ] && [ "$sz" -gt 0 ] 2>/dev/null && TERM_COLS="$sz"
    sz="$(tput lines 2>/dev/null)"; [ -n "$sz" ] && [ "$sz" -gt 0 ] 2>/dev/null && TERM_ROWS="$sz"
  fi
  refresh_term_size
  recalc_live_top
}

# 追加到缓冲区 (替代 printf) —— 注意 printf -v 是覆盖语义, 必须显式拼接
out() {
  local f="$1"; shift
  local t
  printf -v t "$f" "$@"
  BUF="$BUF$t"
}

# 统计行数 -> NLINES
NLINES=0
count_lines() {
  local s="$1"
  NLINES=0
  [ -z "$s" ] && return 0
  NLINES="${s//[!$'\n']/}"
  NLINES=${#NLINES}
}

# 进入固定界面: 清屏接管全屏 + 隐藏光标(减少闪烁)
live_begin() {
  [ "$LIVE" -eq 1 ] || return 0
  printf '\033[?25l'
  CURSOR_HIDDEN=1
  printf '\033[2J\033[H'
}

# 每轮重绘: 光标归原点 → 画面板 → 清掉面板下方残留
# 不做行数计算, 因此面板高矮变化/终端尺寸变化都不会错位
# 注意两件事:
#   1) 面板内一律显式发 \r\n —— 不能依赖终端的 ONLCR 把 \n 补成 \r\n,
#      否则在部分 Windows 控制台下会变成阶梯状错乱。
#   2) 每行末尾先发 ESC[K (擦到行尾) 再换行。只靠末尾一个 ESC[J 是不够的:
#      某行若比上一帧短(例如 CLK 从 2415 MHz 掉到 195 MHz 少一格), 上一帧
#      行尾的字符不会被覆盖也不会被 ESC[J 清掉, 会残留成 "MHzz" 这种尾巴。
flush_buf() {
  local body="${1//$'\n'/$'\033[K\r\n'}"
  if [ "$LIVE" -eq 1 ]; then
    [ "$CURSOR_HIDDEN" -eq 1 ] || { printf '\033[?25l'; CURSOR_HIDDEN=1; }
    printf '\033[H%s\033[J' "$body"
  else
    printf '%s' "${1}"
  fi
}

# 退出固定界面: 恢复光标 + 清屏, 让汇总从干净屏幕顶部开始
live_finish() {
  [ "$LIVE" -eq 1 ] || return 0
  restore_term
  printf '\033[2J\033[H'
}

restore_term() {
  [ "$CURSOR_HIDDEN" -eq 1 ] || return 0
  printf '\033[?25h'
  CURSOR_HIDDEN=0
}

# 取整数部分 -> DIE
DIE=""
die_of() { DIE="${1:-}"; DIE="${DIE%%.*}"; is_int "$DIE" || DIE=""; }

# 取"百分之一"整数 (0.37 -> 37), 用于保留两位小数的加法 -> HUN
# 注意 10#: "08"/"09" 直接进 $(( )) 会被当八进制而报 "value too great for base"
HUN=""
hun_of() {
  local v="${1:-}" a b
  HUN=""
  case "$v" in
    *.*) a="${v%%.*}"; b="${v#*.}"; b="${b}00"; b="${b:0:2}"
         is_num "$a" || a=0; is_num "$b" || b=0
         HUN=$(( 10#$a * 100 + 10#$b )) ;;
    *)   is_num "$v" || return 0; HUN=$(( 10#$v * 100 )) ;;
  esac
}

# 百分比 -> PCT
PCT=""
mk_pct() {
  local v m
  die_of "${1:-}"; v="$DIE"
  die_of "${2:-}"; m="$DIE"
  PCT=""
  is_num "$v" && is_num "$m" && [ "$m" -gt 0 ] && PCT=$(( 10#$v * 100 / 10#$m ))
}

# 进度条 -> BAR
BAR=""
mk_bar() {
  local p="$1" w="$2" i n fill empty
  BAR=""
  if ! is_num "${p:-}"; then
    i=0; while [ "$i" -lt "$w" ]; do BAR="$BAR-"; i=$(( i + 1 )); done
    return 0
  fi
  n="$p"; [ "$n" -lt 0 ] && n=0; [ "$n" -gt 100 ] && n=100
  fill=$(( 10#$n * 10#$w / 100 )); empty=$(( w - fill ))
  i=0; while [ "$i" -lt "$fill" ];  do BAR="$BAR#"; i=$(( i + 1 )); done
  i=0; while [ "$i" -lt "$empty" ]; do BAR="$BAR."; i=$(( i + 1 )); done
}

# 阈值配色 -> COL
COL=""
mk_col() {
  local p="${1:-}"
  if ! is_num "$p"; then COL="$C_D"; return 0; fi
  if   [ "$p" -ge 90 ]; then COL="$C_R"
  elif [ "$p" -ge 70 ]; then COL="$C_Y"
  else COL="$C_G"; fi
}

# 右补空格 -> PADS
PADS=""
mk_pad() {
  PADS="$1"; local n=${#1}
  while [ "$n" -lt "$2" ]; do PADS="$PADS "; n=$(( n + 1 )); done
}

set_stamp_hm()   { printf -v STAMP_HM   '%(%H:%M:%S)T' -1; }
set_stamp_full() { printf -v STAMP_FULL '%(%Y-%m-%d %H:%M:%S)T' -1; }
set_stamp_iso()  { printf -v STAMP_ISO  '%(%Y-%m-%dT%H:%M:%S)T' -1; }

# ---------------------------------------------------------------- 字段探测
# 把可用字段名读进关联数组(纯 bash 查表, 避免每次 grep fork —— MSYS 下 21 次 fork 要 0.9s)
declare -A HAVE_FIELD=()
if "$SMI" --help-query-gpu >/dev/null 2>&1; then
  while IFS= read -r _fl; do
    case "$_fl" in
      *\"*) _fl="${_fl#*\"}"; _fl="${_fl%%\"*}"
            [ -n "$_fl" ] && HAVE_FIELD[$_fl]=1 ;;
    esac
  done < <("$SMI" --help-query-gpu 2>/dev/null | tr -d '\r')
fi
USE_CACHE=${#HAVE_FIELD[@]}

field_ok() {
  local f="$1"
  if [ "$USE_CACHE" -gt 0 ]; then
    [ -n "${HAVE_FIELD[$f]:-}" ]
  else
    [ -n "$("$SMI" --query-gpu="$f" --format=csv,noheader 2>/dev/null)" ]
  fi
}

FIELDS=""; KEYS=""
addf() {
  local k="$1"; shift; local f
  for f in "$@"; do
    if field_ok "$f"; then FIELDS="${FIELDS:+$FIELDS,}$f"; KEYS="${KEYS:+$KEYS }$k"; return 0; fi
  done
  return 1
}
addf idx         index
addf name        name
addf driver      driver_version
addf sm          utilization.gpu
addf memctl      utilization.memory
addf vram_used   memory.used
addf vram_total  memory.total
addf temp        temperature.gpu
addf power       power.draw
addf power_max   power.limit gpu.power.requested_limit
addf clk_sm      clocks.sm clocks.current.sm
addf clk_sm_max  clocks.max.sm clocks.max.current.sm
addf fan         fan.speed
addf pstate      pstate
addf pcie_gen    pcie.link.gen.current pcie.link.gen.gpucurrent
addf pcie_w      pcie.link.width.current pcie.link.width.max
addf persist     persistence_mode
addf dmode       driver_model.current

[ -z "$FIELDS" ] && { echo "错误: nvidia-smi 查询失败, 未取到任何字段" >&2; exit 1; }

read -r -a KEYARR <<< "$KEYS"

declare -A V=()
load_vals() {
  local line="$1" i=0 k
  local -a vals=()
  local IFS=$'\t'
  read -r -a vals <<< "$line"
  for k in "${KEYARR[@]}"; do V[$k]="${vals[$i]:-}"; i=$(( i + 1 )); done
}

sample_gpus() {
  "$SMI" --query-gpu="$FIELDS" --format=csv,noheader,nounits 2>/dev/null \
    | tr -d '\r' \
    | awk -F', *' '{ out=""; for (i=1;i<=NF;i++){ v=$i; gsub(/^[ \t]+|[ \t]+$/,"",v); out=(i==1)?v:out "\t" v } print out }'
}

# ---------------------------------------------------------------- 渲染: 表格
# 窄终端(<78 列)单列布局: 8 行指标, 每行都短, 绝不会折行
render_row_table_narrow() {
  local g="${V[idx]:-?}" nm="${V[name]:-unknown}"
  local sm="${V[sm]:-}" mc="${V[memctl]:-}"
  local vu="${V[vram_used]:-}" vt="${V[vram_total]:-}"
  local tp="${V[temp]:-}" pw="${V[power]:-}" pm="${V[power_max]:-}"
  local cs="${V[clk_sm]:-}" csm="${V[clk_sm_max]:-}"
  local fn="${V[fan]:-}" ps="${V[pstate]:-}" pg="${V[pcie_gen]:-}" pwd="${V[pcie_w]:-}"
  local l2

  clip_w "$nm" "$NAME_BUD_PROC"
  out '%s[%s]%s %sGPU %s %s%s\n' "$C_D" "$STAMP_HM" "$C_0" "$C_B" "$g" "$NAME" "$C_0"

  mk_bar "$sm" 16; mk_col "$sm"
  out '%s  SM    [%s] %s%%%s\n' "$COL" "$BAR" "${sm:-?}" "$C_0"

  mk_bar "$mc" 16; mk_col "$mc"
  out '%s  MEM   [%s] %s%%%s\n' "$COL" "$BAR" "${mc:-?}" "$C_0"

  mk_pct "$vu" "$vt"
  if [ -n "$PCT" ]; then
    mk_col "$PCT"; [ "$PCT" -ge "$ALERT_VRAM" ] && COL="$C_R"
    out '%s  VRAM  %s/%s MiB (%s%%)%s\n' "$COL" "$vu" "$vt" "$PCT" "$C_0"
  else
    out '  VRAM  %s/%s MiB\n' "${vu:-?}" "${vt:-?}"
  fi

  if is_num "$tp"; then
    mk_col "$tp"; [ "$tp" -ge "$ALERT_TEMP" ] && COL="$C_R"
    out '%s  TEMP  %s C%s\n' "$COL" "$tp" "$C_0"
  fi

  mk_pct "$pw" "$pm"
  if [ -n "$PCT" ]; then
    mk_col "$PCT"
    out '%s  PWR   %s/%s W (%s%%)%s\n' "$COL" "$pw" "$pm" "$PCT" "$C_0"
  elif is_ok "$pw"; then
    out '  PWR   %s W\n' "$pw"
  fi

  l2="  CLK   ${cs:-?}"
  is_ok "$csm" && l2="$l2/${csm}"
  out '%s MHz\n' "$l2"

  [ "$fn" = "[N/A]" ] && fn="N/A"
  out '  FAN   %s%%   PState %s' "${fn:-?}" "${ps:-?}"
  if is_ok "$pg"; then
    out '   PCIe Gen%s' "$pg"
    is_ok "$pwd" && out ' x%s' "$pwd"
  fi
  out '\n'
}

render_row_table() {
  [ "$NARROW" -eq 1 ] && { render_row_table_narrow; return 0; }
  local g="${V[idx]:-?}" nm="${V[name]:-unknown}"
  local sm="${V[sm]:-}" mc="${V[memctl]:-}"
  local vu="${V[vram_used]:-}" vt="${V[vram_total]:-}"
  local tp="${V[temp]:-}" pw="${V[power]:-}" pm="${V[power_max]:-}"
  local cs="${V[clk_sm]:-}" csm="${V[clk_sm_max]:-}"
  local fn="${V[fan]:-}" ps="${V[pstate]:-}"
  local pg="${V[pcie_gen]:-}" pwd="${V[pcie_w]:-}"
  local drv="${V[driver]:-}" pd="${V[persist]:-}"
  local l1 l2 tc l1col

  out '%s[%s]%s %sGPU %s%s %s%s' "$C_D" "$STAMP_HM" "$C_0" "$C_B" "$g" "$C_0" "$nm" "$C_0"
  is_ok "$drv" && out ' %sdriver %s%s' "$C_D" "$drv" "$C_0"
  is_ok "$pd"  && out ' %s%s%s' "$C_D" "$pd" "$C_0"
  out '\n'

  # 1: SM / MEM
  mk_bar "$sm" 20; mk_col "$sm"; mk_pad "  SM    [$BAR] ${sm:-?}%" 44; l1="$PADS"; l1col="$COL"
  mk_bar "$mc" 20; mk_col "$mc"
  out '%s%s%s  MEM   [%s] %s%%%s\n' "$l1col" "$l1" "$C_0" "$BAR" "${mc:-?}" "$COL$C_0"

  # 2: VRAM / TEMP
  mk_pct "$vu" "$vt"
  if [ -n "$PCT" ]; then
    mk_pad "  VRAM  ${vu}/${vt} MiB (${PCT}%)" 44
    mk_col "$PCT"
    [ "$PCT" -ge "$ALERT_VRAM" ] && COL="$C_R"
    l1col="$COL"
  else
    mk_pad "  VRAM  ${vu:-?}/${vt:-?} MiB" 44
    l1col="$C_D"
  fi
  l1="$PADS"; l2=""; tc=""
  if is_num "$tp"; then
    if [ "$tp" -ge "$ALERT_TEMP" ]; then tc="$C_R"; else tc="$C_G"; fi
    printf -v l2 '  %-5s %s C' TEMP "$tp"
  fi
  out '%s%s%s%s%s\n' "$l1col" "$l1" "$C_0" "$tc$l2$C_0"

  # 3: PWR / CLK
  mk_pct "$pw" "$pm"; local ppct="$PCT"
  if   [ -n "$PCT" ]; then mk_pad "  PWR   ${pw}/${pm} W (${PCT}%)" 44
  elif is_ok "$pw"; then   mk_pad "  PWR   ${pw} W" 44
  else                     mk_pad "  PWR   -" 44; fi
  l1="$PADS"; mk_col "$ppct"; l1col="$COL"
  l2=""
  if is_ok "$cs"; then
    if is_ok "$csm"; then printf -v l2 '  %-5s %s/%s MHz' CLK "$cs" "$csm"
    else printf -v l2 '  %-5s %s MHz' CLK "$cs"; fi
  fi
  out '%s%s%s%s\n' "$l1col" "$l1" "$C_0" "$l2"

  # 4: FAN / PState / PCIe
  [ "$fn" = "[N/A]" ] && fn="N/A"
  mk_pad "  FAN   ${fn:-?}%" 44
  l1="$PADS"
  printf -v l2 '  %-5s %s' PState "${ps:-?}"
  if is_ok "$pg"; then
    l2="$l2   PCIe Gen${pg}"
    is_ok "$pwd" && l2="$l2 x${pwd}"
  fi
  out '%s%s\n' "$l1" "$l2"
}

render_row_csv() {
  local out="\"$STAMP_FULL\"" k v
  for k in "${KEYARR[@]}"; do
    v="${V[$k]:-}"
    case "$v" in
      *", "*) v="${v//, /|}" ;;
      "[N/A]") v="" ;;
    esac
    out="$out,$v"
  done
  printf '%s\n' "$out"
}

render_row_json() {
  local out="" k v
  for k in "${KEYARR[@]}"; do
    v="${V[$k]:-}"
    case "$k" in
      name|driver|pstate|dmode) out="${out:+$out,}\"$k\":\"$v\"" ;;
      persist) if is_ok "$v"; then out="${out:+$out,}\"$k\":\"$v\""; else out="${out:+$out,}\"$k\":null"; fi ;;
      *) if is_dec "$v"; then out="${out:+$out,}\"$k\":$v"; else out="${out:+$out,}\"$k\":null"; fi ;;
    esac
  done
  printf '{"ts":"%s",%s}\n' "$STAMP_ISO" "$out"
}

log_row_csv() {
  local k v out="\"$STAMP_FULL\""
  for k in "${KEYARR[@]}"; do
    v="${V[$k]:-}"
    case "$v" in *","*) out="$out,\"$v\"" ;; *) out="$out,$v" ;; esac
  done
  printf '%s\n' "$out" >> "$LOGFILE"
}

# ---------------------------------------------------------------- 进程采集(异步)
# 文件格式 (每行一条, 空格分隔):
#   PID <pid> <进程名>              — 来自显存采集
#   MEM <pid> <专属显存字节>
#   NAM <pid> <进程名>              — 来自引擎采集(补齐未在显存 Top-N 的进程)
#   ENG <pid> <引擎类型> <百分比>
PROC_MEM="$TMPBASE/gpuwatch-mem-$$.txt"
PROC_ENG="$TMPBASE/gpuwatch-eng-$$.txt"
PROC_MEM_NEW="$PROC_MEM.new"
PROC_ENG_NEW="$PROC_ENG.new"
JOB_MEM=""; JOB_ENG=""
TS_MEM=0; TS_ENG=0

# 取进程完整命令行 -> CMDLINE
# 注意: PID 必须先校验是纯数字 —— 空 PID 时 "/proc//cmdline" 会被解析成
# /proc/cmdline, 那是内核启动参数, 不是进程命令行(实测踩过)。
CMDLINE=""
proc_cmdline() {
  local p="${1:-}"
  CMDLINE=""
  case "$p" in ''|*[!0-9]*) return 0 ;; esac
  if [ -r "/proc/$p/cmdline" ]; then
    CMDLINE="$(tr '\0' ' ' < "/proc/$p/cmdline" 2>/dev/null)"
    CMDLINE="${CMDLINE% }"
  fi
  if [ -z "$CMDLINE" ]; then
    CMDLINE="$(ps -o args= -p "$p" 2>/dev/null | head -1)"
    CMDLINE="${CMDLINE% }"
  fi
  return 0
}

dump_mem_linux() {
  local apps pids users raw pid
  apps="$($SUDO "$SMI" --query-compute-apps=pid,process_name,used_memory --format=csv,noheader,nounits 2>/dev/null | tr -d '\r')"
  [ -z "$apps" ] && return 0
  # 取属主: 一次 ps 拿全, 只查这些 pid。用来区分"谁的进程", 也是提示 --sudo 的依据。
  pids="$(printf '%s\n' "$apps" | awk -F', *' '{ p=$1; gsub(/^[ \t]+|[ \t]+$/,"",p); if (p ~ /^[0-9]+$/) printf "%s,", p }')"
  pids="${pids%,}"
  users=""
  [ -n "$pids" ] && users="$(ps -o pid=,user= -p "$pids" 2>/dev/null)"
  raw="$(printf '%s\n' "$apps" | awk -F', *' -v U="$users" '
    BEGIN { n = split(U, a, "\n")
            for (i = 1; i <= n; i++) { split(a[i], b, " "); if (b[1] != "") own[b[1]] = b[2] } }
    { p=$1; nm=$2; m=$3
      gsub(/^[ \t]+|[ \t]+$/,"",p); gsub(/^[ \t]+|[ \t]+$/,"",nm); gsub(/^[ \t]+|[ \t]+$/,"",m)
      if (p == "" || p !~ /^[0-9]+$/) next
      print "PID " p " " nm
      if (own[p] != "") print "USR " p " " own[p]
      if (m ~ /^[0-9]+$/ && m+0 > 0) print "MEM " p " " (m*1048576) }')"
  [ -z "$raw" ] && return 0
  printf '%s\n' "$raw"
  # 完整命令行(不截断, 由渲染层按终端宽度裁剪)
  pid="$(printf '%s\n' "$raw" | awk '$1 == "PID" { print $2 }' | head -n "$TOP_N")"
  for pid in $pid; do
    proc_cmdline "$pid"
    [ -n "$CMDLINE" ] && printf 'CMD %s %s\n' "$pid" "$CMDLINE"
  done
}

dump_mem_win() {
  [ -z "$PS_EXE" ] && return 0
  "$PS_EXE" -NoProfile -NonInteractive -Command "
    \$ErrorActionPreference='SilentlyContinue';
    \$m=@{}
    foreach(\$x in (Get-Counter '\GPU Process Memory(*)\Dedicated Usage').CounterSamples){
      if(\$x.InstanceName -match 'pid_(\d+)'){ \$p=\$matches[1]
        if(\$m.ContainsKey(\$p)){ \$m[\$p]+=\$x.CookedValue } else { \$m[\$p]=\$x.CookedValue } } }
    \$top = \$m.GetEnumerator() | Sort-Object Value -Descending | Select-Object -First $TOP_N
    \$ids = @(); foreach(\$e in \$top){ \$ids += [int]\$e.Key }
    foreach(\$e in \$top){
      \$n='?'; try { \$n=(Get-Process -Id ([int]\$e.Key)).ProcessName } catch {}
      'PID {0} {1}' -f \$e.Key, \$n
      'MEM {0} {1}' -f \$e.Key, [int64]\$e.Value }
    # 完整命令行(用户要求看全, 不留缩写)
    foreach(\$pr in (Get-CimInstance Win32_Process | Where-Object { \$ids -contains \$_.ProcessId })){
      \$cl = \$pr.CommandLine; if (-not \$cl) { \$cl = \$pr.ExecutablePath }; if (-not \$cl) { \$cl = \$pr.Name }
      if (\$cl) { 'PCMD {0} {1}' -f \$pr.ProcessId, \$cl } }
  " 2>/dev/null | tr -d '\r' | grep -E '^(PID|MEM|PCMD) [0-9]+ '
}

dump_eng_win() {
  [ -z "$PS_EXE" ] && return 0
  "$PS_EXE" -NoProfile -NonInteractive -Command "
    \$ErrorActionPreference='SilentlyContinue';
    \$seen=@{}
    foreach(\$x in (Get-Counter '\GPU Engine(*)\Utilization Percentage').CounterSamples){
      if(\$x.CookedValue -gt 0.01 -and \$x.InstanceName -match 'pid_(\d+)'){ \$p=\$matches[1]; \$t='other'
        if(\$x.InstanceName -match 'engtype_([A-Za-z0-9_]+)'){ \$t=\$matches[1] }
        if(-not \$seen.ContainsKey(\$p)){ \$seen[\$p]=1
          \$n='?'; try { \$n=(Get-Process -Id ([int]\$p)).ProcessName } catch {}
          'NAM {0} {1}' -f \$p, \$n }
        'ENG {0} {1} {2:N2}' -f \$p, \$t, \$x.CookedValue } }
  " 2>/dev/null | tr -d '\r' | grep -E '^(NAM|ENG) [0-9]+ '
}

# Linux/WSL: 每进程明细取自 nvidia-smi pmon
#   · pmon 的利用率是两次采样的差值, 单次采样基本全是 '-', 所以用 -c 2 取后一次;
#     同一个 pid 会出现两次, 解析时后一次覆盖前一次即可。
#   · 部分 GeForce/驱动不支持逐进程 SM/编解码, 会始终返回 '-'; 但显存列(fb, MB)
#     通常可用 —— 所以 fb 必须收, 不能因为 SM 是 '-' 就整行丢弃。
#   · 表头随驱动版本变化(有的没有 jpg/ofa/ccpm), 因此按表头名字定位列, 不写死列号。
dump_eng_linux() {
  local raw cnt
  # -E 时要看利用率, pmon 的利用率是采样差值算的, 所以要多次采样取峰值;
  # 不开 -E 时 pmon 只用来"枚举全部进程(含图形进程 G)和它们的显存", 单次采样即可。
  cnt=3; [ "$ENGINE" -eq 1 ] || cnt=1
  raw="$($SUDO "$SMI" pmon -d 1 -c "$cnt" -s um 2>/dev/null | tr -d '\r')"
  [ -z "$raw" ] && raw="$($SUDO "$SMI" pmon -d 1 -c "$cnt" 2>/dev/null | tr -d '\r')"
  [ -z "$raw" ] && return 0
  local eng pids pid
  eng="$(printf '%s\n' "$raw" | awk '
    /^[ \t]*#/ {
      if (!hdr) { hdr = 1; hdr_nf = NF
        for (i = 1; i <= NF; i++) { t = $i; gsub(/[^A-Za-z]/, "", t); if (t != "") hd[t] = i } }
      next
    }
    NF >= 4 {
      if (!offdone) { off = hdr_nf - NF; offdone = 1 }
      ip = hd["pid"] - off; if (ip < 1) next
      p = $ip
      if (p !~ /^[0-9]+$/) next
      ism = hd["sm"] - off;   imem = hd["mem"] - off
      ienc = hd["enc"] - off; idec = hd["dec"] - off; ifb = hd["fb"] - off
      ityp = hd["type"] - off; icmd = hd["command"] - off
      typ = (ityp >= 1) ? $(ityp) : ""
      cnm = (icmd >= 1) ? $(icmd) : ""
      if (!(p in seen)) { seen[p] = 1; order[++n] = p
        ms[p] = "-"; mm[p] = "-"; me[p] = "-"; md[p] = "-"; mf[p] = "-"; ty[p] = typ; cn[p] = cnm }
      if (cn[p] == "" && cnm != "") cn[p] = cnm
      if (ism  >= 1 && $(ism)  ~ /^[0-9]+$/) { if (ms[p] == "-" || $(ism)+0  > ms[p]+0) ms[p] = $(ism)+0 }
      if (imem >= 1 && $(imem) ~ /^[0-9]+$/) { if (mm[p] == "-" || $(imem)+0 > mm[p]+0) mm[p] = $(imem)+0 }
      if (ienc >= 1 && $(ienc) ~ /^[0-9]+$/) { if (me[p] == "-" || $(ienc)+0 > me[p]+0) me[p] = $(ienc)+0 }
      if (idec >= 1 && $(idec) ~ /^[0-9]+$/) { if (md[p] == "-" || $(idec)+0 > md[p]+0) md[p] = $(idec)+0 }
      if (ifb  >= 1 && $(ifb)  ~ /^[0-9]+$/) { if (mf[p] == "-" || $(ifb)+0  > mf[p]+0) mf[p] = $(ifb)+0 }
    }
    END { for (i = 1; i <= n; i++) { p = order[i]
            printf "PENG %s %s %s %s %s %s\n", p, ms[p], mm[p], me[p], md[p], mf[p]
            if (cn[p] != "") printf "PNAM %s %s\n", p, cn[p]
            if (ty[p] != "") printf "PTYP %s %s\n", p, ty[p] } }')"
  [ -z "$eng" ] && return 0
  printf '%s\n' "$eng"
  # 图形进程(Xorg/gnome-shell 之类)只出现在 pmon 里, 顺手取它们的完整命令行和属主。
  # 属主用 USRG 标记: 不计入"有别人的进程可提权读取"的提示 —— 对图形进程,
  # GeForce 驱动本来就不给逐进程利用率, 提权也读不到, 不该误导用户去输密码。
  pids="$(printf '%s\n' "$eng" | awk '$1 == "PNAM" { print $2 }' | head -n "$(( TOP_N * 2 ))" | sort -u)"
  [ -z "$pids" ] && return 0
  for pid in $pids; do
    proc_cmdline "$pid"
    [ -n "$CMDLINE" ] && printf 'PCMD %s %s\n' "$pid" "$CMDLINE"
  done
  ps -o pid=,user= -p "$(printf '%s' "$pids" | tr '\n' ',' | sed 's/,$//')" 2>/dev/null \
    | awk '{ print "USRG " $1 " " $2 }'
}

# 平台分发: 采集统一走后台, 两个平台同一套调度
dump_mem_all() { if [ "$OS" = "windows" ]; then dump_mem_win; else dump_mem_linux; fi; }
dump_eng_all() { if [ "$OS" = "windows" ]; then dump_eng_win; else dump_eng_linux; fi; }

# ---------------------------------------------------------------- 提权 (--sudo)
# NVML 只给自己进程的占用数字, 别人的进程一律 '-'; 要读别人的必须 root。
# 设计: 只在数据采集那几条命令上加 sudo, 不整体重跑脚本 —— 因为本脚本常以
#   bash <(curl ...) 方式运行, 此时 $0 是 /dev/fd/NN, exec sudo 重跑会失效。
# 密码只在启动时交互输入一次(sudo -v), 之后由后台 keepalive 每 60 秒
# 用 `sudo -n -v` 悄悄续期, 所以一轮监控里只需输一次密码。
SUDO_NOTE_FILE=""

# 是否存在"别的用户的 GPU 计算进程" —— 决定要不要主动提示提权
# 只看计算进程: 别人的计算进程才有"提权就能读到占用数字"这回事; 图形进程
# (gnome-shell/Xorg)不管提不提权都读不到利用率, 拿它去弹密码是骚扰。
others_exist() {
  [ "$OS" != "windows" ] || return 1
  local apps pids users u
  apps="$("$SMI" --query-compute-apps=pid --format=csv,noheader 2>/dev/null | tr -d '\r')"
  [ -z "$apps" ] && return 1
  pids="$(printf '%s\n' "$apps" | tr -d ' ' | grep -E '^[0-9]+$' | paste -sd, -)"
  [ -z "$pids" ] && return 1
  users="$(ps -o user= -p "$pids" 2>/dev/null | sort -u)"
  for u in $users; do
    [ -n "$u" ] && [ "$u" != "$ME" ] && return 0
  done
  return 1
}

# 默认自动提权:
#   1) sudo 凭证已缓存(近期输过密码) -> 静默提权, 不打扰
#   2) 凭证未缓存, 但确实有别人的 GPU 计算进程, 且是交互终端 -> 主动提示输一次密码
#      (这正是用户要的: 想看别人的进程就得给权限, 且只问这一次)
#   3) 其他情况 -> 不提权, 不打扰
sudo_auto() {
  SUDO=""; SUDO_STATE="none"
  [ "$AUTO_SUDO" -eq 1 ] || return 0
  [ "$OS" != "windows" ] || return 0
  [ "$USE_SUDO" -eq 0 ] || return 0        # --sudo 已单独处理过
  command -v sudo >/dev/null 2>&1 || return 0

  if sudo -n -v >/dev/null 2>&1; then
    SUDO="sudo -n"; SUDO_STATE="ok"
  elif { [ -t 0 ] || [ -t 2 ]; } && others_exist; then
    printf '\n%s[提权] 检测到其他用户的 GPU 进程 —— 读取它们的占用数字需要 root。%s\n' "$C_Y" "$C_0"
    printf '       请输入一次密码(仅此一次, 之后自动续期; 不想提权就按 Ctrl+C 重跑并加 --no-sudo):\n'
    if sudo -v; then
      SUDO="sudo -n"; SUDO_STATE="ok"
      printf '%s       ✓ 已提权%s\n' "$C_G" "$C_0"
    else
      printf '%s       ✗ 提权失败, 按普通权限继续%s\n' "$C_Y" "$C_0"
      return 0
    fi
  else
    return 0
  fi

  [ "$SUDO_STATE" = "ok" ] || return 0
  SUDO_NOTE_FILE="${TMPBASE}/gpuwatch-sudo-$$.flag"
  rm -f "$SUDO_NOTE_FILE" 2>/dev/null
  (
    while :; do
      sleep 60
      if ! sudo -n -v >/dev/null 2>&1; then echo expired > "$SUDO_NOTE_FILE"; break; fi
    done
  ) 2>/dev/null &
  SUDO_KA_JOB=$!
  return 0
}

sudo_prime() {
  local note
  if ! command -v sudo >/dev/null 2>&1; then
    printf '%s警告: 找不到 sudo, --sudo 已忽略%s\n' "$C_Y" "$C_0" >&2
    return 1
  fi
  if [ ! -t 0 ] && [ ! -t 2 ]; then
    printf '%s警告: 当前不是交互终端, 无法输入密码, --sudo 已忽略%s\n' "$C_Y" "$C_0" >&2
    return 1
  fi
  SUDO_NOTE_FILE="${TMPBASE}/gpuwatch-sudo-$$.flag"
  rm -f "$SUDO_NOTE_FILE" 2>/dev/null
  printf '\n%s[提权] 读取其他用户的进程占用需要 root 权限, 请输入一次密码:%s\n' "$C_B" "$C_0"
  if sudo -v; then
    SUDO="sudo -n"
    SUDO_STATE="ok"
    printf '%s✓ 已提权; 监控期间后台自动续期, 不会再打断你%s\n' "$C_G" "$C_0"
    # keepalive: 仅在凭证失效时写一个标记文件, 由面板读取后提示(避免污染 TUI)
    (
      while :; do
        sleep 60
        if ! sudo -n -v >/dev/null 2>&1; then echo expired > "$SUDO_NOTE_FILE"; break; fi
      done
    ) 2>/dev/null &
    SUDO_KA_JOB=$!
    return 0
  fi
  printf '%s✗ 提权失败(密码错误或无 sudo 权限), 以普通权限继续%s\n' "$C_Y" "$C_0"
  SUDO=""; SUDO_STATE="none"
  return 1
}

# 读 keepalive 的状态标记 -> SUDO_EXPIRED (只读文件, 无 fork)
SUDO_EXPIRED=0
check_sudo_flag() {
  SUDO_EXPIRED=0
  [ "$SUDO_STATE" = "ok" ] || return 0
  [ -n "$SUDO_NOTE_FILE" ] && [ -f "$SUDO_NOTE_FILE" ] && SUDO_EXPIRED=1
}

# 启动后台采集(不阻塞) —— 两个平台同一套调度, 全部异步
# 采集耗时: Windows 显存计数器 ~2s / 引擎计数器 ~6s; Linux pmon ~2s
proc_refresh() {
  local now rmem reng due_mem=0 due_eng=0
  printf -v now '%(%s)T' -1
  rmem=$(( 10#$PROC_EVERY * 10#${INTERVAL%%.*} )); [ "$rmem" -lt 3 ] && rmem=3
  reng=$(( rmem * 2 )); [ "$reng" -lt 6 ] && reng=6

  if [ ! -s "$PROC_MEM" ]; then due_mem=1
  elif [ $(( now - TS_MEM )) -ge "$rmem" ]; then due_mem=1; fi

  # Linux/WSL: pmon 是唯一能枚举图形进程(G, 如 Xorg/gnome-shell)的来源, 所以
  #   只要显示进程表就收, 不受 -E 限制; 不开 -E 时用单次采样, 开销小。
  # Windows: 图形进程本来就能从性能计数器拿到, 只有 -E 才需要引擎数据。
  if [ "$OS" != "windows" ]; then
    if [ ! -s "$PROC_ENG" ]; then due_eng=1
    elif [ $(( now - TS_ENG )) -ge "$reng" ]; then due_eng=1; fi
  elif [ "$ENGINE" -eq 1 ]; then
    if [ -z "$PS_EXE" ]; then due_eng=0
    elif [ ! -s "$PROC_ENG" ]; then due_eng=1
    elif [ $(( now - TS_ENG )) -ge "$reng" ]; then due_eng=1; fi
  fi

  [ "$due_mem" -eq 0 ] && [ "$due_eng" -eq 0 ] && return 0

  # 两件都到期 → 合成一个后台任务, 省一次 fork
  # 整个后台块屏蔽 stderr: 退出时清理会删掉临时文件, 后台的 mv 可能撞上
  # "源文件已被删除" 而报错, 那种竞态提示对用户没有意义。
  if [ "$due_mem" -eq 1 ] && [ "$due_eng" -eq 1 ]; then
    if [ -n "$JOB_MEM" ] && kill -0 "$JOB_MEM" 2>/dev/null; then return 0; fi
    TS_MEM="$now"; TS_ENG="$now"
    (
      dump_mem_all > "$PROC_MEM_NEW" 2>/dev/null && [ -f "$PROC_MEM_NEW" ] && mv -f "$PROC_MEM_NEW" "$PROC_MEM"
      dump_eng_all > "$PROC_ENG_NEW" 2>/dev/null && [ -f "$PROC_ENG_NEW" ] && mv -f "$PROC_ENG_NEW" "$PROC_ENG"
    ) 2>/dev/null &
    JOB_MEM=$!
    return 0
  fi

  if [ "$due_mem" -eq 1 ]; then
    if [ -n "$JOB_MEM" ] && kill -0 "$JOB_MEM" 2>/dev/null; then return 0; fi
    TS_MEM="$now"
    ( dump_mem_all > "$PROC_MEM_NEW" 2>/dev/null && [ -f "$PROC_MEM_NEW" ] && mv -f "$PROC_MEM_NEW" "$PROC_MEM" ) 2>/dev/null &
    JOB_MEM=$!
  fi

  if [ "$due_eng" -eq 1 ]; then
    if [ -n "$JOB_ENG" ] && kill -0 "$JOB_ENG" 2>/dev/null; then return 0; fi
    TS_ENG="$now"
    ( dump_eng_all > "$PROC_ENG_NEW" 2>/dev/null && [ -f "$PROC_ENG_NEW" ] && mv -f "$PROC_ENG_NEW" "$PROC_ENG" ) 2>/dev/null &
    JOB_ENG=$!
  fi
}

# ---------------------------------------------------------------- 进程渲染
declare -A P_NAME=() P_MEM=() P_3D=() P_COPY=() P_ENC=() P_DEC=() P_COMP=()
declare -A P_LSM=() P_LMEM=() P_LENC=() P_LDEC=() P_LFB=() P_LKEY=()
declare -A P_OWNER=() P_CMD=() P_TYPE=()
HAVE_ENG=0
N_OTHERS=0               # 不属于当前用户的进程数

load_proc_data() {
  P_NAME=(); P_MEM=(); P_3D=(); P_COPY=(); P_ENC=(); P_DEC=(); P_COMP=()
  P_LSM=(); P_LMEM=(); P_LENC=(); P_LDEC=(); P_LFB=(); P_LKEY=()
  P_OWNER=(); P_CMD=(); P_TYPE=(); N_OTHERS=0
  local tag pid rest t val h
  if [ -s "$PROC_MEM" ]; then
    while read -r tag pid rest; do
      case "$tag" in
        PID) [ -n "$pid" ] && P_NAME[$pid]="$rest" ;;
        CMD) [ -n "$pid" ] && [ -n "$rest" ] && P_CMD[$pid]="$rest" ;;
        PCMD) [ -n "$pid" ] && [ -n "$rest" ] && P_CMD[$pid]="$rest" ;;
        USR) [ -n "$pid" ] && { P_OWNER[$pid]="$rest"; [ -n "$rest" ] && [ "$rest" != "$ME" ] && N_OTHERS=$(( N_OTHERS + 1 )); } ;;
        MEM) [ -n "$pid" ] && { is_num "$rest" && P_MEM[$pid]="$rest" || P_MEM[$pid]=0; } ;;
      esac
    done < "$PROC_MEM"
  fi
  # 注意: 这里不能再要求 -E —— Linux 下 pmon 同时是"图形进程的来源", 不开 -E 也要解析
  if [ -s "$PROC_ENG" ]; then
    local -a _f=()
    while read -r tag pid rest; do
      case "$tag" in
        NAM) [ -n "$pid" ] && [ -z "${P_NAME[$pid]:-}" ] && P_NAME[$pid]="$rest" ;;
        ENG)
          [ -z "$pid" ] && continue
          t="${rest%% *}"; val="${rest##* }"
          hun_of "$val"; h="${HUN:-0}"
          case "${t,,}" in
            *3d*)      P_3D[$pid]=$((   ${P_3D[$pid]:-0}   + h )) ;;
            *copy*)    P_COPY[$pid]=$(( ${P_COPY[$pid]:-0} + h )) ;;
            *encode*)  P_ENC[$pid]=$((  ${P_ENC[$pid]:-0}  + h )) ;;
            *decode*)  P_DEC[$pid]=$((  ${P_DEC[$pid]:-0}  + h )) ;;
            *compute*) P_COMP[$pid]=$(( ${P_COMP[$pid]:-0} + h )) ;;
          esac
          HAVE_ENG=1 ;;
        PENG)
          # Linux/WSL: PENG <pid> <sm> <mem> <enc> <dec> <fb MiB>
          [ -z "$pid" ] && continue
          read -r -a _f <<< "$rest"
          P_LSM[$pid]="${_f[0]:--}"; P_LMEM[$pid]="${_f[1]:--}"
          P_LENC[$pid]="${_f[2]:--}"; P_LDEC[$pid]="${_f[3]:--}"
          P_LFB[$pid]="${_f[4]:--}"
          HAVE_ENG=1 ;;
        PNAM) [ -n "$pid" ] && [ -z "${P_NAME[$pid]:-}" ] && [ -n "$rest" ] && P_NAME[$pid]="$rest" ;;
        PCMD) [ -n "$pid" ] && [ -n "$rest" ] && P_CMD[$pid]="$rest" ;;
        PTYP) [ -n "$pid" ] && P_TYPE[$pid]="$rest" ;;
        # 图形进程属主: 只显示, 不计入"可提权读取"的提示(图形进程提权也读不到利用率)
        USRG) [ -n "$pid" ] && [ -n "$rest" ] && P_OWNER[$pid]="$rest" ;;
      esac
    done < "$PROC_ENG"
  fi

  # pmon 是唯一能看到图形进程(Xorg/gnome-shell 等)显存的来源 —— 它的 fb 列(MB)
  # 补给那些不在计算进程表里的进程, 这样图形进程也能出现在主表里。
  # 计算进程的显存以 nvidia-smi 查询值为准, 不用 fb 覆盖。
  if [ "$OS" != "windows" ] && [ "${#P_LFB[@]}" -gt 0 ]; then
    local lp lfb
    for lp in "${!P_LFB[@]}"; do
      lfb="${P_LFB[$lp]:-}"
      is_num "$lfb" || continue
      [ -n "${P_MEM[$lp]:-}" ] && continue
      P_MEM[$lp]=$(( 10#$lfb * 1000000 ))
    done
  fi
}

# 显示两位小数 (输入为百分之一整数) -> DEC2   (10# 强制十进制, 防 08 被当八进制)
DEC2=""
dec2() {
  local h="${1:-0}"
  is_int "$h" || { DEC2="0.00"; return 0; }
  local neg="" a b
  [ "$h" -lt 0 ] && { neg="-"; h=$(( -h )); }
  a=$(( 10#$h / 100 )); b=$(( 10#$h % 100 ))
  if [ "$b" -lt 10 ]; then DEC2="${neg}${a}.0${b}"; else DEC2="${neg}${a}.${b}"; fi
}

# 按指定数组降序排序 pid -> SORTED (数组名传入)
sort_desc_by() {
  SORTED=()
  local -n src="$1"
  local pid n i j tmp
  for pid in "${!src[@]}"; do SORTED+=("$pid"); done
  n=${#SORTED[@]}
  [ "$n" -lt 2 ] && return 0
  for (( i = 0; i < n; i++ )); do
    for (( j = i + 1; j < n; j++ )); do
      if [ "${src[${SORTED[$j]}]:-0}" -gt "${src[${SORTED[$i]}]:-0}" ]; then
        tmp="${SORTED[$i]}"; SORTED[$i]="${SORTED[$j]}"; SORTED[$j]="$tmp"
      fi
    done
  done
}

show_procs() {
  out '%s[%s] %s进程占用%s\n' "$C_D" "$STAMP_HM" "$C_B" "$C_0"
  load_proc_data

  local pid nm mb shown tmax
  tmax="$TOP_N"
  [ "$LIVE" -eq 1 ] && tmax="$LIVE_TOP"

  if [ "$tmax" -eq 0 ]; then
    out '  %s(窗口太小, 已隐藏明细)%s\n' "$C_D" "$C_0"
    return 0
  fi

  if [ "${#P_MEM[@]}" -gt 0 ]; then
    sort_desc_by P_MEM
    out '  PID      专属显存     进程\n'
    shown=0
    for pid in "${SORTED[@]}"; do
      [ "$shown" -ge "$tmax" ] && break
      shown=$(( shown + 1 ))
      mb=$(( ${P_MEM[$pid]:-0} / 1048576 ))
      name_of "$pid" "$NAME_BUD_PROC"
      out '  %-8s %-12s %s\n' "$pid" "${mb} MiB" "$NAME"
    done
  elif [ ! -s "$PROC_MEM" ] && [ ! -s "$PROC_ENG" ]; then
    # 注意: 没有任何计算进程时 PROC_MEM 会是空文件, 但 pmon 仍可能有图形进程 —
    # 所以"还在采集"必须两个来源都空才成立。
    out '  %s(进程数据采集中, 稍候…)%s\n' "$C_D" "$C_0"
  else
    out '  %s(无进程占用 GPU 显存)%s\n' "$C_D" "$C_0"
  fi

  # 提示: 有别人的进程却读不到占用 / sudo 凭证过期
  check_sudo_flag
  if [ "$N_OTHERS" -gt 0 ] && [ "$SUDO_STATE" != "ok" ]; then
    if [ "$NARROW" -eq 1 ]; then
      out '  %s%d 个他人进程: 加 --sudo 看占用%s\n' "$C_D" "$N_OTHERS" "$C_0"
    else
      out '  %s提示: 有 %d 个其他用户的进程, 其占用数字需要加 --sudo 才能读取%s\n' "$C_D" "$N_OTHERS" "$C_0"
    fi
  fi
  if [ "$SUDO_EXPIRED" -eq 1 ]; then
    if [ "$NARROW" -eq 1 ]; then
      out '  %ssudo 已过期, 明细回退%s\n' "$C_Y" "$C_0"
    else
      out '  %ssudo 凭证已过期, 占用明细回退为非特权(重启脚本可重输密码)%s\n' "$C_Y" "$C_0"
    fi
  fi

  [ "$ENGINE" -eq 1 ] || return 0

  # ---------------- Linux/WSL: nvidia-smi pmon 明细 (SM/MEM/Enc/Dec/显存) ----------------
  if [ "$OS" != "windows" ]; then
    if [ "$HAVE_ENG" -eq 0 ]; then
      if [ ! -s "$PROC_ENG" ]; then
        out '\n  %s(引擎数据采集中, 首次约需 2 秒…)%s\n' "$C_D" "$C_0"
      else
        out '\n  %s(该卡/驱动未提供逐进程明细; nvidia-smi pmon 全部返回 "-")%s\n' "$C_D" "$C_0"
      fi
      return 0
    fi
    # 排序键: 优先 SM, 不支持时(全为 '-')自然退化为按显存排
    local s f
    for pid in "${!P_NAME[@]}"; do
      s="${P_LSM[$pid]:-}"; is_num "$s" || s=0
      f="${P_LFB[$pid]:-}"; is_num "$f" || f=0
      P_LKEY[$pid]=$(( 10#$s * 100000 + 10#$f ))
    done
    sort_desc_by P_LKEY
    if [ "$NARROW" -eq 1 ]; then
      out '\n  %s逐进程占用: SM/MEM/Enc/Dec(%%), 显存 MiB%s\n' "$C_B" "$C_0"
    else
      out '\n  %s逐进程占用: SM 计算 / MEM 带宽 / Enc 编码 / Dec 解码 (%%), 以及专用显存 (MiB)%s\n' "$C_B" "$C_0"
    fi
    if [ "$ENG_COMPACT" -eq 1 ]; then
      # 终端太窄: 只留 SM% 和显存, 把宽度让给命令行
      out '  %-8s %-6s %-9s %s\n' "PID" "SM%" "VRAM" "进程"
      shown=0
      for pid in "${SORTED[@]}"; do
        [ "$shown" -ge "$tmax" ] && break
        shown=$(( shown + 1 ))
        name_of "$pid" "$NAME_BUD_ENG"
        out '  %-8s %-6s %-9s %s\n' "$pid" "${P_LSM[$pid]:--}" "${P_LFB[$pid]:--}" "$NAME"
      done
    else
    out '  %-8s %-7s %-7s %-7s %-7s %-9s %s\n' "PID" "SM%" "MEM%" "Enc" "Dec" "VRAM" "进程"
    shown=0
    for pid in "${SORTED[@]}"; do
      [ "$shown" -ge "$tmax" ] && break
      shown=$(( shown + 1 ))
      name_of "$pid" "$NAME_BUD_ENG"
      out '  %-8s %-7s %-7s %-7s %-7s %-9s %s\n' "$pid" \
        "${P_LSM[$pid]:--}" "${P_LMEM[$pid]:--}" "${P_LENC[$pid]:--}" "${P_LDEC[$pid]:--}" \
        "${P_LFB[$pid]:--}" "$NAME"
    done
    fi
    if [ "$NARROW" -eq 1 ]; then
      out '  %s"-"=该进程本窗口无 GPU 活动%s\n' "$C_D" "$C_0"
    else
      out '  %s(注: "-" 表示该进程在采样窗口内无 GPU 活动; 数字为多次采样的峰值)%s\n' "$C_D" "$C_0"
    fi
    return 0
  fi

  # ---------------- Windows: GPU 性能计数器的 3D/Copy/Enc/Dec/Compute ----------------
  local tot=()
  for pid in "${!P_NAME[@]}"; do
    tot+=("${P_3D[$pid]:-0} ${P_COPY[$pid]:-0} ${P_ENC[$pid]:-0} ${P_DEC[$pid]:-0} ${P_COMP[$pid]:-0}")
  done
  declare -A P_TOT=()
  for pid in "${!P_NAME[@]}"; do
    P_TOT[$pid]=$(( ${P_3D[$pid]:-0} + ${P_COPY[$pid]:-0} + ${P_ENC[$pid]:-0} + ${P_DEC[$pid]:-0} + ${P_COMP[$pid]:-0} ))
  done

  if [ ! -s "$PROC_ENG" ]; then
    out '\n  %s(引擎数据采集中, 首次约需 6 秒…)%s\n' "$C_D" "$C_0"
    unset P_TOT
    return 0
  fi
  if [ "${#P_TOT[@]}" -eq 0 ]; then
    out '\n  %s(当前无进程使用 GPU 引擎)%s\n' "$C_D" "$C_0"
    unset P_TOT
    return 0
  fi

  sort_desc_by P_TOT
  if [ "$NARROW" -eq 1 ]; then
    out '\n  %s引擎占用(%%): 3D/Copy/Enc/Dec/Compute%s\n' "$C_B" "$C_0"
  else
    out '\n  %s每进程引擎占用率(%%): 3D 合成 / Copy 拷贝 / Enc 编码 / Dec 解码 / Compute CUDA%s\n' "$C_B" "$C_0"
  fi
  if [ "$ENG_COMPACT" -eq 1 ]; then
    # 终端太窄: 只留 3D 和 Compute, 把宽度让给命令行
    out '  %-8s %-6s %-8s %s\n' "PID" "3D" "Compute" "进程"
    shown=0
    for pid in "${SORTED[@]}"; do
      [ "$shown" -ge "$tmax" ] && break
      shown=$(( shown + 1 ))
      dec2 "${P_3D[$pid]:-0}";   local c1="$DEC2"
      dec2 "${P_COMP[$pid]:-0}"; local c2="$DEC2"
      name_of "$pid" "$NAME_BUD_ENG"
      out '  %-8s %-6s %-8s %s\n' "$pid" "$c1" "$c2" "$NAME"
    done
  else
  out '  %-8s %-7s %-7s %-7s %-7s %-8s %s\n' "PID" "3D" "Copy" "Enc" "Dec" "Compute" "进程"
  shown=0
  for pid in "${SORTED[@]}"; do
    [ "$shown" -ge "$tmax" ] && break
    shown=$(( shown + 1 ))
    dec2 "${P_3D[$pid]:-0}";   local v1="$DEC2"
    dec2 "${P_COPY[$pid]:-0}"; local v2="$DEC2"
    dec2 "${P_ENC[$pid]:-0}";  local v3="$DEC2"
    dec2 "${P_DEC[$pid]:-0}";  local v4="$DEC2"
    dec2 "${P_COMP[$pid]:-0}"; local v5="$DEC2"
    name_of "$pid" "$NAME_BUD_ENG"
    out '  %-8s %-7s %-7s %-7s %-7s %-8s %s\n' "$pid" "$v1" "$v2" "$v3" "$v4" "$v5" "$NAME"
  done
  fi
  unset P_TOT
}

# ---------------------------------------------------------------- 诊断模式
doctor() {
  printf '%s=== GPU 监控环境诊断 ===%s\n' "$C_B" "$C_0"
  printf '  %-20s %s\n' "平台"       "$OS ($(uname -s) $(uname -r))"
  printf '  %-20s %s\n' "nvidia-smi" "$SMI"
  printf '  %-20s %s\n' "主机"       "$(uname -n 2>/dev/null)"
  set_stamp_full
  printf '  %-20s %s\n' "时间"       "$STAMP_FULL"
  printf '  %-20s %s\n' "驱动版本"   "$("$SMI" --query-gpu=driver_version --format=csv,noheader 2>/dev/null | tr -d '\r' | head -1)"

  printf '\n%s--- 显卡清单 ---%s\n' "$C_B" "$C_0"
  local inv
  inv="$("$SMI" --query-gpu=index,name,uuid,compute_mode,persistence_mode --format=csv,noheader 2>/dev/null | tr -d '\r')"
  [ -z "$inv" ] && inv="$("$SMI" --query-gpu=index,name --format=csv,noheader 2>/dev/null | tr -d '\r')"
  [ -z "$inv" ] && inv="(读取失败)"
  printf '%s\n' "$inv" | sed 's/^[ \t]*/  /'

  printf '\n%s--- 显示与驱动模型 ---%s\n' "$C_B" "$C_0"
  local da dm mod
  da="$("$SMI" --query-gpu=display_active --format=csv,noheader 2>/dev/null | tr -d '\r' | head -1)"
  dm="$("$SMI" --query-gpu=display_mode  --format=csv,noheader 2>/dev/null | tr -d '\r' | head -1)"
  mod="$("$SMI" --query-gpu=driver_model.current --format=csv,noheader 2>/dev/null | tr -d '\r' | head -1)"
  [ -n "${da:-}" ] && printf '  %-20s %s\n' "display_active" "$da"
  [ -n "${dm:-}" ] && printf '  %-20s %s\n' "display_mode"   "$dm"
  if [ -n "${mod:-}" ]; then
    printf '  %-20s %s\n' "driver_model" "$mod"
    case "$mod" in
      *TCC*)  printf '  %s→ TCC(纯计算): 不参与桌面渲染%s\n' "$C_G" "$C_0" ;;
      *WDDM*) printf '  %s→ WDDM: 桌面合成(DWM)会占用本卡 3D 引擎%s\n' "$C_Y" "$C_0" ;;
    esac
  fi
  printf '  %s注: display_active=Disabled 只表示没接物理显示器, 不代表桌面/RDP 没用它渲染%s\n' "$C_D" "$C_0"

  printf '\n%s--- 系统全部显示适配器 ---%s\n' "$C_B" "$C_0"
  if [ "$OS" = "windows" ] && [ -n "$PS_EXE" ]; then
    "$PS_EXE" -NoProfile -NonInteractive -Command "
      [Console]::OutputEncoding=[System.Text.Encoding]::UTF8;
      \$ErrorActionPreference='SilentlyContinue';
      Get-CimInstance Win32_VideoController | ForEach-Object { '  {0}  |  driver {1}  |  chip {2}' -f \$_.Name, \$_.DriverVersion, \$_.VideoProcessor }
    " 2>/dev/null | tr -d '\r'
  elif command -v lspci >/dev/null 2>&1; then
    lspci 2>/dev/null | grep -Ei 'vga|3d controller|display' | sed 's/^/  /'
  else
    printf '  %s(lspci 不可用)%s\n' "$C_D" "$C_0"
  fi

  printf '\n%s--- 当前占用 GPU 的进程 (前 %s 条, 按显存) ---%s\n' "$C_B" "$TOP_N" "$C_0"
  if [ "$OS" = "windows" ] && [ -n "$PS_EXE" ]; then
    "$PS_EXE" -NoProfile -NonInteractive -Command "
      [Console]::OutputEncoding=[System.Text.Encoding]::UTF8;
      \$ErrorActionPreference='SilentlyContinue';
      \$m=@{}
      foreach(\$x in (Get-Counter '\GPU Process Memory(*)\Dedicated Usage').CounterSamples){
        if(\$x.InstanceName -match 'pid_(\d+)'){ \$p=\$matches[1]
          if(\$m.ContainsKey(\$p)){ \$m[\$p]+=\$x.CookedValue } else { \$m[\$p]=\$x.CookedValue } } }
      \$m.GetEnumerator() | Sort-Object Value -Descending | Select-Object -First $TOP_N | ForEach-Object {
        \$n='?'; try { \$n=(Get-Process -Id ([int]\$_.Key)).ProcessName } catch {}
        '  {0,-8} {1,8:N0} MiB  {2}' -f \$_.Key, (\$_.Value/1MB), \$n }
    " 2>/dev/null | tr -d '\r'
  else
    local apps
    apps="$("$SMI" --query-compute-apps=pid,process_name,used_memory --format=csv,noheader,nounits 2>/dev/null | tr -d '\r')"
    if [ -n "$apps" ]; then printf '%s\n' "$apps" | sed 's/^/  /'
    else printf '  %s(无)%s\n' "$C_D" "$C_0"; fi
  fi

  printf '\n%s--- 建议 ---%s\n' "$C_B" "$C_0"
  if [ "$OS" != "windows" ]; then
    if command -v sudo >/dev/null 2>&1; then
      if sudo -n -v >/dev/null 2>&1; then
        printf '  · sudo 凭证: %s当前有效%s —— 可直接加 --sudo 读取其他用户的进程占用\n' "$C_G" "$C_0"
      else
        printf '  · sudo 凭证: %s未缓存%s —— 用 --sudo 启动会提示输一次密码(仅一次, 之后自动续期)\n' "$C_Y" "$C_0"
      fi
    else
      printf '  · 未安装 sudo: 只能看到自己进程的占用明细\n'
    fi
    printf '  · 本机属于其他用户的 GPU 进程, 不提升权限时占用列恒为 "-"\n'
  fi
  case "$OS" in
    windows) [ -z "$PS_EXE" ] && printf '  · 未找到 powershell: -E 引擎明细与每进程显存不可用\n' ;;
    wsl)     printf '  · WSL: nvidia-smi 报错时检查 /usr/lib/wsl/lib 是否在 PATH\n' ;;
    linux)   command -v nvtop >/dev/null 2>&1 || printf '  · 可装 nvtop 获得交互界面: sudo apt install nvtop\n' ;;
  esac
  printf '  · 排查 GPU 忙时卡顿: ./gpuwatch.sh -E 看 3D 引擎被哪个进程占住\n'
  printf '  · 长期记录: ./gpuwatch.sh -i 2 -l gpu.csv  (Ctrl+C 退出)\n'
}

cleanup() {
  local j
  for j in $JOB_MEM $JOB_ENG $SUDO_KA_JOB; do [ -n "$j" ] && kill "$j" 2>/dev/null; done
  # 先等采集收尾再删临时文件: 否则后台的 mv 会撞上已删除的源文件, 报 "cannot stat"
  for j in $JOB_MEM $JOB_ENG; do [ -n "$j" ] && wait "$j" 2>/dev/null; done
  rm -f "$PROC_MEM" "$PROC_MEM_NEW" "$PROC_ENG" "$PROC_ENG_NEW" 2>/dev/null
  [ -n "$SUDO_NOTE_FILE" ] && rm -f "$SUDO_NOTE_FILE" 2>/dev/null
  return 0
}

if [ "$DOCTOR" -eq 1 ]; then doctor; exit 0; fi

# ---------------------------------------------------------------- 主流程
# 提权在进入固定界面之前做: 密码提示(仅 --sudo)必须在正常屏幕上出现, 不能被清屏吞掉
if [ "$USE_SUDO" -eq 1 ]; then
  sudo_prime || sudo_auto
else
  sudo_auto
fi

if [ -n "$LOGFILE" ]; then
  if [ ! -f "$LOGFILE" ]; then
    if ! { printf 'ts,%s\n' "$FIELDS" > "$LOGFILE"; } 2>/dev/null; then
      echo "警告: 无法写入 $LOGFILE, 已忽略 -l" >&2; LOGFILE=""
    fi
  fi
fi

S_SAMPLES=0; S_SM=0; S_MEMU=0; S_TEMP=0; S_VRAM=0; S_PWR=0
printf -v S_START '%(%s)T' -1

print_summary() {
  [ "$S_SAMPLES" -eq 0 ] && return 0
  local end dur
  printf -v end '%(%s)T' -1
  dur=$(( end - S_START ))
  printf '\n%s=== 汇总: %s 次采样 / %s 秒 ===%s\n' "$C_B" "$S_SAMPLES" "$dur" "$C_0"
  printf '  SM 利用率峰值   %s%%\n' "$S_SM"
  printf '  显存带宽峰值    %s%%\n' "$S_MEMU"
  printf '  温度峰值        %s C\n' "$S_TEMP"
  printf '  显存峰值        %s MiB\n' "$S_VRAM"
  printf '  功耗峰值        %s W\n' "$S_PWR"
}
exit_now() {
  [ "$DONE_FINAL" -eq 1 ] && exit 130
  DONE_FINAL=1
  live_finish
  print_summary
  cleanup
  exit 130
}
# 兜底: 无论怎么退出都恢复光标, 免得终端里光标消失
trap 'restore_term' EXIT
trap 'exit_now' INT TERM

[ "$FORMAT" = "csv" ] && [ "$COUNT" -eq 0 ] && printf 'ts,%s\n' "$FIELDS"

# 实时表格模式默认原地刷新(固定界面). 仅在真终端下启用:
#   - 输出被重定向到文件/管道时自动关闭, 保证日志干净
#   - 可用 --no-live 强制关闭, --live 强制开启
if [ "$FORMAT" = "table" ]; then
  if   [ "$LIVE_FORCE_OFF" -eq 1 ]; then LIVE=0
  elif [ "$LIVE_FORCE_ON"  -eq 1 ]; then LIVE=1
  elif [ -t 1 ]; then LIVE=1
  else LIVE=0; fi
  if [ "$LIVE" -eq 1 ]; then
    query_term_size
    live_begin
  else
    # 滚动模式: 把显示名预算设为"不裁剪", 日志/管道里保留完整命令行
    recalc_live_top
  fi
fi

FIRST_PROC_DONE=0
i=0
while :; do
  i=$(( i + 1 ))
  gpus="$(sample_gpus)"
  if [ -z "$gpus" ]; then
    set_stamp_hm
    printf '%s[%s] 采样失败: nvidia-smi 无输出%s\n' "$C_R" "$STAMP_HM" "$C_0" >&2
    sleep "$INTERVAL"; continue
  fi
  S_SAMPLES=$(( S_SAMPLES + 1 ))
  set_stamp_hm
  [ "$FORMAT" = "csv" ] && [ "$i" -eq 1 ] && [ "$COUNT" -ne 0 ] && printf 'ts,%s\n' "$FIELDS"

  while IFS= read -r line; do
    [ -z "$line" ] && continue
    load_vals "$line"

    lv="${V[sm]:-}";     die_of "$lv"; lv="$DIE"; is_num "$lv" && [ "$lv" -gt "$S_SM" ]   && S_SM="$lv"
    lv="${V[memctl]:-}"; die_of "$lv"; lv="$DIE"; is_num "$lv" && [ "$lv" -gt "$S_MEMU" ] && S_MEMU="$lv"
    lv="${V[temp]:-}";   die_of "$lv"; lv="$DIE"; is_num "$lv" && [ "$lv" -gt "$S_TEMP" ] && S_TEMP="$lv"
    lv="${V[vram_used]:-}"; die_of "$lv"; lv="$DIE"; is_num "$lv" && [ "$lv" -gt "$S_VRAM" ] && S_VRAM="$lv"
    lv="${V[power]:-}";  die_of "$lv"; lv="$DIE"; is_num "$lv" && [ "$lv" -gt "$S_PWR" ]  && S_PWR="$lv"

    case "$FORMAT" in
      csv)  set_stamp_full; render_row_csv ;;
      json) set_stamp_iso; render_row_json ;;
      *)    BUF=""; [ "$PROC_ONLY" -eq 0 ] && render_row_table ;;
    esac
    [ -n "$LOGFILE" ] && { set_stamp_full; log_row_csv; }
  done <<< "$gpus"

  if [ "$SHOW_PROC" -eq 1 ] && [ "$FORMAT" = "table" ]; then
    proc_refresh
    # 有限次采样(批处理)时等首次采集完成, 保证输出里能看到进程; 实时模式绝不等待
    if [ "$COUNT" -ne 0 ] && [ "$FIRST_PROC_DONE" -eq 0 ]; then
      FIRST_PROC_DONE=1
      if [ -n "$JOB_MEM" ]; then wait "$JOB_MEM" 2>/dev/null; JOB_MEM=""; fi
      if [ "$ENGINE" -eq 1 ] && [ -n "$JOB_ENG" ]; then wait "$JOB_ENG" 2>/dev/null; JOB_ENG=""; fi
    fi
    # 固定界面下每轮都渲染(数据来自后台缓存, 显示廉价且高度稳定);
    # 滚动模式下按 PROC_EVERY 节流
    if [ "$LIVE" -eq 1 ]; then
      show_procs
    elif [ "$PROC_EVERY" -le 1 ] || [ "$(( (i - 1) % PROC_EVERY ))" -eq 0 ]; then
      show_procs
    fi
  fi

  # 表格模式: 整屏原地重绘(固定界面), 或逐轮追加(滚动模式)
  if [ "$FORMAT" = "table" ]; then
    if [ "$LIVE" -eq 1 ]; then
      # 尺寸可能在运行中改变(拖窗口): 每轮用零 fork 的方式刷新, 每 3 轮再问一次 tput
      # (mintty 等终端不填 COLUMNS/LINES, 只能靠 tput)
      refresh_term_size
      if [ "$(( i % 3 ))" -eq 1 ]; then query_term_size; fi
      recalc_live_top
      flush_buf "$BUF"
    else
      printf '%s' "$BUF"
    fi
  fi

  [ "$COUNT" -ne 0 ] && [ "$i" -ge "$COUNT" ] && break
  sleep "$INTERVAL"
done

if [ -n "$JOB_MEM" ] && kill -0 "$JOB_MEM" 2>/dev/null; then
  wait "$JOB_MEM" 2>/dev/null
fi
if [ -n "$JOB_ENG" ] && kill -0 "$JOB_ENG" 2>/dev/null; then
  wait "$JOB_ENG" 2>/dev/null
fi
DONE_FINAL=1
live_finish
print_summary
cleanup
