josie / alder-tools

VID era: SVID reads, --vid/--log tools, blue VID overlay on vCore graph

- sensors.rs: per-core IA32_PERF_STATUS MSR decode (bits 47:32, /8192 V);
  vid_max() ignores unreadable CPUs; graceful fail without root/msr
- main.rs: --vid continuous SVID print (root); --log appends CSV per poll;
  short flags group (-lvt)
- log.rs (new): ./aldermon-vid.log CSV — ts,sio_vcore,vid_max,vid_delta,
  pkg_watts,per-core vid/freq_khz/msr; vid_delta = delivered - requested
- plot.rs: render() gains overlay: Option<(&Ring, Color)> on the shared
  box/scale; series_bits() helper; shared cells = primary wins; auto-scale
  folds both rings' windows
- ui.rs: App.vid_graph Ring pushed every vcore tick (NaN on miss = gap);
  PlotSpec.overlay; hero shows VID + signed droop delta (n/a needs root
  vs transient miss); key_spans() tints numeric VID token blue
- README: --log, VID panel + privileges; .gitignore aldermon-vid.log
- 27 tests, clippy -D warnings clean, fmt clean; pty-verified non-root;
  UAT-confirmed live under root (both traces render, gap = droop)

e719f7780fc81c67dfe88e2ee2ad677a7f230920
josie <josie@example.com> · 2026-10-08T19:17 · browse files at this commit

parents: 470e333

diff --git a/aldermon/.gitignore b/aldermon/.gitignore
index 63d4885..3d687d5 100644
--- a/aldermon/.gitignore
+++ b/aldermon/.gitignore
@@ -1,2 +1,3 @@
 /target
 /references
+/aldermon-vid.log
diff --git a/aldermon/README.md b/aldermon/README.md
index 569c625..8f06b95 100644
--- a/aldermon/README.md
+++ b/aldermon/README.md
@@ -8,10 +8,22 @@ voltage for overclock-safety work.
 ```sh
 aldermon --tui          # TUI (default workhorse)
 aldermon --vid          # VID spike tool (debug, needs root + msr module)
+aldermon --log          # append CSV samples to ./aldermon-vid.log
 aldermon                # one-shot sensor dump
 aldermon --help         # options
 ```
 
+The TUI always shows the delivered vCore (SIO in0), the CPU's requested SVID
+setpoint (IA32_PERF_STATUS), and the delta (delivered − requested; negative =
+VRM droop, positive = LLC overshoot). The VID read needs root +
+`/dev/cpu/*/msr`; without it the panel shows `VID n/a (needs root)`.
+
+`--log` appends one CSV row per poll to `./aldermon-vid.log` (header +
+per-CPU VID / frequency / raw MSR columns). With `--tui` it logs live; with
+`--vid` it logs continuously until interrupted.
+
+Short flags group: `-lvt` == `--log --vid --tui`.
+
 The TUI needs a real terminal; piped/redirected stdout exits with a clear
 error instead of a panic.
 
