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)");
+ }
+}
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