From d1e773d4962359a76e23c23ceed408f6d59a9491 Mon Sep 17 00:00:00 2001 From: Spencer Francisco Date: Thu, 9 Jul 2026 09:26:45 -0500 Subject: [PATCH 1/2] fix: correctly detect Snapdragon X2 SKUs when the CPU name has no token (issue #2) The v0.4.4 fix matched only a numeric SKU token in ProcessorNameString, but some OEM firmware (the reporter's Surface Laptop 8, X2E-78-100) reports a bare marketing name with no token at all, so it fell through to "Unknown X2 Elite SKU" with Base/Boost/TDP blanked out every time. Add a layered detection pipeline (sensors/chips.rs, sensors/identity.rs): exact SKU token -> family from name or registry Identifier -> SKU inferred from real core count + rated clock (~MHz, the same signal CPU-Z/HWiNFO use) -> honest family-only fallback with real boost populated -> generic. Two SKUs (X2E-80/84-100) publish identical CPU-visible specs and get one honest combined label instead of a guess. Also fixes a latent bug where every X Elite SKU collapsed to X1E-80-100, and replaces a fabricated "X1P-80-100" table entry with the real X1P-42/46-100 SKUs. Speed now comes from per-core % Processor Performance x ~MHz (the fastest core), replacing a single _Total counter that blends idle Efficiency cores into a busy Prime cluster's reported clock. Real OS P/E topology now consistently overrides the static profile table for every match path, not just the inferred ones, so the new "Copy detection report" diagnostic (Tools menu) never contradicts itself. 24 unit tests cover the reported scenario, the ambiguous pair, the name-independent Identifier path, and X1/X2 token regressions. --- AGENTS.md | 8 +- SENSORS.md | 34 +- package-lock.json | 4 +- package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/src/lib.rs | 19 +- src-tauri/src/sensors/chips.rs | 777 ++++++++++++++++++++++++------ src-tauri/src/sensors/identity.rs | 189 ++++++++ src-tauri/src/sensors/mod.rs | 1 + src-tauri/src/sensors/pdh.rs | 200 ++++++-- src-tauri/src/sensors/types.rs | 10 + src-tauri/tauri.conf.json | 2 +- 13 files changed, 1038 insertions(+), 212 deletions(-) create mode 100644 src-tauri/src/sensors/identity.rs 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", From 53dbb13f6f75d8d80db183b9624a588bf899c716 Mon Sep 17 00:00:00 2001 From: Spencer Francisco Date: Thu, 9 Jul 2026 09:26:55 -0500 Subject: [PATCH 2/2] feat: honest detected-processor display + detection-report tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ProcessorInfo: Model no longer doubles up on placeholder text ("Snapdragon X2 Elite Unknown X2 Elite SKU"); CPUID now shows the real registry Identifier string instead of repeating the marketing model; Model gets a hover tooltip naming the detection basis when it wasn't an exact SKU token match. About dialog: replace the unbounded full SKU-lineup list (which only grows with every future family) with a single line for the actually-detected processor, and swap the per-core-temperature disclosure for a general "this is a monitoring/detection tool, not a benchmark" scope note. Tools menu: add "Copy detection report", dumping every raw identity signal plus how the chip was matched to the clipboard — lets a reporter hand over one paste instead of screenshots for a machine we don't own. --- src/App.tsx | 5 +++ src/app/types.ts | 2 + src/components/AboutDialog.tsx | 4 +- src/components/ChipsList.tsx | 75 ++++++++++---------------------- src/components/MenuBar.tsx | 5 +++ src/components/ProcessorInfo.tsx | 27 ++++++++++-- src/styles.css | 3 +- 7 files changed, 63 insertions(+), 58 deletions(-) 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;