diff --git a/aldermon/src/log.rs b/aldermon/src/log.rs
new file mode 100644
index 0000000..4dd6099
--- /dev/null
+++ b/aldermon/src/log.rs
@@ -0,0 +1,178 @@
+//! Append-only CSV log of the vCore/V I D cross-check, for off-line analysis
+//! (a separate session consumes `aldermon-vid.log`). The only file this
+//! module writes is the log; sensor reads stay in `sensors.rs`.
+//!
+//! Columns: `ts_unix_ms, sio_vcore, vid_max, vid_delta, pkg_watts`, then one
+//! triple per logical CPU `cpuN_vid, cpuN_freq_khz, cpuN_msr`. `vid_delta` is
+//! delivered − requested (`sio_vcore − vid_max`); negative means the VRM
+//! delivered less than the SVID setpoint asked for (droop), positive means
+//! load-line calibration pushed it above. `cpuN_msr` is the raw
+//! IA32_PERF_STATUS value so a consumer can re-derive the voltage field.
+
+use std::fs::{self, File, OpenOptions};
+use std::io::{self, Write};
+use std::path::Path;
+use std::time::{SystemTime, UNIX_EPOCH};
+
+use crate::sensors;
+
+/// One poll's cross-check snapshot. Vectors are per logical CPU, indexed
+/// from 0; missing/offline CPUs read as None.
+pub struct Sample {
+    pub sio_vcore: Option<f64>,
+    pub vid_max: Option<f64>,
+    pub pkg_watts: Option<f64>,
+    pub vid_msr: Vec<Option<u64>>,
+    pub freq_khz: Vec<Option<u64>>,
+}
+
+pub struct VidLogger {
+    file: File,
+    cpus: usize,
+}
+
+impl VidLogger {
+    /// Open `path` for append, writing a header first if the file is new or
+    /// empty. `cpus` fixes the per-CPU column count for the file's lifetime.
+    pub fn open(path: &Path, cpus: usize) -> io::Result<VidLogger> {
+        let fresh = fs::metadata(path).map(|m| m.len() == 0).unwrap_or(true);
+        let mut file = OpenOptions::new().create(true).append(true).open(path)?;
+        if fresh {
+            writeln!(file, "{}", header(cpus))?;
+        }
+        Ok(VidLogger { file, cpus })
+    }
+
+    /// Append one row and flush so a reader (another session, or `tail -f`)
+    /// sees it immediately even while the TUI keeps running.
+    pub fn log(&mut self, s: &Sample) -> io::Result<()> {
+        let ts_ms = SystemTime::now()
+            .duration_since(UNIX_EPOCH)
+            .map(|d| d.as_millis())
+            .unwrap_or(0);
+        writeln!(self.file, "{}", row(ts_ms, s, self.cpus))?;
+        self.file.flush()
+    }
+}
+
+/// One CSV row (no trailing newline). `vid_delta` is delivered − requested;
+/// blank when either side is unreadable. Offline CPUs leave their three
+/// fields blank (not shifted) so columns stay aligned.
+fn row(ts_ms: u128, s: &Sample, cpus: usize) -> String {
+    let delta = match (s.sio_vcore, s.vid_max) {
+        (Some(got), Some(ask)) => format!("{:.4}", got - ask),
+        _ => String::new(),
+    };
+    let mut row = format!(
+        "{ts_ms},{},{},{delta},{}",
+        opt(s.sio_vcore, 3),
+        opt(s.vid_max, 3),
+        opt(s.pkg_watts, 2),
+    );
+    for cpu in 0..cpus {
+        let msr = s.vid_msr.get(cpu).copied().flatten();
+        let vid = msr.map(sensors::vid_from_msr);
+        let freq = s.freq_khz.get(cpu).copied().flatten();
+        row.push(',');
+        row.push_str(&opt(vid, 3));
+        row.push(',');
+        // Frequencies are integer kHz — no decimal point.
+        row.push_str(&freq.map(|f| f.to_string()).unwrap_or_default());
+        row.push(',');
+        row.push_str(&msr.map(|m| format!("0x{m:016x}")).unwrap_or_default());
+    }
+    row
+}
+
+fn opt(v: Option<f64>, decimals: usize) -> String {
+    v.map(|v| format!("{v:.decimals$}")).unwrap_or_default()
+}
+
+fn header(cpus: usize) -> String {
+    let mut h = String::from("ts_unix_ms,sio_vcore,vid_max,vid_delta,pkg_watts");
+    for cpu in 0..cpus {
+        h.push_str(&format!(",cpu{cpu}_vid,cpu{cpu}_freq_khz,cpu{cpu}_msr"));
+    }
+    h
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn sample() -> Sample {
+        Sample {
+            sio_vcore: Some(1.152),
+            vid_max: Some(1.200),
+            pkg_watts: Some(95.5),
+            // cpu0 readable (VID field 0x2660 = 9824), cpu1 offline.
+            vid_msr: vec![Some(0x0000_2660_0000_0000), None],
+            freq_khz: vec![Some(4_500_000), None],
+        }
+    }
+
+    #[test]
+    fn header_has_one_triple_per_cpu() {
+        let h = header(2);
+        assert_eq!(
+            h,
+            "ts_unix_ms,sio_vcore,vid_max,vid_delta,pkg_watts,cpu0_vid,cpu0_freq_khz,cpu0_msr,cpu1_vid,cpu1_freq_khz,cpu1_msr"
+        );
+        assert_eq!(h.matches(',').count(), 4 + 3 * 2);
+    }
+
+    #[test]
+    fn row_columns_align_and_delta_is_delivered_minus_requested() {
+        let r = row(1_700_000_000_000, &sample(), 2);
+        let cols: Vec<&str> = r.split(',').collect();
+        assert_eq!(cols.len(), 5 + 3 * 2);
+        assert_eq!(cols[1], "1.152");
+        assert_eq!(cols[2], "1.200");
+        assert_eq!(cols[3], "-0.0480"); // 1.152 − 1.200
+        assert_eq!(cols[4], "95.50");
+        // cpu0: vid 0x2660 = 9824 / 8192 = 1.199 V
+        assert_eq!(cols[5], "1.199");
+        assert_eq!(cols[6], "4500000");
+        assert_eq!(cols[7], "0x0000266000000000");
+        // cpu1 offline: three blanks, columns still present.
+        assert_eq!(&cols[8..11], &["", "", ""]);
+    }
+
+    #[test]
+    fn missing_values_leave_blanks_not_garbage() {
+        let s = Sample {
+            sio_vcore: None,
+            vid_max: None,
+            pkg_watts: None,
+            vid_msr: vec![None],
+            freq_khz: vec![None],
+        };
+        let r = row(0, &s, 1);
+        let cols: Vec<&str> = r.split(',').collect();
+        assert_eq!(cols, ["0", "", "", "", "", "", "", ""]);
+    }
+
+    #[test]
+    fn writes_header_once_then_appends() {
+        let dir = std::env::temp_dir().join(format!("aldermon-log-test-{}", std::process::id()));
+        let _ = fs::remove_dir_all(&dir);
+        fs::create_dir_all(&dir).unwrap();
+        let path = dir.join("vid.log");
+        {
+            let mut l = VidLogger::open(&path, 2).unwrap();
+            l.log(&sample()).unwrap();
+        }
+        {
+            let mut l = VidLogger::open(&path, 2).unwrap();
+            l.log(&sample()).unwrap();
+        }
+        let text = fs::read_to_string(&path).unwrap();
+        let lines: Vec<&str> = text.lines().collect();
+        assert_eq!(lines.len(), 3); // header + 2 rows
+        assert!(lines[0].starts_with("ts_unix_ms,"));
+        assert!(!lines[1].starts_with("ts")); // header only once
+        assert_eq!(lines[0].matches(',').count(), lines[1].matches(',').count());
+        assert_eq!(lines[1], lines[2]); // same sample logged twice identically
+        fs::remove_dir_all(&dir).unwrap();
+    }
+}
diff --git a/aldermon/src/main.rs b/aldermon/src/main.rs
index d8da846..43d4c16 100644
--- a/aldermon/src/main.rs
+++ b/aldermon/src/main.rs
@@ -2,14 +2,17 @@
 //!
 //! Modes: no args = one-shot sensor dump; `--tui` = live TUI (vCore hero,
 //! per-core bars, braille graphs, F2 settings); `--vid` = root-only VID vs
-//! in0 cross-check (debug spike, kept as a tool).
+//! in0 cross-check (debug spike, kept as a tool); `--log` = append CSV rows
+//! to ./aldermon-vid.log for off-line analysis.
 
 mod config;
+mod log;
 mod plot;
 mod sensors;
 mod ui;
 
 use std::io::IsTerminal;
+use std::path::Path;
 use std::thread::sleep;
 use std::time::{Duration, Instant};
 
@@ -20,33 +23,82 @@ usage: aldermon [OPTION]
 
   (no option)  one-shot sensor dump to stdout
   --tui        live monitoring TUI (q/Esc quit, F2 settings)
