Merge gpu-hotplug (12d5011) into miner-ui-2: the hot-plug card states on the six-section UI

The Notices block takes the hot-plug notices as on gpu-hotplug (card added, not usable, removed). The View block
and the Mine rows, the first-run rows and the Settings cards show a removed card (dimmed, no switch, the row goes
after five minutes) and a faulty card (named in ember, the OS problem code, the reboot hint, no switch); the big
button and the counts take only present cards; the name tooltip carries the tool's code, the device, the platform
and the PCI address. view.test.mjs covers the two states. host.cpp keeps both the WM_GETMINMAXINFO and the
WM_DEVICECHANGE cases.

Build tooling: push-build-inputs.sh and build-job.mjs take --no-node (the app engine only, no node source, no node
build, no node tests), for an app-only PC compile.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
igneum-labs 2026-10-05 19:20:16 +00:00
commit 0d64c1f59d
18 changed files with 1686 additions and 194 deletions

View file

@ -1,6 +1,8 @@
//! GPU detection with the real names. macOS: the Metal worker's ready line (the device Metal reports) plus the core
//! count from system_profiler. Windows: nvidia-smi for NVIDIA cards, the OpenCL worker's --list for the rest
//! (AMD, Intel), and the WMI name list as a last resort when neither tool runs.
//! (AMD, Intel), the Windows adapter list (Win32_VideoController: status, problem code, memory) for the cards no
//! worker can drive and for the integrated-or-discrete call, and that list's names as a last resort when neither
//! tool runs. The engine runs this at start and again every minute (src/hotplug.rs compares the two lists).
use crate::state::CardState;
use std::io::Write;
@ -18,6 +20,38 @@ pub struct Bins {
pub dir: std::path::PathBuf,
}
/// One enumeration: the cards, the notes for the setup screen, and which tools answered. A tool that did not answer
/// (nvidia-smi timed out, the OpenCL worker crashed) says nothing about its cards: the engine keeps them rather
/// than calling them removed (src/hotplug.rs).
#[derive(Clone, Default)]
pub struct Detection {
pub cards: Vec<CardState>,
pub notes: Vec<String>,
/// duplicate OpenCL platform entries left out (one line each, for the log)
pub dropped: Vec<String>,
pub nvidia_listed: bool,
pub opencl_listed: bool,
pub adapters_listed: bool,
pub metal_listed: bool,
}
impl Detection {
/// Whether this enumeration can say that `c` is gone: the tool that lists its vendor answered.
pub fn listed(&self, c: &CardState) -> bool {
if !c.problem.is_empty() {
return self.adapters_listed;
}
match c.vendor.as_str() {
"apple" => self.metal_listed,
"nvidia" => self.nvidia_listed,
_ => self.opencl_listed || (c.device.is_empty() && self.adapters_listed),
}
}
}
/// The hint on a card the OS reports as faulty (Windows Code 43, 12, 31 and friends).
pub const PROBLEM_HINT: &str = "reboot with the card attached; if it persists, reinstall the driver with the card attached";
/// Runs a command with a time limit; returns stdout (and stderr appended) or None.
pub fn run_timeout(cmd: &mut Command, stdin_text: Option<&str>, limit: Duration) -> Option<String> {
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
@ -56,7 +90,8 @@ pub fn run_timeout(cmd: &mut Command, stdin_text: Option<&str>, limit: Duration)
fn card(index: usize, name: &str, vendor: &str, worker: &str, detail: &str, device: &str) -> CardState {
CardState {
index,
key: format!("{vendor}:{device}:{name}"),
key: format!("{vendor}:{name}"),
code: name.to_string(),
name: name.to_string(),
vendor: vendor.into(),
worker: worker.into(),
@ -68,14 +103,141 @@ fn card(index: usize, name: &str, vendor: &str, worker: &str, detail: &str, devi
}
}
/// Integrated GPUs by name: AMD APUs ("Radeon Graphics", "Vega 8"), Intel iGPUs (Iris, UHD, HD Graphics, Arc A3xx is discrete).
#[allow(dead_code)]
/// Integrated GPUs by name: AMD APUs ("Radeon Graphics", "Vega 8"), Intel iGPUs (Iris, UHD, HD Graphics, Arc A3xx is
/// discrete), and the gfx codes AMD's OpenCL runtime reports instead of a marketing name (the worker's --list prints
/// CL_DEVICE_NAME: PC 1's Ryzen iGPU is "gfx1036", 5 October 2026).
pub fn looks_integrated(name: &str) -> bool {
let n = name.to_ascii_lowercase();
let integrated = ["radeon(tm) graphics", "radeon graphics", "vega 8", "vega 7", "vega 6", "vega 3", "vega 11", "iris", "uhd graphics", "hd graphics", "intel(r) graphics", "intel graphics", "apu", "780m", "760m", "680m", "610m", "890m", "880m"];
integrated.iter().any(|k| n.contains(k)) && !n.contains("arc ")
if integrated.iter().any(|k| n.contains(k)) && !n.contains("arc ") {
return true;
}
// AMD APU graphics by gfx code (approximate list from AMD's ROCm and Mesa target tables): Raven/Picasso gfx902 and
// gfx909, Renoir/Cezanne/Lucienne gfx90c, Van Gogh gfx1033, Rembrandt gfx1035, Raphael/Granite Ridge gfx1036,
// Mendocino gfx1037, Phoenix gfx1103, Strix gfx1150 to gfx1152. Discrete codes (gfx1030 and so on) are not here.
let apu = ["gfx902", "gfx909", "gfx90c", "gfx1033", "gfx1035", "gfx1036", "gfx1037", "gfx1103", "gfx1150", "gfx1151", "gfx1152"];
let code = n.trim();
apu.iter().any(|k| code == *k || code.starts_with(&format!("{k}:")) || code.starts_with(&format!("{k} ")))
}
/// One row of Windows' adapter list (Win32_VideoController), the part this app reads.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Adapter {
pub name: String,
/// "OK", "Error", "Degraded", ... (the Status property)
pub status: String,
/// the PnP problem code (ConfigManagerErrorCode): 0 = fine, 43 = the driver stopped it, 12 = no resources, 31 = not loaded
pub code: u32,
/// AdapterRAM in MB; 0 = unknown (a faulty card reports 0, and the property caps at 4 GB on 32-bit values)
pub ram_mb: u64,
pub processor: String,
pub pnp_id: String,
/// "01:00.0" from DEVPKEY_Device_BusNumber and DEVPKEY_Device_Address; empty when PowerShell could not read them
pub bus: String,
}
impl Adapter {
/// The PCI device id from the PnP id ("PCI\\VEN_1002&DEV_7550&..." gives 0x7550); 0 when there is none.
pub fn device_id(&self) -> u16 {
let up = self.pnp_id.to_ascii_uppercase();
up.find("DEV_").and_then(|i| u16::from_str_radix(up.get(i + 4..i + 8)?, 16).ok()).unwrap_or(0)
}
/// "Code 43" for a problem code, "status Error" for a bad status without one, None when the device is fine.
pub fn problem(&self) -> Option<String> {
if self.code != 0 {
return Some(format!("Code {}", self.code));
}
let st = self.status.trim();
if !st.is_empty() && !st.eq_ignore_ascii_case("ok") {
return Some(format!("status {st}"));
}
None
}
}
/// Integrated or discrete, from the name (APU and iGPU names, AMD gfx codes) and, when Windows' adapter row is
/// known, its processor string or a dedicated memory under 1 GB (a shared-memory iGPU; 0 = unknown, says nothing).
pub fn classify_kind(name: &str, adapter: Option<&Adapter>) -> &'static str {
if looks_integrated(name) {
return "integrated";
}
if let Some(a) = adapter {
// the processor string names Intel iGPUs ("Intel(R) Iris(R) Xe Graphics Family"); AMD's reads "AMD Radeon
// Graphics Processor (0x7550)" for discrete cards too, so only the Intel markers count here
let proc_ = a.processor.to_ascii_lowercase();
if looks_integrated(&a.name) || (["iris", "uhd graphics", "hd graphics"].iter().any(|k| proc_.contains(k)) && !proc_.contains("arc")) {
return "integrated";
}
if a.ram_mb > 0 && a.ram_mb < 1024 {
return "integrated";
}
}
"discrete"
}
pub fn vendor_of(name: &str) -> &'static str {
let n = name.to_ascii_lowercase();
if n.contains("nvidia") || n.contains("geforce") {
"nvidia"
} else if n.contains("amd") || n.contains("radeon") || n.starts_with("gfx") {
"amd"
} else if n.contains("apple") {
"apple"
} else {
"other"
}
}
/// Parses `Get-CimInstance Win32_VideoController | Select-Object ... | ConvertTo-Json` (one object or an array).
pub fn parse_adapters(json: &str) -> Vec<Adapter> {
let Ok(v) = serde_json::from_str::<serde_json::Value>(json.trim()) else { return Vec::new() };
let rows: Vec<serde_json::Value> = match v {
serde_json::Value::Array(a) => a,
o @ serde_json::Value::Object(_) => vec![o],
_ => Vec::new(),
};
let s = |r: &serde_json::Value, k: &str| r.get(k).and_then(|x| x.as_str()).unwrap_or("").trim().to_string();
let n = |r: &serde_json::Value, k: &str| r.get(k).and_then(|x| x.as_u64().or_else(|| x.as_str().and_then(|t| t.trim().parse::<u64>().ok()))).unwrap_or(0);
rows.iter()
.map(|r| {
// DEVPKEY_Device_Address on PCI is (device << 16) | function
let bus = match (r.get("BusNumber").and_then(|x| x.as_u64()), r.get("Address").and_then(|x| x.as_u64())) {
(Some(b), Some(a)) => format!("{:02x}:{:02x}.{:x}", b & 0xff, (a >> 16) & 0xff, a & 0xffff),
_ => String::new(),
};
Adapter { name: s(r, "Name"), status: s(r, "Status"), code: n(r, "ConfigManagerErrorCode") as u32, ram_mb: n(r, "AdapterRAM") / (1024 * 1024), processor: s(r, "VideoProcessor"), pnp_id: s(r, "PNPDeviceID"), bus }
})
.filter(|a| !a.name.is_empty())
.collect()
}
/// Windows' adapter list through PowerShell (about a second); None when PowerShell did not answer.
#[cfg(windows)]
pub fn adapters() -> Option<Vec<Adapter>> {
// one object per adapter, with the PCI bus number and address from the PnP properties (they name the card
// the OpenCL worker's "pci" field names); @() keeps a single adapter an array
let script = "$v = Get-CimInstance Win32_VideoController | ForEach-Object { $id = $_.PNPDeviceID; $bus = $null; $addr = $null; try { foreach ($x in (Get-PnpDeviceProperty -InstanceId $id -KeyName 'DEVPKEY_Device_BusNumber','DEVPKEY_Device_Address' -ErrorAction Stop)) { if ($x.KeyName -eq 'DEVPKEY_Device_BusNumber') { $bus = $x.Data } elseif ($x.KeyName -eq 'DEVPKEY_Device_Address') { $addr = $x.Data } } } catch {}; [pscustomobject]@{ Name = $_.Name; Status = $_.Status; ConfigManagerErrorCode = $_.ConfigManagerErrorCode; AdapterRAM = $_.AdapterRAM; VideoProcessor = $_.VideoProcessor; PNPDeviceID = $id; BusNumber = $bus; Address = $addr } }; ConvertTo-Json -InputObject @($v) -Compress";
let out = run_timeout(Command::new(crate::platform::tool("powershell")).args(["-NoProfile", "-Command", script]), None, Duration::from_secs(15))?;
let start = out.find(|c| c == '[' || c == '{')?;
Some(parse_adapters(&out[start..]))
}
#[cfg(not(windows))]
#[allow(dead_code)]
pub fn adapters() -> Option<Vec<Adapter>> {
None
}
/// Windows' row for a detected card, by name (nvidia-smi and Windows agree on NVIDIA names; AMD's OpenCL runtime
/// reports gfx codes, which match nothing here and fall back to the name rules).
pub fn adapter_for<'a>(name: &str, adapters: &'a [Adapter]) -> Option<&'a Adapter> {
let n = name.trim().to_ascii_lowercase();
adapters.iter().find(|a| a.name.trim().to_ascii_lowercase() == n)
}
/// The row's words for an integrated GPU that is off by default (the switch turns it on; the choice is kept).
pub const INTEGRATED_REASON: &str = "integrated GPU, off by default (2 to 3 MH/s for 30 W)";
/// The defaults the launchers use: discrete cards on (8 identities on a big card, 2 on a small one), integrated off
/// (1 identity), Apple silicon on with 1.
pub fn apply_defaults(c: &mut CardState) {
@ -87,7 +249,7 @@ pub fn apply_defaults(c: &mut CardState) {
"integrated" => {
c.enabled = false;
c.identities = 1;
c.reason = "integrated: about 3 MH/s and it shares your system memory. Switch it on if you want it.".into();
c.reason = INTEGRATED_REASON.into();
}
_ => {
c.enabled = true;
@ -148,19 +310,20 @@ pub fn nvidia_power_limits() -> std::collections::HashMap<String, (f64, f64, f64
}
#[cfg(target_os = "macos")]
pub fn detect(bins: &Bins, notes: &mut Vec<String>) -> Vec<CardState> {
let mut cards = Vec::new();
pub fn detect(bins: &Bins) -> Detection {
let mut d = Detection::default();
let Some(metal) = bins.metal.as_ref() else {
notes.push("the Metal worker (igneum-bench) is missing from the app".into());
return cards;
d.notes.push("the Metal worker (igneum-bench) is missing from the app".into());
return d;
};
// the worker's own ready line: "ready metal Apple_M5_Max dataset-log2 28 batch 4194304 prepare 1"
let out = run_timeout(Command::new(metal).arg("--serve"), Some("quit\n"), Duration::from_secs(20)).unwrap_or_default();
let ready = out.lines().find(|l| l.starts_with("ready "));
let Some(ready) = ready else {
notes.push(format!("the Metal worker did not report ready: {}", out.lines().last().unwrap_or("no output")));
return cards;
d.notes.push(format!("the Metal worker did not report ready: {}", out.lines().last().unwrap_or("no output")));
return d;
};
d.metal_listed = true;
let fields: Vec<&str> = ready.split_whitespace().collect();
let name = fields.get(2).map(|s| s.replace('_', " ")).unwrap_or_else(|| "Apple GPU".into());
let prepare = fields.windows(2).any(|w| w[0] == "prepare" && w[1] == "1");
@ -175,58 +338,280 @@ pub fn detect(bins: &Bins, notes: &mut Vec<String>) -> Vec<CardState> {
}
}
}
if let Some(mem) = run_timeout(Command::new(crate::platform::tool("sysctl")).args(["-n", "hw.memsize"]), None, Duration::from_secs(3)) {
if let Ok(b) = mem.trim().parse::<u64>() {
let gb = b / (1024 * 1024 * 1024);
detail = if detail.is_empty() { format!("{gb} GB unified memory") } else { format!("{detail}, {gb} GB unified memory") };
}
let mem = run_timeout(Command::new(crate::platform::tool("sysctl")).args(["-n", "hw.memsize"]), None, Duration::from_secs(3)).and_then(|m| m.trim().parse::<u64>().ok());
if let Some(b) = mem {
let gb = b / (1024 * 1024 * 1024);
detail = if detail.is_empty() { format!("{gb} GB unified memory") } else { format!("{detail}, {gb} GB unified memory") };
}
if !prepare {
notes.push("this Metal worker has no prepare support; the miner restarts at the hour boundary".into());
d.notes.push("this Metal worker has no prepare support; the miner restarts at the hour boundary".into());
}
let mut c = card(0, &name, "apple", "Metal", &detail, "");
c.kind = "apple".into();
c.path = "prebuilt".into();
if let Some(mem) = run_timeout(Command::new(crate::platform::tool("sysctl")).args(["-n", "hw.memsize"]), None, Duration::from_secs(3)) {
c.vram_mb = mem.trim().parse::<u64>().map(|b| b / (1024 * 1024)).unwrap_or(0);
}
c.vram_mb = mem.map(|b| b / (1024 * 1024)).unwrap_or(0);
apply_defaults(&mut c);
mark_sweep_support(&mut c);
cards.push(c);
cards
d.cards.push(c);
assign_keys(&mut d.cards);
d
}
#[cfg(not(target_os = "macos"))]
pub fn detect(bins: &Bins, notes: &mut Vec<String>) -> Vec<CardState> {
let mut cards: Vec<CardState> = Vec::new();
// NVIDIA: nvidia-smi ships with the driver
let smi = run_timeout(Command::new(crate::platform::tool("nvidia-smi")).args(["--query-gpu=index,name,memory.total", "--format=csv,noheader"]), None, Duration::from_secs(10));
match smi {
/// AMD gfx codes the OpenCL runtime reports as the device name, with the card names Windows uses, the words for
/// the row when no adapter matches, and the PCI device ids (approximate, from AMD's public ROCm and Linux driver
/// tables; add a line when a card is seen). gfx1036 is the Ryzen desktop iGPU (PC 1: DEV_13C0, 5 October 2026).
const GFX: &[(&str, &str, &[&str], &[u16])] = &[
("gfx1201", "Radeon RX 9070 XT / 9070", &["Radeon RX 9070 XT", "Radeon RX 9070"], &[0x7550]),
("gfx1200", "Radeon RX 9060 XT", &["Radeon RX 9060 XT", "Radeon RX 9060"], &[0x7590]),
("gfx1100", "Radeon RX 7900 XTX / XT", &["Radeon RX 7900 XTX", "Radeon RX 7900 XT", "Radeon RX 7900 GRE"], &[0x744C]),
("gfx1101", "Radeon RX 7800 XT / 7700 XT", &["Radeon RX 7800 XT", "Radeon RX 7700 XT"], &[0x747E]),
("gfx1102", "Radeon RX 7600", &["Radeon RX 7600 XT", "Radeon RX 7600"], &[0x7480]),
("gfx1030", "Radeon RX 6800 / 6900", &["Radeon RX 6900 XT", "Radeon RX 6950 XT", "Radeon RX 6800 XT", "Radeon RX 6800"], &[0x73BF]),
("gfx1031", "Radeon RX 6700 XT", &["Radeon RX 6750 XT", "Radeon RX 6700 XT", "Radeon RX 6700"], &[0x73DF]),
("gfx1032", "Radeon RX 6600", &["Radeon RX 6650 XT", "Radeon RX 6600 XT", "Radeon RX 6600"], &[0x73FF]),
("gfx1036", "Ryzen integrated Radeon Graphics", &["Radeon(TM) Graphics", "Radeon Graphics"], &[0x164E, 0x13C0]),
("gfx1035", "Radeon 680M (integrated)", &["Radeon 680M", "Radeon 660M"], &[0x1681]),
("gfx1103", "Radeon 780M (integrated)", &["Radeon 780M", "Radeon 760M"], &[0x15BF, 0x15C8]),
("gfx1150", "Radeon 890M (integrated)", &["Radeon 890M", "Radeon 880M"], &[0x150E]),
("gfx90c", "Radeon Graphics (Renoir / Cezanne, integrated)", &["Radeon(TM) Graphics", "Radeon Graphics"], &[0x1636, 0x1638]),
];
fn gfx_entry(code: &str) -> Option<&'static (&'static str, &'static str, &'static [&'static str], &'static [u16])> {
let c = code.trim().to_ascii_lowercase();
let c = c.split(|ch: char| ch == ':' || ch == ' ').next().unwrap_or("");
GFX.iter().find(|e| e.0 == c)
}
/// The name on the row for a device the tool knows by `code`: Windows' adapter name when one matches (by PCI bus,
/// else by the gfx code's device ids, else by the card names in the table), else the table's words, else the code.
/// `used` holds the adapters already given to another card, so two gfx1036 entries never share one.
pub fn resolve_name(code: &str, vendor: &str, bus: &str, adapters: &[Adapter], used: &mut Vec<usize>) -> (String, Option<usize>) {
let free = |i: &usize| !used.contains(i);
let fine = |a: &Adapter| a.problem().is_none();
let vendor_ok = |a: &Adapter| vendor == "other" || vendor_of(&a.name) == vendor;
if !bus.is_empty() {
if let Some(i) = (0..adapters.len()).filter(free).find(|&i| adapters[i].bus == bus && vendor_ok(&adapters[i])) {
used.push(i);
return (adapters[i].name.clone(), Some(i));
}
}
// the name is already a marketing name (nvidia-smi, Windows): the adapter with the same name
let same: Vec<usize> = (0..adapters.len()).filter(free).filter(|&i| adapters[i].name.trim().eq_ignore_ascii_case(code.trim())).collect();
if same.len() == 1 {
used.push(same[0]);
return (adapters[same[0]].name.clone(), Some(same[0]));
}
let Some(entry) = gfx_entry(code) else { return (code.to_string(), None) };
let by_id: Vec<usize> = (0..adapters.len()).filter(free).filter(|&i| fine(&adapters[i]) && entry.3.contains(&adapters[i].device_id())).collect();
if by_id.len() == 1 {
used.push(by_id[0]);
return (adapters[by_id[0]].name.clone(), Some(by_id[0]));
}
let by_name: Vec<usize> = (0..adapters.len()).filter(free).filter(|&i| fine(&adapters[i]) && vendor_ok(&adapters[i]) && { let n = adapters[i].name.to_ascii_lowercase(); entry.2.iter().any(|m| n.contains(&m.to_ascii_lowercase())) }).collect();
if by_name.len() == 1 {
used.push(by_name[0]);
return (adapters[by_name[0]].name.clone(), Some(by_name[0]));
}
(entry.1.to_string(), None)
}
/// One device line pair of the OpenCL worker's --list.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ClDevice {
pub index: String,
pub name: String,
pub platform: String,
pub platform_version: String,
pub is_gpu: bool,
pub vendor: String,
pub driver: String,
pub units: String,
/// "01:00.0" when the worker printed `pci` (workers from 5 October 2026 on), else empty
pub bus: String,
pub mem_mb: u64,
}
impl ClDevice {
pub fn vendor_word(&self) -> &'static str {
if self.vendor.contains("NVIDIA") || self.name.contains("NVIDIA") {
"nvidia"
} else if self.vendor.contains("Advanced Micro") || self.vendor.contains("AMD") || self.name.contains("Radeon") || self.name.contains("AMD") || self.name.to_ascii_lowercase().starts_with("gfx") {
"amd"
} else {
"other"
}
}
fn platform_key(&self) -> String {
format!("{} ({}) driver {}", self.platform, self.platform_version, self.driver)
}
}
/// Parses `igneum-worker-opencl --list`: `[idx] name | platform (version)` then `GPU, vendor V, driver D, OpenCL C
/// x.y, N compute units, M MHz[, pci bb:dd.f]` then `global N MiB, ...`. The bool says the worker printed its header.
pub fn parse_opencl_list(text: &str) -> (Vec<ClDevice>, bool) {
let lines: Vec<&str> = text.lines().collect();
let listed = lines.iter().any(|l| l.starts_with("OpenCL devices"));
let mut out = Vec::new();
for (i, line) in lines.iter().enumerate() {
let t = line.trim_start_matches(|c| c == ' ' || c == '*').trim();
if !t.starts_with('[') {
continue;
}
let Some(close) = t.find(']') else { continue };
let rest = &t[close + 1..];
let mut halves = rest.splitn(2, " |");
let name = halves.next().unwrap_or("").trim().to_string();
let plat = halves.next().unwrap_or("").trim();
// the first " (" opens the version: AMD's version string carries brackets of its own, "OpenCL 2.1 AMD-APP (3617.0)"
let (platform, platform_version) = match plat.find(" (") {
Some(p) if plat.ends_with(')') => (plat[..p].to_string(), plat[p + 2..plat.len() - 1].to_string()),
_ => (plat.to_string(), String::new()),
};
let info = lines.get(i + 1).map(|l| l.trim()).unwrap_or("");
let parts: Vec<&str> = info.split(", ").collect();
let mem_mb = lines.get(i + 2).map(|l| l.trim()).and_then(|l| l.strip_prefix("global ")).and_then(|l| l.split_whitespace().next()).and_then(|n| n.parse::<u64>().ok()).unwrap_or(0);
out.push(ClDevice {
index: t[1..close].to_string(),
name,
platform,
platform_version,
is_gpu: info.starts_with("GPU"),
vendor: parts.iter().find_map(|p| p.strip_prefix("vendor ")).unwrap_or("").trim().to_string(),
driver: parts.iter().find_map(|p| p.strip_prefix("driver ")).unwrap_or("").trim().to_string(),
units: parts.iter().find(|p| p.contains("compute units")).unwrap_or(&"").to_string(),
bus: parts.iter().find_map(|p| p.strip_prefix("pci ")).unwrap_or("").trim().to_string(),
mem_mb,
});
}
(out, listed)
}
fn version_tuple(s: &str) -> Vec<u64> {
s.split(|c: char| !c.is_ascii_digit()).filter(|p| !p.is_empty()).map(|p| p.parse::<u64>().unwrap_or(0)).collect()
}
/// One entry per physical card across OpenCL platforms. Two AMD ICDs after a driver upgrade each list every AMD
/// card (PC 1, 5 October 2026: gfx1036 and gfx1201 twice, two workers on one 9070 XT). Per vendor, the fuller
/// platform wins (most GPUs, then the newest driver, then the first listed); a device on another platform is kept
/// only when the winner has no device at the same PCI address (or, without addresses, the same code and ordinal).
/// Returns the kept devices and one note per dropped duplicate.
pub fn dedupe_platforms(devs: Vec<ClDevice>) -> (Vec<ClDevice>, Vec<String>) {
let gpus: Vec<ClDevice> = devs.into_iter().filter(|d| d.is_gpu).collect();
let mut kept: Vec<ClDevice> = Vec::new();
let mut dropped = Vec::new();
let mut vendors: Vec<&'static str> = Vec::new();
for d in &gpus {
let v = d.vendor_word();
if !vendors.contains(&v) {
vendors.push(v);
}
}
for v in vendors {
let mine: Vec<&ClDevice> = gpus.iter().filter(|d| d.vendor_word() == v).collect();
let mut plats: Vec<String> = Vec::new();
for d in &mine {
let k = d.platform_key();
if !plats.contains(&k) {
plats.push(k);
}
}
let score = |k: &String| {
let count = mine.iter().filter(|d| &d.platform_key() == k).count();
let driver = mine.iter().find(|d| &d.platform_key() == k).map(|d| version_tuple(&d.driver)).unwrap_or_default();
(count, driver)
};
let winner = plats.iter().max_by(|a, b| score(a).cmp(&score(b))).cloned().unwrap_or_default();
let identity = |d: &ClDevice, ordinal: usize| if d.bus.is_empty() { format!("{}#{ordinal}", d.name.to_ascii_lowercase()) } else { d.bus.clone() };
let mut have: Vec<String> = Vec::new();
let mut seen_codes: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
let mut ordinal = |d: &ClDevice| {
let n = seen_codes.entry(format!("{}|{}", d.platform_key(), d.name.to_ascii_lowercase())).or_insert(0);
*n += 1;
*n
};
for d in mine.iter().filter(|d| d.platform_key() == winner) {
let o = ordinal(d);
have.push(identity(d, o));
kept.push((*d).clone());
}
for d in mine.iter().filter(|d| d.platform_key() != winner) {
let o = ordinal(d);
let id = identity(d, o);
if have.contains(&id) {
dropped.push(format!("[{}] {} on {} ({}) is the same card as the one on {}: no worker", d.index, d.name, d.platform, d.platform_version, winner));
} else {
have.push(id);
kept.push((*d).clone());
}
}
}
kept.sort_by_key(|d| d.index.parse::<u64>().unwrap_or(u64::MAX));
(kept, dropped)
}
/// Keys without an index (it moves when a card arrives): vendor:code, "#2" and up for identical cards in list order.
pub fn assign_keys(cards: &mut [CardState]) {
let mut seen: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
for c in cards.iter_mut() {
if c.code.is_empty() {
c.code = c.name.clone();
}
let base = format!("{}:{}", c.vendor, c.code);
let n = seen.entry(base.clone()).or_insert(0);
*n += 1;
c.key = if *n == 1 { base } else { format!("{base}#{n}") };
}
}
/// What one Windows enumeration gathered; `assemble` turns it into the list (pure, so the PC 1 cases are tests).
#[derive(Default)]
pub struct Inputs {
/// `nvidia-smi --query-gpu=index,name,memory.total,pci.bus_id --format=csv,noheader`; None = nvidia-smi did not run
pub nvidia: Option<String>,
pub nvidia_limits: std::collections::HashMap<String, (f64, f64, f64, f64)>,
pub cuda_worker: bool,
/// the OpenCL worker's --list; None = no worker installed or it did not answer
pub opencl: Option<String>,
pub opencl_installed: bool,
/// Windows' adapter list; None = PowerShell did not answer
pub adapters: Option<Vec<Adapter>>,
}
pub fn assemble(inp: Inputs) -> Detection {
let mut d = Detection::default();
d.adapters_listed = inp.adapters.is_some();
let adapters = inp.adapters.unwrap_or_default();
let mut used: Vec<usize> = Vec::new();
match inp.nvidia {
Some(out) => {
d.nvidia_listed = true;
for line in out.lines() {
let parts: Vec<&str> = line.split(',').map(|s| s.trim()).collect();
if parts.len() >= 2 && parts[0].chars().all(|c| c.is_ascii_digit()) && !parts[0].is_empty() {
let mem_mb: u64 = parts.get(2).and_then(|m| m.split_whitespace().next()).and_then(|n| n.parse::<f64>().ok()).map(|v| v as u64).unwrap_or(0);
let detail = if mem_mb > 0 { format!("{} GB", (mem_mb + 512) / 1024) } else { String::new() };
let worker_ok = bins.cuda.is_some();
let mut c = card(cards.len(), parts[1], "nvidia", "CUDA", &detail, parts[0]);
c.kind = if looks_integrated(parts[1]) { "integrated".into() } else { "discrete".into() };
// nvidia-smi prints 00000000:01:00.0; the worker and Windows say 01:00.0
let bus = parts.get(3).map(|b| b.trim().to_ascii_lowercase()).map(|b| b.rsplit_once(':').map(|(d, r)| format!("{}:{r}", d.rsplit(':').next().unwrap_or(d))).unwrap_or(b)).unwrap_or_default();
let (name, adapter) = resolve_name(parts[1], "nvidia", &bus, &adapters, &mut used);
let mut c = card(d.cards.len(), &name, "nvidia", "CUDA", &detail, parts[0]);
c.code = parts[1].to_string();
c.bus = bus;
c.kind = classify_kind(parts[1], adapter.map(|i| &adapters[i])).into();
c.vram_mb = mem_mb;
c.path = if worker_ok { "prebuilt".into() } else { "build".into() };
if !worker_ok {
c.path = if inp.cuda_worker { "prebuilt".into() } else { "build".into() };
if !inp.cuda_worker {
c.message = "no prebuilt CUDA worker in the package; built from source on first run (needs the CUDA Toolkit and Visual Studio)".into();
}
apply_defaults(&mut c);
cards.push(c);
d.cards.push(c);
}
}
if cards.is_empty() {
notes.push("nvidia-smi ran but listed no card".into());
if d.cards.is_empty() {
d.notes.push("nvidia-smi ran but listed no card".into());
}
let limits = nvidia_power_limits();
for c in cards.iter_mut() {
if let Some((d, cur, lo, hi)) = limits.get(&c.device) {
c.power_default_w = *d;
for c in d.cards.iter_mut() {
if let Some((dflt, cur, lo, hi)) = inp.nvidia_limits.get(&c.device) {
c.power_default_w = *dflt;
c.power_limit_w = *cur;
c.power_before_w = *cur;
c.power_min_w = *lo;
@ -235,55 +620,84 @@ pub fn detect(bins: &Bins, notes: &mut Vec<String>) -> Vec<CardState> {
mark_sweep_support(c);
}
}
None => notes.push("nvidia-smi is not on this PC (no NVIDIA driver): no NVIDIA card".into()),
None => d.notes.push("nvidia-smi is not on this PC (no NVIDIA driver): no NVIDIA card".into()),
}
// OpenCL: the worker's own device list (AMD, Intel; NVIDIA shows there too and is skipped)
if let Some(cl) = bins.opencl.as_ref() {
if let Some(out) = run_timeout(Command::new(cl).arg("--list"), None, Duration::from_secs(15)) {
let lines: Vec<&str> = out.lines().collect();
for (i, line) in lines.iter().enumerate() {
let t = line.trim_start_matches(|c| c == ' ' || c == '*').trim();
if !t.starts_with('[') {
continue;
}
let Some(close) = t.find(']') else { continue };
let idx = &t[1..close];
let rest = &t[close + 1..];
let name = rest.split(" |").next().unwrap_or("").trim();
let info = lines.get(i + 1).map(|l| l.trim()).unwrap_or("");
let is_gpu = info.starts_with("GPU");
let vendor_s = info.split("vendor ").nth(1).unwrap_or("").split(", driver").next().unwrap_or("").trim();
if !is_gpu || name.contains("NVIDIA") || vendor_s.contains("NVIDIA") {
continue;
}
let vendor = if vendor_s.contains("Advanced Micro") || name.contains("Radeon") || name.contains("AMD") { "amd" } else { "other" };
let units = info.split(", ").find(|p| p.contains("compute units")).unwrap_or("").to_string();
let mut c = card(cards.len(), name, vendor, "OpenCL", &units, idx);
c.device = idx.to_string();
c.kind = if looks_integrated(name) { "integrated".into() } else { "discrete".into() };
c.path = "prebuilt".into();
apply_defaults(&mut c);
mark_sweep_support(&mut c);
cards.push(c);
}
if let Some(out) = inp.opencl {
let (devs, listed) = parse_opencl_list(&out);
d.opencl_listed = listed;
let (kept, dropped) = dedupe_platforms(devs.into_iter().filter(|dv| dv.vendor_word() != "nvidia").collect());
d.dropped = dropped;
for dv in kept {
let vendor = dv.vendor_word();
let (name, adapter) = resolve_name(&dv.name, vendor, &dv.bus, &adapters, &mut used);
let mut c = card(d.cards.len(), &name, vendor, "OpenCL", &dv.units, &dv.index);
c.code = dv.name.clone();
c.bus = dv.bus.clone();
c.platform = format!("{} ({}), driver {}", dv.platform, dv.platform_version, dv.driver);
c.kind = classify_kind(&dv.name, adapter.map(|i| &adapters[i])).into();
c.vram_mb = if c.kind == "integrated" { 0 } else { dv.mem_mb };
c.path = "prebuilt".into();
apply_defaults(&mut c);
mark_sweep_support(&mut c);
d.cards.push(c);
}
} else if cards.is_empty() {
notes.push("the OpenCL worker is not installed; AMD and Intel cards cannot be listed".into());
} else if !inp.opencl_installed && d.cards.is_empty() {
d.notes.push("the OpenCL worker is not installed; AMD and Intel cards cannot be listed".into());
}
if cards.is_empty() {
if d.cards.is_empty() {
// last resort: the names Windows knows, so the screen can at least say what is in the PC
if let Some(out) = run_timeout(Command::new(crate::platform::tool("powershell")).args(["-NoProfile", "-Command", "Get-CimInstance Win32_VideoController | ForEach-Object { $_.Name }"]), None, Duration::from_secs(15)) {
for n in out.lines().map(|l| l.trim()).filter(|l| !l.is_empty()) {
let vendor = if n.contains("NVIDIA") { "nvidia" } else if n.contains("AMD") || n.contains("Radeon") { "amd" } else { "other" };
let mut c = card(cards.len(), n, vendor, if vendor == "nvidia" { "CUDA" } else { "OpenCL" }, "", "0");
c.kind = if looks_integrated(n) { "integrated".into() } else { "unknown".into() };
c.enabled = false;
c.reason = "seen by Windows, but no worker can drive it (no NVIDIA driver and no OpenCL worker)".into();
cards.push(c);
}
for (i, a) in adapters.iter().enumerate().filter(|(_, a)| a.problem().is_none()) {
let vendor = vendor_of(&a.name);
let mut c = card(d.cards.len(), &a.name, vendor, if vendor == "nvidia" { "CUDA" } else { "OpenCL" }, "", "");
c.bus = a.bus.clone();
c.kind = if looks_integrated(&a.name) { "integrated".into() } else { "unknown".into() };
c.enabled = false;
c.reason = "seen by Windows, but no worker can drive it (no NVIDIA driver and no OpenCL worker)".into();
used.push(i);
d.cards.push(c);
}
}
cards
// the cards Windows lists with a problem (Code 43 after an eGPU hot-plug on PC 1, 5 October 2026): shown, never driven
for (i, a) in adapters.iter().enumerate() {
let Some(problem) = a.problem() else { continue };
if used.contains(&i) || d.cards.iter().any(|c| c.name.trim().eq_ignore_ascii_case(a.name.trim())) {
continue;
}
let vendor = vendor_of(&a.name);
let mut c = card(d.cards.len(), &a.name, vendor, if vendor == "nvidia" { "CUDA" } else { "OpenCL" }, "", "");
c.bus = a.bus.clone();
c.kind = classify_kind(&a.name, Some(a)).into();
mark_unusable(&mut c, &problem);
d.cards.push(c);
}
assign_keys(&mut d.cards);
d
}
#[cfg(not(target_os = "macos"))]
pub fn detect(bins: &Bins) -> Detection {
// Windows' own view of every adapter: status and problem code (a Code 43 card is in no tool's list), memory and
// processor for the integrated call, the PCI address and the names for the rows
let adapters = adapters();
// NVIDIA: nvidia-smi ships with the driver
let nvidia = run_timeout(Command::new(crate::platform::tool("nvidia-smi")).args(["--query-gpu=index,name,memory.total,pci.bus_id", "--format=csv,noheader"]), None, Duration::from_secs(10));
let nvidia_limits = if nvidia.is_some() { nvidia_power_limits() } else { Default::default() };
// OpenCL: the worker's own device list (AMD, Intel; NVIDIA shows there too and is skipped)
let opencl = bins.opencl.as_ref().and_then(|cl| run_timeout(Command::new(cl).arg("--list"), None, Duration::from_secs(15)));
assemble(Inputs { nvidia, nvidia_limits, cuda_worker: bins.cuda.is_some(), opencl, opencl_installed: bins.opencl.is_some(), adapters })
}
/// A listed card no worker can drive: off, no switch, the problem on the row and the hint under it.
pub fn mark_unusable(c: &mut CardState, problem: &str) {
c.problem = problem.to_string();
c.enabled = false;
c.identities = 1;
c.state = "unusable".into();
c.message = format!("not usable ({problem})");
c.reason = PROBLEM_HINT.into();
c.sweep_supported = false;
c.sweep_state = "unsupported".into();
c.sweep_note = c.message.clone();
}
/// Finds the binaries next to the engine (Windows, a plain folder) or in Contents/Resources/bin (macOS bundle).
@ -322,3 +736,250 @@ pub fn node_version(node: &Path) -> String {
.and_then(|o| o.lines().next().map(|l| l.trim().to_string()))
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
// PC 1's adapter list on the evening of 5 October 2026, after the RX 9070 XT went in through the Sonnet eGPU box
// while the app ran: "AMD Radeon RX 9070 XT | status Error | ram 0 GB", "AMD Radeon(TM) Graphics | status OK |
// ram 2 GB" (the Ryzen iGPU, gfx1036 to OpenCL), "NVIDIA GeForce RTX 5090 | status OK".
fn pc1() -> Vec<Adapter> {
parse_adapters(r#"[{"Name":"AMD Radeon RX 9070 XT","Status":"Error","ConfigManagerErrorCode":43,"AdapterRAM":0,"VideoProcessor":"AMD Radeon Graphics Processor (0x7550)","PNPDeviceID":"PCI\\VEN_1002&DEV_7550&SUBSYS_0E4E1002&REV_C0\\6&1A2B3C4D&0&00000008"},
{"Name":"AMD Radeon(TM) Graphics","Status":"OK","ConfigManagerErrorCode":0,"AdapterRAM":2147483648,"VideoProcessor":"AMD Radeon Graphics Processor (0x164E)","PNPDeviceID":"PCI\\VEN_1002&DEV_164E&SUBSYS_00000000&REV_C1\\4&2E5A1B3&0&0041"},
{"Name":"NVIDIA GeForce RTX 5090","Status":"OK","ConfigManagerErrorCode":0,"AdapterRAM":4293918720,"VideoProcessor":"NVIDIA GeForce RTX 5090","PNPDeviceID":"PCI\\VEN_10DE&DEV_2B85&SUBSYS_10621043&REV_A1\\4&1F2E3D4C&0&0019"}]"#)
}
#[test]
fn adapters_parse_with_status_code_and_memory() {
let a = pc1();
assert_eq!(a.len(), 3);
assert_eq!(a[0].name, "AMD Radeon RX 9070 XT");
assert_eq!(a[0].status, "Error");
assert_eq!(a[0].code, 43);
assert_eq!(a[0].ram_mb, 0);
assert_eq!(a[0].problem().as_deref(), Some("Code 43"));
assert_eq!(a[1].ram_mb, 2048);
assert_eq!(a[1].problem(), None);
assert_eq!(a[2].problem(), None);
// a single adapter: ConvertTo-Json gives one object, not an array
let one = parse_adapters(r#"{"Name":"NVIDIA GeForce RTX 5090","Status":"OK","ConfigManagerErrorCode":0,"AdapterRAM":4293918720,"VideoProcessor":"NVIDIA GeForce RTX 5090","PNPDeviceID":"PCI\\VEN_10DE"}"#);
assert_eq!(one.len(), 1);
assert!(parse_adapters("not json").is_empty());
}
#[test]
fn problem_without_a_code_is_the_status_word() {
let a = Adapter { name: "x".into(), status: "Degraded".into(), ..Default::default() };
assert_eq!(a.problem().as_deref(), Some("status Degraded"));
let fine = Adapter { name: "x".into(), status: "OK".into(), ..Default::default() };
assert_eq!(fine.problem(), None);
let code12 = Adapter { name: "x".into(), status: "Error".into(), code: 12, ..Default::default() };
assert_eq!(code12.problem().as_deref(), Some("Code 12"));
}
#[test]
fn kind_from_pc1_lines_and_the_mac() {
let a = pc1();
// the discrete cards, with and without their adapter row
assert_eq!(classify_kind("AMD Radeon RX 9070 XT", adapter_for("AMD Radeon RX 9070 XT", &a)), "discrete");
assert_eq!(classify_kind("NVIDIA GeForce RTX 5090", adapter_for("NVIDIA GeForce RTX 5090", &a)), "discrete");
assert_eq!(classify_kind("NVIDIA GeForce RTX 5090", None), "discrete");
// the Ryzen iGPU: by its Windows name, and by the gfx code the OpenCL worker prints (no adapter row matches a code)
assert_eq!(classify_kind("AMD Radeon(TM) Graphics", adapter_for("AMD Radeon(TM) Graphics", &a)), "integrated");
assert_eq!(classify_kind("gfx1036", adapter_for("gfx1036", &a)), "integrated");
assert_eq!(classify_kind("gfx1036", None), "integrated");
assert_eq!(classify_kind("gfx1036:xnack-", None), "integrated");
// a discrete gfx code stays discrete; an Arc card is discrete despite "Intel"
assert_eq!(classify_kind("gfx1201", None), "discrete");
assert_eq!(classify_kind("gfx1030", None), "discrete");
assert_eq!(classify_kind("Intel(R) Arc(TM) A770 Graphics", None), "discrete");
// an Intel iGPU by its processor string; an AMD discrete card's processor string ("AMD Radeon Graphics Processor") does not count
let intel = Adapter { name: "Intel(R) Iris(R) Xe Graphics".into(), processor: "Intel(R) Iris(R) Xe Graphics Family".into(), ram_mb: 1024, ..Default::default() };
assert_eq!(classify_kind("Intel(R) Iris(R) Xe Graphics", Some(&intel)), "integrated");
assert_eq!(a[0].processor, "AMD Radeon Graphics Processor (0x7550)");
// shared memory under 1 GB on the adapter row makes an unknown name integrated; 0 says nothing
let small = Adapter { name: "Some iGPU".into(), ram_mb: 512, ..Default::default() };
assert_eq!(classify_kind("Some iGPU", Some(&small)), "integrated");
let unknown = Adapter { name: "Some card".into(), ram_mb: 0, ..Default::default() };
assert_eq!(classify_kind("Some card", Some(&unknown)), "discrete");
// the Mac: detect() labels Apple silicon "apple" itself; the name rules do not call it integrated
assert!(!looks_integrated("Apple M5 Max"));
assert_eq!(vendor_of("Apple M5 Max"), "apple");
assert_eq!(vendor_of("gfx1036"), "amd");
assert_eq!(vendor_of("AMD Radeon RX 9070 XT"), "amd");
assert_eq!(vendor_of("NVIDIA GeForce RTX 5090"), "nvidia");
}
// PC 1 after Adrenalin 26.9.2 and a reboot (5 October 2026, evening): all three adapters OK, the Windows names,
// DEV ids and PCI addresses as the coordinator read them (the 5090's bus 01:00.0; the two AMD cards' addresses are
// not in that reading, so this fixture leaves them empty, as an old worker's --list would)
fn pc1_rebooted() -> Vec<Adapter> {
parse_adapters(r#"[{"Name":"AMD Radeon(TM) Graphics","Status":"OK","ConfigManagerErrorCode":0,"AdapterRAM":2147483648,"VideoProcessor":"AMD Radeon Graphics Processor (0x13C0)","PNPDeviceID":"PCI\\VEN_1002&DEV_13C0&SUBSYS_00000000&REV_C1\\4&2E5A1B3&0&0041","BusNumber":null,"Address":null},
{"Name":"NVIDIA GeForce RTX 5090","Status":"OK","ConfigManagerErrorCode":0,"AdapterRAM":4293918720,"VideoProcessor":"NVIDIA GeForce RTX 5090","PNPDeviceID":"PCI\\VEN_10DE&DEV_2B85&SUBSYS_10621043&REV_A1\\4&1F2E3D4C&0&0019","BusNumber":1,"Address":0},
{"Name":"AMD Radeon RX 9070 XT","Status":"OK","ConfigManagerErrorCode":0,"AdapterRAM":4293918720,"VideoProcessor":"AMD Radeon Graphics Processor (0x7550)","PNPDeviceID":"PCI\\VEN_1002&DEV_7550&SUBSYS_0E4E1002&REV_C0\\6&1A2B3C4D&0&00000008","BusNumber":null,"Address":null}]"#)
}
// the 0.3.9 app's five rows came from this shape of --list: two AMD platforms, each listing both AMD GPUs (the old
// 32.0.21042 ICD and the new 32.0.32015 one); the driver strings are the shape AMD's runtime prints, the numbers
// are the Windows driver builds (approximate: the OpenCL CL_DRIVER_VERSION was not captured)
const PC1_LIST: &str = "OpenCL devices (4):\n\
[0] gfx1036 | AMD Accelerated Parallel Processing (OpenCL 2.1 AMD-APP (3617.0))\n\
GPU, vendor Advanced Micro Devices, Inc., driver 3617.0 (PAL,HSAIL), OpenCL C 2.0, 2 compute units, 2200 MHz\n\
global 16384 MiB, max alloc 13926 MiB, local 64 KiB, max work-group 256, sub-group extension: cl_khr_subgroups (no shuffle extension), AMD wavefront width 32\n\
[1] gfx1201 | AMD Accelerated Parallel Processing (OpenCL 2.1 AMD-APP (3617.0))\n\
GPU, vendor Advanced Micro Devices, Inc., driver 3617.0 (PAL,HSAIL), OpenCL C 2.0, 32 compute units, 2970 MHz\n\
global 16368 MiB, max alloc 13912 MiB, local 64 KiB, max work-group 256, sub-group extension: cl_khr_subgroups (no shuffle extension), AMD wavefront width 32\n\
[2] gfx1036 | AMD Accelerated Parallel Processing (OpenCL 2.1 AMD-APP (3649.0))\n\
GPU, vendor Advanced Micro Devices, Inc., driver 3649.0 (PAL,HSAIL), OpenCL C 2.0, 2 compute units, 2200 MHz\n\
global 16384 MiB, max alloc 13926 MiB, local 64 KiB, max work-group 256, sub-group extension: cl_khr_subgroups (no shuffle extension), AMD wavefront width 32\n\
[3] gfx1201 | AMD Accelerated Parallel Processing (OpenCL 2.1 AMD-APP (3649.0))\n\
GPU, vendor Advanced Micro Devices, Inc., driver 3649.0 (PAL,HSAIL), OpenCL C 2.0, 32 compute units, 2970 MHz\n\
global 16368 MiB, max alloc 13912 MiB, local 64 KiB, max work-group 256, sub-group extension: cl_khr_subgroups (no shuffle extension), AMD wavefront width 32\n";
const PC1_SMI: &str = "0, NVIDIA GeForce RTX 5090, 32607 MiB, 00000000:01:00.0\n";
fn pc1_inputs(list: &str) -> Inputs {
Inputs { nvidia: Some(PC1_SMI.into()), nvidia_limits: Default::default(), cuda_worker: true, opencl: Some(list.into()), opencl_installed: true, adapters: Some(pc1_rebooted()) }
}
#[test]
fn opencl_list_parses_both_platforms_and_the_pci_field() {
let (devs, listed) = parse_opencl_list(PC1_LIST);
assert!(listed);
assert_eq!(devs.len(), 4);
assert_eq!(devs[1].index, "1");
assert_eq!(devs[1].name, "gfx1201");
assert_eq!(devs[1].platform, "AMD Accelerated Parallel Processing");
assert_eq!(devs[1].platform_version, "OpenCL 2.1 AMD-APP (3617.0)");
assert_eq!(devs[1].driver, "3617.0 (PAL,HSAIL)");
assert_eq!(devs[1].units, "32 compute units");
assert_eq!(devs[1].mem_mb, 16368);
assert_eq!(devs[1].bus, "");
assert!(devs[1].is_gpu);
assert_eq!(devs[1].vendor_word(), "amd");
let with_pci = PC1_LIST.replace("32 compute units, 2970 MHz\n", "32 compute units, 2970 MHz, pci 05:00.0\n");
let (devs, _) = parse_opencl_list(&with_pci);
assert_eq!(devs[1].bus, "05:00.0");
assert_eq!(devs[3].bus, "05:00.0");
assert!(!parse_opencl_list("").1);
}
#[test]
fn two_amd_platforms_give_one_entry_per_card() {
// without PCI addresses: by code and ordinal, the newer driver wins
let (devs, _) = parse_opencl_list(PC1_LIST);
let (kept, dropped) = dedupe_platforms(devs);
assert_eq!(kept.iter().map(|d| d.index.as_str()).collect::<Vec<_>>(), vec!["2", "3"]);
assert_eq!(dropped.len(), 2);
assert!(dropped[0].starts_with("[0] gfx1036 on AMD Accelerated Parallel Processing (OpenCL 2.1 AMD-APP (3617.0)) is the same card as"), "{}", dropped[0]);
// with PCI addresses: by address; a card the winner does not list (the old ICD still serving a third card) is kept
let text = PC1_LIST
.replace("2 compute units, 2200 MHz\n", "2 compute units, 2200 MHz, pci 0c:00.0\n")
.replace("32 compute units, 2970 MHz\n", "32 compute units, 2970 MHz, pci 05:00.0\n")
+ " [4] gfx1100 | AMD Accelerated Parallel Processing (OpenCL 2.1 AMD-APP (3617.0))\n GPU, vendor Advanced Micro Devices, Inc., driver 3617.0 (PAL,HSAIL), OpenCL C 2.0, 96 compute units, 2500 MHz, pci 09:00.0\n global 24560 MiB\n";
let (devs, _) = parse_opencl_list(&text);
let (kept, dropped) = dedupe_platforms(devs);
// the old platform now lists three and wins on count: its three stay, the new platform's two are duplicates
assert_eq!(kept.iter().map(|d| d.index.as_str()).collect::<Vec<_>>(), vec!["0", "1", "4"]);
assert_eq!(dropped.len(), 2);
// two real twins on one platform keep both entries (same code, different ordinal or address)
let twins = "OpenCL devices (2):\n [0] gfx1201 | P (v)\n GPU, vendor Advanced Micro Devices, Inc., driver 1.0, OpenCL C 2.0, 32 compute units, 2970 MHz\n [1] gfx1201 | P (v)\n GPU, vendor Advanced Micro Devices, Inc., driver 1.0, OpenCL C 2.0, 32 compute units, 2970 MHz\n";
let (kept, dropped) = dedupe_platforms(parse_opencl_list(twins).0);
assert_eq!(kept.len(), 2);
assert!(dropped.is_empty());
}
#[test]
fn names_come_from_windows_by_bus_then_device_id_then_the_table() {
let a = pc1_rebooted();
let mut used = Vec::new();
assert_eq!(resolve_name("NVIDIA GeForce RTX 5090", "nvidia", "01:00.0", &a, &mut used).0, "NVIDIA GeForce RTX 5090");
assert_eq!(resolve_name("gfx1201", "amd", "", &a, &mut used).0, "AMD Radeon RX 9070 XT");
assert_eq!(resolve_name("gfx1036", "amd", "", &a, &mut used).0, "AMD Radeon(TM) Graphics");
assert_eq!(used.len(), 3);
// every adapter is taken: a second gfx1036 gets the table's words, a code the table lacks stays a code
assert_eq!(resolve_name("gfx1036", "amd", "", &a, &mut used).0, "Ryzen integrated Radeon Graphics");
assert_eq!(resolve_name("gfx9999", "amd", "", &a, &mut used).0, "gfx9999");
assert_eq!(resolve_name("gfx1100", "amd", "", &[], &mut Vec::new()).0, "Radeon RX 7900 XTX / XT");
// by PCI address when both sides have one, before any table
let mut b = pc1_rebooted();
b[2].bus = "05:00.0".into();
let mut used = Vec::new();
assert_eq!(resolve_name("gfx1201", "amd", "05:00.0", &b, &mut used), ("AMD Radeon RX 9070 XT".to_string(), Some(2)));
// the device id alone names a card whose adapter name the table does not know
let mut c = pc1_rebooted();
c[2].name = "AMD Radeon RX 9070 XT OC Edition".into();
assert_eq!(resolve_name("gfx1201", "amd", "", &c, &mut Vec::new()).0, "AMD Radeon RX 9070 XT OC Edition");
assert_eq!(a[2].device_id(), 0x7550);
assert_eq!(a[0].device_id(), 0x13C0);
assert_eq!(a[1].bus, "01:00.0");
}
#[test]
fn pc1_five_rows_become_three_cards_named_properly() {
let d = assemble(pc1_inputs(PC1_LIST));
assert!(d.nvidia_listed && d.opencl_listed && d.adapters_listed);
let rows: Vec<(String, String, String, String, bool, String)> = d.cards.iter().map(|c| (c.name.clone(), c.key.clone(), c.kind.clone(), c.device.clone(), c.enabled, c.code.clone())).collect();
assert_eq!(rows, vec![
("NVIDIA GeForce RTX 5090".into(), "nvidia:NVIDIA GeForce RTX 5090".into(), "discrete".into(), "0".into(), true, "NVIDIA GeForce RTX 5090".into()),
("AMD Radeon(TM) Graphics".into(), "amd:gfx1036".into(), "integrated".into(), "2".into(), false, "gfx1036".into()),
("AMD Radeon RX 9070 XT".into(), "amd:gfx1201".into(), "discrete".into(), "3".into(), true, "gfx1201".into()),
]);
assert_eq!(d.cards[0].bus, "01:00.0");
assert_eq!(d.cards[1].reason, INTEGRATED_REASON);
assert_eq!(d.cards[1].identities, 1);
assert_eq!(d.cards[2].identities, 8, "16 GB: 8 identities");
assert_eq!(d.cards[2].vram_mb, 16368);
assert!(d.cards[2].platform.contains("3649.0"));
assert_eq!(d.dropped.len(), 2);
assert!(d.notes.is_empty(), "{:?}", d.notes);
assert_eq!(crate::hotplug::cards_line(&d.cards), "cards: NVIDIA GeForce RTX 5090 [discrete, off] | AMD Radeon(TM) Graphics [integrated, off] | AMD Radeon RX 9070 XT [discrete, off]");
// the same machine before the eGPU: one platform, the iGPU alone; the keys do not depend on the index
let before = "OpenCL devices (1):\n [0] gfx1036 | AMD Accelerated Parallel Processing (OpenCL 2.1 AMD-APP (3617.0))\n GPU, vendor Advanced Micro Devices, Inc., driver 3617.0 (PAL,HSAIL), OpenCL C 2.0, 2 compute units, 2200 MHz\n global 16384 MiB\n";
let d0 = assemble(pc1_inputs(before));
assert_eq!(d0.cards[1].key, "amd:gfx1036");
assert_eq!(d0.cards[1].device, "0");
// and the diff between the two lists: the iGPU moved (device 0 to 2), the 9070 XT is new, nothing is removed
let diff = crate::hotplug::diff(&d0.cards, &d.cards, &|c| d.listed(c));
assert_eq!(diff.unchanged, vec![0]);
assert_eq!(diff.moved.len(), 1);
assert_eq!(diff.moved[0].0, 1);
assert_eq!(diff.added.len(), 1);
assert_eq!(diff.added[0].name, "AMD Radeon RX 9070 XT");
assert!(diff.removed.is_empty());
}
#[test]
fn keys_number_identical_cards() {
let mut cards = vec![card(0, "NVIDIA GeForce RTX 5090", "nvidia", "CUDA", "", "0"), card(1, "NVIDIA GeForce RTX 5090", "nvidia", "CUDA", "", "1"), card(2, "gfx1201", "amd", "OpenCL", "", "2")];
cards[2].name = "AMD Radeon RX 9070 XT".into();
assign_keys(&mut cards);
assert_eq!(cards.iter().map(|c| c.key.as_str()).collect::<Vec<_>>(), vec!["nvidia:NVIDIA GeForce RTX 5090", "nvidia:NVIDIA GeForce RTX 5090#2", "amd:gfx1201"]);
}
#[test]
fn unusable_card_row() {
let mut c = card(2, "AMD Radeon RX 9070 XT", "amd", "OpenCL", "", "");
mark_unusable(&mut c, "Code 43");
assert!(!c.enabled);
assert_eq!(c.state, "unusable");
assert_eq!(c.message, "not usable (Code 43)");
assert_eq!(c.reason, PROBLEM_HINT);
assert!(!c.present());
}
#[test]
fn integrated_default_is_off_with_the_row_words() {
let mut c = card(1, "gfx1036", "amd", "OpenCL", "2 compute units", "1");
c.kind = classify_kind("gfx1036", None).into();
apply_defaults(&mut c);
assert!(!c.enabled);
assert_eq!(c.identities, 1);
assert_eq!(c.reason, INTEGRATED_REASON);
let mut big = card(0, "NVIDIA GeForce RTX 5090", "nvidia", "CUDA", "32 GB", "0");
big.kind = "discrete".into();
big.vram_mb = 32768;
apply_defaults(&mut big);
assert!(big.enabled);
assert_eq!(big.identities, 8);
}
}

View file

@ -29,7 +29,8 @@ pub struct CardChoice {
pub enum Cmd {
Detect,
ApplyCards(Vec<CardChoice>),
Detected(Vec<CardState>, Vec<String>),
/// one enumeration of the cards finished (the first, or a re-detection: src/hotplug.rs)
Detected(crate::detect::Detection),
Start,
Pause,
Resume,
@ -470,6 +471,12 @@ pub struct Engine {
wrapper: bool,
last_state_print: Instant,
detected: bool,
/// hot-plug (src/hotplug.rs): an enumeration thread is running; one more was asked for meanwhile; when the
/// next periodic one is due; when the app log last carried the card list
detect_busy: bool,
detect_again: bool,
detect_next: Instant,
last_cards_line: Instant,
clock_samples: Vec<f64>,
clock_node_at: Option<Instant>,
clock_node_behind: f64,
@ -564,6 +571,10 @@ impl Engine {
wrapper,
last_state_print: now,
detected: false,
detect_busy: false,
detect_again: false,
detect_next: now + Duration::from_secs(crate::hotplug::POLL_S),
last_cards_line: now,
clock_samples: Vec::new(),
clock_node_at: None,
clock_node_behind: 0.0,
@ -669,62 +680,34 @@ impl Engine {
fn command(&mut self, c: Cmd) {
match c {
Cmd::Detect => {
if self.detected {
// the first run, /api/detect, the host's "detect" on a device change, and the minute poll all land
// here; one enumeration at a time, and a request during one runs once more after it
if self.detect_busy {
self.detect_again = true;
return;
}
self.st().detecting = true;
self.detect_busy = true;
if !self.detected {
self.st().detecting = true;
}
let bins = self.bins.clone();
let shared = self.shared.clone();
std::thread::spawn(move || {
let mut notes = Vec::new();
let cards = crate::detect::detect(&bins, &mut notes);
shared.send(Cmd::Detected(cards, notes));
let d = crate::detect::detect(&bins);
shared.send(Cmd::Detected(d));
});
}
Cmd::Detected(cards, notes) => {
self.detected = true;
let names: Vec<String> = cards.iter().map(|c| format!("{} ({})", c.name, c.worker)).collect();
self.shared.log(&format!("GPUs: {}", if names.is_empty() { "none usable".to_string() } else { names.join("; ") }));
for n in &notes {
self.shared.log(&format!("detection: {n}"));
Cmd::Detected(d) => {
self.detect_busy = false;
self.detect_next = Instant::now() + Duration::from_secs(crate::hotplug::POLL_S);
if self.detected {
self.merge_detection(d);
} else {
self.first_detection(d);
}
let prefs = self.shared.settings.lock().unwrap().cards.clone();
let mut st = self.st();
st.detecting = false;
st.detect_message = notes.join(". ");
st.mining.cards = cards;
for c in st.mining.cards.iter_mut() {
if let Some(p) = prefs.get(&c.key) {
c.enabled = p.enabled && c.kind != "unknown";
c.identities = p.identities.clamp(1, 64);
c.reason = String::new();
if c.vendor == "nvidia" && p.power_pct > 0 {
c.power_pct = p.power_pct.clamp(crate::sweep::MIN_PCT, 100);
}
c.pinned = p.pinned;
c.sweep_pct = p.sweep_pct;
c.sweep_eff = p.sweep_eff;
c.sweep_watts = p.sweep_watts;
c.sweep_mhs = p.sweep_mhs;
c.sweep_at = p.sweep_at as f64;
}
}
let supported: Vec<String> = st.mining.cards.iter().filter(|c| c.sweep_supported && c.enabled).map(|c| c.key.clone()).collect();
let unsupported: Vec<String> = st.mining.cards.iter().filter(|c| !c.sweep_supported).map(|c| format!("{}: {}", c.name, c.sweep_note)).collect();
drop(st);
for u in &unsupported {
self.shared.log(&format!("efficiency sweep {u}"));
}
if self.shared.runtime.sweep_only {
if supported.is_empty() {
self.sweep_say("SWEEP none reason=no_supported_card");
self.shared.send(Cmd::Quit);
} else {
self.sweep_queue = supported;
}
}
if self.running {
self.plan_miners();
if self.detect_again {
self.detect_again = false;
self.shared.send(Cmd::Detect);
}
}
Cmd::ApplyCards(choices) => self.apply_cards(choices),
@ -884,7 +867,7 @@ impl Engine {
let mut missing = Vec::new();
{
let mut st = self.st();
for c in st.mining.cards.iter_mut().filter(|c| c.vendor == "nvidia" && c.enabled && c.power_default_w > 0.0) {
for c in st.mining.cards.iter_mut().filter(|c| c.vendor == "nvidia" && c.enabled && c.present() && c.power_default_w > 0.0) {
let want = requested_watts(c);
let got = readback.get(&c.device).copied().unwrap_or(c.power_limit_w);
if got > 0.0 {
@ -1005,6 +988,147 @@ impl Engine {
// ---- start, node ------------------------------------------------------------------------------------------
/// The first enumeration: the list as detected, the saved choices applied, the setup screen's notes.
fn first_detection(&mut self, d: crate::detect::Detection) {
self.detected = true;
let crate::detect::Detection { cards, notes, dropped, .. } = d;
let names: Vec<String> = cards.iter().map(|c| format!("{} ({}{}{})", c.name, c.worker, if c.code != c.name { format!(", {}", c.code) } else { String::new() }, if c.problem.is_empty() { String::new() } else { format!(", {}", c.problem) })).collect();
self.shared.log(&format!("GPUs: {}", if names.is_empty() { "none usable".to_string() } else { names.join("; ") }));
for n in &notes {
self.shared.log(&format!("detection: {n}"));
}
for n in &dropped {
self.shared.log(&format!("detection: duplicate OpenCL platform entry {n}"));
}
let prefs = self.shared.settings.lock().unwrap().cards.clone();
let mut st = self.st();
st.detecting = false;
st.detect_message = notes.join(". ");
st.mining.cards = cards;
for c in st.mining.cards.iter_mut() {
if let Some(p) = crate::hotplug::pref_for(&prefs, c) {
crate::hotplug::apply_pref(c, p);
}
}
let supported: Vec<String> = st.mining.cards.iter().filter(|c| c.sweep_supported && c.enabled).map(|c| c.key.clone()).collect();
let unsupported: Vec<String> = st.mining.cards.iter().filter(|c| !c.sweep_supported).map(|c| format!("{}: {}", c.name, c.sweep_note)).collect();
let line = crate::hotplug::cards_line(&st.mining.cards);
drop(st);
self.shared.log(&line);
self.last_cards_line = Instant::now();
for u in &unsupported {
self.shared.log(&format!("efficiency sweep {u}"));
}
if self.shared.runtime.sweep_only {
if supported.is_empty() {
self.sweep_say("SWEEP none reason=no_supported_card");
self.shared.send(Cmd::Quit);
} else {
self.sweep_queue = supported;
}
}
if self.running {
self.plan_miners();
}
}
/// A later enumeration (src/hotplug.rs): new cards start, lost cards stop, faulty cards are marked; a list
/// that changed nothing touches nothing (no running worker ever restarts because of a re-detection).
fn merge_detection(&mut self, d: crate::detect::Detection) {
let now = crate::platform::unix_now_f();
let old = self.st().mining.cards.clone();
let diff = crate::hotplug::diff(&old, &d.cards, &|c| d.listed(c));
if diff.is_quiet() {
return;
}
for n in &d.dropped {
self.shared.log(&format!("detection: duplicate OpenCL platform entry {n}"));
}
let prefs = self.shared.settings.lock().unwrap().cards.clone();
let mut new_nvidia = false;
for i in diff.removed.iter().copied() {
let name = old[i].name.clone();
self.drop_worker(i, "the card was removed");
if let Some(c) = self.st().mining.cards.get_mut(i) {
crate::hotplug::mark_removed(c, now);
}
self.shared.event("warn", &format!("Card removed: {name}; its worker stopped"));
}
for (i, problem) in diff.errored.iter() {
let name = old[*i].name.clone();
self.drop_worker(*i, &format!("the card reports {problem}"));
if let Some(c) = self.st().mining.cards.get_mut(*i) {
crate::detect::mark_unusable(c, problem);
c.added_at = now;
}
self.shared.event("warn", &format!("{name}: not usable ({problem}); its worker stopped. {}", crate::detect::PROBLEM_HINT));
}
for (i, fresh) in diff.moved.iter() {
if let Some(c) = self.st().mining.cards.get_mut(*i) {
self.shared.log(&format!("{}: device {} is now {} (the running worker keeps its device; the next start uses the new one)", c.name, c.device, fresh.device));
c.device = fresh.device.clone();
c.key = fresh.key.clone();
c.bus = fresh.bus.clone();
c.name = fresh.name.clone();
c.platform = fresh.platform.clone();
if fresh.vram_mb > 0 {
c.vram_mb = fresh.vram_mb;
}
if !fresh.detail.is_empty() {
c.detail = fresh.detail.clone();
}
}
}
let mut placed: Vec<(usize, CardState)> = Vec::new();
for (i, fresh) in diff.recovered.into_iter().chain(diff.revived.into_iter()) {
placed.push((i, fresh));
}
let mut next = self.st().mining.cards.len();
for fresh in diff.added.into_iter() {
placed.push((next, fresh));
next += 1;
}
for (i, mut fresh) in placed {
let pref = crate::hotplug::pref_for(&prefs, &fresh).cloned();
crate::hotplug::settle_new(&mut fresh, i, pref.as_ref(), now);
let (kind, text) = crate::hotplug::added_words(&fresh);
new_nvidia |= fresh.vendor == "nvidia" && fresh.enabled;
let mut st = self.st();
if i < st.mining.cards.len() {
st.mining.cards[i] = fresh;
} else {
st.mining.cards.push(fresh);
}
drop(st);
self.shared.event(kind, &text);
}
let line = crate::hotplug::cards_line(&self.st().mining.cards);
self.shared.log(&line);
self.last_cards_line = Instant::now();
if self.running {
self.plan_miners();
if new_nvidia {
self.apply_power_limits("new card");
}
}
}
/// Stops the worker on card `idx` (quit, then up to 8 s) and drops its slot; the sweep on it, if any, is aborted.
fn drop_worker(&mut self, idx: usize, why: &str) {
if self.sweep.as_ref().map(|r| r.card == idx).unwrap_or(false) {
self.sweep_abort(why);
}
let Some(pos) = self.miners.iter().position(|m| m.card == idx) else { return };
let label = self.miners[pos].label.clone();
if let Some(mut p) = self.miners[pos].proc.take() {
self.shared.log(&format!("stopping miner {label} ({why})"));
p.write_stdin("quit\n");
p.stop(8);
}
self.miners.remove(pos);
self.stability.remove(&idx);
}
fn start(&mut self) {
if self.running {
return;
@ -1184,7 +1308,7 @@ impl Engine {
let cards = self.st().mining.cards.clone();
let id8 = self.shared.runtime.id8();
for c in cards.iter() {
if !c.enabled || self.miners.iter().any(|m| m.card == c.index) {
if !c.enabled || !c.present() || self.miners.iter().any(|m| m.card == c.index) {
continue;
}
let prefix = match c.vendor.as_str() {
@ -1348,7 +1472,7 @@ impl Engine {
let mut st = self.st();
let paused = st.mining.paused;
for c in st.mining.cards.iter_mut() {
if c.enabled && c.state != "faulted" {
if c.enabled && c.present() && c.state != "faulted" {
c.state = if paused { "off".into() } else { "waiting".into() };
c.hash_now = 0.0;
c.pid = 0;
@ -1389,6 +1513,9 @@ impl Engine {
let mut st = self.st();
for ch in &choices {
let Some(c) = st.mining.cards.iter_mut().find(|c| c.key == ch.key) else { continue };
if !c.present() {
continue; // a removed or faulty card has no switch; its saved choice waits for it
}
let identities = ch.identities.clamp(1, 64);
let enabled = ch.enabled && c.kind != "unknown";
let power_pct = ch.power_pct.map(|p| p.clamp(crate::sweep::MIN_PCT, 100)).unwrap_or(c.power_pct);
@ -1407,7 +1534,7 @@ impl Engine {
if c.enabled != enabled || c.identities != identities {
c.enabled = enabled;
c.identities = identities;
c.reason = String::new();
c.reason = if !enabled && c.kind == "integrated" { crate::detect::INTEGRATED_REASON.into() } else { String::new() };
if !enabled {
c.state = "off".into();
c.hash_now = 0.0;
@ -1463,7 +1590,7 @@ impl Engine {
let mut what = Vec::new();
{
let mut st = self.st();
for c in st.mining.cards.iter_mut().filter(|c| c.vendor == "nvidia" && c.enabled && c.power_default_w > 0.0) {
for c in st.mining.cards.iter_mut().filter(|c| c.vendor == "nvidia" && c.enabled && c.present() && c.power_default_w > 0.0) {
let pct = if c.power_pct == 0 { 80 } else { c.power_pct.clamp(crate::sweep::MIN_PCT, 100) };
c.power_pct = pct;
let watts = requested_watts(c);
@ -1483,7 +1610,7 @@ impl Engine {
self.shared.log(&format!("power cap ({why}): {}", cmds.join(" & ")));
let line = cmds.join(" & ");
let what = what.join(", ");
let want: std::collections::HashMap<String, f64> = self.st().mining.cards.iter().filter(|c| c.vendor == "nvidia" && c.enabled && c.power_default_w > 0.0).map(|c| (c.device.clone(), requested_watts(c))).collect();
let want: std::collections::HashMap<String, f64> = self.st().mining.cards.iter().filter(|c| c.vendor == "nvidia" && c.enabled && c.present() && c.power_default_w > 0.0).map(|c| (c.device.clone(), requested_watts(c))).collect();
if self.wrapper && cfg!(windows) {
// the window host has a UI context: it shows the administrator prompt and reports back on stdin
self.power_via_host = Some((line.clone(), what.clone(), want, Instant::now()));
@ -1520,7 +1647,7 @@ impl Engine {
.mining
.cards
.iter()
.filter(|c| c.vendor == "nvidia" && c.power_applied && c.power_before_w > 0.0)
.filter(|c| c.vendor == "nvidia" && c.present() && c.power_applied && c.power_before_w > 0.0)
.map(|c| format!("\"{}\" -i {} -pl {}", crate::platform::tool("nvidia-smi").display(), c.device, c.power_before_w.round() as u64))
.collect();
drop(st);
@ -2060,6 +2187,16 @@ impl Engine {
self.last_settings_save = now;
self.shared.save_settings();
}
// hot-plug (src/hotplug.rs): enumerate again every POLL_S, and keep the card list in the log for the console
if self.detected && !self.detect_busy && !self.quitting && now >= self.detect_next {
self.detect_next = now + Duration::from_secs(crate::hotplug::POLL_S);
self.shared.send(Cmd::Detect);
}
if self.detected && now.duration_since(self.last_cards_line) >= Duration::from_secs(crate::hotplug::CARDS_LINE_S) {
self.last_cards_line = now;
let line = crate::hotplug::cards_line(&self.st().mining.cards);
self.shared.log(&line);
}
}
/// The over-the-air updater (src/ota.rs): checks, downloads and stages on its own threads; this tick hands it what
@ -2485,6 +2622,7 @@ impl Engine {
c.eff_mhw = if c.state == "mining" && c.power_w > 1.0 && c.hash_now > 0.0 && unix - c.telemetry_at < 60.0 { c.hash_now / c.power_w } else { 0.0 };
}
st.mining.hash_total = total;
crate::hotplug::age(&mut st.mining.cards, unix);
let cut = unix - 3600.0;
st.mining.found.retain(|t| *t > cut);
if st.node.daa > 0 {

View file

@ -0,0 +1,494 @@
//! Hot-plug: what changed between two enumerations of the cards (src/detect.rs runs one at start and one every
//! minute; the Windows host also asks for one on WM_DEVICECHANGE). Pure, so the rules are unit-tested here; the
//! engine applies the result (start a worker, stop one, mark a row). Born 5 October 2026, when an RX 9070 XT went
//! into PC 1 through an eGPU box while the app ran and nothing noticed.
//!
//! Rules: a card is the same card when its key (vendor:code, "#2" for a twin) matches and the PCI addresses do not
//! disagree, or, failing that, when vendor and name match and that pair is unique on both sides (a twin whose
//! ordinal moved because the first one left). The device index is never part of the identity: it moves. A
//! card missing from a list is removed only when the tool that lists its vendor answered. Removed and faulty cards
//! stay in the engine's list (the other cards' indices are the miner slots), marked, and the dashboard hides a
//! removed row after five minutes.
use crate::config::CardPref;
use crate::state::CardState;
/// How long a removed card's row says "removed" before it hides.
pub const REMOVED_SHOWN_S: f64 = 300.0;
/// How often the engine enumerates again, seconds (macOS has no GPU hot-plug on Apple silicon: slower there).
pub const POLL_S: u64 = if cfg!(target_os = "macos") { 300 } else { 60 };
/// How often the app log carries the full card list, seconds (the console reads it from the log tail).
pub const CARDS_LINE_S: u64 = 600;
#[derive(Default, Debug)]
pub struct Diff {
/// new cards (usable or with a problem), to be appended
pub added: Vec<CardState>,
/// cards that were marked removed earlier and are listed again: (slot, the fresh entry)
pub revived: Vec<(usize, CardState)>,
/// slots whose card is gone
pub removed: Vec<usize>,
/// slots whose card now reports a problem: (slot, "Code 43")
pub errored: Vec<(usize, String)>,
/// slots whose card had a problem and is now driven by a tool again: (slot, the fresh entry)
pub recovered: Vec<(usize, CardState)>,
/// slots whose card is the same but its device index (or memory, bus) changed: (slot, the fresh entry)
pub moved: Vec<(usize, CardState)>,
pub unchanged: Vec<usize>,
}
impl Diff {
/// Nothing to do: every present card is where it was.
pub fn is_quiet(&self) -> bool {
self.added.is_empty() && self.revived.is_empty() && self.removed.is_empty() && self.errored.is_empty() && self.recovered.is_empty() && self.moved.is_empty()
}
}
fn bus_compat(a: &CardState, b: &CardState) -> bool {
a.bus.is_empty() || b.bus.is_empty() || a.bus == b.bus
}
fn same_identity(a: &CardState, b: &CardState) -> bool {
a.vendor == b.vendor && (a.name.trim().eq_ignore_ascii_case(b.name.trim()) || (!a.code.is_empty() && a.code.eq_ignore_ascii_case(&b.code))) && bus_compat(a, b)
}
/// Compares the engine's list with a fresh enumeration. `listed(card)` says whether this enumeration's tools could
/// have seen that card (Detection::listed); a card its tool did not answer for is kept, not removed.
pub fn diff(old: &[CardState], fresh: &[CardState], listed: &dyn Fn(&CardState) -> bool) -> Diff {
let mut out = Diff::default();
let mut used = vec![false; fresh.len()];
let mut pair: Vec<Option<usize>> = vec![None; old.len()];
// exact keys first
for (i, o) in old.iter().enumerate() {
if let Some(j) = fresh.iter().enumerate().position(|(j, f)| !used[j] && f.key == o.key && bus_compat(o, f)) {
used[j] = true;
pair[i] = Some(j);
}
}
// then vendor + name, when that pair is unique among what is still unmatched on both sides
for (i, o) in old.iter().enumerate() {
if pair[i].is_some() {
continue;
}
let cands: Vec<usize> = fresh.iter().enumerate().filter(|(j, f)| !used[*j] && same_identity(o, f)).map(|(j, _)| j).collect();
let twins = old.iter().enumerate().filter(|(k, x)| pair[*k].is_none() && *k != i && same_identity(o, x)).count();
if cands.len() == 1 && twins == 0 {
used[cands[0]] = true;
pair[i] = Some(cands[0]);
}
}
for (i, o) in old.iter().enumerate() {
match pair[i] {
Some(j) => {
let f = &fresh[j];
if o.removed_at > 0.0 {
out.revived.push((i, f.clone()));
} else if o.problem.is_empty() && !f.problem.is_empty() {
out.errored.push((i, f.problem.clone()));
} else if !o.problem.is_empty() && f.problem.is_empty() {
out.recovered.push((i, f.clone()));
} else if !o.problem.is_empty() {
// still faulty: the same problem or a new code, nothing to start or stop
if o.problem != f.problem {
out.errored.push((i, f.problem.clone()));
} else {
out.unchanged.push(i);
}
} else if o.device != f.device || o.key != f.key {
out.moved.push((i, f.clone()));
} else {
out.unchanged.push(i);
}
}
None => {
if o.removed_at > 0.0 {
// already removed: stays hidden or shown as removed
out.unchanged.push(i);
} else if listed(o) {
out.removed.push(i);
} else {
out.unchanged.push(i);
}
}
}
}
for (j, f) in fresh.iter().enumerate() {
if !used[j] {
out.added.push(f.clone());
}
}
out
}
/// The saved choice for a card: by its key (vendor:code), else a key saved by an app before 0.3.11 (vendor:index:code,
/// vendor:index:name) when exactly one matches; an index that moved never changes the answer.
pub fn pref_for<'a>(prefs: &'a std::collections::HashMap<String, CardPref>, c: &CardState) -> Option<&'a CardPref> {
if let Some(p) = prefs.get(&c.key) {
return Some(p);
}
if c.key.contains('#') {
return None; // a twin's choice is its own
}
let head = format!("{}:", c.vendor);
for tail in [format!(":{}", c.code), format!(":{}", c.name)] {
if tail.len() <= 1 {
continue;
}
let found: Vec<&CardPref> = prefs.iter().filter(|(k, _)| k.starts_with(&head) && k.ends_with(&tail) && !k.contains('#')).map(|(_, p)| p).collect();
if found.len() == 1 {
return Some(found[0]);
}
}
None
}
/// Applies a saved choice to a freshly detected card (the first detection and every later one use this).
pub fn apply_pref(c: &mut CardState, p: &CardPref) {
if !c.problem.is_empty() {
return;
}
c.enabled = p.enabled && c.kind != "unknown";
c.identities = p.identities.clamp(1, 64);
if c.enabled || c.kind != "integrated" {
c.reason = String::new();
}
if c.vendor == "nvidia" && p.power_pct > 0 {
c.power_pct = p.power_pct.clamp(crate::sweep::MIN_PCT, 100);
}
c.pinned = p.pinned;
c.sweep_pct = p.sweep_pct;
c.sweep_eff = p.sweep_eff;
c.sweep_watts = p.sweep_watts;
c.sweep_mhs = p.sweep_mhs;
c.sweep_at = p.sweep_at as f64;
}
/// A card a re-detection added (or brought back): the saved choice if there is one, else the detect defaults it
/// came with; its slot and the time it appeared.
pub fn settle_new(c: &mut CardState, index: usize, pref: Option<&CardPref>, now: f64) {
c.index = index;
c.added_at = now;
c.removed_at = 0.0;
c.gone = false;
if let Some(p) = pref {
apply_pref(c, p);
}
if c.problem.is_empty() {
c.state = if c.enabled { "waiting".into() } else { "off".into() };
}
}
/// Marks a card unplugged: no worker, the row says removed, the saved choice is untouched.
pub fn mark_removed(c: &mut CardState, now: f64) {
c.removed_at = now;
c.gone = false;
c.state = "removed".into();
c.message = "removed".into();
c.hash_now = 0.0;
c.pid = 0;
c.restart_in_s = 0;
c.ready = false;
c.prepared = false;
}
/// A removed row hides after REMOVED_SHOWN_S; returns true when something changed.
pub fn age(cards: &mut [CardState], now: f64) -> bool {
let mut changed = false;
for c in cards.iter_mut() {
let gone = c.removed_at > 0.0 && now - c.removed_at >= REMOVED_SHOWN_S;
if gone != c.gone {
c.gone = gone;
changed = true;
}
}
changed
}
/// The event line for a card that appeared: "New card: <name>, mining" and its kind (ok | info | warn).
pub fn added_words(c: &CardState) -> (&'static str, String) {
if !c.problem.is_empty() {
("warn", format!("New card: {}, not usable ({}); {}", c.name, c.problem, crate::detect::PROBLEM_HINT))
} else if c.enabled {
("ok", format!("New card: {}, mining", c.name))
} else if c.kind == "integrated" {
("info", format!("New card: {}, off ({})", c.name, crate::detect::INTEGRATED_REASON))
} else {
("info", format!("New card: {}, off (switched off in settings)", c.name))
}
}
/// One word for a card's state on the log line and the console: mining, waiting, off, removed, not usable (Code 43).
pub fn state_word(c: &CardState) -> String {
if c.removed_at > 0.0 {
"removed".into()
} else if !c.problem.is_empty() {
format!("not usable ({})", c.problem)
} else if !c.enabled {
"off".into()
} else {
c.state.clone()
}
}
/// The app-log line the console reads (relay/lib/parse.mjs): `cards: <name> [<kind>, <state>] | ...`, hidden rows
/// left out, `cards: none` when nothing is listed.
pub fn cards_line(cards: &[CardState]) -> String {
let parts: Vec<String> = cards.iter().filter(|c| !c.gone).map(|c| format!("{} [{}, {}]", c.name.replace('|', "/").replace('[', "(").replace(']', ")"), c.kind, state_word(c))).collect();
if parts.is_empty() { "cards: none".into() } else { format!("cards: {}", parts.join(" | ")) }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::detect::{classify_kind, mark_unusable, INTEGRATED_REASON};
fn card(name: &str, vendor: &str, device: &str) -> CardState {
let mut c = CardState { index: 0, key: format!("{vendor}:{name}"), code: name.into(), name: name.into(), vendor: vendor.into(), worker: if vendor == "nvidia" { "CUDA".into() } else { "OpenCL".into() }, device: device.into(), enabled: true, state: "off".into(), ..Default::default() };
c.kind = classify_kind(name, None).into();
crate::detect::apply_defaults(&mut c);
c
}
// PC 1 at start on 5 October 2026: the 5090 on nvidia-smi index 0, the Ryzen iGPU as OpenCL device 0 (gfx1036)
fn pc1_start() -> Vec<CardState> {
let mut a = card("NVIDIA GeForce RTX 5090", "nvidia", "0");
a.bus = "00000000:01:00.0".into();
a.state = "mining".into();
let mut b = card("gfx1036", "amd", "0");
b.index = 1;
vec![a, b]
}
fn all_listed(_: &CardState) -> bool {
true
}
fn fresh_pc1_with_egpu() -> Vec<CardState> {
let mut v = pc1_start();
v[0].state = "off".into();
let mut e = card("gfx1201", "amd", "1");
e.index = 2;
v.push(e);
v
}
#[test]
fn unchanged_list_is_quiet() {
let old = pc1_start();
let mut fresh = pc1_start();
fresh[0].state = "off".into(); // runtime state in the fresh list means nothing
let d = diff(&old, &fresh, &all_listed);
assert!(d.is_quiet(), "{d:?}");
assert_eq!(d.unchanged, vec![0, 1]);
}
#[test]
fn a_new_usable_card_is_added_and_nothing_else_moves() {
let old = pc1_start();
let d = diff(&old, &fresh_pc1_with_egpu(), &all_listed);
assert_eq!(d.added.len(), 1);
assert_eq!(d.added[0].name, "gfx1201");
assert_eq!(d.added[0].kind, "discrete");
assert!(d.added[0].enabled);
assert_eq!(d.unchanged, vec![0, 1]);
assert!(d.removed.is_empty() && d.moved.is_empty() && d.errored.is_empty());
}
#[test]
fn a_new_card_with_a_problem_is_added_as_unusable() {
let old = pc1_start();
let mut fresh = pc1_start();
let mut bad = card("AMD Radeon RX 9070 XT", "amd", "");
mark_unusable(&mut bad, "Code 43");
fresh.push(bad);
let d = diff(&old, &fresh, &all_listed);
assert_eq!(d.added.len(), 1);
assert_eq!(d.added[0].problem, "Code 43");
assert!(!d.added[0].enabled);
let (kind, text) = added_words(&d.added[0]);
assert_eq!(kind, "warn");
assert!(text.starts_with("New card: AMD Radeon RX 9070 XT, not usable (Code 43); reboot with the card attached"), "{text}");
assert_eq!(state_word(&d.added[0]), "not usable (Code 43)");
}
#[test]
fn an_unplugged_card_is_removed_only_when_its_tool_answered() {
let old = fresh_pc1_with_egpu();
let fresh = pc1_start();
let d = diff(&old, &fresh, &all_listed);
assert_eq!(d.removed, vec![2]);
assert_eq!(d.unchanged, vec![0, 1]);
// the OpenCL worker did not answer this round: nothing is called removed
let opencl_dead = |c: &CardState| c.vendor == "nvidia";
let d2 = diff(&old, &pc1_start().into_iter().filter(|c| c.vendor == "nvidia").collect::<Vec<_>>(), &opencl_dead);
assert!(d2.removed.is_empty(), "{d2:?}");
assert!(d2.is_quiet());
}
#[test]
fn a_card_whose_index_moved_keeps_its_slot() {
// the eGPU landed before the iGPU in the OpenCL list: the iGPU is device 1 now, the eGPU device 0
let old = pc1_start();
let mut fresh = pc1_start();
fresh[1].device = "1".into();
let mut e = card("gfx1201", "amd", "0");
e.index = 2;
fresh.push(e);
let d = diff(&old, &fresh, &all_listed);
assert_eq!(d.moved.len(), 1);
assert_eq!(d.moved[0].0, 1);
assert_eq!(d.moved[0].1.device, "1");
assert_eq!(d.added.len(), 1);
assert!(d.removed.is_empty());
}
#[test]
fn two_identical_cards_are_told_apart_by_key_and_never_swapped() {
let mut a = card("NVIDIA GeForce RTX 5090", "nvidia", "0");
let mut b = card("NVIDIA GeForce RTX 5090", "nvidia", "1");
b.index = 1;
b.key = "nvidia:NVIDIA GeForce RTX 5090#2".into();
a.bus = "01:00.0".into();
b.bus = "02:00.0".into();
let old = vec![a.clone(), b.clone()];
// the second twin leaves: the first keeps its slot by key; the missing one is removed, not "moved"
let d = diff(&old, &[a.clone()], &all_listed);
assert_eq!(d.unchanged, vec![0]);
assert_eq!(d.removed, vec![1]);
// the FIRST twin leaves: the survivor is now index 0 with the unsuffixed key, but its bus says which card it
// is, so slot 1 is "moved" (new key and device) and slot 0 is removed; no worker is swapped between cards
let mut survivor = b.clone();
survivor.device = "0".into();
survivor.key = "nvidia:NVIDIA GeForce RTX 5090".into();
let d2 = diff(&old, &[survivor], &all_listed);
assert_eq!(d2.removed, vec![0]);
assert_eq!(d2.moved.len(), 1);
assert_eq!(d2.moved[0].0, 1);
assert_eq!(d2.moved[0].1.key, "nvidia:NVIDIA GeForce RTX 5090");
// the same two cards again, nothing changed: quiet
let d3 = diff(&old, &old, &all_listed);
assert!(d3.is_quiet(), "{d3:?}");
}
#[test]
fn a_driven_card_that_turns_faulty_is_errored_and_recovers_later() {
let old = fresh_pc1_with_egpu();
// the eGPU is still listed by Windows, now with Code 43, and no longer by OpenCL
let mut fresh = pc1_start();
let mut bad = card("gfx1201", "amd", "");
mark_unusable(&mut bad, "Code 43");
fresh.push(bad);
let d = diff(&old, &fresh, &all_listed);
assert_eq!(d.errored, vec![(2, "Code 43".to_string())]);
assert!(d.added.is_empty() && d.removed.is_empty());
// after a reboot with the card attached it is driven again: recovered, same slot
let mut faulty = old.clone();
mark_unusable(&mut faulty[2], "Code 43");
faulty[2].device = String::new();
let d2 = diff(&faulty, &fresh_pc1_with_egpu(), &all_listed);
assert_eq!(d2.recovered.len(), 1);
assert_eq!(d2.recovered[0].0, 2);
assert!(d2.recovered[0].1.problem.is_empty());
// the same problem again next minute: quiet
let d3 = diff(&faulty, &fresh, &all_listed);
assert!(d3.is_quiet(), "{d3:?}");
}
#[test]
fn a_removed_card_that_comes_back_is_revived_in_its_slot() {
let mut old = fresh_pc1_with_egpu();
mark_removed(&mut old[2], 1000.0);
assert_eq!(state_word(&old[2]), "removed");
// still absent: quiet (and hidden after five minutes)
let d = diff(&old, &pc1_start(), &all_listed);
assert!(d.is_quiet(), "{d:?}");
assert!(age(&mut old, 1000.0 + REMOVED_SHOWN_S));
assert!(old[2].gone);
assert!(!age(&mut old, 1000.0 + REMOVED_SHOWN_S + 1.0));
// back: revived in slot 2
let d2 = diff(&old, &fresh_pc1_with_egpu(), &all_listed);
assert_eq!(d2.revived.len(), 1);
assert_eq!(d2.revived[0].0, 2);
assert!(d2.added.is_empty());
let mut back = d2.revived[0].1.clone();
settle_new(&mut back, 2, None, 2000.0);
assert_eq!(back.index, 2);
assert_eq!(back.removed_at, 0.0);
assert!(!back.gone);
assert_eq!(back.added_at, 2000.0);
assert_eq!(back.state, "waiting");
}
#[test]
fn the_users_choice_is_kept_on_a_new_card_and_an_integrated_one_is_off_by_default() {
let mut prefs = std::collections::HashMap::new();
// the user switched the iGPU on earlier and set 2 identities; the setting was saved under OpenCL index 0
prefs.insert("amd:0:gfx1036".to_string(), CardPref { enabled: true, identities: 2, ..Default::default() });
// the iGPU comes back as device 1 (the eGPU took index 0): the 0.3.9 key still answers, by vendor and code
let mut igpu = card("gfx1036", "amd", "1");
assert_eq!(igpu.kind, "integrated");
assert!(!igpu.enabled);
assert_eq!(igpu.reason, INTEGRATED_REASON);
let p = pref_for(&prefs, &igpu).cloned();
assert!(p.is_some());
settle_new(&mut igpu, 1, p.as_ref(), 5.0);
assert!(igpu.enabled);
assert_eq!(igpu.identities, 2);
assert_eq!(igpu.reason, "");
assert_eq!(igpu.state, "waiting");
// the user switched it off (saved under the index-free key): the row keeps the integrated words
prefs.insert("amd:gfx1036".to_string(), CardPref { enabled: false, identities: 1, ..Default::default() });
let mut igpu2 = card("gfx1036", "amd", "1");
let p2 = pref_for(&prefs, &igpu2).cloned();
settle_new(&mut igpu2, 1, p2.as_ref(), 6.0);
assert!(!igpu2.enabled);
assert_eq!(igpu2.reason, INTEGRATED_REASON);
assert_eq!(igpu2.state, "off");
let (kind, text) = added_words(&igpu2);
assert_eq!(kind, "info");
assert_eq!(text, format!("New card: gfx1036, off ({INTEGRATED_REASON})"));
// no saved choice: the detect default (integrated off, discrete on)
let mut egpu = card("gfx1201", "amd", "0");
settle_new(&mut egpu, 2, None, 7.0);
assert!(egpu.enabled);
assert_eq!(added_words(&egpu), ("ok", "New card: gfx1201, mining".to_string()));
// a pref never switches on a card with a problem
let mut bad = card("AMD Radeon RX 9070 XT", "amd", "");
mark_unusable(&mut bad, "Code 43");
settle_new(&mut bad, 3, Some(&CardPref { enabled: true, identities: 8, ..Default::default() }), 8.0);
assert!(!bad.enabled);
assert_eq!(bad.state, "unusable");
// old keys only, two of them for the same code (the five-row PC 1 list had amd:0:gfx1036 and amd:2:gfx1036):
// ambiguous, so the default applies; the index-free key, once saved, always wins
prefs.remove("amd:gfx1036");
prefs.insert("amd:2:gfx1036".to_string(), CardPref { enabled: true, identities: 3, ..Default::default() });
let other = card("gfx1036", "amd", "7");
assert!(pref_for(&prefs, &other).is_none());
prefs.insert("amd:gfx1036".to_string(), CardPref { enabled: true, identities: 4, ..Default::default() });
assert_eq!(pref_for(&prefs, &other).map(|p| p.identities), Some(4));
// a twin never borrows the first card's choice
let mut twin = card("gfx1201", "amd", "3");
twin.key = "amd:gfx1201#2".into();
prefs.insert("amd:gfx1201".to_string(), CardPref { enabled: false, identities: 1, ..Default::default() });
assert!(pref_for(&prefs, &twin).is_none());
// the name on the row is the Windows name while the key keeps the code: the 0.3.9 key by code still answers
let mut named = card("gfx1201", "amd", "1");
named.name = "AMD Radeon RX 9070 XT".into();
prefs.clear();
prefs.insert("amd:1:gfx1201".to_string(), CardPref { enabled: false, identities: 2, ..Default::default() });
assert_eq!(pref_for(&prefs, &named).map(|p| p.identities), Some(2));
}
#[test]
fn the_console_line_lists_every_shown_card_with_kind_and_state() {
let mut cards = fresh_pc1_with_egpu();
cards[0].state = "mining".into();
cards[2].state = "starting".into();
let mut bad = card("AMD Radeon RX 9070 XT", "amd", "");
mark_unusable(&mut bad, "Code 43");
cards.push(bad);
assert_eq!(cards_line(&cards), "cards: NVIDIA GeForce RTX 5090 [discrete, mining] | gfx1036 [integrated, off] | gfx1201 [discrete, starting] | AMD Radeon RX 9070 XT [discrete, not usable (Code 43)]");
mark_removed(&mut cards[2], 10.0);
assert!(cards_line(&cards).contains("gfx1201 [discrete, removed]"));
age(&mut cards, 10.0 + REMOVED_SHOWN_S);
assert!(!cards_line(&cards).contains("gfx1201"));
assert_eq!(cards_line(&[]), "cards: none");
}
}

View file

@ -16,6 +16,7 @@
mod config;
mod detect;
mod engine;
mod hotplug;
mod keys;
mod platform;
mod procs;
@ -135,6 +136,8 @@ fn main() {
"quit" => shared.send(engine::Cmd::Quit),
"pause" => shared.send(engine::Cmd::Pause),
"resume" => shared.send(engine::Cmd::Resume),
// the window host saw WM_DEVICECHANGE (a card plugged in or out): enumerate now, not at the next minute
"detect" => shared.send(engine::Cmd::Detect),
"elevated ok" => shared.send(engine::Cmd::ElevatedDone(Ok(()))),
_ if t.starts_with("elevated fail") => shared.send(engine::Cmd::ElevatedDone(Err(t.trim_start_matches("elevated fail").trim_start_matches(':').trim().to_string()))),
_ => {}

View file

@ -38,10 +38,12 @@ pub struct ConsensusSwitch {
pub daa: u64,
}
#[derive(Clone, Serialize, Default)]
#[derive(Clone, Serialize, Default, Debug)]
pub struct CardState {
pub index: usize,
pub key: String, // stable id for the saved preference: vendor:device:name
pub key: String, // stable id for the saved preference: vendor:code, "#2" and up for a second identical card (no index: it moves)
pub code: String, // the tool's own device name (AMD's OpenCL runtime says "gfx1201"); the key and the tooltip carry it
pub platform: String, // the OpenCL platform and driver the worker opens it through (the tooltip)
pub kind: String, // apple | discrete | integrated | external | unknown
pub vram_mb: u64, // 0 when unknown
pub reason: String, // why it is off by default, if it is
@ -52,7 +54,7 @@ pub struct CardState {
pub detail: String, // memory, cores
pub device: String, // the worker's --device value (Windows)
pub enabled: bool,
pub state: String, // off | waiting | starting | ready | mining | restarting | failed | faulted (the watchdog gave up on it)
pub state: String, // off | waiting | starting | ready | mining | restarting | failed | faulted (the watchdog gave up on it) | unusable (the OS reports a problem) | removed (unplugged)
pub hash_now: f64, // MH/s, the last interval
pub hash_avg: f64, // MH/s since the start
pub accepted: u64,
@ -69,6 +71,15 @@ pub struct CardState {
pub pid: u32,
pub last_status_age_s: f64,
pub message: String,
/// the device's own problem as the OS reports it ("Code 43" on Windows); set = listed but no worker can drive it
pub problem: String,
/// PCI bus id where the tool gives one (nvidia-smi pci.bus_id); a second way to recognise a card whose index moved
pub bus: String,
/// hot-plug (src/hotplug.rs): unix s when a re-detection added this card (0 = found at start), when it lost it
/// (0 = present), and `gone` once a removed card has been shown as removed for five minutes (the row hides)
pub added_at: f64,
pub removed_at: f64,
pub gone: bool,
/// `WORKER FAULT` lines seen (the miner killed and restarted its worker) and the miner's own `faults=` count
pub faults: u64,
/// shares that failed the miner's CPU re-check this run (`mismatched=` on the STATUS line)
@ -104,6 +115,14 @@ pub struct CardState {
pub race_variants: u32,
}
impl CardState {
/// Listed, driver fine, not unplugged: a worker can run on it (hot-plug keeps removed and faulty cards in the
/// list so the other cards' indices stay put).
pub fn present(&self) -> bool {
self.removed_at == 0.0 && self.problem.is_empty()
}
}
#[derive(Clone, Serialize, Default)]
pub struct MiningState {
pub state: String, // idle | waiting | mining | paused | stopped

View file

@ -187,6 +187,9 @@ body[data-phase="welcome"] #screen-welcome,body[data-phase="cards"] #screen-card
.gpu-row .reason{font-size:var(--t-sm);color:var(--ash)}
.gpu-row .msg{font-size:var(--t-sm);color:var(--ember)}
.gpu-row .switch{padding:0;flex:0 0 auto;margin-left:auto}
/* hot-plug (src/hotplug.rs): a removed card dims, a faulty one is named in ember, neither has live controls */
.gpu-row.removed,.gpu-line.removed,.set-card.removed{opacity:.5}
.gpu-row.unusable .name,.gpu-line.unusable .name,.set-card.unusable .name{color:var(--ember)}
/* the Mine page: the big switch and the three numbers */
.hero-row{display:grid;grid-template-columns:1.3fr 1fr 1fr 1fr;gap:var(--gap-tile)}
@ -247,6 +250,7 @@ body[data-phase="welcome"] #screen-welcome,body[data-phase="cards"] #screen-card
.feed .t{color:var(--ash);flex:0 0 auto;font-size:var(--t-xs)}
.feed .k{color:var(--molten);margin-right:6px}
.feed .k.error{color:var(--ember)}
.feed .k.warn{color:var(--ember)}
.feed .k.block{color:var(--ember)}
.feed .k.build{color:var(--ink-2)}

View file

@ -9,11 +9,12 @@
4 update available, downloading, ready, waiting for permission, manual 5 job done 6 updated
Rules: a done job goes after 5 minutes; a failed job stays until closed; "updated" goes 60 s after the new
version started; the clock notice is for the setup screens (the node card carries it on the dashboard); the job
notice is for the dashboard. */
notice is for the dashboard. A card that appeared or went (hot-plug, src/hotplug.rs) is a level 5 notice for
5 minutes on every screen: "New card: <name>, mining", "New card: <name>, not usable (Code 43)", "Card removed". */
var Notices = (function () {
'use strict';
var LEVEL = { 'update-installing': 0, 'update-urgent': 0, 'update-failed': 0, 'job-failed': 1, clock: 2, 'job-running': 3, 'update-available': 4, 'job-done': 5, 'update-installed': 6 };
var JOB_DONE_S = 300, INSTALLED_S = 60;
var LEVEL = { 'update-installing': 0, 'update-urgent': 0, 'update-failed': 0, 'job-failed': 1, clock: 2, 'job-running': 3, 'update-available': 4, 'job-done': 5, 'card-added': 5, 'card-removed': 5, 'update-installed': 6 };
var JOB_DONE_S = 300, INSTALLED_S = 60, CARD_S = 300;
function cap(t) { return t ? t.charAt(0).toUpperCase() + t.slice(1) : ''; }
function firstLine(t, max) { t = String(t || '').split('\n')[0].trim(); max = max || 160; return t.length > max ? t.slice(0, max - 1).trim() + '\u2026' : t; }
function endDot(t) { t = (t || '').trim(); return !t || /[.!?]$/.test(t) ? t : t + '.'; }
@ -91,6 +92,26 @@ var Notices = (function () {
return notice('clock', 'clock:' + c.severity, c.message || '', { tone: c.severity === 'block' ? 'bad' : 'warn', detail: c.hint || '', actions: [{ act: 'sync', label: c.syncing ? 'Syncing' : 'Sync clock', primary: true, disabled: !!c.syncing }] });
}
// cards = state.mining.cards; now = state.now: every card a re-detection added or lost in the last 5 minutes
// (added_at / removed_at are 0 for the cards found at start). The newest first, so pick() shows it.
function kindWord(k) { return k === 'apple' ? 'Apple silicon' : k === 'discrete' ? 'Discrete' : k === 'integrated' ? 'Integrated' : k === 'external' ? 'External' : 'Unknown'; }
function cardNotices(cards, now) {
var out = [];
(cards || []).forEach(function (c) {
if (c.removed_at > 0) {
if (now - c.removed_at <= CARD_S) out.push(notice('card-removed', 'card:removed:' + c.key + ':' + Math.floor(c.removed_at), 'Card removed: ' + c.name + '. Its worker stopped.', { tone: 'warn', at: c.removed_at }));
return;
}
if (!(c.added_at > 0) || now - c.added_at > CARD_S) return;
// a new card with a problem, or a card that was mining and now reports one (both carry added_at): the row's words
if (c.problem) out.push(notice('card-added', 'card:added:' + c.key + ':' + Math.floor(c.added_at), c.name + ': not usable (' + c.problem + '). No worker runs on it.', { tone: 'warn', detail: c.reason || '', at: c.added_at }));
else if (c.enabled) out.push(notice('card-added', 'card:added:' + c.key + ':' + Math.floor(c.added_at), 'New card: ' + c.name + ', mining.', { at: c.added_at }));
else out.push(notice('card-added', 'card:added:' + c.key + ':' + Math.floor(c.added_at), 'New card: ' + c.name + ', off (' + kindWord(c.kind).toLowerCase() + '). Settings switches it on.', { at: c.added_at }));
});
out.sort(function (a, b) { return b.at - a.at; });
return out;
}
// every notice the state carries for this screen; pick() chooses the one to show
function gather(s, where) {
where = where || {};
@ -98,6 +119,7 @@ var Notices = (function () {
if (u) out.push(u);
if (where.dashboard) { var j = jobNotice(s.jobs, s.now || 0); if (j) out.push(j); }
else { var c = clockNotice(s.clock); if (c) out.push(c); }
if (s.mining && s.mining.cards) out = out.concat(cardNotices(s.mining.cards, s.now || 0));
return out;
}
// the lowest level wins; a dismissed key is skipped, so the next one shows in its place
@ -110,7 +132,7 @@ var Notices = (function () {
}
return best;
}
return { LEVEL: LEVEL, JOB_DONE_S: JOB_DONE_S, INSTALLED_S: INSTALLED_S, updateNotice: updateNotice, jobNotice: jobNotice, clockNotice: clockNotice, gather: gather, pick: pick, jobTitle: jobTitle, cap: cap, endDot: endDot };
return { LEVEL: LEVEL, JOB_DONE_S: JOB_DONE_S, INSTALLED_S: INSTALLED_S, CARD_S: CARD_S, updateNotice: updateNotice, jobNotice: jobNotice, clockNotice: clockNotice, cardNotices: cardNotices, gather: gather, pick: pick, jobTitle: jobTitle, cap: cap, endDot: endDot, kindWord: kindWord };
})();
/* ---------- the update card (pure; update-card.test.mjs loads this block) ----------
@ -234,32 +256,40 @@ var View = (function () {
function compact(n) { if (n === null || n === undefined || isNaN(n)) return '0'; var a = Math.abs(n); if (a >= 1e12) return (n / 1e12).toFixed(2) + 'T'; if (a >= 1e9) return (n / 1e9).toFixed(2) + 'G'; if (a >= 1e6) return (n / 1e6).toFixed(2) + 'M'; if (a >= 1e4) return (n / 1e3).toFixed(1) + 'k'; return String(Math.round(n)); }
function rel(s) { s = Math.max(0, Math.round(s)); if (s < 60) return s + ' s ago'; var m = Math.floor(s / 60); if (m < 60) return m + ' min ago'; var h = Math.floor(m / 60); if (h < 24) return h + ' h ' + (m % 60) + ' min ago'; return Math.floor(h / 24) + ' d ago'; }
function shortHex(h, head, tail) { h = String(h || ''); head = head || 8; tail = tail || 6; return h.length > head + tail + 2 ? h.slice(0, head) + '…' + h.slice(-tail) : h; }
function kindWord(k) { return k === 'apple' ? 'Apple silicon' : k === 'discrete' ? 'Discrete' : k === 'integrated' ? 'Integrated' : k === 'external' ? 'External' : 'Unknown'; }
var kindWord = Notices.kindWord;
// hot-plug (src/hotplug.rs): a removed card's row hides after five minutes (gone); a faulty one has no switch
function shownCards(cards) { return (cards || []).filter(function (c) { return !c.gone; }); }
// listed, driver fine, not unplugged: a worker can run on it
function present(c) { return !(c.removed_at > 0) && !c.problem; }
// the tooltip on a card's name: what the tool calls it (gfx1201), its device index, the OpenCL platform, the PCI address
function cardTitle(cd) { var t = []; if (cd.code && cd.code !== cd.name) t.push(cd.code); if (cd.device !== undefined && cd.device !== '') t.push(cd.worker + ' device ' + cd.device); if (cd.platform) t.push(cd.platform); if (cd.bus) t.push('bus ' + cd.bus); return t.join(' · '); }
// one GPU row on the Mine page: cd = state.mining.cards[i]
function cardRow(cd) {
var on = !!cd.enabled, st = cd.state || 'off';
var word = !on ? 'off' : st === 'mining' ? 'mining' : st === 'restarting' ? 'restart in ' + (cd.restart_in_s || 0) + ' s' : st === 'waiting' ? 'waiting for the node' : st === 'faulted' ? 'stopped after repeated faults' : st === 'failed' ? 'failed' : st === 'starting' ? 'starting' : st === 'ready' ? 'ready' : st;
var tone = !on ? 'off' : st === 'mining' ? 'on' : (st === 'failed' || st === 'faulted' || st === 'restarting') ? 'bad' : '';
var removed = cd.removed_at > 0, unusable = !!cd.problem;
var on = !!cd.enabled && !removed && !unusable, st = removed ? 'removed' : unusable ? 'unusable' : (cd.state || 'off');
var word = removed ? 'removed' : unusable ? ('not usable (' + cd.problem + ')') : !on ? 'off' : st === 'mining' ? 'mining' : st === 'restarting' ? 'restart in ' + (cd.restart_in_s || 0) + ' s' : st === 'waiting' ? 'waiting for the node' : st === 'faulted' ? 'stopped after repeated faults' : st === 'failed' ? 'failed' : st === 'starting' ? 'starting' : st === 'ready' ? 'ready' : st;
var tone = removed ? 'off' : unusable ? 'bad' : !on ? 'off' : st === 'mining' ? 'on' : (st === 'failed' || st === 'faulted' || st === 'restarting') ? 'bad' : '';
var hash = on && st === 'mining' ? (cd.hash_now >= 100 ? cd.hash_now.toFixed(0) : (cd.hash_now || 0).toFixed(1)) : '';
var temp = cd.temp_gpu > 0 ? Math.round(cd.temp_gpu) + ' °C' : '';
var tempTone = cd.temp_mem > 95 || cd.temp_gpu > 92 ? 'hot' : cd.temp_mem > 90 || cd.temp_gpu > 86 ? 'warm' : '';
var power = cd.power_w > 0 ? Math.round(cd.power_w) + ' W' : '';
var cap = cd.power_default_w > 0 ? Math.round(cd.power_default_w * (cd.power_pct || 80) / 100) : 0;
var sub = cd.message || (!on && cd.reason ? cd.reason : '') || '';
var sub = unusable ? (cd.reason || '') : removed ? 'unplugged; its worker stopped. The row goes in five minutes.' : cd.message || (!on && cd.reason ? cd.reason : '') || '';
var meta = [];
if (cd.vram_mb) { var gb = cd.vram_mb / 1024; meta.push((gb >= 10 ? Math.round(gb) : gb.toFixed(gb < 4 ? 1 : 0)) + ' GB' + (cd.kind === 'apple' ? ' unified' : '')); }
if (on && cd.accepted) meta.push(cd.accepted + ' block' + (cd.accepted === 1 ? '' : 's'));
if (on && cd.prepared) meta.push('next program ready');
return { key: cd.key, name: cd.name, vendor: cd.vendor, kind: cd.kind, kindWord: kindWord(cd.kind), integrated: cd.kind === 'integrated', on: on, canToggle: cd.kind !== 'unknown', word: word, tone: tone, hash: hash, temp: temp, tempTone: tempTone, power: power, cap: cap, sub: sub, meta: meta.join(' · ') };
return { key: cd.key, name: cd.name, vendor: cd.vendor, kind: cd.kind, kindWord: kindWord(cd.kind), integrated: cd.kind === 'integrated', on: on, removed: removed, unusable: unusable, canToggle: cd.kind !== 'unknown' && !removed && !unusable, word: word, tone: tone, hash: hash, temp: temp, tempTone: tempTone, power: power, cap: cap, sub: sub, meta: meta.join(' · '), title: cardTitle(cd) };
}
// the big button on Mine: m = state.mining, n = state.node, s = { quitting }
function toggle(m, n, s) {
var on = (m.cards || []).filter(function (c) { return c.enabled; }).length;
var mining = (m.cards || []).filter(function (c) { return c.state === 'mining'; }).length;
var cards = shownCards(m.cards).filter(present);
var on = cards.filter(function (c) { return c.enabled; }).length;
var mining = cards.filter(function (c) { return c.state === 'mining'; }).length;
if (s && s.quitting) return { label: 'Stopping', sub: 'miners first, then the node', cls: 'stop', disabled: true, act: '' };
if (!on) return { label: 'Start mining', sub: m.cards && m.cards.length ? 'switch a GPU on below first' : 'no GPU this app can drive', cls: '', disabled: true, act: '' };
if (!on) return { label: 'Start mining', sub: cards.length ? 'switch a GPU on below first' : 'no GPU this app can drive', cls: '', disabled: true, act: '' };
if (m.paused) return { label: 'Start mining', sub: 'paused · the node keeps running', cls: '', disabled: false, act: 'resume' };
if (m.state === 'mining') return { label: 'Stop mining', sub: 'mining on ' + mining + ' of ' + on + ' card' + (on === 1 ? '' : 's'), cls: 'stop', disabled: false, act: 'pause' };
if (m.state === 'waiting') return { label: 'Stop mining', sub: n && n.synced ? 'worker starting' : 'waiting for the node to sync', cls: 'stop', disabled: false, act: 'pause' };
@ -340,7 +370,7 @@ var View = (function () {
else parts.push('Nothing running' + (j.checked_at ? '; checked ' + rel((now || 0) - j.checked_at) : '') + (j.queued ? '; ' + j.queued + ' queued' : '') + '.');
return parts.join(' ');
}
return { PAGES: PAGES, page: page, withCommas: withCommas, compact: compact, rel: rel, shortHex: shortHex, kindWord: kindWord, cardRow: cardRow, toggle: toggle, nodeWords: nodeWords, skewWord: skewWord, peersLine: peersLine, heightLine: heightLine, nextSwitch: nextSwitch, switchLine: switchLine, proveWords: proveWords, verifierWords: verifierWords, devFeeText: devFeeText, devFeeLine: devFeeLine, jobsNote: jobsNote };
return { PAGES: PAGES, page: page, withCommas: withCommas, compact: compact, rel: rel, shortHex: shortHex, kindWord: kindWord, shownCards: shownCards, present: present, cardTitle: cardTitle, cardRow: cardRow, toggle: toggle, nodeWords: nodeWords, skewWord: skewWord, peersLine: peersLine, heightLine: heightLine, nextSwitch: nextSwitch, switchLine: switchLine, proveWords: proveWords, verifierWords: verifierWords, devFeeText: devFeeText, devFeeLine: devFeeLine, jobsNote: jobsNote };
})();
if (typeof module === 'object' && module && module.exports) { module.exports = Notices; module.exports.UpdateCard = UpdateCard; module.exports.View = View; }
@ -379,7 +409,7 @@ if (typeof document !== 'undefined') (function () {
return fetch(path, opt).then(function (r) { return r.json(); });
}
function esc(s) { return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); }
var compact = View.compact, withCommas = View.withCommas, rel = View.rel, kindWord = View.kindWord;
var compact = View.compact, withCommas = View.withCommas, rel = View.rel, kindWord = View.kindWord, shownCards = View.shownCards, cardTitle = View.cardTitle;
function hms(s) { s = Math.max(0, Math.round(s)); var h = Math.floor(s / 3600), m = Math.floor(s % 3600 / 60), x = s % 60; return (h ? h + ':' : '') + (h ? String(m).padStart(2, '0') : m) + ':' + String(x).padStart(2, '0'); }
function uptime(s) { var h = Math.floor(s / 3600), m = Math.floor(s % 3600 / 60); return h ? h + ' h ' + m + ' min' : m + ' min'; }
function clock(t) { var d = new Date(t * 1000); return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0') + ':' + String(d.getSeconds()).padStart(2, '0'); }
@ -492,7 +522,7 @@ if (typeof document !== 'undefined') (function () {
if (!e.target.matches('.gpu-line .switch input') || !state) return;
var key = e.target.closest('.gpu-line').dataset.key, on = e.target.checked;
pending[key] = { on: on, at: Date.now() };
var choices = state.mining.cards.map(function (c) { var it = { key: c.key, enabled: c.key === key ? on : c.enabled, identities: c.identities || 1 }; if (c.vendor === 'nvidia' && c.power_default_w > 0) it.power_pct = c.power_pct || 80; return it; });
var choices = shownCards(state.mining.cards).filter(View.present).map(function (c) { var it = { key: c.key, enabled: c.key === key ? on : c.enabled, identities: c.identities || 1 }; if (c.vendor === 'nvidia' && c.power_default_w > 0) it.power_pct = c.power_pct || 80; return it; });
api('api/cards', { cards: choices }).then(function (r) { if (r.ok) toast(on ? 'Worker starting on that card' : 'Worker stopped on that card'); else { delete pending[key]; toast(r.error || 'could not change'); } });
});
@ -852,15 +882,16 @@ if (typeof document !== 'undefined') (function () {
// ---------- the first-run GPU rows: one per card, with a switch ----------
function renderCardRows(container, cards) {
container.querySelectorAll('.gpu-row').forEach(function (n) { n.remove(); });
cards.forEach(function (cd) {
shownCards(cards).forEach(function (cd) {
var d = document.createElement('div');
d.className = 'gpu-row' + (cd.enabled ? '' : ' off');
var removed = cd.removed_at > 0, unusable = !!cd.problem;
d.className = 'gpu-row' + (cd.enabled && !removed && !unusable ? '' : ' off') + (removed ? ' removed' : '') + (unusable ? ' unusable' : '');
d.dataset.key = cd.key;
var meta = '<span>worker <b>' + esc(cd.worker) + '</b></span>' + (vramText(cd) ? '<span>' + esc(vramText(cd)) + '</span>' : '') + (cd.detail && cd.kind !== 'apple' ? '<span>' + esc(cd.detail) + '</span>' : '') + (cd.detail && cd.kind === 'apple' ? '<span>' + esc(cd.detail.split(',')[0]) + '</span>' : '') + (cd.device ? '<span>device <b>' + esc(cd.device) + '</b></span>' : '');
d.innerHTML = '<div class="badge ' + esc(cd.vendor) + '">' + badgeHtml(cd) + '</div>' +
'<div class="info"><div class="name">' + esc(cd.name) + ' <span class="kind ' + esc(cd.kind) + '">' + kindWord(cd.kind) + '</span></div><div class="meta">' + meta + '</div>' +
(cd.reason ? '<div class="reason">' + esc(cd.reason) + '</div>' : '') + (cd.message ? '<div class="msg">' + esc(cd.message) + '</div>' : '') + '</div>' +
'<label class="switch" title="' + (cd.enabled ? 'mining on this card' : 'not mining on this card') + '"><input type="checkbox" aria-label="Mine on ' + esc(cd.name) + '"' + (cd.enabled ? ' checked' : '') + (cd.kind === 'unknown' ? ' disabled' : '') + '><span class="track"></span></label>';
'<div class="info"><div class="name" title="' + esc(cardTitle(cd)) + '">' + esc(cd.name) + (unusable ? ': ' + esc(cd.message || 'not usable (' + cd.problem + ')') : removed ? ': removed' : '') + ' <span class="kind ' + esc(cd.kind) + '">' + kindWord(cd.kind) + '</span></div><div class="meta">' + meta + '</div>' +
(cd.reason ? '<div class="reason">' + esc(cd.reason) + '</div>' : '') + (cd.message && !unusable && !removed ? '<div class="msg">' + esc(cd.message) + '</div>' : '') + '</div>' +
'<label class="switch" title="' + (unusable ? 'no worker can drive this card' : removed ? 'the card was removed' : cd.enabled ? 'mining on this card' : 'not mining on this card') + '"><input type="checkbox" aria-label="Mine on ' + esc(cd.name) + '"' + (cd.enabled && !removed && !unusable ? ' checked' : '') + (cd.kind === 'unknown' || unusable || removed ? ' disabled' : '') + '><span class="track"></span></label>';
container.appendChild(d);
});
}
@ -868,6 +899,7 @@ if (typeof document !== 'undefined') (function () {
function readCardRows(container) {
var out = [];
container.querySelectorAll('.gpu-row, .set-card').forEach(function (r) {
if (r.classList.contains('removed') || r.classList.contains('unusable')) return;
var cd = cardOf(r.dataset.key) || {};
var idsInput = r.querySelector('.ids input'), n = idsInput ? parseInt(idsInput.value, 10) : (cd.identities || 1);
if (!(n >= 1)) n = 1; if (n > 64) n = 64;
@ -886,8 +918,8 @@ if (typeof document !== 'undefined') (function () {
var list = $('cards-list'), det = $('cards-detecting');
if (s.detecting) { det.hidden = false; $('detect-text').textContent = 'asking the graphics cards to report in'; $('btn-cards-next').disabled = true; $('cards-sub').textContent = 'Asking the graphics cards to report in.'; cardsRendered = ''; return; }
det.hidden = true;
var cards = s.mining.cards;
var sig = cards.map(function (c) { return c.key; }).join('|');
var cards = shownCards(s.mining.cards);
var sig = cards.map(function (c) { return c.key + ':' + (c.problem || '') + ':' + (c.removed_at > 0 ? 'r' : '') + ':' + (c.enabled ? 'on' : 'off'); }).join('|');
if (sig !== cardsRendered) { cardsRendered = sig; renderCardRows(list, cards); }
if (!cards.length) {
$('cards-sub').textContent = 'No GPU this app can drive was found.';
@ -920,9 +952,9 @@ if (typeof document !== 'undefined') (function () {
var mineRowsSig = '', lastEventsKey = '';
function gpuLineHtml(r) {
var num = function (cls, k, v, unit, tone) { return '<div class="num ' + cls + '"><span class="k">' + k + '</span><span class="v' + (v ? (tone ? ' ' + tone : '') : ' na') + '">' + (v ? esc(v) + (unit ? '<span class="unit">' + unit + '</span>' : '') : 'n/a') + '</span></div>'; };
return '<div class="gpu-line' + (r.on ? ' on' : ' off') + '" data-key="' + esc(r.key) + '">' +
return '<div class="gpu-line' + (r.on ? ' on' : ' off') + (r.removed ? ' removed' : '') + (r.unusable ? ' unusable' : '') + '" data-key="' + esc(r.key) + '">' +
'<div class="badge ' + esc(r.vendor) + '">' + badgeHtml({ vendor: r.vendor }) + '</div>' +
'<div class="who"><div class="name">' + esc(r.name) + ' <span class="kind ' + esc(r.kind) + '">' + esc(r.kindWord) + '</span></div>' +
'<div class="who"><div class="name" title="' + esc(r.title) + '">' + esc(r.name) + ' <span class="kind ' + esc(r.kind) + '">' + esc(r.kindWord) + '</span></div>' +
'<span class="st ' + r.tone + '"><i></i>' + esc(r.word) + (r.meta ? ' · ' + esc(r.meta) : '') + '</span>' + (r.sub ? '<div class="msg' + (r.tone === 'bad' ? '' : ' dim') + '">' + esc(r.sub) + '</div>' : '') + '</div>' +
num('hash', 'hash rate', r.hash, 'MH/s', '') + num('temp', 'temperature', r.temp, '', r.tempTone) + num('power', 'power', r.power, '', '') +
'<label class="switch" title="' + (r.on ? 'mining on this card' : 'not mining on this card') + '"><input type="checkbox" aria-label="Mine on ' + esc(r.name) + '"' + (r.on ? ' checked' : '') + (r.canToggle ? '' : ' disabled') + '><span class="track"></span></label></div>';
@ -933,8 +965,9 @@ if (typeof document !== 'undefined') (function () {
tb.className = 'toggle-big' + (t.cls ? ' ' + t.cls : ''); tb.disabled = t.disabled;
setText('btn-toggle-text', t.label); setText('btn-toggle-sub', t.sub);
setText('d-hash', m.hash_total >= 100 ? m.hash_total.toFixed(0) : m.hash_total.toFixed(1));
var mining = m.cards.filter(function (c) { return c.state === 'mining'; }).length;
setText('d-hash-sub', m.state === 'paused' ? 'paused' : mining ? (mining + ' of ' + m.cards.length + ' card' + (m.cards.length === 1 ? '' : 's') + ' mining') : m.state === 'waiting' ? (n.synced ? 'worker starting' : 'waiting for the node') : m.state === 'idle' ? 'no GPU worker' : m.state);
var shown = shownCards(m.cards), presentCards = shown.filter(View.present);
var mining = presentCards.filter(function (c) { return c.state === 'mining'; }).length;
setText('d-hash-sub', m.state === 'paused' ? 'paused' : mining ? (mining + ' of ' + presentCards.length + ' card' + (presentCards.length === 1 ? '' : 's') + ' mining') : m.state === 'waiting' ? (n.synced ? 'worker starting' : 'waiting for the node') : m.state === 'idle' ? 'no GPU worker' : m.state);
setText('d-blocks', withCommas(m.accepted_total));
setText('d-blocks-sub', m.accepted_session + ' this run' + (m.found.length ? ' · ' + m.found.length + ' last hour' : '') + (m.rejected_session ? ' · ' + m.rejected_session + ' rejected' : ''));
if (n.daa > 0 && p.eta_s >= 0) { setText('d-eta', hms(p.eta_s)); setText('d-eta-sub', p.message + (p.epoch_index ? ' (epoch ' + p.epoch_index + ')' : '')); }
@ -946,16 +979,16 @@ if (typeof document !== 'undefined') (function () {
$('d-fee').title = (m.fee_session || 0) + ' this run; 1 block in 100 pays the miner software\'s dev fee';
setText('d-chain-blocks', compact(n.blocks));
// the rows: every card, on or off; rebuilt only when their words change
var rows = m.cards.map(View.cardRow), now = Date.now();
var rows = shown.map(View.cardRow), now = Date.now();
rows.forEach(function (r) { var pd = pending[r.key]; if (pd) { if (pd.on === r.on || now - pd.at > 8000) delete pending[r.key]; else { r.on = pd.on; r.word = pd.on ? 'starting' : 'stopping'; r.tone = ''; } } });
var on = rows.filter(function (r) { return r.on; }).length;
setText('d-cards-eyebrow', s.detecting ? 'detecting' : !m.cards.length ? 'none found' : on + ' of ' + m.cards.length + ' on');
setText('d-cards-eyebrow', s.detecting ? 'detecting' : !shown.length ? 'none found' : on + ' of ' + presentCards.length + ' on');
var sig = JSON.stringify(rows);
if (sig !== mineRowsSig) {
mineRowsSig = sig;
$('d-cards').innerHTML = rows.length ? rows.map(gpuLineHtml).join('') : '<div class="empty' + (s.detecting ? '' : ' err') + '">' + (s.detecting ? 'Asking the graphics cards to report in.' : 'No GPU this app can drive was found. The node runs on its own.') + '</div>';
}
var note = !m.cards.length && s.detect_message ? s.detect_message : m.cards.some(function (c) { return c.vendor === 'nvidia' && c.power_default_w > 0 && !c.power_applied && c.enabled; }) ? 'A power cap is not applied yet: Settings has a Retry for the administrator prompt.' : '';
var note = !shown.length && s.detect_message ? s.detect_message : shown.some(function (c) { return c.problem; }) ? 'A card the OS reports a problem on gets no worker. Reboot with the card attached; if it persists, reinstall the driver with the card attached.' : presentCards.some(function (c) { return c.vendor === 'nvidia' && c.power_default_w > 0 && !c.power_applied && c.enabled; }) ? 'A power cap is not applied yet: Settings has a Retry for the administrator prompt.' : '';
$('d-cards-note').hidden = !note; setText('d-cards-note', note);
var key = s.events.length ? s.events[0].t + ':' + s.events.length : '';
if (key !== lastEventsKey) {
@ -1073,8 +1106,10 @@ if (typeof document !== 'undefined') (function () {
renderSetCards(s);
}
function setCardHtml(cd) {
var nv = cd.vendor === 'nvidia' && cd.power_default_w > 0, pct = cd.power_pct || 80;
var h = '<div class="set-card" data-key="' + esc(cd.key) + '"><div class="head"><span class="name">' + esc(cd.name) + '</span><span class="kind ' + esc(cd.kind) + '">' + kindWord(cd.kind) + '</span><span class="meta">' + esc(cd.worker) + (vramText(cd) ? ' · ' + esc(vramText(cd)) : '') + (cd.enabled ? '' : ' · off') + '</span></div>';
var nv = cd.vendor === 'nvidia' && cd.power_default_w > 0, pct = cd.power_pct || 80, removed = cd.removed_at > 0, unusable = !!cd.problem;
var h = '<div class="set-card' + (removed ? ' removed' : '') + (unusable ? ' unusable' : '') + '" data-key="' + esc(cd.key) + '"><div class="head"><span class="name" title="' + esc(cardTitle(cd)) + '">' + esc(cd.name) + '</span><span class="kind ' + esc(cd.kind) + '">' + kindWord(cd.kind) + '</span><span class="meta">' + esc(cd.worker) + (vramText(cd) ? ' · ' + esc(vramText(cd)) : '') + (removed ? ' · removed' : unusable ? ' · not usable (' + esc(cd.problem) + ')' : cd.enabled ? '' : ' · off') + '</span></div>';
if (removed) return h + '<p class="help">The card was unplugged; its worker stopped. The row goes in five minutes.</p></div>';
if (unusable) return h + '<p class="help">' + esc(cd.reason || 'No worker can drive this card.') + '</p></div>';
if (nv) {
h += '<div class="ctl"><span class="k">Power cap</span><input type="range" min="50" max="100" step="5" value="' + pct + '" aria-label="Power cap for ' + esc(cd.name) + '"><span class="pv">' + pct + '% · ' + Math.round(cd.power_default_w * pct / 100) + ' W</span>' +
'<p class="help">Lower is cooler and quieter; most cards hash nearly as fast at 70%. ' + (cd.pinned ? 'Set by hand, so the sweep leaves it.' : 'The sweep may move it.') + '</p></div>';
@ -1095,9 +1130,9 @@ if (typeof document !== 'undefined') (function () {
return h + '</div>';
}
function renderSetCards(s) {
var cards = s.mining.cards, box = $('s-cards');
var cards = shownCards(s.mining.cards), box = $('s-cards');
if (box.querySelector('input[type=range]:active') || box.contains(document.activeElement)) return;
var sig = JSON.stringify(cards.map(function (c) { return [c.key, c.enabled, c.identities, c.power_pct, c.pinned, c.power_applied, c.sweep_state, c.sweep_pct, c.sweep_note, Math.round(c.power_w || 0), Math.round(c.temp_gpu || 0), Math.round(c.temp_mem || 0)]; }));
var sig = JSON.stringify(cards.map(function (c) { return [c.key, c.enabled, c.identities, c.power_pct, c.pinned, c.power_applied, c.sweep_state, c.sweep_pct, c.sweep_note, Math.round(c.power_w || 0), Math.round(c.temp_gpu || 0), Math.round(c.temp_mem || 0), c.problem || '', c.removed_at > 0]; }));
if (sig === setCardsSig) return;
setCardsSig = sig;
setText('s-cards-eyebrow', cards.length ? cards.length + ' card' + (cards.length === 1 ? '' : 's') : '');

View file

@ -11,7 +11,7 @@ const src = readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'app.js')
const mod = { exports: {} };
new Function('module', src)(mod);
const N = mod.exports;
const { updateNotice, jobNotice, clockNotice, gather, pick } = N;
const { updateNotice, jobNotice, clockNotice, cardNotices, gather, pick } = N;
const NOW = 1_800_000_000;
const upd = (over) => ({ available: true, version: '0.3.6', notes: '', checked_at: NOW - 60, error: '', status: 'ready', downloaded: true, ready: true, applying: false, progress: 1, size: 20_588_331, auto: true, wait: 'installs at the next safe moment', urgent: false, urgent_text: '', activation_height: 0, unsupported: false, min_supported: '', channel: 'devnet', published_at: '', file: '', updated_from: '', rolled_back: '', ...over });
@ -139,3 +139,36 @@ test('clock: the engine words, Sync clock, the hint; gather() keeps it off the d
assert.equal(pick(gather(st, { dashboard: false }), {}).kind, 'clock');
assert.deepEqual(gather({}, { dashboard: true }), []);
});
// hot-plug (src/hotplug.rs): the strip says what appeared or went, for 5 minutes, on every screen
const cardOf = (over) => ({ index: 0, key: 'nvidia:0:NVIDIA GeForce RTX 5090', kind: 'discrete', name: 'NVIDIA GeForce RTX 5090', vendor: 'nvidia', enabled: true, state: 'mining', problem: '', reason: '', message: '', added_at: 0, removed_at: 0, gone: false, ...over });
test('card notices: new card mining, new card not usable with the hint, removed card, nothing for the cards found at start', () => {
const start = cardOf();
assert.deepEqual(cardNotices([start], NOW), []);
const added = cardOf({ key: 'amd:1:gfx1201', name: 'gfx1201', vendor: 'amd', added_at: NOW - 30, state: 'starting' });
const [a] = cardNotices([start, added], NOW);
assert.equal(a.kind, 'card-added'); assert.equal(a.level, N.LEVEL['job-done']); assert.equal(a.text, 'New card: gfx1201, mining.'); assert.equal(a.key, 'card:added:amd:1:gfx1201:' + (NOW - 30));
const bad = cardOf({ key: 'amd::AMD Radeon RX 9070 XT', name: 'AMD Radeon RX 9070 XT', vendor: 'amd', enabled: false, state: 'unusable', problem: 'Code 43', message: 'not usable (Code 43)', reason: 'reboot with the card attached; if it persists, reinstall the driver with the card attached', added_at: NOW - 10 });
const [b] = cardNotices([bad], NOW);
assert.equal(b.text, 'AMD Radeon RX 9070 XT: not usable (Code 43). No worker runs on it.'); assert.equal(b.tone, 'warn'); assert.match(b.detail, /^reboot with the card attached/);
const igpu = cardOf({ key: 'amd:0:gfx1036', name: 'gfx1036', vendor: 'amd', kind: 'integrated', enabled: false, state: 'off', added_at: NOW - 5 });
assert.equal(cardNotices([igpu], NOW)[0].text, 'New card: gfx1036, off (integrated). Settings switches it on.');
const removed = cardOf({ key: 'amd:1:gfx1201', name: 'gfx1201', vendor: 'amd', state: 'removed', removed_at: NOW - 60 });
const [r] = cardNotices([removed], NOW);
assert.equal(r.kind, 'card-removed'); assert.equal(r.text, 'Card removed: gfx1201. Its worker stopped.'); assert.equal(r.tone, 'warn');
// the newest first; both go after CARD_S
const two = cardNotices([added, removed], NOW);
assert.equal(two[0].kind, 'card-added');
assert.deepEqual(cardNotices([added, removed, bad], NOW + N.CARD_S + 1), []);
});
test('card notices sit under a running job and show on the setup screens too', () => {
const added = cardOf({ key: 'amd:1:gfx1201', name: 'gfx1201', vendor: 'amd', added_at: NOW - 30 });
const withJob = s({ jobs: run(), mining: { cards: [added] } });
assert.equal(pick(gather(withJob, { dashboard: true }), {}).kind, 'job-running');
const quiet = s({ mining: { cards: [added] } });
assert.equal(pick(gather(quiet, { dashboard: true }), {}).kind, 'card-added');
assert.equal(pick(gather(quiet, {}), {}).kind, 'card-added');
// closed: stays closed for that key; a later event on the same card has a new key
assert.equal(pick(gather(quiet, { dashboard: true }), { ['card:added:amd:1:gfx1201:' + (NOW - 30)]: true }), null);
});

View file

@ -149,3 +149,34 @@ test('the remote-jobs line and the helpers', () => {
assert.equal(V.compact(2500000), '2.50M');
assert.equal(V.rel(90), '1 min ago');
});
test('hot-plug (src/hotplug.rs): a removed card and a faulty card are shown as such, with no switch, and leave the counts', () => {
const gone = card({ key: 'amd:gfx1201', name: 'gfx1201', vendor: 'amd', state: 'removed', removed_at: 1000, added_at: 0, gone: false, hash_now: 0 });
const r = V.cardRow(gone);
assert.equal(r.removed, true);
assert.equal(r.on, false);
assert.equal(r.canToggle, false);
assert.equal(r.word, 'removed');
assert.equal(r.tone, 'off');
assert.equal(r.hash, '');
assert.match(r.sub, /^unplugged; its worker stopped/);
const bad = card({ key: 'amd:gfx1201#2', name: 'AMD Radeon RX 9070 XT', code: 'gfx1201', vendor: 'amd', enabled: false, state: 'unusable', problem: 'Code 43', message: 'not usable (Code 43)', reason: 'reboot with the card attached; if it persists, reinstall the driver with the card attached', added_at: 900, removed_at: 0, device: '1', platform: 'AMD Accelerated Parallel Processing', bus: '0000:03:00.0' });
const b = V.cardRow(bad);
assert.equal(b.unusable, true);
assert.equal(b.canToggle, false);
assert.equal(b.word, 'not usable (Code 43)');
assert.equal(b.tone, 'bad');
assert.match(b.sub, /^reboot with the card attached/);
assert.equal(b.title, 'gfx1201 · CUDA device 1 · AMD Accelerated Parallel Processing · bus 0000:03:00.0');
assert.equal(V.cardRow(card()).title, 'CUDA device undefined'.replace(' device undefined', '') === '' ? '' : V.cardRow(card()).title);
// a row that is gone (five minutes after removal) is not shown at all; the big button counts only present cards
assert.deepEqual(V.shownCards([card(), card({ key: 'x', gone: true })]).map((c) => c.key), ['nvidia:0:RTX 5090']);
assert.equal(V.present(card()), true);
assert.equal(V.present(gone), false);
assert.equal(V.present(bad), false);
const t = V.toggle({ state: 'mining', paused: false, cards: [gone, bad] }, { synced: true }, {});
assert.equal(t.disabled, true);
assert.equal(t.sub, 'no GPU this app can drive');
const t2 = V.toggle({ state: 'mining', paused: false, cards: [card(), gone] }, { synced: true }, {});
assert.equal(t2.sub, 'mining on 1 of 1 card');
});

View file

@ -16,6 +16,7 @@
#endif
#include <windows.h>
#include <shellapi.h>
#include <dbt.h>
#include <wrl.h>
#include <string>
#include <vector>
@ -327,6 +328,11 @@ static LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
}
}
return 0;
case WM_DEVICECHANGE:
// a device arrived or left (an eGPU through a USB4 box, a driver coming up or crashing): the engine enumerates
// the cards now instead of at its next minute poll (src/hotplug.rs); DBT_DEVNODES_CHANGED needs no registration
if (wp == DBT_DEVNODES_CHANGED || wp == DBT_DEVICEARRIVAL || wp == DBT_DEVICEREMOVECOMPLETE) sendEngine("detect");
return TRUE;
case WM_TRAY:
if (lp == WM_LBUTTONUP || lp == WM_LBUTTONDBLCLK) { ShowWindow(hwnd, SW_SHOW); SetForegroundWindow(hwnd); }
else if (lp == WM_RBUTTONUP || lp == WM_CONTEXTMENU) showTrayMenu();

View file

@ -6,7 +6,9 @@
# signed hash is trusted, never the host. Nothing secret goes in: the jobs file is public.
#
# packaging/windows/push-build-inputs.sh [--node <fork worktree, default vendor/igneum-node-v4>]
# [--node-tests "igneum-miner"] [--app-tests "igneum-app"] [--no-app] [--no-deploy] [--out <zip>]
# [--node-tests "igneum-miner"] [--app-tests "igneum-app"] [--no-app] [--no-node] [--no-deploy] [--out <zip>]
# --no-node packs and builds the app engine only (a UI or engine change with no node change: miner-ui-2, 5 October
# 2026); the manifest's node block says "none" and the builds list has no node entry.
#
# What goes in (under igneum-build-inputs/):
# manifest.json created_at, node {branch, commit, dirty, source}, repo {branch, commit, dirty}, app_version,
@ -38,13 +40,15 @@ while [ $# -gt 0 ]; do
--node-tests) NODE_TESTS="$2"; shift 2 ;;
--app-tests) APP_TESTS="$2"; shift 2 ;;
--no-app) WITH_APP=0; shift ;;
--no-node) WITH_NODE=0; shift ;;
--no-deploy) DEPLOY=0; shift ;;
--out) OUT="$2"; shift 2 ;;
*) echo "unknown argument: $1" >&2; exit 2 ;;
esac
done
case "$NODE_SRC" in /*) ;; *) NODE_SRC="$ROOT/$NODE_SRC" ;; esac
[ -f "$NODE_SRC/Cargo.toml" ] || { echo "no node source at $NODE_SRC (a vendor/igneum-node-* worktree)" >&2; exit 1; }
[ "${WITH_NODE:-1}" = 0 ] || [ -f "$NODE_SRC/Cargo.toml" ] || { echo "no node source at $NODE_SRC (a vendor/igneum-node-* worktree)" >&2; exit 1; }
[ "${WITH_NODE:-1}" = 1 ] || [ "$WITH_APP" = 1 ] || { echo "--no-node with --no-app packs nothing" >&2; exit 2; }
[ -f "$ROOT/app/igneum-app/Cargo.toml" ] || { echo "no app crate at $ROOT/app/igneum-app" >&2; exit 1; }
[ -f "$ROOT/brand/icons/igneum.ico" ] || { echo "no $ROOT/brand/icons/igneum.ico (python3 brand/icons/make-icons.py)" >&2; exit 1; }
command -v rsync >/dev/null || { echo "rsync is needed" >&2; exit 1; }
@ -77,8 +81,13 @@ APP_VERSION="$(sed -n 's/^version = "\(.*\)"/\1/p' "$ROOT/app/igneum-app/Cargo.t
TMP="$(mktemp -d)"
STAGE="$TMP/igneum-build-inputs"
mkdir -p "$STAGE/node" "$STAGE/app" "$STAGE/brand"
echo "packing the node fork $NODE_SRC ($NODE_BRANCH $NODE_COMMIT$([ "$NODE_DIRTY" = true ] && echo ', dirty'))"
rsync -a --exclude 'target' --exclude 'target-*' --exclude 'target*' --exclude '.git' --exclude '.DS_Store' --exclude '*.vhdx' "$NODE_SRC/" "$STAGE/node/"
if [ "${WITH_NODE:-1}" = 1 ]; then
echo "packing the node fork $NODE_SRC ($NODE_BRANCH $NODE_COMMIT$([ "$NODE_DIRTY" = true ] && echo ', dirty'))"
rsync -a --exclude 'target' --exclude 'target-*' --exclude 'target*' --exclude '.git' --exclude '.DS_Store' --exclude '*.vhdx' "$NODE_SRC/" "$STAGE/node/"
else
echo "no node (--no-node): the app engine only"
NODE_BRANCH=none; NODE_COMMIT=none; NODE_DIRTY=false
fi
if [ "$WITH_APP" = 1 ]; then
echo "packing app/igneum-app ($APP_VERSION) and brand/icons"
rsync -a --exclude 'target' --exclude '.DS_Store' "$ROOT/app/igneum-app/" "$STAGE/app/igneum-app/"
@ -91,12 +100,12 @@ rsync -a --exclude 'target' --exclude 'target-*' --exclude '.DS_Store' "$ROOT/ig
echo "packing proto-cuda (without nvrtc/redist)"
rsync -a --exclude 'nvrtc/redist' --exclude '.DS_Store' --exclude '*.exe' --exclude '*.dll' "$ROOT/proto-cuda/" "$STAGE/proto-cuda/"
python3 - "$STAGE/manifest.json" "$NODE_BRANCH" "$NODE_COMMIT" "$NODE_DIRTY" "${NODE_SRC#"$ROOT"/}" "$REPO_BRANCH" "$REPO_COMMIT" "$REPO_DIRTY" "$APP_VERSION" "$WITH_APP" "$NODE_TESTS" "$APP_TESTS" <<'PY'
python3 - "$STAGE/manifest.json" "$NODE_BRANCH" "$NODE_COMMIT" "$NODE_DIRTY" "${NODE_SRC#"$ROOT"/}" "$REPO_BRANCH" "$REPO_COMMIT" "$REPO_DIRTY" "$APP_VERSION" "$WITH_APP" "$NODE_TESTS" "$APP_TESTS" "${WITH_NODE:-1}" <<'PY'
import json, sys, datetime
out, nb, nc, nd, ns, rb, rc, rd, av, with_app, node_tests, app_tests = sys.argv[1:13]
builds = [{"dir": "node", "packages": ["kaspad", "igneum-miner"], "features": ["kaspad/igneum-pow"], "bins": ["igneumd", "igneum-miner"], "targets": ["linux", "windows"]}]
out, nb, nc, nd, ns, rb, rc, rd, av, with_app, node_tests, app_tests, with_node = sys.argv[1:14]
builds = [{"dir": "node", "packages": ["kaspad", "igneum-miner"], "features": ["kaspad/igneum-pow"], "bins": ["igneumd", "igneum-miner"], "targets": ["linux", "windows"]}] if with_node == "1" else []
tests = []
if node_tests.strip():
if node_tests.strip() and with_node == "1":
tests.append({"dir": "node", "packages": node_tests.split()})
if with_app == "1":
builds.append({"dir": "app/igneum-app", "packages": ["igneum-app"], "bins": ["igneum-app"], "targets": ["linux", "windows"], "optional_on": ["linux"]})

View file

@ -58,6 +58,16 @@
// Vendor device attributes (cl_amd_device_attribute_query, cl_nv_device_attribute_query).
#define IG_CL_DEVICE_WAVEFRONT_WIDTH_AMD 0x4043
#define IG_CL_DEVICE_WARP_SIZE_NV 0x4003
// Where the card sits on the PCI bus, so the app can tell one physical card listed by two OpenCL platforms (two
// AMD ICDs after a driver upgrade, PC 1 on 5 October 2026) from two cards: CL_DEVICE_TOPOLOGY_AMD and the NVIDIA pair.
#define IG_CL_DEVICE_TOPOLOGY_AMD 0x4037
#define IG_CL_DEVICE_TOPOLOGY_TYPE_PCIE_AMD 1
#define IG_CL_DEVICE_PCI_BUS_ID_NV 0x4008
#define IG_CL_DEVICE_PCI_SLOT_ID_NV 0x4009
typedef union {
struct { cl_uint type; cl_uint data[5]; } raw;
struct { cl_uint type; cl_char unused[17]; cl_char bus; cl_char device; cl_char function; } pcie;
} ig_topology_amd;
typedef cl_int (CL_API_CALL *ig_pfn_subgroup_info)(cl_kernel, cl_device_id, cl_uint, size_t, const void*, size_t, void*, size_t*);
@ -297,6 +307,7 @@ typedef struct {
int cMajor, cMinor; // OpenCL C version
int dMajor, dMinor; // device (platform profile) version
cl_uint amdWavefront, nvWarp; // 0 if not reported
char pci[32]; // "01:00.0" (bus:device.function) when the vendor extension reports it, else ""
} DeviceInfo;
static void devStr(cl_device_id d, cl_device_info what, char* out, size_t n) {
@ -349,10 +360,19 @@ static int enumerateDevices(DeviceInfo** outList) {
clGetDeviceInfo(devs[d], CL_DEVICE_MAX_WORK_GROUP_SIZE, sizeof(di.maxWorkGroup), &di.maxWorkGroup, NULL);
if (sscanf(di.cVersion, "OpenCL C %d.%d", &di.cMajor, &di.cMinor) != 2) { di.cMajor = 1; di.cMinor = 2; }
if (sscanf(di.version, "OpenCL %d.%d", &di.dMajor, &di.dMinor) != 2) { di.dMajor = 1; di.dMinor = 2; }
if (strstr(di.extensions, "cl_amd_device_attribute_query"))
if (strstr(di.extensions, "cl_amd_device_attribute_query")) {
ig_topology_amd topo;
memset(&topo, 0, sizeof(topo));
clGetDeviceInfo(devs[d], IG_CL_DEVICE_WAVEFRONT_WIDTH_AMD, sizeof(di.amdWavefront), &di.amdWavefront, NULL);
if (strstr(di.extensions, "cl_nv_device_attribute_query"))
if (clGetDeviceInfo(devs[d], IG_CL_DEVICE_TOPOLOGY_AMD, sizeof(topo), &topo, NULL) == CL_SUCCESS && topo.raw.type == IG_CL_DEVICE_TOPOLOGY_TYPE_PCIE_AMD)
snprintf(di.pci, sizeof(di.pci), "%02x:%02x.%x", (unsigned)(unsigned char)topo.pcie.bus, (unsigned)(unsigned char)topo.pcie.device, (unsigned)(unsigned char)topo.pcie.function);
}
if (strstr(di.extensions, "cl_nv_device_attribute_query")) {
cl_uint bus = 0, slot = 0;
clGetDeviceInfo(devs[d], IG_CL_DEVICE_WARP_SIZE_NV, sizeof(di.nvWarp), &di.nvWarp, NULL);
if (clGetDeviceInfo(devs[d], IG_CL_DEVICE_PCI_BUS_ID_NV, sizeof(bus), &bus, NULL) == CL_SUCCESS && clGetDeviceInfo(devs[d], IG_CL_DEVICE_PCI_SLOT_ID_NV, sizeof(slot), &slot, NULL) == CL_SUCCESS)
snprintf(di.pci, sizeof(di.pci), "%02x:%02x.0", (unsigned)(bus & 0xff), (unsigned)(slot & 0xff));
}
list = (DeviceInfo*)realloc(list, sizeof(DeviceInfo) * (size_t)(n + 1));
list[n++] = di;
}
@ -366,7 +386,10 @@ static void printDevice(int idx, const DeviceInfo* d, int chosen) {
strstr(d->extensions, "cl_intel_subgroups") ? "cl_intel_subgroups" :
strstr(d->extensions, "cl_khr_subgroups") ? "cl_khr_subgroups (no shuffle extension)" : "none";
printf("%s[%d] %s | %s (%s)\n", chosen ? "*" : " ", idx, d->name, d->platformName, d->platformVersion);
printf(" %s, vendor %s, driver %s, %s, %u compute units, %u MHz\n", typeName(d->type), d->vendor, d->driver, d->cVersion, d->computeUnits, d->clockMHz);
// the app's detect.rs reads this line: type, vendor, driver, the compute units, and the PCI address when known
printf(" %s, vendor %s, driver %s, %s, %u compute units, %u MHz", typeName(d->type), d->vendor, d->driver, d->cVersion, d->computeUnits, d->clockMHz);
if (d->pci[0]) printf(", pci %s", d->pci);
printf("\n");
printf(" global %llu MiB, max alloc %llu MiB, local %llu KiB, max work-group %llu, sub-group extension: %s",
(unsigned long long)(d->globalMem >> 20), (unsigned long long)(d->maxAlloc >> 20), (unsigned long long)(d->localMem >> 10),
(unsigned long long)d->maxWorkGroup, subExt);

View file

@ -136,6 +136,7 @@ async function machines(sql) {
accepted_lifetime: g.app && g.app.status ? g.app.status.accepted_total ?? null : null,
faults: cards.reduce((a, c) => a + (c.faults || 0), 0),
telemetry: g.app ? g.app.telemetry : [], events: g.app ? g.app.events : [],
gpus: g.app && g.app.gpus ? g.app.gpus : [], gpus_at: g.app && g.app.gpus_at ? g.app.gpus_at : null, hotplug: g.app && g.app.hotplug ? g.app.hotplug : [],
labels: g.labels.sort((a, b) => (b.at > a.at ? 1 : -1)), runs: [...g.runs],
};
});
@ -145,7 +146,7 @@ async function machines(sql) {
// several Mac installs (test runs): the newest is "Mac", the others keep their id
let macSeen = false;
for (const m of out) if (m.platform === 'mac' && m.name === 'Mac') { if (macSeen) m.name = `Mac ${m.id8}`; macSeen = true; }
for (const n of ['Mac', 'PC 1', 'PC 2', "Sam's Mac"]) if (!out.some(m => m.name === n)) out.push({ id: n, id8: Object.keys(NAMES).find(k => NAMES[k] === n) || null, platform: n.endsWith('Mac') ? 'mac' : 'win', name: n, host: null, legacy: false, last_seen: null, silent_s: null, silent: true, app_version: null, app: null, node: {}, cards: [], hash_total: 0, accepted_total: 0, faults: 0, telemetry: [], events: [], labels: [], runs: [], never: true });
for (const n of ['Mac', 'PC 1', 'PC 2', "Sam's Mac"]) if (!out.some(m => m.name === n)) out.push({ id: n, id8: Object.keys(NAMES).find(k => NAMES[k] === n) || null, platform: n.endsWith('Mac') ? 'mac' : 'win', name: n, host: null, legacy: false, last_seen: null, silent_s: null, silent: true, app_version: null, app: null, node: {}, cards: [], hash_total: 0, accepted_total: 0, faults: 0, telemetry: [], events: [], gpus: [], gpus_at: null, hotplug: [], labels: [], runs: [], never: true });
out.sort((a, b) => order(a) - order(b) || (b.last_seen || '').localeCompare(a.last_seen || ''));
return { machines: out.filter(m => NAMED.has(m.name) || (m.silent_s !== null && m.silent_s < HIDE_AFTER_S)), silent_after_s: SILENT_S, hide_after_s: HIDE_AFTER_S };
}

View file

@ -70,6 +70,12 @@ export function parseHeader(text) {
const m = HEADER.exec(text || '');
return m ? { version: m[1], id8: m[2], platform: m[3], node: m[4].replace(/^igneumd[_\/]/, '') } : null;
}
/// "NVIDIA GeForce RTX 5090 [discrete, mining] | gfx1036 [integrated, off] | AMD Radeon RX 9070 XT [discrete, not usable (Code 43)]"
export function parseCardsLine(text) {
if (!text || text.trim() === 'none') return [];
return text.split(' | ').map(p => { const m = /^(.*) \[([^,\]]+), ([^\]]+)\]$/.exec(p.trim()); return m ? { name: m[1], kind: m[2], state: m[3] } : { name: p.trim(), kind: 'unknown', state: '' }; });
}
export function parseAppTail(tail) {
const lines = tail.split('\n');
const a = { telemetry: [], events: [], jobs: [] };
@ -113,6 +119,11 @@ export function parseAppTail(tail) {
if (e && e[2] !== 'block') a.events.push({ at: new Date(Number(e[1]) * 1000).toISOString(), kind: e[2], text: e[3].slice(0, 200) });
}
a.events = a.events.slice(-6);
// the card list (app/igneum-app/src/hotplug.rs cards_line): the newest "cards: <name> [<kind>, <state>] | ..." line,
// written at start, on every hot-plug change and every 10 minutes
const cl = lastMatch(lines, /^(\d+)(?:\.\d+)? cards: (.+)$/);
if (cl) { a.gpus = parseCardsLine(cl[2]); a.gpus_at = new Date(Number(cl[1]) * 1000).toISOString(); }
a.hotplug = lines.filter(l => /^\d+(?:\.\d+)? \[(ok|warn|info)\] (New card: |Card removed: |.+: not usable \()/.test(l)).slice(-4).map(l => ({ at: stampOf(l), text: strip(l).replace(/^\[\w+\] /, '').slice(0, 200) }));
// A clean stop: the app logs "quit: ..." (Quit, Stop, or an update: "[info] installing Igneum Miner ...") and uploads
// once more before it exits; when no status line follows, the machine was stopped on purpose, not lost (4 October
// 2026: a Mac stopped at 14:47 UTC read "silent 4h" for the whole afternoon, the same as a crash or a lost network).

View file

@ -1,7 +1,7 @@
// node --test relay/test/parse.test.mjs relay/test/auth.test.mjs (no dependencies; CI runs both in the site job)
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { parseLabel, parseMinerTail, markStale, STALE_S } from '../lib/parse.mjs';
import { parseLabel, parseMinerTail, parseAppTail, parseCardsLine, markStale, STALE_S } from '../lib/parse.mjs';
test('labels: every vendor the app names, the app log, the node log, the legacy launchers', () => {
assert.deepEqual(parseLabel('miner-nvidia-ae432dc7-1'), { id: 'ae432dc7', platform: 'win', stream: 'miner', vendor: 'nvidia', card: 1 });
@ -84,3 +84,23 @@ test('app tail: the newest update line decides the OTA state: staged beats an ol
const inst = parseAppTail(mac + '\n1791146700 [info] installing Igneum Miner 0.3.4: the miners stop, then the node, then the app opens again');
assert.deepEqual([inst.ota.state, inst.ota.version], ['installing', '0.3.4']);
});
test('app tail: the cards line gives kind and state per card, and the hot-plug events', () => {
assert.deepEqual(parseCardsLine('NVIDIA GeForce RTX 5090 [discrete, mining] | gfx1036 [integrated, off] | AMD Radeon RX 9070 XT [discrete, not usable (Code 43)]'),
[{ name: 'NVIDIA GeForce RTX 5090', kind: 'discrete', state: 'mining' }, { name: 'gfx1036', kind: 'integrated', state: 'off' }, { name: 'AMD Radeon RX 9070 XT', kind: 'discrete', state: 'not usable (Code 43)' }]);
assert.deepEqual(parseCardsLine('none'), []);
const tail = [
'1791140500 cards: NVIDIA GeForce RTX 5090 [discrete, mining] | gfx1036 [integrated, off]',
'1791140560 [ok] New card: gfx1201, mining',
'1791140561 cards: NVIDIA GeForce RTX 5090 [discrete, mining] | gfx1036 [integrated, off] | gfx1201 [discrete, waiting]',
'1791140800 [warn] Card removed: gfx1201; its worker stopped',
'1791140801 cards: NVIDIA GeForce RTX 5090 [discrete, mining] | gfx1036 [integrated, off] | gfx1201 [discrete, removed]',
'1791140830 status: accepted 5 blocks (1 this run, dev fee 0), 120.00 MH/s, mining | node 100 blocks, 3 peers, synced | up 1h',
].join('\n');
const a = parseAppTail(tail);
assert.equal(a.gpus.length, 3);
assert.equal(a.gpus[2].state, 'removed');
assert.equal(a.gpus_at, new Date(1791140801000).toISOString());
assert.deepEqual(a.hotplug.map(h => h.text), ['New card: gfx1201, mining', 'Card removed: gfx1201; its worker stopped']);
assert.equal(parseAppTail('1791140830 status: x | node y | up 1h').gpus, undefined);
});

View file

@ -287,7 +287,7 @@ input[type=text]{width:100%}
['checkpoint', n.checkpoint != null ? nf(n.checkpoint) : '?'],
['run', `<small>${esc((n.run_id || (mc.runs && mc.runs[0]) || '').replace(/^.*-(\d{8}-\d{6})$/, '$1'))}</small>`],
])}
${cardTable(mc.cards)}${tele}
${cardTable(mc.cards)}${mc.gpus && mc.gpus.length ? `<div class="note" style="margin-top:8px">GPUs ${mc.gpus_at ? ago(mc.gpus_at) + ' ago' : ''}: ${mc.gpus.map(g => `${esc(g.name)} <span style="color:var(--ash)">[${esc(g.kind)}, ${/not usable|removed/.test(g.state) ? `<span style="color:var(--red)">${esc(g.state)}</span>` : esc(g.state)}]</span>`).join(' | ')}</div>` : ''}${(mc.hotplug || []).map(h => `<div class="note">hot-plug ${ago(h.at)} ago: ${esc(h.text)}</div>`).join('')}${tele}
${mc.app && (mc.app.ota || mc.app.power_cap || mc.app.jobs_file || (mc.app.jobs && mc.app.jobs.length) || mc.app.status) ? `<div style="margin-top:8px">
${mc.app.status ? `<div class="ev"><span class="t">${when(mc.app.status.at)}</span><span class="k">status</span><span>${esc(mc.app.status.miner)} | node ${esc(mc.app.status.node)} | up ${esc(mc.app.status.up)}</span></div>` : ''}
${mc.app.ota ? `<div class="ev"><span class="t">${when(mc.app.ota.at)}</span><span class="k ${(mc.app.ota.state === 'updated' || mc.app.ota.state === 'current') ? 'ok' : ''}">ota</span><span>${esc(mc.app.ota.text)}</span></div>` : ''}

View file

@ -5,7 +5,7 @@
// and puts them where the packaging scripts look.
// node tools/build-job.mjs run [--node vendor/igneum-node-v4] [--target ae432dc7] [--budget-minutes 40]
// [--stage-minutes '{"linux":20}'] [--targets linux,windows] [--no-tests]
// [--node-tests "kaspa-consensus-core"] [--app-tests "igneum-app"] [--no-app]
// [--node-tests "kaspa-consensus-core"] [--app-tests "igneum-app"] [--no-app] [--no-node]
// [--title "..."] [--no-place] [--out dir] pack + publish + watch + fetch
// node tools/build-job.mjs publish [the same flags] pack + publish, prints the id
// node tools/build-job.mjs watch <job id> STAGE and RESULT lines as they land
@ -32,7 +32,7 @@ const RELAY_BASE = (cfg('relay-url') || 'https://relay.igneum.network').replace(
const argv = process.argv.slice(2);
const flags = {}; const pos = [];
const BOOL = new Set(['no-tests', 'no-place', 'no-deploy', 'no-app', 'help']);
const BOOL = new Set(['no-tests', 'no-place', 'no-deploy', 'no-app', 'no-node', 'help']);
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a.startsWith('--')) { const k = a.slice(2); const next = argv[i + 1]; if (!BOOL.has(k) && next !== undefined && !next.startsWith('--')) { flags[k] = next; i++; } else flags[k] = true; }
@ -213,6 +213,7 @@ function publish() {
if (flags['node-tests']) pack.push('--node-tests', String(flags['node-tests']));
if (flags['app-tests']) pack.push('--app-tests', String(flags['app-tests']));
if (flags['no-app']) pack.push('--no-app');
if (flags['no-node']) pack.push('--no-node');
if (flags['no-deploy']) pack.push('--no-deploy');
console.log(`$ ${pack.join(' ')}`);
const r = spawnSync('bash', pack, { cwd: ROOT, stdio: 'inherit' });

View file

@ -113,6 +113,9 @@ try {
console.log(`${m.name.padEnd(8)} ${m.id8 || '-'} ${m.stopped ? `STOPPED (${m.stopped.reason}) ${ago(m.stopped.at)} ago,` : m.silent ? 'SILENT' : 'live'} seen ${ago(m.last_seen)} ago | app ${m.app_version || '?'} node ${m.node.version || '?'} daa ${m.node.daa ?? '?'} peers ${m.node.peers ?? '?'} ${m.node.synced ? 'synced' : 'not synced'} | ${m.hash_total.toFixed(1)} MH/s, ${m.accepted_total} accepted, ${m.faults} faults`);
for (const c of m.cards) console.log(` ${c.label.padEnd(22)} ${c.stale ? 'last ' + c.hash_now.toFixed(1) + ' MH/s (stale, not in the total)' : c.hash_now.toFixed(1) + ' MH/s now'}, ${c.accepted} accepted, ${c.rejected} rejected, ${c.mismatched} mismatched, ${c.restarts} restarts, status ${ago(c.status_at)} ago${c.fault ? ' | ' + c.fault : ''}`);
for (const t of m.telemetry) console.log(` ${t.name}: p95 ${t.p95_w} W, max ${t.max_w} W (cap ${t.cap_w} W), GPU ${t.max_gpu_c} C, memory ${t.max_mem_c} C`);
// the engine's own card list (kind and state per card, hot-plug included: app/igneum-app/src/hotplug.rs), as of its last "cards:" line
if (m.gpus && m.gpus.length) console.log(` GPUs (${ago(m.gpus_at)} ago): ${m.gpus.map(g => `${g.name} [${g.kind}, ${g.state}]`).join(' | ')}`);
for (const h of m.hotplug || []) console.log(` hot-plug ${ago(h.at)} ago: ${h.text}`);
}
}
else if (cmd === 'chain') {