diff --git a/AGENTS.md b/AGENTS.md index b20c0a0..4438f14 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -121,7 +121,7 @@ Users can always override AI agent version decisions: - **Frontend**: React 18 with TypeScript, built with Vite, served in the Tauri WebView2 - **Styling**: CSS custom properties drive theming (dark/light), tokens in `src/app/theme.ts` - **Backend**: Rust (Tauri 2), native ARM64 binary -- **Sensors**: Real telemetry via the native Windows PDH API (ACPI thermal zones, per-core load, live CPU frequency); CPU identity via the registry + `GetSystemInfo` +- **Sensors**: Real telemetry via the native Windows PDH API (ACPI thermal zones, per-core load, per-core-clock-derived Speed); CPU identity via the registry + `GetSystemInfo` (`sensors/identity.rs`), matched to a chip profile via layered detection in `sensors/chips.rs::match_profile` (SKU token → family/subfamily from name or Identifier → inferred SKU from core count + rated clock → honest family-only fallback → generic) — see `SENSORS.md` §6 - **State Management**: React hooks (useState, useEffect, useCallback) - **Tray**: Exactly one tray icon, always (do NOT re-add the `app.trayIcon` block to `tauri.conf.json` — it duplicates the runtime-built icon). It always shows the single honest CPU temperature — there is no per-core sensor to drive a per-core/mode tray menu. The number is rendered with the native system font via GDI (`sensors/tray_render.rs`, Windows-only, behind a small `tray_icon_size()`/`render_number_rgba()` seam so a macOS menu-bar or Linux tray backend can implement the same two functions later). - **Settings Persistence**: Tauri store plugin (JSON in app data folder) @@ -129,7 +129,7 @@ Users can always override AI agent version decisions: - `src/app/` - TypeScript types, theme tokens, hooks (useSettings, useSensors) - `src/components/` - React components (MenuBar, ProcessorInfo, TempTable, SettingsDialog, OverheatDialog, AboutDialog, MiniMode) - `src-tauri/src/` - Rust backend (lib.rs = app wiring, sensors/ = telemetry) -- `src-tauri/src/sensors/` - Sensor providers (pdh.rs = primary, chips.rs = profiles, tray.rs = color/mode logic, tray_render.rs = Windows GDI digit rendering, types.rs = data shapes) +- `src-tauri/src/sensors/` - Sensor providers (pdh.rs = primary, identity.rs = raw CPU identity signals from the registry/topology, chips.rs = profiles + detection, tray.rs = color/mode logic, tray_render.rs = Windows GDI digit rendering, types.rs = data shapes) - `tools/` - Phase 0 sensor probe scripts (PowerShell) + icon generator ## Real-data contract (CRITICAL) - **All telemetry must be REAL.** No simulated, random, or fallback values anywhere. @@ -141,8 +141,8 @@ Users can always override AI agent version decisions: ## Main window layout structure The application displays all sensor information in a single window matching Core Temp's actual layout: 1. **Native title bar** — the OS draws it (icon, title, minimize/close); the window is decorated and opaque (no custom chrome, no transparency/blur). Dark/light native chrome follows the app theme via `getCurrentWindow().setTheme()`. -2. **Menu Bar** (`src/components/MenuBar.tsx`) — File (Exit) / Options (Settings, Overheat protection, Toggle Mini Mode, Always on top) / Tools (Refresh sensors) / Help (About ARMtemp). Rendered as themed HTML dropdowns (a native HMENU doesn't follow dark/light mode on Windows) styled to look like real Win32 menus. No unit toggle — Fahrenheit lives in Settings → Display. Launch flags `--settings` / `--overheat` / `--about` deep-link the dialogs. -3. **Select CPU** row (combo + `[N] Core(s) [N] Thread(s)` sunken count boxes) + **Processor Information** group box (Win32 etched border, sunken read-only value fields): Model / Platform / Frequency / CPUID full rows; `Boost | Lithography` and `Throttle | TDP` pairs. VID and Revision are intentionally omitted (permanently unavailable on Snapdragon X); Throttle is the live ACPI passive-limit status (red "Yes" while the firmware throttles). +2. **Menu Bar** (`src/components/MenuBar.tsx`) — File (Exit) / Options (Settings, Overheat protection, Toggle Mini Mode, Always on top) / Tools (Refresh sensors, Copy detection report) / Help (About ARMtemp). Rendered as themed HTML dropdowns (a native HMENU doesn't follow dark/light mode on Windows) styled to look like real Win32 menus. No unit toggle — Fahrenheit lives in Settings → Display. Launch flags `--settings` / `--overheat` / `--about` deep-link the dialogs. "Copy detection report" (`get_detection_report` command) copies a plain-text dump of every raw CPU identity signal plus how the chip was matched, for diagnosing misdetections on machines the maintainer doesn't own (see issue #2). +3. **Select CPU** row (combo + `[N] Core(s) [N] Thread(s)` sunken count boxes) + **Processor Information** group box (Win32 etched border, sunken read-only value fields): Model / Platform / Frequency / CPUID full rows; `Boost | Lithography` and `Throttle | TDP` pairs. VID and Revision are intentionally omitted (permanently unavailable on Snapdragon X); Throttle is the live ACPI passive-limit status (red "Yes" while the firmware throttles). CPUID shows the real registry `Identifier` string (e.g. "ARMv8 (64-bit) Family 8 Model 2 Revision 201"), not a repeat of the Model field's marketing string. 4. **Temperature Readings** group box — Tj. Max row, one **CPU Temp** row (Cur. | Min. | Max. | Avg., **colored temperature text** — the app's single honest CPU temperature), then per-core rows (Core # | Load | Min. | Max. | Avg., plain text — genuinely per-core). 5. **Status Bar** — thin native strip with CPU Temp / Low / High (session, since app start). 6. **Mini-mode** drops native decorations at runtime (`setDecorations(false)` + `setSize()`) for a compact always-on-top box, and restores them on exit — matches Core Temp's mini mode. diff --git a/SENSORS.md b/SENSORS.md index 22978e9..d07f1a9 100644 --- a/SENSORS.md +++ b/SENSORS.md @@ -141,11 +141,35 @@ failure described in §7. single CPU temperature ARMtemp displays (there is no per-core sensor to report individually). - **`Processor Information`** — `% Processor Time` per core (instances named - `group,core`, e.g. `0,3`) and `Processor Frequency` on `_Total` — a genuinely **live** - frequency, unlike `Win32_Processor.CurrentClockSpeed` (§1 note below). -- **Registry + `GetSystemInfo`** — CPU name (`HKLM\HARDWARE\DESCRIPTION\System\ - CentralProcessor\0\ProcessorNameString`) and logical core count, used to auto-select - the chip profile for labelling. + `group,core`, e.g. `0,3`), `% Processor Performance` per core, and `Processor + Frequency` on `_Total`. **Speed** is computed per-core (`% Processor Performance × + that core's registry `~MHz`) and the fastest core wins — the `_Total` counter alone + blends both P/E clusters into a meaningless average on a heterogeneous chip (see + issue #2: an idle Efficiency cluster dragged a busy X2's reported Speed well below + its real Prime-cluster clock); `_Total` is kept only as a fallback if the per-core + counters aren't available. +- **Registry + `GetSystemInfo`** (`sensors/identity.rs`) — `ProcessorNameString`, + `Identifier` (MIDR-derived: "Model 1" = 1st-gen Oryon/X1, "Model 2" = Oryon V3/X2), + `VendorIdentifier`, per-core `~MHz` (each core's cluster rated/boost clock — the same + value CPU-Z's "Original Processor Frequency" and HWiNFO read), and logical core count. +- **Chip detection** (`sensors/chips.rs::match_profile`) is layered, since issue #2 + showed some OEM firmware (Surface, Snapdragon X2 Elite) reports a bare marketing name + with no SKU token in it at all — token matching alone silently produced "Unknown X2 + Elite SKU" for it every time: + 1. Exact SKU token in the name (e.g. `X2E78100`) — used when present. + 2. Family/subfamily from the name text ("X2 Elite", "X2 Elite Extreme", "X2 Plus", …), + or — if the name has no usable text — the registry `Identifier`'s Oryon generation + + a Qualcomm vendor check. + 3. Within that family, the exact SKU inferred from real core count + real rated clock + (`~MHz`) — nearest-clock match within a tolerance, since every family+core-count + group's neighboring SKUs are ≥300 MHz apart. X2E-80-100 and X2E-84-100 publish + identical CPU-visible specs (same 4.7 GHz boost, cache, core split) and are + reported as one honest combined "X2E-80/84-100" label rather than a guess. + 4. Family recognized but no SKU candidate fits (unreleased part): label the family + honestly with `boost_ghz` from the real measured clock — never "Generic". + 5. Nothing Snapdragon-shaped: generic fallback. + Real P/E core counts from the OS topology query (below) override any static table + cluster split whenever they're available and consistent with the detected core count. - **Power** — not wired; confirmed unavailable from userspace (§3). - Emit a single `sensor-update` Tauri event each tick with the merged snapshot. - Strict real-only contract: any field with no real source is `None` → UI shows "—". diff --git a/package-lock.json b/package-lock.json index be5189f..dfb417b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "armtemp", - "version": "0.4.4", + "version": "0.4.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "armtemp", - "version": "0.4.4", + "version": "0.4.5", "dependencies": { "@tauri-apps/api": "^2.5.0", "@tauri-apps/plugin-autostart": "^2.3.0", diff --git a/package.json b/package.json index 8bd146c..0a3da06 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "armtemp", "private": true, - "version": "0.4.4", + "version": "0.4.5", "type": "module", "description": "Native Snapdragon X / X2 temperature monitor for Windows on ARM", "scripts": { diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 127363d..ae4978d 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -49,7 +49,7 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "armtemp" -version = "0.4.4" +version = "0.4.5" dependencies = [ "anyhow", "serde", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 3e0e7c2..1459b82 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "armtemp" -version = "0.4.4" +version = "0.4.5" description = "Native Snapdragon X / X2 temperature monitor for Windows on ARM" authors = ["Spencer Francisco"] edition = "2021" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 028f803..b6657b6 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -228,6 +228,17 @@ fn exit_app(app: tauri::AppHandle) { app.exit(0); } +/// Plain-text dump of every raw CPU identity signal plus how the chip was +/// matched — see issue #2, where the only way to diagnose a misdetected +/// machine we don't own was asking the reporter for more screenshots. +#[tauri::command] +fn get_detection_report(state: tauri::State<'_, Arc>) -> String { + state + .provider + .detection_report() + .unwrap_or_else(|e| format!("detection report unavailable: {e}")) +} + /// Keeps the tray menu's "Mini-mode" checkmark honest. Mini mode is ephemeral /// UI state (not persisted settings), so the frontend reports it here on /// every toggle rather than routing it through `update_settings`. @@ -609,8 +620,9 @@ pub fn run() { let provider = PdhProvider::new(); let profile = provider.profile().ok().flatten(); eprintln!( - "[armtemp] detected: {}", - profile.as_ref().map(|p| p.name).unwrap_or("unknown") + "[armtemp] detected: {} {}", + profile.as_ref().map(|p| p.name).unwrap_or("unknown"), + profile.as_ref().map(|p| p.model).unwrap_or("") ); let state = Arc::new(AppState { provider, @@ -694,7 +706,8 @@ pub fn run() { refresh_now, update_settings, exit_app, - set_mini_state + set_mini_state, + get_detection_report ]) .run(tauri::generate_context!()) .expect("error while running ARMtemp"); diff --git a/src-tauri/src/sensors/chips.rs b/src-tauri/src/sensors/chips.rs index 40b0bee..bb09809 100644 --- a/src-tauri/src/sensors/chips.rs +++ b/src-tauri/src/sensors/chips.rs @@ -1,10 +1,28 @@ //! Chip profiles for the Snapdragon X / X2 families. This is the real silicon //! reference table ported from the Claude design's `chips` array. It is used to //! LABEL detected hardware (name, model, TjMax, cluster layout for P/E core -//! coloring) — never to fabricate readings. The actual CPU is detected at -//! runtime from the registry + `GetSystemInfo` (see `sensors/pdh.rs`) and -//! matched against these profiles. +//! coloring) — never to fabricate readings. +//! +//! Detection is layered, cheapest/most-certain evidence first (see +//! `match_profile`): +//! A. SKU token in `ProcessorNameString` (e.g. "X2E78100") — exact, when present. +//! B. Family/subfamily parsed from the name ("X2 Elite", "X2 Elite Extreme", +//! "X2 Plus", …), or — if the name carries no usable text at all — the +//! registry `Identifier`'s MIDR part number (Oryon generation) plus vendor. +//! C. Within that family, the exact SKU inferred from real, hardware-reported +//! signals: logical core count, P/E topology, and each core's `~MHz` +//! (rated/boost clock) — the same signals CPU-Z and HWiNFO use, since +//! issue #2 showed some OEM firmware (Surface) reports a bare marketing +//! name with no SKU token in it at all. +//! D. Family recognized but no SKU matches (unreleased/future part): label +//! the family honestly, with boost populated from the real measured +//! clock — never "Generic". +//! E. Nothing Snapdragon-shaped detected: generic fallback. +//! +//! The actual CPU identity is gathered in `sensors::identity` (registry + +//! `GetSystemInfo`/topology query in `sensors::pdh`). +use crate::sensors::identity::CpuIdentity; use crate::sensors::types::CoreKind; /// A known Snapdragon X-family SoC profile. @@ -13,23 +31,25 @@ pub struct ChipProfile { pub clusters: &'static [(CoreKind, u32)], pub id: &'static str, pub name: &'static str, - /// Regex-style substring matched (lowercase, alphanumeric-only — see - /// `normalize_cpu_name`) against the detected CPU name. + /// Alphanumeric-lowercase substring matched against the normalized + /// detected CPU name (see `CpuIdentity::normalized_name`). Empty for + /// profiles that are never reached via name-token matching (Stage B/C/D + /// results). pub model_match: &'static str, /// Marketing model string. pub model: &'static str, /// Thermal junction max in °C. pub tjmax_c: f64, - /// Base clock GHz (informational; not always real). + /// Base (all-core) clock GHz (informational; not always real). pub base_ghz: f64, /// Boost clock GHz (informational). pub boost_ghz: f64, - /// Nominal TDP in watts (informational). 0 means unpublished/unknown — + /// Nominal TDP in watts (informational). 0 means unpublished/unverified — /// the UI renders that as an honest "—" (see `pdh.rs`'s `tdp_w` mapping). pub tdp_w: u32, - /// Microarchitecture label, e.g. "Oryon" (X/X Plus/X Elite) or "Oryon 3" - /// (X2 family, Qualcomm's 3rd Gen Oryon CPU). Empty string for the - /// generic/unknown fallback profile. + /// Microarchitecture label, e.g. "Oryon" (X1 family) or "Oryon 3" (X2 + /// family, Qualcomm's 3rd Gen Oryon). Empty string for the generic + /// fallback profile. pub uarch: &'static str, /// Process node, e.g. "4 nm" (spec label describing the detected chip /// model — not a live telemetry reading). Empty for the generic fallback. @@ -42,25 +62,50 @@ impl ChipProfile { } } -/// Keep only ASCII alphanumerics, lowercased. Makes chip-name matching immune -/// to hyphens, spaces, and "(R)"/"(TM)" marks that vary across OEM firmware -/// (e.g. `Snapdragon(R) X2 Elite - X2E-78-100` vs `X2E78100`). -pub fn normalize_cpu_name(s: &str) -> String { - s.chars() - .filter(|c| c.is_ascii_alphanumeric()) - .map(|c| c.to_ascii_lowercase()) - .collect() +/// How a `ChipProfile` was determined — surfaced to the UI/diagnostics report +/// so "we're not sure" is never presented as "we're sure". +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MatchBasis { + /// An exact SKU token (e.g. "x2e78") was found in the CPU name. + SkuToken, + /// No SKU token in the name; the exact SKU was inferred from real core + /// count + real rated clock within a family identified from the name (or + /// Identifier). + Inferred, + /// The family/subfamily is known but no specific SKU matched (unreleased + /// part, or two SKUs are indistinguishable from CPU-visible signals). + FamilyOnly, + /// Nothing Snapdragon-shaped was detected at all. + Generic, +} + +impl MatchBasis { + /// Short human-readable label for the UI/diagnostics report. + pub fn label(&self) -> &'static str { + match self { + MatchBasis::SkuToken => "exact SKU token in CPU name", + MatchBasis::Inferred => "inferred from core count + rated clock", + MatchBasis::FamilyOnly => "family recognized, SKU unconfirmed", + MatchBasis::Generic => "unrecognized", + } + } +} + +pub struct Detection { + pub profile: ChipProfile, + pub basis: MatchBasis, } /// The real Snapdragon X / X2 lineup (matches the design and real silicon). -/// X2 specs are sourced from Qualcomm's official X2 Elite / X2 Plus product -/// briefs. Qualcomm does not publish a TDP for any X2 SKU (OEM-defined power -/// limits vary widely), so `tdp_w` is 0 (renders as "—") for the whole family; -/// TjMax is not published either and 105.0 is carried over from the X1 value -/// plus a small margin as a placeholder pending real-hardware confirmation. +/// X1 specs are sourced from Qualcomm's X Elite / X Plus product briefs and +/// notebookcheck's per-SKU pages. X2 specs are sourced from Qualcomm's X2 +/// Elite / X2 Plus product briefs. Qualcomm does not publish a TDP for every +/// SKU (OEM-defined power limits vary widely); `tdp_w` is 0 (renders as "—") +/// wherever no published figure was found — never a guess. pub static CHIPS: &[ChipProfile] = &[ + // --- Snapdragon X family (1st Gen Oryon, 4 nm) ---------------------- ChipProfile { - id: "X", + id: "X126", name: "Snapdragon X", model_match: "x126", model: "X1-26-100", @@ -73,9 +118,35 @@ pub static CHIPS: &[ChipProfile] = &[ lithography: "4 nm", }, ChipProfile { - id: "XP", + id: "X1P42", + name: "Snapdragon X Plus", + model_match: "x1p42", + model: "X1P-42-100", + tjmax_c: 100.0, + base_ghz: 3.2, + boost_ghz: 3.4, + tdp_w: 0, + clusters: &[(CoreKind::Performance, 8)], + uarch: "Oryon", + lithography: "4 nm", + }, + ChipProfile { + id: "X1P46", + name: "Snapdragon X Plus", + model_match: "x1p46", + model: "X1P-46-100", + tjmax_c: 100.0, + base_ghz: 3.4, + boost_ghz: 4.0, + tdp_w: 0, + clusters: &[(CoreKind::Performance, 8)], + uarch: "Oryon", + lithography: "4 nm", + }, + ChipProfile { + id: "X1P64", name: "Snapdragon X Plus", - // X1P64100 (this machine), X1P-64-100 family. + // X1P64100 (this dev machine). model_match: "x1p64", model: "X1P-64-100", tjmax_c: 100.0, @@ -87,23 +158,35 @@ pub static CHIPS: &[ChipProfile] = &[ lithography: "4 nm", }, ChipProfile { - id: "XP8", + id: "X1P66", name: "Snapdragon X Plus", - // 8-core X1P-80-100 (decoded differently to avoid colliding with Elite). - model_match: "x1p80", - model: "X1P-80-100", + model_match: "x1p66", + model: "X1P-66-100", tjmax_c: 100.0, base_ghz: 3.4, boost_ghz: 4.0, - tdp_w: 30, - clusters: &[(CoreKind::Performance, 8)], + tdp_w: 0, + clusters: &[(CoreKind::Performance, 10)], + uarch: "Oryon", + lithography: "4 nm", + }, + ChipProfile { + id: "X1E78", + name: "Snapdragon X Elite", + model_match: "x1e78", + model: "X1E-78-100", + tjmax_c: 100.0, + base_ghz: 3.4, + boost_ghz: 3.4, // no dual-core boost above base — the entry-tier Elite. + tdp_w: 35, + clusters: &[(CoreKind::Performance, 12)], uarch: "Oryon", lithography: "4 nm", }, ChipProfile { - id: "XE", + id: "X1E80", name: "Snapdragon X Elite", - model_match: "x1e", + model_match: "x1e80", model: "X1E-80-100", tjmax_c: 100.0, base_ghz: 3.4, @@ -113,6 +196,19 @@ pub static CHIPS: &[ChipProfile] = &[ uarch: "Oryon", lithography: "4 nm", }, + ChipProfile { + id: "X1E84", + name: "Snapdragon X Elite", + model_match: "x1e84", + model: "X1E-84-100", + tjmax_c: 100.0, + base_ghz: 3.8, + boost_ghz: 4.2, + tdp_w: 45, + clusters: &[(CoreKind::Performance, 12)], + uarch: "Oryon", + lithography: "4 nm", + }, // --- Snapdragon X2 family (3rd Gen Oryon, 3 nm) --------------------- // Cluster naming: Qualcomm's X2 marketing calls the two clusters "Prime" // and "Performance" (there is no "Efficiency" cluster name in X2 specs). @@ -239,6 +335,29 @@ pub static CHIPS: &[ChipProfile] = &[ }, ]; +fn chip_by_id(id: &str) -> ChipProfile { + CHIPS + .iter() + .find(|c| c.id == id) + .cloned() + .unwrap_or_else(|| panic!("chips.rs: no CHIPS entry with id {id}")) +} + +/// X2E-80-100 and X2E-84-100 publish IDENTICAL CPU-visible specs (same 4.7 +/// GHz boost, same 34 MB cache, same core split) — notebookcheck's only +/// stated difference is dual-core boost (4.4 vs 4.7 GHz) and NPU TOPS rating, +/// neither of which is readable from this app's data sources. Rather than +/// guess, both map to one honestly-labeled combined profile. +fn x2e_80_84_ambiguous() -> ChipProfile { + let base = chip_by_id("X2E80"); + ChipProfile { + id: "X2E80OR84", + model_match: "", + model: "X2E-80/84-100", + ..base + } +} + /// Build an even Prime/Performance cluster split for an X2 SKU that isn't in /// `CHIPS` (a newer/unreleased part). `total` is the detected logical core /// count. Mirrors the real lineup's split ratios: 18-core parts are 12+6, @@ -265,167 +384,535 @@ fn x2_family_clusters(total: u32) -> &'static [(CoreKind, u32)] { } } -/// Match a detected CPU name string against the profile table. `detected_name` -/// need not be pre-normalized — this normalizes internally. Returns the -/// matched profile; an unrecognized X2 SKU still gets a correctly-named X2 -/// Elite/Plus family profile (not a generic fallback); anything else gets a -/// generic Snapdragon fallback with the detected core count split evenly. -/// Always returns something usable. -pub fn match_profile(detected_name: &str, core_count: u32) -> ChipProfile { - let normalized = normalize_cpu_name(detected_name); - for c in CHIPS { - if normalized.contains(c.model_match) { - return c.clone(); +fn homogeneous_cluster(total: u32) -> &'static [(CoreKind, u32)] { + Box::leak(vec![(CoreKind::Performance, total)].into_boxed_slice()) +} + +/// Prefer the OS's real per-core P/E topology over any static table split. +/// Falls back to `fallback` when the topology wasn't available or its counts +/// don't add up to the detected logical core count. +fn resolve_clusters( + id: &CpuIdentity, + cores: u32, + fallback: &'static [(CoreKind, u32)], +) -> &'static [(CoreKind, u32)] { + if let (Some(p), Some(e)) = (id.perf_cores, id.eff_cores) { + if cores > 0 && p + e == cores { + let mut v = Vec::new(); + if p > 0 { + v.push((CoreKind::Performance, p)); + } + if e > 0 { + v.push((CoreKind::Efficiency, e)); + } + if !v.is_empty() { + return Box::leak(v.into_boxed_slice()); + } } } - // Unknown X2 SKU: still label the family correctly instead of "Generic". - if normalized.contains("x2e") { - return ChipProfile { - id: "X2E", - name: "Snapdragon X2 Elite", - model_match: "", - model: "Unknown X2 Elite SKU", - tjmax_c: 105.0, - base_ghz: 0.0, - boost_ghz: 0.0, - tdp_w: 0, - clusters: x2_family_clusters(core_count), - uarch: "Oryon 3", - lithography: "3 nm", - }; + fallback +} + +/// Pick the candidate whose published clock is closest to `measured_mhz`, +/// rejecting the match entirely if even the closest one is further than +/// `tolerance_mhz` away (an unrecognized SKU, not a noisy reading of a known +/// one — every known family+core-count group's neighboring SKUs are at least +/// twice `tolerance_mhz` apart). +fn pick_by_clock( + measured_mhz: u32, + candidates: Vec<(u32, ChipProfile)>, + tolerance_mhz: u32, +) -> Option { + candidates + .into_iter() + .min_by_key(|(mhz, _)| (*mhz as i64 - measured_mhz as i64).unsigned_abs()) + .filter(|(mhz, _)| (*mhz as i64 - measured_mhz as i64).unsigned_abs() as u32 <= tolerance_mhz) + .map(|(_, p)| p) +} + +const CLOCK_TOLERANCE_MHZ: u32 = 180; + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum Gen { + X1, + X2, +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum Sub { + Elite, + Plus, + Extreme, +} + +/// Stage B: family + subfamily from the CPU name; if the name carries no +/// usable text at all, fall back to the registry `Identifier`'s MIDR part +/// number (Oryon generation) — gated on the vendor string actually saying +/// Qualcomm, so a non-Snapdragon ARM chip that happens to share a Model +/// number never gets mislabeled. +fn detect_family(id: &CpuIdentity) -> Option<(Gen, Option)> { + let normalized = id.normalized_name(); + if !normalized.is_empty() { + if normalized.contains("x2") { + let sub = if normalized.contains("extreme") { + Some(Sub::Extreme) + } else if normalized.contains("plus") { + Some(Sub::Plus) + } else if normalized.contains("elite") { + Some(Sub::Elite) + } else { + None + }; + return Some((Gen::X2, sub)); + } + if normalized.contains("x1") || normalized.contains("snapdragon") { + let sub = if normalized.contains("elite") { + Some(Sub::Elite) + } else if normalized.contains("plus") { + Some(Sub::Plus) + } else { + None + }; + return Some((Gen::X1, sub)); + } } - if normalized.contains("x2p") { - return ChipProfile { - id: "X2P", - name: "Snapdragon X2 Plus", - model_match: "", - model: "Unknown X2 Plus SKU", - tjmax_c: 105.0, - base_ghz: 0.0, - boost_ghz: 0.0, - tdp_w: 0, - clusters: x2_family_clusters(core_count), - uarch: "Oryon 3", - lithography: "3 nm", - }; + let vendor_is_qualcomm = id + .vendor + .as_deref() + .map(|v| v.to_lowercase().contains("qualcomm")) + .unwrap_or(false); + if !vendor_is_qualcomm { + return None; + } + match id.oryon_generation() { + Some(2) => Some((Gen::X2, None)), + Some(1) => Some((Gen::X1, None)), + _ => None, + } +} + +fn x2_elite_candidates(cores: u32) -> Vec<(u32, ChipProfile)> { + match cores { + 18 => vec![(4700, chip_by_id("X2E88")), (5000, chip_by_id("X2E90"))], + 12 => vec![(4000, chip_by_id("X2E78")), (4700, x2e_80_84_ambiguous())], + _ => vec![], + } +} + +fn x2_elite_extreme_candidates(cores: u32) -> Vec<(u32, ChipProfile)> { + match cores { + 18 => vec![(4700, chip_by_id("X2EX94")), (5000, chip_by_id("X2EX96"))], + _ => vec![], } - // Generic fallback: Snapdragon detected but unknown SKU/family. Even P/E - // split when there are many cores; all-P otherwise. - let (clusters, tjmax): (&'static [(CoreKind, u32)], f64) = if core_count >= 12 { +} + +fn x2_plus_candidates(cores: u32) -> Vec<(u32, ChipProfile)> { + match cores { + 10 => vec![(4000, chip_by_id("X2P64"))], + 6 => vec![(4000, chip_by_id("X2P42"))], + _ => vec![], + } +} + +fn x1_elite_candidates(cores: u32) -> Vec<(u32, ChipProfile)> { + match cores { + 12 => vec![ + (3400, chip_by_id("X1E78")), + (4000, chip_by_id("X1E80")), + (4200, chip_by_id("X1E84")), + ], + _ => vec![], + } +} + +fn x1_plus_candidates(cores: u32) -> Vec<(u32, ChipProfile)> { + match cores { + 8 => vec![(3400, chip_by_id("X1P42")), (4000, chip_by_id("X1P46"))], + 10 => vec![(3400, chip_by_id("X1P64")), (4000, chip_by_id("X1P66"))], + _ => vec![], + } +} + +fn x1_candidates(cores: u32) -> Vec<(u32, ChipProfile)> { + match cores { + 8 => vec![(3000, chip_by_id("X126"))], + _ => vec![], + } +} + +/// Stage C: within a known family/subfamily, infer the exact SKU from real +/// core count + real rated clock. `None` if the family has no candidates for +/// this core count, or the measured clock is too far from every candidate. +fn infer_within_family(gen: Gen, sub: Option, id: &CpuIdentity) -> Option<(ChipProfile, MatchBasis)> { + let cores = id.logical_cores; + let max_mhz = id.max_mhz()?; + let candidates = match (gen, sub) { + (Gen::X2, Some(Sub::Extreme)) => x2_elite_extreme_candidates(cores), + (Gen::X2, Some(Sub::Plus)) => x2_plus_candidates(cores), + (Gen::X2, Some(Sub::Elite)) | (Gen::X2, None) => x2_elite_candidates(cores), + (Gen::X1, Some(Sub::Elite)) => x1_elite_candidates(cores), + (Gen::X1, Some(Sub::Plus)) => x1_plus_candidates(cores), + (Gen::X1, Some(Sub::Extreme)) => vec![], // no X1 "Extreme" tier exists + (Gen::X1, None) => x1_candidates(cores), + }; + let picked = pick_by_clock(max_mhz, candidates, CLOCK_TOLERANCE_MHZ)?; + let clusters = resolve_clusters(id, cores, picked.clusters); + Some((ChipProfile { clusters, ..picked }, MatchBasis::Inferred)) +} + +/// `(name, model, tjmax_c, uarch, lithography, base_ghz, fallback_clusters)` +/// for an as-yet-unconfirmed profile — see `family_fallback`. +type FamilyFallbackSpec = ( + &'static str, + &'static str, + f64, + &'static str, + &'static str, + f64, + &'static [(CoreKind, u32)], +); + +/// Stage D: family/subfamily recognized but no SKU candidate matched (an +/// unreleased or otherwise unlisted part). Labels the family honestly and +/// fills in `boost_ghz` from the real measured clock instead of leaving it +/// (and the UI's Boost field) fabricated-looking zero/"—". +fn family_fallback(gen: Gen, sub: Option, id: &CpuIdentity) -> (ChipProfile, MatchBasis) { + let cores = id.logical_cores; + let boost_ghz = id.max_mhz().map(|m| m as f64 / 1000.0).unwrap_or(0.0); + let (name, model, tjmax_c, uarch, lithography, base_ghz, fallback_clusters): FamilyFallbackSpec = match (gen, sub) { + (Gen::X2, Some(Sub::Extreme)) => ( + "Snapdragon X2 Elite Extreme", + "Unknown X2 Elite Extreme SKU", + 105.0, + "Oryon 3", + "3 nm", + 0.0, + x2_family_clusters(cores), + ), + (Gen::X2, Some(Sub::Plus)) => ( + "Snapdragon X2 Plus", + "Unknown X2 Plus SKU", + 105.0, + "Oryon 3", + "3 nm", + 0.0, + x2_family_clusters(cores), + ), + (Gen::X2, _) => ( + "Snapdragon X2 Elite", + "Unknown X2 Elite SKU", + 105.0, + "Oryon 3", + "3 nm", + 0.0, + x2_family_clusters(cores), + ), + (Gen::X1, Some(Sub::Plus)) => ( + "Snapdragon X Plus", + "Unknown X Plus SKU", + 100.0, + "Oryon", + "4 nm", + 0.0, + homogeneous_cluster(cores), + ), + (Gen::X1, Some(Sub::Elite)) | (Gen::X1, Some(Sub::Extreme)) => ( + "Snapdragon X Elite", + "Unknown X Elite SKU", + 100.0, + "Oryon", + "4 nm", + 0.0, + homogeneous_cluster(cores), + ), + (Gen::X1, None) => ( + "Snapdragon X", + "Unknown Snapdragon X SKU", + 100.0, + "Oryon", + "4 nm", + 0.0, + homogeneous_cluster(cores), + ), + }; + let clusters = resolve_clusters(id, cores, fallback_clusters); + let profile = ChipProfile { + id: "FAM", + name, + model_match: "", + model, + tjmax_c, + base_ghz, + boost_ghz, + tdp_w: 0, + clusters, + uarch, + lithography, + }; + (profile, MatchBasis::FamilyOnly) +} + +/// Stage E: nothing Snapdragon-shaped detected. Even P/E split when there +/// are many cores; all-P otherwise. +fn generic_fallback(id: &CpuIdentity) -> Detection { + let cores = id.logical_cores; + let (fallback_clusters, tjmax): (&'static [(CoreKind, u32)], f64) = if cores >= 12 { ( Box::leak( vec![ - (CoreKind::Performance, core_count * 2 / 3), - (CoreKind::Efficiency, core_count - core_count * 2 / 3), + (CoreKind::Performance, cores * 2 / 3), + (CoreKind::Efficiency, cores - cores * 2 / 3), ] .into_boxed_slice(), ), 105.0, ) } else { - ( - Box::leak(vec![(CoreKind::Performance, core_count)].into_boxed_slice()), - 100.0, - ) + (homogeneous_cluster(cores), 100.0) }; - ChipProfile { - id: "GEN", - name: "Snapdragon", - model_match: "", - model: "Generic", - tjmax_c: tjmax, - base_ghz: 0.0, - boost_ghz: 0.0, - tdp_w: 0, - clusters, - uarch: "", - lithography: "", + let clusters = resolve_clusters(id, cores, fallback_clusters); + Detection { + profile: ChipProfile { + id: "GEN", + name: "Snapdragon", + model_match: "", + model: "Generic", + tjmax_c: tjmax, + base_ghz: 0.0, + boost_ghz: 0.0, + tdp_w: 0, + clusters, + uarch: "", + lithography: "", + }, + basis: MatchBasis::Generic, + } +} + +/// Match the detected CPU identity against the profile table. Always returns +/// something usable — see the module doc comment for the stage order. +/// +/// Real OS topology (`resolve_clusters`) overrides every matched profile's +/// static `clusters`, not just the inferred/fallback ones — a token match +/// (Stage A) still gets the real per-core P/E split when the OS reports one +/// that's consistent with the detected core count, so a diagnostic report's +/// "Cores" line never contradicts its own "Real P/E topology" line. +pub fn match_profile(id: &CpuIdentity) -> Detection { + let normalized = id.normalized_name(); + if !normalized.is_empty() { + for c in CHIPS { + if normalized.contains(c.model_match) { + let clusters = resolve_clusters(id, id.logical_cores, c.clusters); + return Detection { + profile: ChipProfile { clusters, ..c.clone() }, + basis: MatchBasis::SkuToken, + }; + } + } + } + if let Some((gen, sub)) = detect_family(id) { + if let Some((profile, basis)) = infer_within_family(gen, sub, id) { + return Detection { profile, basis }; + } + let (profile, basis) = family_fallback(gen, sub, id); + return Detection { profile, basis }; } + generic_fallback(id) } #[cfg(test)] mod tests { use super::*; + fn identity(name: &str, cores: u32, perf: Option, eff: Option, max_mhz: Option) -> CpuIdentity { + CpuIdentity { + name: Some(name.to_string()), + identifier: None, + vendor: Some("Qualcomm Technologies Inc".to_string()), + per_core_mhz: max_mhz.map(|m| vec![m]).unwrap_or_default(), + logical_cores: cores, + perf_cores: perf, + eff_cores: eff, + } + } + + // --- The actual reported bug: issue #2 round 2 ---------------------- + #[test] - fn x2_elite_78_real_world_format_matches() { - // Real-world registry ProcessorNameString format (no hyphens). - let p = match_profile( - "Snapdragon(R) X2 Elite - X2E78100 - Qualcomm(R) Oryon(TM) CPU", - 12, + fn x2_elite_78_bare_marketing_name_infers_via_clock() { + // Surface Laptop 8's real registry string per issue #2: no SKU + // token, just the marketing name. HWiNFO/CPU-Z read the same 4032 + // MHz rated clock via ~MHz that this app now also reads. + let id = identity("Qualcomm Snapdragon X2 Elite", 12, Some(6), Some(6), Some(4032)); + let d = match_profile(&id); + assert_eq!(d.profile.model, "X2E-78-100"); + assert_eq!(d.profile.name, "Snapdragon X2 Elite"); + assert_eq!(d.basis, MatchBasis::Inferred); + assert_eq!(d.profile.total_cores(), 12); + assert_eq!(d.profile.boost_ghz, 4.0); + assert_ne!(d.profile.model, "Unknown X2 Elite SKU"); + } + + #[test] + fn x2_elite_80_or_84_ambiguous_gets_honest_combined_label() { + let id = identity("Qualcomm Snapdragon X2 Elite", 12, Some(6), Some(6), Some(4700)); + let d = match_profile(&id); + assert_eq!(d.profile.model, "X2E-80/84-100"); + assert_eq!(d.basis, MatchBasis::Inferred); + assert_eq!(d.profile.boost_ghz, 4.7); + assert_eq!(d.profile.base_ghz, 4.0); + } + + #[test] + fn token_matched_profile_still_honors_real_topology_over_static_table() { + // Real report from this dev machine (X1P-64-100, issue #2 follow-up): + // the static table assumes a homogeneous 10-Performance-core cluster, + // but the OS topology reports an asymmetric 6P/4E split (confirmed by + // per-core ~MHz: 6 cores at 3418 MHz, 4 at 2976 MHz). The matched + // profile's `clusters` must reflect that real split, not the stale + // static one — otherwise a diagnostics dump's "Cores" line + // contradicts its own "Real P/E topology" line. + let id = identity( + "Snapdragon(R) X 10-core X1P64100 @ 3.40 GHz", + 10, + Some(6), + Some(4), + Some(3418), ); - assert_eq!(p.model, "X2E-78-100"); - assert_eq!(p.name, "Snapdragon X2 Elite"); - assert_eq!(p.total_cores(), 12); - assert_eq!(p.clusters, &[(CoreKind::Performance, 6), (CoreKind::Efficiency, 6)]); + let d = match_profile(&id); + assert_eq!(d.profile.model, "X1P-64-100"); + assert_eq!(d.basis, MatchBasis::SkuToken); + assert_eq!(d.profile.clusters, &[(CoreKind::Performance, 6), (CoreKind::Efficiency, 4)]); } #[test] - fn x2_elite_78_hyphenated_variant_matches() { - let p = match_profile("Snapdragon(R) X2 Elite - X2E-78-100", 12); - assert_eq!(p.model, "X2E-78-100"); + fn x2_elite_extreme_bare_name_infers_18_core_skus() { + let id96 = identity("Snapdragon X2 Elite Extreme", 18, Some(12), Some(6), Some(5000)); + assert_eq!(match_profile(&id96).profile.model, "X2E-96-100"); + let id94 = identity("Snapdragon X2 Elite Extreme", 18, Some(12), Some(6), Some(4700)); + assert_eq!(match_profile(&id94).profile.model, "X2E-94-100"); } #[test] - fn x2_elite_88_18core_matches() { - let p = match_profile("Snapdragon X2 Elite - X2E88100", 18); - assert_eq!(p.model, "X2E-88-100"); - assert_eq!(p.total_cores(), 18); - assert_eq!(p.clusters, &[(CoreKind::Performance, 12), (CoreKind::Efficiency, 6)]); + fn x2_elite_bare_name_infers_18_core_skus() { + let id90 = identity("Snapdragon X2 Elite", 18, Some(12), Some(6), Some(5000)); + assert_eq!(match_profile(&id90).profile.model, "X2E-90-100"); + let id88 = identity("Snapdragon X2 Elite", 18, Some(12), Some(6), Some(4700)); + assert_eq!(match_profile(&id88).profile.model, "X2E-88-100"); } #[test] - fn x2_plus_64_10core_matches() { - let p = match_profile("Snapdragon X2 Plus - X2P64100", 10); - assert_eq!(p.model, "X2P-64-100"); - assert_eq!(p.total_cores(), 10); + fn name_independent_path_uses_identifier_and_vendor() { + // Name totally unusable (empty/garbage); Identifier says Oryon V3 + // (Model 2) and vendor says Qualcomm — still infers correctly. + let id = CpuIdentity { + name: Some("Unknown CPU".to_string()), + identifier: Some("ARMv8 (64-bit) Family 8 Model 2 Revision 201".to_string()), + vendor: Some("Qualcomm Technologies Inc".to_string()), + per_core_mhz: vec![4032], + logical_cores: 12, + perf_cores: Some(6), + eff_cores: Some(6), + }; + let d = match_profile(&id); + assert_eq!(d.profile.model, "X2E-78-100"); } #[test] - fn unknown_x2_elite_sku_gets_family_profile_not_generic() { - let p = match_profile("Snapdragon X2 Elite - X2E99100", 18); - assert_eq!(p.name, "Snapdragon X2 Elite"); - assert_ne!(p.model, "Generic"); - assert_eq!(p.uarch, "Oryon 3"); - assert_eq!(p.tjmax_c, 105.0); - assert_eq!(p.total_cores(), 18); - assert_eq!(p.clusters, &[(CoreKind::Performance, 12), (CoreKind::Efficiency, 6)]); + fn non_qualcomm_vendor_with_matching_model_number_is_not_mislabeled() { + let id = CpuIdentity { + name: Some("Unknown CPU".to_string()), + identifier: Some("ARMv8 (64-bit) Family 8 Model 2 Revision 201".to_string()), + vendor: Some("Some Other Vendor Inc".to_string()), + per_core_mhz: vec![4032], + logical_cores: 12, + perf_cores: Some(6), + eff_cores: Some(6), + }; + let d = match_profile(&id); + assert_eq!(d.profile.model, "Generic"); + assert_eq!(d.basis, MatchBasis::Generic); } #[test] - fn unknown_x2_plus_sku_gets_family_profile_not_generic() { - let p = match_profile("Snapdragon X2 Plus - X2P99100", 6); - assert_eq!(p.name, "Snapdragon X2 Plus"); - assert_ne!(p.model, "Generic"); - assert_eq!(p.uarch, "Oryon 3"); + fn unknown_future_x2_sku_gets_family_fallback_with_real_boost() { + // A hypothetical future 24-core X2 Elite: no clock candidate fits. + let id = identity("Snapdragon X2 Elite", 24, Some(18), Some(6), Some(5200)); + let d = match_profile(&id); + assert_eq!(d.profile.name, "Snapdragon X2 Elite"); + assert_eq!(d.profile.model, "Unknown X2 Elite SKU"); + assert_eq!(d.basis, MatchBasis::FamilyOnly); + assert_eq!(d.profile.boost_ghz, 5.2); // real measured clock, not 0.0/"—" + assert_eq!(d.profile.total_cores(), 24); + assert_eq!(d.profile.clusters, &[(CoreKind::Performance, 18), (CoreKind::Efficiency, 6)]); } + // --- Token match (Stage A) regressions -------------------------------- + #[test] - fn x1_plus_64_regression() { - let p = match_profile("Snapdragon(R) X Plus - X1P64100", 10); - assert_eq!(p.model, "X1P-64-100"); - assert_eq!(p.name, "Snapdragon X Plus"); + fn x2_elite_78_real_world_token_format_matches() { + let id = identity( + "Snapdragon(R) X2 Elite - X2E78100 - Qualcomm(R) Oryon(TM) CPU", + 12, + None, + None, + None, + ); + let d = match_profile(&id); + assert_eq!(d.profile.model, "X2E-78-100"); + assert_eq!(d.basis, MatchBasis::SkuToken); + assert_eq!(d.profile.clusters, &[(CoreKind::Performance, 6), (CoreKind::Efficiency, 6)]); } #[test] - fn x1_26_regression_guards_key_change() { - // Guards the "x1-26" -> "x126" model_match key change. - let p = match_profile("Snapdragon X - X1-26-100", 8); - assert_eq!(p.model, "X1-26-100"); - assert_eq!(p.name, "Snapdragon X"); + fn x2_elite_88_18core_token_matches() { + let id = identity("Snapdragon X2 Elite - X2E88100", 18, None, None, None); + let d = match_profile(&id); + assert_eq!(d.profile.model, "X2E-88-100"); + assert_eq!(d.profile.total_cores(), 18); } #[test] - fn x1_elite_regression() { - let p = match_profile("Snapdragon(R) X Elite - X1E80100", 12); - assert_eq!(p.model, "X1E-80-100"); + fn x2_plus_64_10core_token_matches() { + let id = identity("Snapdragon X2 Plus - X2P64100", 10, None, None, None); + assert_eq!(match_profile(&id).profile.model, "X2P-64-100"); + } + + #[test] + fn x1e_token_matches_the_exact_sku_not_the_old_catch_all() { + // Regression: the old `model_match: "x1e"` matched EVERY X Elite SKU + // to X1E-80-100. Each SKU must now resolve to its own model. + let id78 = identity("Snapdragon(R) X Elite - X1E78100", 12, None, None, None); + assert_eq!(match_profile(&id78).profile.model, "X1E-78-100"); + let id84 = identity("Snapdragon(R) X Elite - X1E84100", 12, None, None, None); + assert_eq!(match_profile(&id84).profile.model, "X1E-84-100"); + let id80 = identity("Snapdragon(R) X Elite - X1E80100", 12, None, None, None); + assert_eq!(match_profile(&id80).profile.model, "X1E-80-100"); + } + + #[test] + fn x1_plus_64_regression() { + let id = identity("Snapdragon(R) X Plus - X1P64100", 10, None, None, None); + let d = match_profile(&id); + assert_eq!(d.profile.model, "X1P-64-100"); + assert_eq!(d.profile.name, "Snapdragon X Plus"); + } + + #[test] + fn x1_26_regression_guards_key_change() { + let id = identity("Snapdragon X - X1-26-100", 8, None, None, None); + let d = match_profile(&id); + assert_eq!(d.profile.model, "X1-26-100"); + assert_eq!(d.profile.name, "Snapdragon X"); } #[test] fn non_snapdragon_falls_back_to_generic_even_split() { - let p = match_profile("Some Other CPU", 8); - assert_eq!(p.model, "Generic"); - assert_eq!(p.name, "Snapdragon"); - assert_eq!(p.total_cores(), 8); + let id = identity("Some Other CPU", 8, None, None, None); + let d = match_profile(&id); + assert_eq!(d.profile.model, "Generic"); + assert_eq!(d.profile.name, "Snapdragon"); + assert_eq!(d.profile.total_cores(), 8); + assert_eq!(d.basis, MatchBasis::Generic); } } diff --git a/src-tauri/src/sensors/identity.rs b/src-tauri/src/sensors/identity.rs new file mode 100644 index 0000000..e934353 --- /dev/null +++ b/src-tauri/src/sensors/identity.rs @@ -0,0 +1,189 @@ +//! Real CPU identity signals gathered once from the registry + OS topology. +//! No WMI/COM — plain `RegGetValueW` reads (see `pdh.rs`'s header for why WMI +//! is avoided in this process). This is the raw evidence `chips::match_profile` +//! reasons from; nothing here is fabricated or guessed. +//! +//! Why more than just `ProcessorNameString`: issue #2 showed that some OEM +//! firmware (Surface Laptop 8, Snapdragon X2 Elite) reports a bare marketing +//! name with no SKU token (`X2E78100`) in it at all, so name-substring +//! matching alone cannot identify the exact part. CPU-Z/HWiNFO manage it by +//! also reading the per-core `~MHz` registry value (each core's cluster max +//! clock) and the real P/E core topology — both gathered here. + +use windows::core::HSTRING; +use windows::Win32::System::Registry::{ + RegGetValueW, HKEY_LOCAL_MACHINE, RRF_RT_REG_DWORD, RRF_RT_REG_SZ, +}; + +const ERROR_SUCCESS: u32 = 0; + +/// Every real, non-fabricated identity signal available for the detected CPU. +#[derive(Clone, Debug, Default)] +pub struct CpuIdentity { + /// `ProcessorNameString` — e.g. "Snapdragon(R) X2 Elite" or + /// "Snapdragon(R) X 10-core X1P64100 @ 3.40 GHz". May lack a SKU token. + pub name: Option, + /// `Identifier` — e.g. "ARMv8 (64-bit) Family 8 Model 2 Revision 201". + /// "Model" is the MIDR part number in hex: 0x001 = Oryon (X1 family), + /// 0x002 = Oryon V3 (X2 family) — a name-independent family signal. + pub identifier: Option, + /// `VendorIdentifier` — e.g. "Qualcomm Technologies Inc". + pub vendor: Option, + /// Per-logical-core `~MHz` value (each core's cluster max/rated clock; + /// this is what CPU-Z's "Original Processor Frequency" and HWiNFO show). + pub per_core_mhz: Vec, + /// Logical processor count (`GetSystemInfo`). + pub logical_cores: u32, + /// Real Performance-core count from OS topology, when available. + pub perf_cores: Option, + /// Real Efficiency-core count from OS topology, when available. + pub eff_cores: Option, +} + +impl CpuIdentity { + /// The highest per-core `~MHz` seen — the chip's real rated boost clock, + /// independent of what the name string says. + pub fn max_mhz(&self) -> Option { + self.per_core_mhz.iter().copied().max() + } + + /// MIDR part number decoded from `Identifier`'s "Model " token. + /// `None` if `identifier` is absent or doesn't parse. `1` = Oryon (X1 + /// family), `2` = Oryon V3 (X2 family) — see the Linux kernel's + /// `cputype.h` / pytorch/cpuinfo's ARM uarch table for the same mapping. + pub fn oryon_generation(&self) -> Option { + let id = self.identifier.as_ref()?; + let after = id.split("Model ").nth(1)?; + let token = after.split_whitespace().next()?; + u32::from_str_radix(token, 16).ok() + } + + pub fn name_lower(&self) -> String { + self.name.as_deref().unwrap_or("").to_lowercase() + } + + /// Keep only ASCII alphanumerics, lowercased — immune to hyphens, spaces, + /// and "(R)"/"(TM)" marks that vary across OEM firmware. + pub fn normalized_name(&self) -> String { + self.name_lower() + .chars() + .filter(|c| c.is_ascii_alphanumeric()) + .collect() + } +} + +fn read_reg_sz(subkey: &str, value: &str) -> Option { + unsafe { + let hsubkey = HSTRING::from(subkey); + let hvalue = HSTRING::from(value); + let mut size: u32 = 0; + let st = RegGetValueW(HKEY_LOCAL_MACHINE, &hsubkey, &hvalue, RRF_RT_REG_SZ, None, None, Some(&mut size)); + if st.0 != ERROR_SUCCESS || size == 0 { + return None; + } + let mut buf: Vec = vec![0u16; (size as usize).div_ceil(2)]; + let st2 = RegGetValueW( + HKEY_LOCAL_MACHINE, + &hsubkey, + &hvalue, + RRF_RT_REG_SZ, + None, + Some(buf.as_mut_ptr() as *mut core::ffi::c_void), + Some(&mut size), + ); + if st2.0 != ERROR_SUCCESS { + return None; + } + let s = String::from_utf16_lossy(&buf); + Some(s.trim_end_matches('\0').trim().to_string()) + } +} + +fn read_reg_dword(subkey: &str, value: &str) -> Option { + unsafe { + let hsubkey = HSTRING::from(subkey); + let hvalue = HSTRING::from(value); + let mut data: u32 = 0; + let mut size: u32 = std::mem::size_of::() as u32; + let st = RegGetValueW( + HKEY_LOCAL_MACHINE, + &hsubkey, + &hvalue, + RRF_RT_REG_DWORD, + None, + Some(&mut data as *mut u32 as *mut core::ffi::c_void), + Some(&mut size), + ); + if st.0 == ERROR_SUCCESS { + Some(data) + } else { + None + } + } +} + +/// Gather every identity signal for logical core 0..`logical_cores`. +/// `perf_cores`/`eff_cores` come from the OS topology query already done in +/// `pdh.rs` (`query_core_efficiency_classes`) — passed in rather than +/// re-queried here to keep this module a pure registry reader. +pub fn gather(logical_cores: u32, perf_cores: Option, eff_cores: Option) -> CpuIdentity { + let base = r"HARDWARE\DESCRIPTION\System\CentralProcessor\0"; + let name = read_reg_sz(base, "ProcessorNameString"); + let identifier = read_reg_sz(base, "Identifier"); + let vendor = read_reg_sz(base, "VendorIdentifier"); + + let mut per_core_mhz = Vec::with_capacity(logical_cores as usize); + for i in 0..logical_cores { + let subkey = format!(r"HARDWARE\DESCRIPTION\System\CentralProcessor\{i}"); + if let Some(mhz) = read_reg_dword(&subkey, "~MHz") { + per_core_mhz.push(mhz); + } + } + + CpuIdentity { + name, + identifier, + vendor, + per_core_mhz, + logical_cores, + perf_cores, + eff_cores, + } +} + +#[cfg(test)] +#[allow(clippy::field_reassign_with_default)] +mod tests { + use super::*; + + #[test] + fn oryon_generation_parses_model_1_and_2() { + let mut id = CpuIdentity::default(); + id.identifier = Some("ARMv8 (64-bit) Family 8 Model 1 Revision 201".into()); + assert_eq!(id.oryon_generation(), Some(1)); + id.identifier = Some("ARMv8 (64-bit) Family 8 Model 2 Revision 201".into()); + assert_eq!(id.oryon_generation(), Some(2)); + } + + #[test] + fn oryon_generation_none_when_malformed_or_absent() { + let mut id = CpuIdentity::default(); + assert_eq!(id.oryon_generation(), None); + id.identifier = Some("garbage string".into()); + assert_eq!(id.oryon_generation(), None); + } + + #[test] + fn max_mhz_is_the_highest_per_core_reading() { + let mut id = CpuIdentity::default(); + id.per_core_mhz = vec![2976, 3418, 710]; + assert_eq!(id.max_mhz(), Some(3418)); + } + + #[test] + fn normalized_name_strips_marks_and_lowercases() { + let mut id = CpuIdentity::default(); + id.name = Some("Snapdragon(R) X2 Elite - X2E-78-100".into()); + assert_eq!(id.normalized_name(), "snapdragonrx2elitex2e78100"); + } +} diff --git a/src-tauri/src/sensors/mod.rs b/src-tauri/src/sensors/mod.rs index daefd49..c532ae2 100644 --- a/src-tauri/src/sensors/mod.rs +++ b/src-tauri/src/sensors/mod.rs @@ -1,5 +1,6 @@ //! Sensor subsystem: real telemetry backends for Snapdragon X. pub mod chips; +pub mod identity; pub mod pdh; pub mod tray; #[cfg(target_os = "windows")] diff --git a/src-tauri/src/sensors/pdh.rs b/src-tauri/src/sensors/pdh.rs index 52a35fe..f1e9072 100644 --- a/src-tauri/src/sensors/pdh.rs +++ b/src-tauri/src/sensors/pdh.rs @@ -22,14 +22,14 @@ use windows::Win32::System::Performance::{ PdhGetFormattedCounterValue, PdhOpenQueryW, PDH_FMT_COUNTERVALUE, PDH_FMT_COUNTERVALUE_ITEM_W, PDH_FMT_DOUBLE, }; -use windows::Win32::System::Registry::{RegGetValueW, HKEY_LOCAL_MACHINE, RRF_RT_REG_SZ}; use windows::Win32::System::SystemInformation::{ GetLogicalProcessorInformationEx, GetSystemInfo, RelationProcessorCore, SYSTEM_LOGICAL_PROCESSOR_INFORMATION_EX, }; use windows::core::PCWSTR; -use crate::sensors::chips::{match_profile, ChipProfile}; +use crate::sensors::chips::{match_profile, ChipProfile, Detection, MatchBasis}; +use crate::sensors::identity::{self, CpuIdentity}; use crate::sensors::types::{ kelvin_tenths_to_c, CoreKind, CoreReading, SensorSnapshot, ZoneReading, VALID_ZONE_MIN_KELVIN, }; @@ -44,6 +44,7 @@ const PDH_MORE_DATA: u32 = 0x800007D2; enum Request { Snapshot(mpsc::Sender>), Profile(mpsc::Sender>), + DetectionReport(mpsc::Sender), } /// Handle to the PDH-backed provider. Cheap to clone; all real work happens on @@ -87,6 +88,18 @@ impl PdhProvider { rrx.recv() .map_err(|_| anyhow::anyhow!("pdh worker thread is gone"))? } + + /// A plain-text dump of every raw identity signal + how the chip was + /// matched — for the Tools menu's "Copy detection report" (see issue #2: + /// this is how a reporter can hand over one paste instead of screenshots). + pub fn detection_report(&self) -> anyhow::Result { + let (rtx, rrx) = mpsc::channel(); + self.tx + .send(Request::DetectionReport(rtx)) + .map_err(|_| anyhow::anyhow!("pdh worker thread is gone"))?; + rrx.recv() + .map_err(|_| anyhow::anyhow!("pdh worker thread is gone")) + } } // ---------- worker thread: owns all PDH handles ---------- @@ -100,10 +113,20 @@ struct Query { /// not expose it, in which case `throttled` degrades to `false`. zone_passive: Option, core_load: isize, - /// `\Processor Information(_Total)\Processor Frequency` — a genuinely - /// live value (unlike `Win32_Processor.CurrentClockSpeed`, which mirrors - /// the static max on this firmware). + /// `\Processor Information(_Total)\Processor Frequency` — fallback Speed + /// source when the per-core formula below isn't available. On a + /// heterogeneous P/E chip this is a blended average across clusters (see + /// issue #2: it under-reports a busy Prime cluster whenever the + /// Efficiency cluster is idle), so `perf_pct` is preferred whenever it's + /// present. freq: isize, + /// `% Processor Performance` per core — this tick's utilization relative + /// to that core's OWN nominal/rated frequency; can exceed 100% under + /// boost. Combined with `CpuIdentity::per_core_mhz` (that same core's + /// rated clock) this reproduces Task Manager's documented Speed formula, + /// correctly per-cluster on heterogeneous chips. Optional — some + /// firmware may not expose it. + perf_pct: Option, } impl Drop for Query { @@ -135,7 +158,8 @@ fn open_query() -> anyhow::Result { } // If a required counter fails to add, bail out entirely (retried next - // tick); the optional passive-limit counter degrades gracefully. + // tick); the optional passive-limit/perf-performance counters + // degrade gracefully. let result = (|| -> anyhow::Result { let zone_temp = add_counter(hquery, r"\Thermal Zone Information(*)\Temperature")?; let zone_hp_temp = @@ -144,6 +168,7 @@ fn open_query() -> anyhow::Result { add_counter(hquery, r"\Thermal Zone Information(*)\% Passive Limit").ok(); let core_load = add_counter(hquery, r"\Processor Information(*)\% Processor Time")?; let freq = add_counter(hquery, r"\Processor Information(_Total)\Processor Frequency")?; + let perf_pct = add_counter(hquery, r"\Processor Information(*)\% Processor Performance").ok(); Ok(Query { hquery, zone_temp, @@ -151,6 +176,7 @@ fn open_query() -> anyhow::Result { zone_passive, core_load, freq, + perf_pct, }) })(); @@ -235,34 +261,6 @@ fn parse_core_index(instance_name: &str) -> Option { tail.trim().parse::().ok() } -/// CPU name from the registry — no COM/PowerShell required. -fn read_processor_name() -> Option { - unsafe { - let subkey = HSTRING::from(r"HARDWARE\DESCRIPTION\System\CentralProcessor\0"); - let value = HSTRING::from("ProcessorNameString"); - let mut size: u32 = 0; - let st = RegGetValueW(HKEY_LOCAL_MACHINE, &subkey, &value, RRF_RT_REG_SZ, None, None, Some(&mut size)); - if st.0 != ERROR_SUCCESS || size == 0 { - return None; - } - let mut buf: Vec = vec![0u16; (size as usize).div_ceil(2)]; - let st2 = RegGetValueW( - HKEY_LOCAL_MACHINE, - &subkey, - &value, - RRF_RT_REG_SZ, - None, - Some(buf.as_mut_ptr() as *mut core::ffi::c_void), - Some(&mut size), - ); - if st2.0 != ERROR_SUCCESS { - return None; - } - let s = String::from_utf16_lossy(&buf); - Some(s.trim_end_matches('\0').trim().to_string()) - } -} - /// Logical processor count via `GetSystemInfo` (native, no COM/PowerShell). /// Oryon has no SMT, so physical == logical on every known Snapdragon X SKU. fn logical_core_count() -> u32 { @@ -274,14 +272,6 @@ fn logical_core_count() -> u32 { } } -fn detect_profile() -> ChipProfile { - let cores = logical_core_count(); - match read_processor_name() { - Some(name) => match_profile(&name.to_lowercase(), cores), - None => match_profile("snapdragon", cores), - } -} - /// Query the OS's real per-core Efficiency Class via /// `GetLogicalProcessorInformationEx(RelationProcessorCore)`, keyed by /// logical core index (bit position in the group affinity mask). `None` if @@ -382,6 +372,29 @@ fn kind_for_core(real_kinds: &Option>, profile: &ChipProfile, idx: CoreKind::Performance } +/// Real per-cluster Speed: each core's `% Processor Performance` (this +/// tick's utilization vs that core's OWN nominal frequency) times that +/// core's registry `~MHz` (its cluster's rated frequency) — Task Manager's +/// documented formula. Returns the fastest core's effective clock right now. +/// Correct on heterogeneous P/E chips, unlike the single `_Total` average +/// (see issue #2, where an idle Efficiency cluster dragged that average well +/// below the Prime cluster's real running clock). `None` if either input is +/// unavailable, so the caller can fall back to `_Total Processor Frequency`. +fn effective_clock_mhz(perf_pct_by_core: &HashMap, per_core_mhz: &[u32]) -> Option { + if per_core_mhz.is_empty() || perf_pct_by_core.is_empty() { + return None; + } + perf_pct_by_core + .iter() + .filter_map(|(idx, pct)| { + per_core_mhz + .get(*idx as usize) + .map(|nominal| (*pct / 100.0) * (*nominal as f64)) + }) + .fold(None::, |acc, v| Some(acc.map_or(v, |a| a.max(v)))) + .map(|v| v.round() as u32) +} + /// Per-core running LOAD stats across ticks: (min, max, sum, sample_count). /// Load is genuinely per-core (unlike temperature — see `TempStats` below). type CoreStats = HashMap; @@ -393,6 +406,8 @@ type TempStats = Option<(f64, f64, f64, u64)>; fn build_snapshot( q: &Query, profile: &ChipProfile, + basis: MatchBasis, + identity: &CpuIdentity, real_kinds: &Option>, stats: &mut CoreStats, temp_stats: &mut TempStats, @@ -480,10 +495,20 @@ fn build_snapshot( }); } - // Live frequency: genuinely varies with load (unlike the static - // Win32_Processor.CurrentClockSpeed this backend replaces). `max_clock_mhz` - // comes from the chip profile's boost clock — informational, not live. - let clock_mhz = read_single(q.freq).map(|v| v.round() as u32); + // Live frequency: per-core `% Processor Performance` × that core's real + // rated `~MHz`, correct on heterogeneous P/E chips (see + // `effective_clock_mhz`); falls back to the `_Total` blended-average + // counter only if that per-core data isn't available this tick. + let mut perf_pct_by_core: HashMap = HashMap::new(); + if let Some(perf_pct) = q.perf_pct { + for (name, value) in read_array(perf_pct) { + if let Some(idx) = parse_core_index(&name) { + perf_pct_by_core.insert(idx, value); + } + } + } + let clock_mhz = effective_clock_mhz(&perf_pct_by_core, &identity.per_core_mhz) + .or_else(|| read_single(q.freq).map(|v| v.round() as u32)); let base_clock_mhz = if profile.base_ghz > 0.0 { Some((profile.base_ghz * 1000.0).round() as u32) } else { @@ -520,13 +545,58 @@ fn build_snapshot( max_clock_mhz, bus_speed_mhz: Some(100), // nominal reference clock on Snapdragon X power_w: None, // confirmed empty from userspace on this firmware + cpu_identifier: identity.identifier.clone(), + detection_basis: basis.label().to_string(), tick: 0, } } +/// Plain-text diagnostics dump: every raw identity signal plus how the chip +/// was matched. Lets a reporter paste one block instead of screenshots. +fn format_detection_report(identity: &CpuIdentity, profile: &ChipProfile, basis: MatchBasis) -> String { + format!( + "ARMtemp detection report\n\ + ProcessorNameString: {}\n\ + Identifier: {}\n\ + VendorIdentifier: {}\n\ + Logical cores: {}\n\ + Per-core ~MHz: {:?}\n\ + Real P/E topology: {} performance / {} efficiency\n\ + --- Matched profile ---\n\ + Name: {}\n\ + Model: {}\n\ + Cores: {} ({:?})\n\ + Base/Boost: {:.2} / {:.2} GHz\n\ + Match basis: {}\n", + identity.name.as_deref().unwrap_or("(none)"), + identity.identifier.as_deref().unwrap_or("(none)"), + identity.vendor.as_deref().unwrap_or("(none)"), + identity.logical_cores, + identity.per_core_mhz, + identity.perf_cores.map(|n| n.to_string()).unwrap_or_else(|| "?".to_string()), + identity.eff_cores.map(|n| n.to_string()).unwrap_or_else(|| "?".to_string()), + profile.name, + profile.model, + profile.total_cores(), + profile.clusters, + profile.base_ghz, + profile.boost_ghz, + basis.label(), + ) +} + fn worker(rx: mpsc::Receiver) { - let profile = detect_profile(); - let real_kinds = real_core_kinds(logical_core_count()); + let logical = logical_core_count(); + let real_kinds = real_core_kinds(logical); + let (perf_cores, eff_cores) = match &real_kinds { + Some(kinds) => ( + Some(kinds.iter().filter(|k| **k == CoreKind::Performance).count() as u32), + Some(kinds.iter().filter(|k| **k == CoreKind::Efficiency).count() as u32), + ), + None => (None, None), + }; + let cpu_identity = identity::gather(logical, perf_cores, eff_cores); + let Detection { profile, basis } = match_profile(&cpu_identity); let mut stats: CoreStats = HashMap::new(); let mut temp_stats: TempStats = None; let mut query: Option = None; @@ -536,12 +606,23 @@ fn worker(rx: mpsc::Receiver) { Request::Profile(reply) => { let _ = reply.send(Some(profile.clone())); } + Request::DetectionReport(reply) => { + let _ = reply.send(format_detection_report(&cpu_identity, &profile, basis)); + } Request::Snapshot(reply) => { if query.is_none() { query = open_query().ok(); } let result = match &query { - Some(q) => Ok(build_snapshot(q, &profile, &real_kinds, &mut stats, &mut temp_stats)), + Some(q) => Ok(build_snapshot( + q, + &profile, + basis, + &cpu_identity, + &real_kinds, + &mut stats, + &mut temp_stats, + )), None => Err(anyhow::anyhow!("PDH query not open")), }; if result.is_err() { @@ -586,4 +667,25 @@ mod tests { let kinds = classes_to_kinds(&classes); assert_eq!(kinds, [CoreKind::Efficiency, CoreKind::Efficiency, CoreKind::Performance]); } + + #[test] + fn effective_clock_uses_the_fastest_core_not_a_blended_average() { + // Prime cluster busy at its full rated 4032 MHz; Efficiency cluster + // idle. The old `_Total` counter would blend these into ~3.7 GHz + // (issue #2's reported wrong Speed); the per-core formula must + // report the Prime cluster's real 4032 MHz instead. + let mut perf_pct = HashMap::new(); + perf_pct.insert(0u32, 100.0); // Prime core at 100% of its own nominal + perf_pct.insert(6u32, 10.0); // Efficiency core mostly idle + let per_core_mhz = vec![4032, 4032, 4032, 4032, 4032, 4032, 3400, 3400, 3400, 3400, 3400, 3400]; + assert_eq!(effective_clock_mhz(&perf_pct, &per_core_mhz), Some(4032)); + } + + #[test] + fn effective_clock_none_when_inputs_unavailable() { + assert_eq!(effective_clock_mhz(&HashMap::new(), &[]), None); + let mut perf_pct = HashMap::new(); + perf_pct.insert(0u32, 100.0); + assert_eq!(effective_clock_mhz(&perf_pct, &[]), None); + } } diff --git a/src-tauri/src/sensors/types.rs b/src-tauri/src/sensors/types.rs index 8bc7c9a..f00e54a 100644 --- a/src-tauri/src/sensors/types.rs +++ b/src-tauri/src/sensors/types.rs @@ -98,6 +98,14 @@ pub struct SensorSnapshot { /// Package power in watts. Always `None` — confirmed unavailable from /// userspace on this firmware (see SENSORS.md §3). pub power_w: Option, + /// The registry `Identifier` string, e.g. "ARMv8 (64-bit) Family 8 Model 2 + /// Revision 201" — the machine's real CPUID-derived identity, shown in + /// the UI's CPUID field instead of repeating the marketing model string. + pub cpu_identifier: Option, + /// How `chip_name`/`chip_model` were determined — see `chips::MatchBasis`. + /// Surfaced so an inferred or unconfirmed SKU is never presented as if it + /// were read directly off the chip. + pub detection_basis: String, /// Monotonic tick counter so the UI can detect stale updates. pub tick: u64, } @@ -123,6 +131,8 @@ impl Default for SensorSnapshot { max_clock_mhz: None, bus_speed_mhz: None, power_w: None, + cpu_identifier: None, + detection_basis: String::new(), tick: 0, } } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 1e7f7af..e1c0d4a 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "ARMtemp", - "version": "0.4.4", + "version": "0.4.5", "identifier": "com.armtemp.app", "build": { "frontendDist": "../dist", diff --git a/src/App.tsx b/src/App.tsx index 889d2a8..15d2835 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -100,6 +100,11 @@ export default function App() { onToggleMini={() => setMini((m) => !m)} onToggleAlwaysOnTop={() => update({ alwaysOnTop: !settings.alwaysOnTop })} onRefresh={() => invoke("refresh_now").catch(() => {})} + onCopyDetectionReport={() => + invoke("get_detection_report") + .then((text) => navigator.clipboard.writeText(text)) + .catch(() => {}) + } onExit={() => invoke("exit_app").catch(() => {})} /> diff --git a/src/app/types.ts b/src/app/types.ts index c5c700b..885d242 100644 --- a/src/app/types.ts +++ b/src/app/types.ts @@ -36,6 +36,8 @@ export interface SensorSnapshot { max_clock_mhz: number | null; bus_speed_mhz: number | null; power_w: number | null; + cpu_identifier: string | null; + detection_basis: string; tick: number; } diff --git a/src/components/AboutDialog.tsx b/src/components/AboutDialog.tsx index 8488923..51fbe13 100644 --- a/src/components/AboutDialog.tsx +++ b/src/components/AboutDialog.tsx @@ -66,8 +66,8 @@ export function AboutDialog({ onClose }: Props) {
- Snapdragon X reports temperature for the processor as a whole, not per - individual core. + ARMtemp is a monitoring and hardware-detection tool — it does not benchmark, + stress-test, or rate processor performance.
diff --git a/src/components/ChipsList.tsx b/src/components/ChipsList.tsx index f22d7aa..38a8520 100644 --- a/src/components/ChipsList.tsx +++ b/src/components/ChipsList.tsx @@ -11,8 +11,10 @@ interface Profile { cores: number; } -// Shows the REAL detected processor (from the Rust backend) plus the known -// Snapdragon X / X2 family as reference. The detected one is highlighted. +// Shows only the REAL detected processor (from the Rust backend) — not the +// full known Snapdragon X/X2 lineup, which would grow unbounded as new +// families (X3, …) ship and reads as noise once you already know what's +// detected. export function ChipsList() { const [profile, setProfile] = useState(null); useEffect(() => { @@ -21,60 +23,29 @@ export function ChipsList() { .catch(() => setProfile(null)); }, []); - const known: { name: string; model: string; cores: string }[] = [ - { name: "Snapdragon X", model: "X1-26-100", cores: "8 cores" }, - { name: "Snapdragon X Plus", model: "X1P-64-100", cores: "10 cores" }, - { name: "Snapdragon X Plus", model: "X1P-80-100", cores: "8 cores" }, - { name: "Snapdragon X Elite", model: "X1E-80-100", cores: "12 cores" }, - { name: "Snapdragon X2 Elite Extreme", model: "X2E-96-100", cores: "18 cores" }, - { name: "Snapdragon X2 Elite Extreme", model: "X2E-94-100", cores: "18 cores" }, - { name: "Snapdragon X2 Elite", model: "X2E-90-100", cores: "18 cores" }, - { name: "Snapdragon X2 Elite", model: "X2E-88-100", cores: "18 cores" }, - { name: "Snapdragon X2 Elite", model: "X2E-84-100", cores: "12 cores" }, - { name: "Snapdragon X2 Elite", model: "X2E-80-100", cores: "12 cores" }, - { name: "Snapdragon X2 Elite", model: "X2E-78-100", cores: "12 cores" }, - { name: "Snapdragon X2 Plus", model: "X2P-64-100", cores: "10 cores" }, - { name: "Snapdragon X2 Plus", model: "X2P-42-100", cores: "6 cores" }, - ]; + if (!profile) { + return ( +
+
+ Detecting… +
+
+ ); + } - // Highlighted by `model`, not `name` — several X2 SKUs share the same - // marketing name, so model (the unique part number) is the real key. - const detectedMatch = known.find( - (k) => profile && k.model === profile.model - ); + // "Unknown … SKU" / "Generic" aren't real model numbers — same rule as + // ProcessorInfo.tsx's `isPlaceholderModel`, so an unconfirmed SKU doesn't + // read as "Snapdragon X2 Elite (Unknown X2 Elite SKU · 12 cores)". + const isPlaceholderModel = !profile.model || /^unknown\b/i.test(profile.model) || profile.model === "Generic"; return (
- {known.map((c) => { - const isDetected = detectedMatch && detectedMatch.model === c.model; - return ( - - ); - })} - {profile && !detectedMatch && ( - - )} +
+ {profile.name}{" "} + + ({isPlaceholderModel ? `${profile.cores} cores` : `${profile.model} · ${profile.cores} cores`}) + +
); } diff --git a/src/components/MenuBar.tsx b/src/components/MenuBar.tsx index 69c8266..3cd2a4b 100644 --- a/src/components/MenuBar.tsx +++ b/src/components/MenuBar.tsx @@ -9,6 +9,7 @@ interface MenuBarProps { onToggleMini: () => void; onToggleAlwaysOnTop: () => void; onRefresh: () => void; + onCopyDetectionReport: () => void; onExit: () => void; } @@ -26,6 +27,7 @@ export function MenuBar({ onToggleMini, onToggleAlwaysOnTop, onRefresh, + onCopyDetectionReport, onExit, }: MenuBarProps) { const [openMenu, setOpenMenu] = useState(null); @@ -110,6 +112,9 @@ export function MenuBar({ +
)} diff --git a/src/components/ProcessorInfo.tsx b/src/components/ProcessorInfo.tsx index cf9c2b4..4558d10 100644 --- a/src/components/ProcessorInfo.tsx +++ b/src/components/ProcessorInfo.tsx @@ -13,12 +13,21 @@ interface Props { // (profile boost clock) and Throttle (live ACPI passive-limit status). export function ProcessorInfo({ snap, status }: Props) { const processorStr = snap?.chip_name ?? (status === "error" ? "Sensor unavailable" : "Detecting…"); - const modelStr = snap ? [snap.chip_name, snap.chip_model].filter(Boolean).join(" ") : processorStr; + // "Unknown ... SKU" / "Generic" aren't real model numbers — prefixing + // `chip_name` in front of them read as broken ("Snapdragon X2 Elite + // Unknown X2 Elite SKU"). Show just the family name in that case; a real + // (even ambiguous, e.g. "X2E-80/84-100") model still gets the full string. + const isPlaceholderModel = !snap?.chip_model || /^unknown\b/i.test(snap.chip_model) || snap.chip_model === "Generic"; + const modelStr = snap ? (isPlaceholderModel ? snap.chip_name : [snap.chip_name, snap.chip_model].join(" ")) : processorStr; const speedStr = snap?.clock_mhz != null ? `${(snap.clock_mhz / 1000).toFixed(2)} GHz` : "—"; const baseStr = snap?.base_clock_mhz ? `${(snap.base_clock_mhz / 1000).toFixed(2)} GHz` : "—"; const boostStr = snap?.max_clock_mhz ? `${(snap.max_clock_mhz / 1000).toFixed(2)} GHz` : "—"; const lithographyStr = snap?.lithography || "—"; const tdpStr = snap?.tdp_w != null ? `${snap.tdp_w} W` : "—"; + // The machine's real CPUID-derived identity (registry `Identifier`, e.g. + // "ARMv8 (64-bit) Family 8 Model 2 Revision 201") — not a repeat of the + // marketing model string, which the Model field above already shows. + const cpuidStr = snap?.cpu_identifier || "—"; // Live thermal-throttle indicator: any valid ACPI zone reporting an active // passive limit (< 100 %) means the firmware is throttling right now. const throttled = snap ? snap.zones.some((z) => z.throttled) : null; @@ -38,7 +47,16 @@ export function ProcessorInfo({ snap, status }: Props) {
Processor Information
- + @@ -50,7 +68,7 @@ export function ProcessorInfo({ snap, status }: Props) { valueColor={throttled ? "#e0473a" : undefined} /> - +
@@ -62,11 +80,13 @@ function Field({ value, full, valueColor, + title, }: { label: string; value: string; full?: boolean; valueColor?: string; + title?: string; }) { return ( <> @@ -74,6 +94,7 @@ function Field({ {value} diff --git a/src/styles.css b/src/styles.css index 9f06c59..b1768c0 100644 --- a/src/styles.css +++ b/src/styles.css @@ -669,7 +669,8 @@ img { color: var(--text-2); } .about-disclosure { - margin: -4px 0 12px; + margin: -4px 0 0; + padding-bottom: 20px; font-size: 11px; color: var(--text-3); line-height: 1.4;