-  --vid        VID vs SIO in0 cross-check (needs root + msr module)
-  --help       show this help";
+  --vid        VID vs SIO in0 cross-check spike (needs root + msr module)
+  --log        append CSV samples to ./aldermon-vid.log
+  --help       show this help
+
+The TUI always shows the delivered vCore, the requested VID and the delta
+(needs root + /dev/cpu/*/msr; shows 'VID n/a (needs root)' otherwise).
+--log appends one CSV row per poll; with --tui it logs live, with --vid it
+logs continuously until interrupted (otherwise --vid runs a 5-sample spike).
+
+Short flags group: -lvt == --log --vid --tui.";
+
+#[derive(Default, Clone, Copy)]
+struct Opts {
+    tui: bool,
+    vid: bool,
+    log: bool,
+}
+
+pub const LOG_PATH: &str = "aldermon-vid.log";
 
 fn main() {
     let args: Vec<String> = std::env::args().collect();
+    let mut opts = Opts::default();
     for a in &args[1..] {
-        if a.starts_with('-') && !matches!(a.as_str(), "--tui" | "--vid" | "--help") {
-            eprintln!("aldermon: unknown option '{a}'\n{USAGE}");
-            std::process::exit(2);
+        if let Some(long) = a.strip_prefix("--") {
+            match long {
+                "tui" => opts.tui = true,
+                "vid" => opts.vid = true,
+                "log" => opts.log = true,
+                "help" => {
+                    println!("{USAGE}");
+                    return;
+                }
+                _ => bad_flag(a),
+            }
+        } else if let Some(short) = a.strip_prefix('-') {
+            for c in short.chars() {
+                match c {
+                    't' => opts.tui = true,
+                    'v' => opts.vid = true,
+                    'l' => opts.log = true,
+                    'h' => {
+                        println!("{USAGE}");
+                        return;
+                    }
+                    _ => bad_flag(a),
+                }
+            }
         }
     }
-    if args.iter().any(|a| a == "--help") {
-        println!("{USAGE}");
-        return;
-    }
-    if args.iter().any(|a| a == "--vid") {
-        vid_spike();
-        return;
-    }
-    if args.iter().any(|a| a == "--tui") {
+
+    if opts.tui {
         if !std::io::stdout().is_terminal() {
             eprintln!("aldermon: --tui needs a real terminal (stdout is not a tty)");
             std::process::exit(1);
         }
-        ui::run_tui();
+        ui::run_tui(opts.log);
+        return;
+    }
+    if opts.vid {
+        vid_spike(opts.log);
         return;
     }
+    if opts.log {
+        eprintln!("aldermon: --log needs --tui or --vid (nothing to sample)\n{USAGE}");
+        std::process::exit(2);
+    }
+    dump();
+}
+
+fn bad_flag(a: &str) -> ! {
+    eprintln!("aldermon: unknown option '{a}'\n{USAGE}");
+    std::process::exit(2);
+}
+
+fn dump() {
     println!("== hwmon ==");
     for chip in discover_chips() {
         println!("[{}] {}", chip.name, chip.path.display());
@@ -83,24 +135,101 @@ fn main() {
     }
 }
 
-/// VID cross-check spike: compare SIO in0 (delivered rail) with the CPU's
-/// reported VID (IA32_PERF_STATUS) per sample. Requires root + msr module.
-fn vid_spike() {
-    println!("== VID cross-check (needs root: /dev/cpu/*/msr) ==");
-    for round in 0..5 {
-        let vcore = sensors::sio_vcore();
-        let vids: Vec<Option<f64>> = (0..16).map(sensors::vid_volts).collect();
-        println!("-- sample {} --", round);
-        match vcore {
-            Some(v) => println!("  SIO in0 (vCore): {:?}", v),
-            None => println!("  SIO in0 (vCore): UNAVAILABLE"),
+/// VID cross-check: compare SIO in0 (delivered rail) with the CPU's requested
+/// SVID setpoint (IA32_PERF_STATUS) per sample. Requires root + msr module.
+/// Without `log` this is a bounded 5-sample spike; with `log` it runs until
+/// interrupted, appending CSV rows to ./aldermon-vid.log.
+fn vid_spike(log: bool) {
+    let cpus = sensors::cpu_count();
+    let mut logger = if log {
+        match log::VidLogger::open(Path::new(LOG_PATH), cpus) {
+            Ok(l) => {
+                println!("logging to ./{LOG_PATH} ({cpus} cpus) — Ctrl-C to stop");
+                Some(l)
+            }
+            Err(e) => {
+                eprintln!("aldermon: cannot open {LOG_PATH}: {e}");
+                std::process::exit(1);
+            }
         }
-        for (cpu, vid) in vids.iter().enumerate() {
-            match vid {
-                Some(v) => println!("  cpu{:>2} VID: {:?}", cpu, v),
-                None => println!("  cpu{:>2} VID: unreadable", cpu),
+    } else {
+        None
+    };
+
+    if logger.is_none() {
+        println!("== VID cross-check (needs root: /dev/cpu/*/msr) ==");
+        println!("(hint: --log appends to ./{LOG_PATH} continuously)");
+    }
+    // Package-energy counter for the logged watts (None until the second
+    // sample; the first delta has no baseline).
+    let pkg = rapl_domains()
+        .into_iter()
+        .find(|d| d.name.starts_with("package"));
+    let mut prev_energy: Option<(u64, Instant)> = None;
+    let rounds: u64 = if logger.is_some() { u64::MAX } else { 5 };
+    for round in 0..rounds {
+        let vcore = sensors::sio_vcore();
+        let msrs = sensors::vid_msrs(cpus);
+        let vmax = sensors::vid_max(&msrs);
+        let freqs = cpu_frequencies();
+
+        // Watts over this inter-sample interval (same math as the TUI).
+        let watts = pkg.as_ref().and_then(|d| {
+            let e = sensors::rapl_energy_uj(&d.id)?;
+            let w = prev_energy.and_then(|(prev, t0)| {
+                let dt = t0.elapsed().as_secs_f64();
+                (dt > 0.0 && e >= prev).then(|| (e - prev) as f64 / 1_000_000.0 / dt)
+            });
+            prev_energy = Some((e, Instant::now()));
+            w
+        });
+
+        if let Some(l) = logger.as_mut() {
+            let sample = log::Sample {
+                sio_vcore: vcore,
+                vid_max: vmax,
+                pkg_watts: watts,
+                vid_msr: msrs.clone(),
+                freq_khz: freq_by_cpu(&freqs, cpus),
+            };
+            if let Err(e) = l.log(&sample) {
+                eprintln!("aldermon: log write failed: {e}");
+            }
+            match (vcore, vmax) {
+                (Some(got), Some(ask)) => {
+                    println!("in0 {got:.3} V  VID {ask:.3} V  Δ{:+.3}", got - ask)
+                }
+                (Some(got), None) => println!("in0 {got:.3} V  VID unreadable"),
+                (None, Some(ask)) => println!("in0 unreadable  VID {ask:.3} V"),
+                (None, None) => println!("in0 unreadable  VID unreadable"),
+            }
+        } else {
+            println!("-- sample {} --", round);
+            match vcore {
+                Some(v) => println!("  SIO in0 (vCore): {v:.3} V"),
+                None => println!("  SIO in0 (vCore): UNAVAILABLE"),
+            }
+            for (cpu, msr) in msrs.iter().enumerate() {
+                match msr {
+                    Some(m) => println!(
+                        "  cpu{cpu:>2} VID: {:.3} V  (0x{m:016x})",
+                        sensors::vid_from_msr(*m)
+                    ),
+                    None => println!("  cpu{cpu:>2} VID: unreadable"),
+                }
             }
         }
         sleep(Duration::from_millis(400));
     }
 }
+
+/// Per-logical-cpu frequency vector (kHz), empty where a cpu has no reading.
+fn freq_by_cpu(freqs: &[(usize, u64)], cpus: usize) -> Vec<Option<u64>> {
+    let mut out = vec![None; cpus];
+    for (cpu, khz) in freqs {
+        if let Some(slot) = out.get_mut(*cpu) {
+            *slot = Some(*khz);
+        }
+    }
+    out
+}
diff --git a/aldermon/src/plot.rs b/aldermon/src/plot.rs
index 4ebe745..f2188f0 100644
--- a/aldermon/src/plot.rs
+++ b/aldermon/src/plot.rs
@@ -134,6 +134,56 @@ fn braille_dot(sub_row: usize, sub_col: usize) -> u8 {
     1u8 << bit
 }
 
+/// Accumulate one series' braille dots into a `rows × cols` cell grid.
+/// Samples map 1:1 onto sub-columns (newest at the right edge); consecutive
+/// filled sub-cols gap-fill vertically toward the previous sample so steps
+/// read as a connected line. Returns the grid and the rightmost filled
+/// sub-col (0 = nothing drawn).
+fn series_bits(
+    samples: &[f64],
+    sub_w: usize,
+    rows: usize,
+    cols: usize,
+    to_sub_row: &impl Fn(f64) -> usize,
+) -> (Vec<Vec<u8>>, usize) {
+    let col_vals = map_to_subcols(samples, sub_w);
+    let mut bits = vec![vec![0u8; cols]; rows];
+    let set = |sub_row: usize, sub_col: usize, bits: &mut Vec<Vec<u8>>| {
+        let cr = sub_row / 4;
+        let cc = sub_col / 2;
+        if cr < rows && cc < cols {
+            bits[cr][cc] |= braille_dot(sub_row, sub_col);
+        }
+    };
+    let mut prev_row: Option<usize> = None;
+    let mut rightmost_sub = 0usize;
+    for (sx, &cv) in col_vals.iter().enumerate() {
+        let Some(v) = cv.filter(|v| v.is_finite()) else {
+            prev_row = None; // gap in history / missing read; restart after it
+            continue;
+        };
+        let sr = to_sub_row(v);
+        rightmost_sub = sx;
+        // Fill the vertical span between the previous sample and this one
+        // in THIS column so the step reads as happening at the new sample.
+        match prev_row {
+            Some(pr) if sr > pr => {
+                for r in pr..=sr {
+                    set(r, sx, &mut bits);
+                }
+            }
+            Some(pr) if sr < pr => {
+                for r in sr..=pr {
+                    set(r, sx, &mut bits);
+                }
+            }
+            _ => set(sr, sx, &mut bits),
+        }
+        prev_row = Some(sr);
+    }
+    (bits, rightmost_sub)
+}
+
 /// Braille sub-pixel draw. One sample per sub-col (2 per cell), newest at
 /// the right edge — NO decimation, NO hysteresis. The 4-level vertical
 /// quantization (sub-rows within each cell) is the noise floor: sub-row
@@ -141,8 +191,11 @@ fn braille_dot(sub_row: usize, sub_col: usize) -> u8 {
 /// trace dots never change as the window scrolls (only the right edge
 /// wiggles as new samples arrive). Visible window = sub_w × poll_ms.
 /// Consecutive sub-cols gap-fill vertically so steps read as a connected
-/// line. Returns the number of CELL columns the trace occupied (0 =
-/// nothing drawn); the caller uses it to right-align the peak text.
+/// line. `overlay` draws a second series over the SAME bounds in its own
+/// color (VID vs vCore); where both occupy one cell the primary wins — a
+/// braille glyph holds a single color. Returns the number of CELL columns
+/// occupied by either trace (0 = nothing drawn); the caller uses it to
+/// right-align the peak text.
 #[allow(clippy::too_many_arguments)]
 pub fn render(
     f: &mut Frame,
@@ -152,6 +205,7 @@ pub fn render(
     max: f64,
     color: Color,
     marker_row: Option<u16>,
+    overlay: Option<(&Ring, Color)>,
 ) -> u16 {
     if area.width < 4 || area.height < 2 || max <= min {
         return 0;
@@ -162,13 +216,6 @@ pub fn render(
     let sub_w = cols * 2;
     let span = max - min;
 
-    // 1:1 — take the newest sub_w samples (or fewer if history is short).
-    let samples: Vec<f64> = ring.samples_window(sub_w).collect();
-    if samples.is_empty() {
-        return 0;
-    }
-    let col_vals = map_to_subcols(&samples, sub_w);
-
     // Sub-row (0=top=max) for a value, mapped over the full [0, sub_h-1]
     // band. The caller (ui.rs pad()) adds scale headroom in auto mode so
     // the trace stays clear of the borders; in fixed mode values at the
@@ -180,45 +227,27 @@ pub fn render(
         cont.round() as usize
     };
 
-    // Accumulate braille bits per cell. cells[row][col] = u8; 0 = blank.
-    let mut bits: Vec<Vec<u8>> = vec![vec![0u8; cols]; rows];
-    let mut set_dot = |sub_row: usize, sub_col: usize| {
-        let cr = sub_row / 4;
-        let cc = sub_col / 2;
-        if cr < rows && cc < cols {
-            bits[cr][cc] |= braille_dot(sub_row, sub_col);
-        }
-    };
+    // 1:1 — take the newest sub_w samples (or fewer if history is short).
+    let primary: Vec<f64> = ring.samples_window(sub_w).collect();
+    let (base_bits, base_right) = series_bits(&primary, sub_w, rows, cols, &to_sub_row);
+    let mut rightmost_sub = base_right;
 
-    // Plot each filled sub-col, gap-filling vertically toward the previous
-    // filled sub-col so steps read as a connected line.
-    let mut prev_row: Option<usize> = None;
-    let mut rightmost_sub: usize = 0;
-    for (sx, &cv) in col_vals.iter().enumerate() {
-        let Some(v) = cv else {
-            prev_row = None; // gap in history; restart the line after it
-            continue;
-        };
-        let sr = to_sub_row(v);
-        rightmost_sub = sx;
-        if let Some(pr) = prev_row {
-            // Fill the vertical span between pr and sr in THIS column so
-            // the step reads as happening at the new sample.
-            if sr > pr {
-                for r in pr..=sr {
-                    set_dot(r, sx);
-                }
-            } else if sr < pr {
-                for r in sr..=pr {
-                    set_dot(r, sx);
-                }
-            } else {
-                set_dot(sr, sx);
-            }
-        } else {
-            set_dot(sr, sx);
+    let (overlay_bits, overlay_st) = match overlay {
+        Some((r, c)) => {
+            let s: Vec<f64> = r.samples_window(sub_w).collect();
+            let (b, rs) = series_bits(&s, sub_w, rows, cols, &to_sub_row);
+            rightmost_sub = rightmost_sub.max(rs);
+            (Some(b), Some(Style::default().fg(c)))
         }
-        prev_row = Some(sr);
+        None => (None, None),
+    };
+    let drawn = |grid: &Option<Vec<Vec<u8>>>| match grid {
+        None => false,
+        Some(b) => b.iter().any(|row| row.iter().any(|&c| c != 0)),
+    };
+    let have_base = base_bits.iter().any(|row| row.iter().any(|&b| b != 0));
+    if !have_base && !drawn(&overlay_bits) {
+        return 0;
     }
 
     // Marker row: red '┄' on blank cells, in the caller's [0, rows-1]
@@ -232,17 +261,24 @@ pub fn render(
     let lines: Vec<Line> = (0..rows)
         .map(|r| {
             let mut spans: Vec<Span> = Vec::with_capacity(cols);
-            for &b in bits[r].iter().take(cols) {
-                if b != 0 {
-                    spans.push(Span::styled(
-                        char::from_u32(0x2800 + b as u32).unwrap().to_string(),
-                        st,
-                    ));
+            for cc in 0..cols {
+                let base = base_bits[r][cc];
+                let over = overlay_bits.as_ref().map_or(0, |b| b[r][cc]);
+                let (bits, style) = if base != 0 {
+                    (base, st)
+                } else if over != 0 {
+                    (over, overlay_st.unwrap_or(st))
                 } else if marker_cell_row == Some(r) {
                     spans.push(Span::styled("┄".to_string(), marker_st));
+                    continue;
                 } else {
                     spans.push(Span::raw(" "));
-                }
+                    continue;
+                };
+                spans.push(Span::styled(
+                    char::from_u32(0x2800 + bits as u32).unwrap().to_string(),
+                    style,
+                ));
             }
             Line::from(spans)
         })
@@ -252,11 +288,7 @@ pub fn render(
 
     // Cell columns occupied: round the rightmost filled sub-col up to a
     // cell boundary, +1 to convert index→count.
-    if rightmost_sub == 0 && col_vals[0].is_none() {
-        0
-    } else {
-        (rightmost_sub / 2 + 1) as u16
-    }
+    (rightmost_sub / 2 + 1) as u16
 }
 
 /// Compact axis label: integers stay short ("200", "0"), fractional values
@@ -271,6 +303,89 @@ pub fn fmt_axis(v: f64) -> String {
 #[cfg(test)]
 mod tests {
     use super::*;
+    use ratatui::backend::TestBackend;
+    use ratatui::Terminal;
+
+    /// Render one ring (+ optional overlay) into a TestBackend and return
+    /// the buffer so tests can inspect glyphs/colors.
+    fn render_to_buf(
+        w: u16,
+        h: u16,
+        ring: &Ring,
+        overlay: Option<(&Ring, Color)>,
+    ) -> ratatui::buffer::Buffer {
+        let mut t = Terminal::new(TestBackend::new(w, h)).unwrap();
+        t.draw(|f| {
+            render(
+                f,
+                Rect::new(0, 0, w, h),
+                ring,
+                0.0,
+                1.0,
+                Color::Green,
+                None,
+                overlay,
+            );
+        })
+        .unwrap();
+        t.backend().buffer().clone()
+    }
+
+    #[test]
+    fn series_bits_breaks_line_at_nan_gap() {
+        // 2 cells = 4 sub-cols; sample 2 is NaN → its sub-col stays blank
+        // and the line does not connect across the gap.
+        let to_row = |v: f64| if v > 0.5 { 0 } else { 3 };
+        let (bits, right) = series_bits(&[1.0, 1.0, f64::NAN, 1.0], 4, 1, 2, &to_row);
+        assert_eq!(bits[0][0] & 0x01, 0x01); // sub-col 0 row 0 (left cell)
+        assert_eq!(bits[0][0] & 0x08, 0x08); // sub-col 1 row 0 (left cell)
+        assert_eq!(bits[0][1] & 0x01, 0x00); // sub-col 2 blank (gap)
+        assert_eq!(bits[0][1] & 0x08, 0x08); // sub-col 3 row 0 (right cell)
+        assert_eq!(right, 3);
+    }
+
+    #[test]
+    fn overlay_draws_in_its_own_color_and_shared_scale() {
+        let mut base = Ring::new(8);
+        let mut over = Ring::new(8);
+        // VID (overlay) above vCore (base) on a 0..1 scale: base row lower
+        // (0.25), overlay row upper (0.75).
+        for _ in 0..4 {
+            base.push(0.25);
+            over.push(0.75);
+        }
+        let buf = render_to_buf(8, 4, &base, Some((&over, Color::Blue)));
+        let colored = |c: Color| buf.content().iter().filter(|cell| cell.fg == c).count();
+        assert!(colored(Color::Green) > 0, "base trace drawn green");
+        assert!(colored(Color::Blue) > 0, "overlay trace drawn blue");
+    }
+
+    #[test]
+    fn overlay_renders_when_primary_ring_is_empty() {
+        // The vCore box may have VID history before any vCore sample.
+        let base = Ring::new(8);
+        let mut over = Ring::new(8);
+        over.push(0.5);
+        let cols = {
+            let mut t = Terminal::new(TestBackend::new(8, 4)).unwrap();
+            let mut n = 0;
+            t.draw(|f| {
+                n = render(
+                    f,
+                    Rect::new(0, 0, 8, 4),
+                    &base,
+                    0.0,
+                    1.0,
+                    Color::Green,
+                    None,
+                    Some((&over, Color::Blue)),
+                );
+            })
+            .unwrap();
+            n
+        };
+        assert!(cols > 0, "overlay alone still returns occupied columns");
+    }
 
     #[test]
     fn levels_map_value_to_rows() {
diff --git a/aldermon/src/sensors.rs b/aldermon/src/sensors.rs
index c4f962d..c6349d6 100644
--- a/aldermon/src/sensors.rs
+++ b/aldermon/src/sensors.rs
@@ -96,6 +96,26 @@ pub fn fans(chip: &Chip) -> Vec<(String, f64)> {
     numbered_inputs(&chip.path, "fan", 1.0)
 }
 
+/// Number of logical CPUs present (highest `cpuN` index + 1). Used to size
+/// the per-cpu VID/MSR scan; offline CPUs still enumerate but their MSR read
+/// fails, yielding None.
+pub fn cpu_count() -> usize {
+    let mut max = 0;
+    if let Ok(entries) = fs::read_dir("/sys/devices/system/cpu") {
+        for entry in entries.flatten() {
+            if let Some(n) = entry
+                .file_name()
+                .to_str()
+                .and_then(|n| n.strip_prefix("cpu"))
+                .and_then(|d| d.parse::<usize>().ok())
+            {
+                max = max.max(n + 1);
+            }
+        }
+    }
+    max
+}
+
 /// Current per-cpu frequency in kHz from cpufreq sysfs (no root required).
 pub fn cpu_frequencies() -> Vec<(usize, u64)> {
     let mut out = Vec::new();
@@ -197,12 +217,39 @@ pub fn read_msr(cpu: usize, msr: u64) -> Option<u64> {
     Some(u64::from_le_bytes(buf))
 }
 
-/// IA32_PERF_STATUS (0x198): bits 15:8 hold the CPU VID (SVID), bit 32 the
-/// turbo-shift indicator. VID → volts: 0.5 V + VID × 0.005 V (Intel SVID spec).
-pub fn vid_volts(cpu: usize) -> Option<f64> {
-    let msr = read_msr(cpu, 0x198)?;
-    let vid = ((msr >> 8) & 0xff) as f64;
-    Some(0.5 + vid * 0.005)
+/// IA32_PERF_STATUS (0x198) raw value for `cpu`. Root + msr module required.
+/// Kept raw so the log can carry the un-decoded MSR (a consumer can re-derive
+/// the voltage field if the decode below is ever wrong).
+pub fn perf_status_msr(cpu: usize) -> Option<u64> {
+    read_msr(cpu, 0x198)
+}
+
+/// Decode a raw IA32_PERF_STATUS value to the requested core voltage (SVID
+/// setpoint), in volts.
+///
+/// The voltage lives in bits [47:32], in units of 1/8192 V (Intel digital
+/// VID, Haswell+; i7z helper_functions.c:77 reads 47:32 then divides by
+/// 1<<13). The older Nehalem 8-bit formula on bits 15:8 is WRONG on Alder
+/// Lake — those bits are the P-state frequency ratio (kernel tsc_msr.c
+/// reads them as a ratio; turbostat's MSR_PLATFORM_INFO dump too), which is
+/// why an earlier probe saw a bogus flat 0.54 V (0.5 + idle_ratio 8×0.005).
+pub fn vid_from_msr(msr: u64) -> f64 {
+    ((msr >> 32) & 0xffff) as f64 / (1u32 << 13) as f64
+}
+
+/// Read the raw IA32_PERF_STATUS of every logical cpu `0..n` (None where the
+/// read fails, e.g. an offline cpu).
+pub fn vid_msrs(n: usize) -> Vec<Option<u64>> {
+    (0..n).map(perf_status_msr).collect()
+}
+
+/// Highest requested VID across all logical CPUs (the core asking the VRM
+/// for the most voltage dominates the VRM's response), or None if no cpu's
+/// MSR was readable.
+pub fn vid_max(msrs: &[Option<u64>]) -> Option<f64> {
+    msrs.iter()
+        .filter_map(|m| m.map(vid_from_msr))
+        .fold(None, |acc, v| Some(acc.map_or(v, |a: f64| a.max(v))))
 }
 
 /// Sampled vCore from the nct6798 SIO (hwmon in0). Find the chip by NAME,
@@ -221,3 +268,36 @@ pub fn sio_input(input: &str) -> Option<f64> {
         .find(|c| c.name.starts_with("nct"))?;
     read_int(&chip.path.join(format!("{input}_input"))).map(|mv| mv as f64 / 1000.0)
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn vid_from_msr_decodes_bits_47_32_as_8192ths() {
+        // 1.200 V setpoint = 1.200 × 8192 = 9830.4 → field 9830 (0x2666).
+        assert!((vid_from_msr(9830u64 << 32) - 9830.0 / 8192.0).abs() < 1e-12);
+        // The old (wrong) Nehalem formula read bits 15:8; make sure the idle
+        // ratio 8 no longer leaks into "volts": field 0 must be 0 V.
+        assert_eq!(vid_from_msr(0x0000_0000_0000_0800), 0.0);
+        // Bits below 32 are not part of the voltage field.
+        assert_eq!(
+            vid_from_msr(0x0000_2660_ffff_ffff),
+            vid_from_msr(0x0000_2660_0000_0000)
+        );
+    }
+
+    #[test]
+    fn vid_max_ignores_unreadable_cpus() {
+        let msrs = vec![
+            Some(9000u64 << 32),
+            None,
+            Some(9830u64 << 32),
+            Some(8000u64 << 32),
+        ];
+        let m = vid_max(&msrs).unwrap();
+        assert!((m - 9830.0 / 8192.0).abs() < 1e-9);
+        assert_eq!(vid_max(&[None, None]), None);
+        assert_eq!(vid_max(&[]), None);
+    }
+}
diff --git a/aldermon/src/ui.rs b/aldermon/src/ui.rs
index 54e2603..a47244f 100644
--- a/aldermon/src/ui.rs
+++ b/aldermon/src/ui.rs
@@ -2,6 +2,7 @@
 //! tick; never touches sysfs itself. Style per design.md: terminal-default
 //! background, one-line htop meters, ANSI color classes only.
 
+use std::path::Path;
 use std::time::{Duration, Instant};
 
 use crossterm::event::{self, Event, KeyCode};
@@ -13,6 +14,7 @@ use ratatui::{
 };
 
 use crate::config;
+use crate::log::{self, VidLogger};
 use crate::plot::{self, Ring};
 use crate::sensors;
 
@@ -52,6 +54,9 @@ pub struct App {
     last_pkg_energy: Option<(u64, Instant)>,
     /// Graph history (newest last) for the package-zone plots.
     vcore_graph: Ring,
+    /// Requested SVID setpoint history, overlaid on the vCore plot (blue).
+    /// Stays empty when MSR is unreadable (non-root) → overlay draws nothing.
+    vid_graph: Ring,
     watts_graph: Ring,
     temp_graph: Ring,
     /// Peak clock (max freq across all cores per tick) graph history.
@@ -68,6 +73,17 @@ pub struct App {
     edit_buf: String,
     /// Last settings-input error (inline, cleared on next keypress).
     settings_error: Option<String>,
+    /// MSR is readable (root + /dev/cpu/*/msr), probed once at startup. When
+    /// false the VID read is skipped each poll and the panel shows "VID n/a".
+    msr_readable: bool,
+    /// Requested SVID setpoint this poll, max across logical CPUs (V).
+    vid: Option<f64>,
+    /// Per-logical-cpu raw IA32_PERF_STATUS, for the CSV row.
+    vid_msr: Vec<Option<u64>>,
+    /// --log: CSV sink at ./aldermon-vid.log (None = logging off).
+    logger: Option<VidLogger>,
+    /// Logical CPU count, fixes the logger's per-cpu column count.
+    n_cpus: usize,
 }
 
 /// Settings-pane rows: (label, config field edited, unit).
@@ -239,9 +255,12 @@ struct CoreRow {
 }
 
 impl App {
-    pub fn new() -> App {
+    pub fn new(logger: Option<VidLogger>, n_cpus: usize) -> App {
         let cfg = config::load();
         let cap = ring_capacity(&cfg);
+        // Probe MSR once: a successful read of cpu0's IA32_PERF_STATUS means
+        // we have root + /dev/cpu/0/msr. Avoids a stat syscall every poll.
+        let msr_readable = sensors::perf_status_msr(0).is_some();
         App {
             cfg,
             vcore: None,
@@ -255,6 +274,7 @@ impl App {
             cores: Vec::new(),
             last_pkg_energy: None,
             vcore_graph: Ring::new(cap),
+            vid_graph: Ring::new(cap),
             watts_graph: Ring::new(cap),
             temp_graph: Ring::new(cap),
             clock_graph: Ring::new(cap),
@@ -264,6 +284,11 @@ impl App {
             settings_row: 0,
             edit_buf: String::new(),
             settings_error: None,
+            msr_readable,
+            vid: None,
+            vid_msr: Vec::new(),
+            logger,
+            n_cpus,
         }
     }
 
@@ -273,6 +298,7 @@ impl App {
     fn resize_graphs(&mut self) {
         let cap = ring_capacity(&self.cfg);
         self.vcore_graph = Ring::new(cap);
+        self.vid_graph = Ring::new(cap);
         self.watts_graph = Ring::new(cap);
         self.temp_graph = Ring::new(cap);
         self.clock_graph = Ring::new(cap);
@@ -280,8 +306,16 @@ impl App {
 
     fn poll(&mut self) {
         self.vcore = sensors::sio_vcore();
+        if self.msr_readable {
+            self.vid_msr = sensors::vid_msrs(self.n_cpus);
+            self.vid = sensors::vid_max(&self.vid_msr);
+        }
         if let Some(v) = self.vcore {
             self.vcore_graph.push(v);
+            // Push VID (or NaN on a transient miss) every vcore tick so the
+            // two rings stay column-aligned; NaN renders as a gap and is
+            // ignored by the scale folds.
+            self.vid_graph.push(self.vid.unwrap_or(f64::NAN));
             let now = Instant::now();
             self.peak_ring.push((now, v));
             self.peak_ring
@@ -384,6 +418,30 @@ impl App {
                 };
             }
         }
+
+        // Append this poll's cross-check row (--log), after all sensors for
+        // the tick have been read so the CSV and the display agree.
+        if let Some(logger) = self.logger.as_mut() {
+            let freqs: Vec<Option<u64>> = {
+                let mut out = vec![None; self.n_cpus];
+                for (cpu, khz) in sensors::cpu_frequencies() {
+                    if let Some(slot) = out.get_mut(cpu) {
+                        *slot = Some(khz);
+                    }
+                }
+                out
+            };
+            let sample = log::Sample {
+                sio_vcore: self.vcore,
+                vid_max: self.vid,
+                pkg_watts: self.package_watts,
+                vid_msr: self.vid_msr.clone(),
+                freq_khz: freqs,
+            };
+            if let Err(e) = logger.log(&sample) {
+                self.settings_error = Some(format!("log write failed: {e}"));
+            }
+        }
     }
 }
 
@@ -488,6 +546,54 @@ fn meter_line(
     Line::from(spans)
 }
 
+/// vCore panel text: delivered (SIO in0), requested SVID setpoint, and the
+/// delta (delivered − requested; negative = VRM droop, positive = LLC
+/// overshoot). VID needs root + /dev/cpu/*/msr; `msr_readable` distinguishes
+/// "not root" from a transient read miss.
+fn hero_text(vcore: Option<f64>, vid: Option<f64>, msr_readable: bool) -> String {
+    let base = match vcore {
+        Some(v) => format!("{v:.3} V"),
+        None => "n/a".to_string(),
+    };
+    match (vid, vcore) {
+        (Some(ask), Some(got)) => format!("{base}  VID {ask:.3}  Δ{:+.3}", got - ask),
+        (Some(ask), None) => format!("{base}  VID {ask:.3}"),
+        (None, _) if msr_readable => format!("{base}  VID n/a"),
+        (None, _) => format!("{base}  VID n/a (needs root)"),
+    }
+}
+
+/// Key-row spans for a plot caption: cyan label + bold live value. When the
+/// plot has an overlay AND the value carries a numeric `VID <x>` token, that
+/// token is tinted the overlay's trace color (blue) so the second line needs
+/// no separate legend; a non-numeric `VID n/a` stays default (nothing drawn).
+fn key_spans(label: &str, live: &str, overlay_color: Option<Color>) -> Vec<Span<'static>> {
+    let bold = Style::default().add_modifier(Modifier::BOLD);
+    let mut spans = vec![Span::styled(
+        format!("{label}  "),
+        Style::default().fg(Color::Cyan),
+    )];
+    if let Some(color) = overlay_color {
+        if let Some(pos) = live.find("VID ") {
+            if live[pos + 4..].starts_with(|c: char| c.is_ascii_digit()) {
+                let end = live[pos..]
+                    .find("  ")
+                    .map(|e| pos + e)
+                    .unwrap_or(live.len());
+                spans.push(Span::styled(live[..pos].to_string(), bold));
+                spans.push(Span::styled(
+                    live[pos..end].to_string(),
+                    Style::default().fg(color).add_modifier(Modifier::BOLD),
+                ));
+                spans.push(Span::styled(live[end..].to_string(), bold));
+                return spans;
+            }
+        }
+    }
+    spans.push(Span::styled(live.to_string(), bold));
+    spans
+}
+
 fn fmt_opt_watts(v: Option<f64>) -> String {
     match v {
         Some(w) => format!("{:.1} W", w),
@@ -562,6 +668,8 @@ struct PlotSpec<'a> {
     live: String,
     peak: String,
     ring: &'a Ring,
+    /// Second series drawn over the same bounds (color: VID blue on vCore).
+    overlay: Option<(&'a Ring, Color)>,
     axis_min: f64,
     axis_max: f64,
     trace_min: f64,
@@ -721,10 +829,7 @@ pub fn draw(f: &mut Frame, app: &App) {
         None => Color::DarkGray,
     };
 
-    let hero_text = match app.vcore {
-        Some(v) => format!("{v:.3} V"),
-        None => "n/a".to_string(),
-    };
+    let hero_text = hero_text(app.vcore, app.vid, app.msr_readable);
     let hero_peak = match app.vcore_peak {
         Some(p) => format!("peak {p:.3}"),
         None => String::new(),
@@ -788,7 +893,11 @@ pub fn draw(f: &mut Frame, app: &App) {
                 app.cfg.temp_max,
             )
         } else {
-            let vc = (ring_min(&app.vcore_graph), ring_max(&app.vcore_graph, 0.0));
+            // vCore + VID share one scale, so fold both rings' window bounds.
+            let vc = (
+                ring_min(&app.vcore_graph).min(ring_min(&app.vid_graph)),
+                ring_max(&app.vcore_graph, 0.0).max(ring_max(&app.vid_graph, 0.0)),
+            );
             let ck = (ring_min(&app.clock_graph), ring_max(&app.clock_graph, 0.0));
             let pw = (ring_min(&app.watts_graph), ring_max(&app.watts_graph, 0.0));
             let tp = (ring_min(&app.temp_graph), ring_max(&app.temp_graph, 0.0));
@@ -823,6 +932,7 @@ pub fn draw(f: &mut Frame, app: &App) {
                 live: hero_text.clone(),
                 peak: hero_peak,
                 ring: &app.vcore_graph,
+                overlay: Some((&app.vid_graph, Color::Blue)),
                 axis_min: vc_lo,
                 axis_max: vc_hi,
                 trace_min: p_vc_lo,
@@ -836,6 +946,7 @@ pub fn draw(f: &mut Frame, app: &App) {
                 live: clock_text,
                 peak: clock_peak,
                 ring: &app.clock_graph,
+                overlay: None,
                 axis_min: ck_lo,
                 axis_max: ck_hi,
                 trace_min: p_ck_lo,
@@ -849,6 +960,7 @@ pub fn draw(f: &mut Frame, app: &App) {
                 live: fmt_opt_watts(app.package_watts),
                 peak: watts_peak,
                 ring: &app.watts_graph,
+                overlay: None,
                 axis_min: pw_lo,
                 axis_max: pw_hi,
                 trace_min: p_pw_lo,
@@ -862,6 +974,7 @@ pub fn draw(f: &mut Frame, app: &App) {
                 live: fmt_opt_temp(app.package_temp),
                 peak: temp_peak,
                 ring: &app.temp_graph,
+                overlay: None,
                 axis_min: tp_lo,
                 axis_max: tp_hi,
                 trace_min: p_tp_lo,
@@ -877,6 +990,7 @@ pub fn draw(f: &mut Frame, app: &App) {
             live,
             peak,
             ring,
+            overlay,
             axis_min,
             axis_max,
             trace_min,
@@ -907,10 +1021,9 @@ pub fn draw(f: &mut Frame, app: &App) {
             // col 0; session peak right-aligned against the trace's right
             // edge, never left of KEY_RESERVE). Single spans per style —
             // ratatui's line diff drops separator spans in narrow rects.
-            let key = Line::from(vec![
-                Span::styled(format!("{label}  "), Style::default().fg(Color::Cyan)),
-                Span::styled(live, Style::default().add_modifier(Modifier::BOLD)),
-            ]);
+            // The overlay's key chunk (VID) is tinted like its trace, so
+            // the blue line is legible without a legend.
+            let key = Line::from(key_spans(label, &live, overlay.map(|(_, c)| c)));
             f.render_widget(
                 ratatui::text::Text::from(key),
                 Rect {
@@ -975,6 +1088,7 @@ pub fn draw(f: &mut Frame, app: &App) {
                 trace_max,
                 color,
                 marker,
+                overlay,
             );
             if !peak.is_empty() {
                 let peak_w = peak.chars().count() as u16;
@@ -1147,9 +1261,21 @@ fn settings_key(app: &mut App, key: KeyCode) -> Result<(), String> {
     Ok(())
 }
 
-pub fn run_tui() {
+pub fn run_tui(log_enabled: bool) {
+    let n_cpus = sensors::cpu_count();
+    let logger = if log_enabled {
+        match VidLogger::open(Path::new(crate::LOG_PATH), n_cpus) {
+            Ok(l) => Some(l),
+            Err(e) => {
+                eprintln!("aldermon: cannot open {}: {e}", crate::LOG_PATH);
+                std::process::exit(1);
+            }
+        }
+    } else {
+        None
+    };
     let mut terminal = ratatui::init();
-    let mut app = App::new();
+    let mut app = App::new(logger, n_cpus);
     loop {
         app.poll();
         terminal.draw(|f| draw(f, &app)).expect("draw failed");
@@ -1208,3 +1334,59 @@ fn save_conf(app: &App) -> Result<(), String> {
     }
     Ok(())
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn key_spans_tints_only_the_numeric_vid_token() {
+        let spans = key_spans("vCore", "1.016 V  VID 1.100  Δ-0.084", Some(Color::Blue));
+        let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
+        assert_eq!(text, "vCore  1.016 V  VID 1.100  Δ-0.084");
+        let blue: Vec<&str> = spans
+            .iter()
+            .filter(|s| s.style.fg == Some(Color::Blue))
+            .map(|s| s.content.as_ref())
+            .collect();
+        assert_eq!(blue, ["VID 1.100"]);
+    }
+
+    #[test]
+    fn key_spans_leaves_unreadable_vid_default() {
+        // No numeric VID → no blue token (nothing is drawn for it).
+        let spans = key_spans("vCore", "1.016 V  VID n/a", Some(Color::Blue));
+        assert!(spans.iter().all(|s| s.style.fg != Some(Color::Blue)));
+        // No overlay → plain caption, no tinting.
+        let spans = key_spans("Power", "95.5 W", None);
+        let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
+        assert_eq!(text, "Power  95.5 W");
+    }
+
+    #[test]
+    fn hero_shows_vid_and_signed_delta() {
+        // delivered 0.668, requested 0.821 → −153 mV (droop).
+        let t = hero_text(Some(0.668), Some(0.821), true);
+        assert_eq!(t, "0.668 V  VID 0.821  Δ-0.153");
+        // delivered above requested → + sign.
+        assert_eq!(
+            hero_text(Some(1.30), Some(1.20), true),
+            "1.300 V  VID 1.200  Δ+0.100"
+        );
+    }
+
+    #[test]
+    fn hero_distinguishes_not_root_from_transient_miss() {
+        assert_eq!(
+            hero_text(Some(1.0), None, false),
+            "1.000 V  VID n/a (needs root)"
+        );
+        assert_eq!(hero_text(Some(1.0), None, true), "1.000 V  VID n/a");
+    }
+
+    #[test]
+    fn hero_handles_missing_vcore() {
+        assert_eq!(hero_text(None, Some(1.2), true), "n/a  VID 1.200");
+        assert_eq!(hero_text(None, None, false), "n/a  VID n/a (needs root)");
+    }
+}