fix(qt): preflight preferred GPU presentation

cx
This commit is contained in:
vicrodh committed 2026-08-27 17:54:27 -06:00
1 parent 2772b7486f
commit 6e62724b4b
17 files changed
+975 -187

No files matched your search

-10
View File
@@ -199,15 +199,6 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"
[[package]]
name = "ash"
version = "0.38.0+1.3.281"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f"
dependencies = [
"libloading 0.8.9",
]
[[package]]
name = "ashpd"
version = "0.11.1"
@@ -4517,7 +4508,6 @@ name = "qbz-qt"
version = "2.0.2"
dependencies = [
"arboard",
"ash",
"async-trait",
"cc",
"cxx",
@@ -4696,6 +4696,9 @@ msgstr "integriert"
msgid "Preferred GPU changed — restart QBZ to apply"
msgstr "Bevorzugte GPU geändert — QBZ neu starten, um die Änderung zu übernehmen"
msgid "Selected GPU is unavailable in this display session — using Auto"
msgstr "Die ausgewählte GPU ist in dieser Bildschirmsitzung nicht verfügbar — Auto wird verwendet"
msgid "Renderer changed — restart QBZ to apply"
msgstr "Renderer geändert — QBZ neu starten, um es anzuwenden"
@@ -399,6 +399,9 @@ msgstr "integrated"
msgid "Preferred GPU changed — restart QBZ to apply"
msgstr "Preferred GPU changed — restart QBZ to apply"
msgid "Selected GPU is unavailable in this display session — using Auto"
msgstr "Selected GPU is unavailable in this display session — using Auto"
msgid "Search your library"
msgstr "Search your library"
@@ -4726,6 +4726,9 @@ msgstr "integrada"
msgid "Preferred GPU changed — restart QBZ to apply"
msgstr "GPU preferida cambiada — reinicia QBZ para aplicar"
msgid "Selected GPU is unavailable in this display session — using Auto"
msgstr "La GPU seleccionada no está disponible en esta sesión de pantalla — se usará Auto"
msgid "Renderer changed — restart QBZ to apply"
msgstr "Cambiaste el renderizador — reinicia QBZ para aplicarlo"
@@ -4696,6 +4696,9 @@ msgstr "intégré"
msgid "Preferred GPU changed — restart QBZ to apply"
msgstr "GPU préféré modifié — redémarrez QBZ pour appliquer"
msgid "Selected GPU is unavailable in this display session — using Auto"
msgstr "Le GPU sélectionné n’est pas disponible dans cette session d’affichage — utilisation du mode Auto"
msgid "Renderer changed — restart QBZ to apply"
msgstr "Moteur de rendu modifié — redémarrez QBZ pour l'appliquer"
@@ -4682,6 +4682,9 @@ msgstr "内蔵"
msgid "Preferred GPU changed — restart QBZ to apply"
msgstr "優先GPUを変更しました — 適用するには QBZ を再起動してください"
msgid "Selected GPU is unavailable in this display session — using Auto"
msgstr "選択したGPUはこのディスプレイセッションでは利用できないため、自動設定を使用します"
msgid "Renderer changed — restart QBZ to apply"
msgstr "レンダラーを変更しました — 適用するには QBZ を再起動してください"
@@ -4708,6 +4708,9 @@ msgstr "geïntegreerd"
msgid "Preferred GPU changed — restart QBZ to apply"
msgstr "Voorkeurs-GPU gewijzigd — herstart QBZ om toe te passen"
msgid "Selected GPU is unavailable in this display session — using Auto"
msgstr "De geselecteerde GPU is niet beschikbaar in deze beeldschermsessie — Auto wordt gebruikt"
msgid "Renderer changed — restart QBZ to apply"
msgstr "Renderer gewijzigd — start QBZ opnieuw om toe te passen"
@@ -4696,6 +4696,9 @@ msgstr "integrada"
msgid "Preferred GPU changed — restart QBZ to apply"
msgstr "GPU preferida alterada — reinicie o QBZ para aplicar"
msgid "Selected GPU is unavailable in this display session — using Auto"
msgstr "A GPU selecionada não está disponível nesta sessão gráfica — será usado Auto"
msgid "Renderer changed — restart QBZ to apply"
msgstr "Renderizador alterado — reinicie o QBZ para aplicar"
@@ -4712,6 +4712,9 @@ msgstr "встроенный"
msgid "Preferred GPU changed — restart QBZ to apply"
msgstr "Предпочитаемый GPU изменён — перезапустите QBZ, чтобы применить"
msgid "Selected GPU is unavailable in this display session — using Auto"
msgstr "Выбранный GPU недоступен в этом сеансе отображения — используется авто"
msgid "Renderer changed — restart QBZ to apply"
msgstr "Отрисовщик изменён — перезапустите QBZ, чтобы применить"
-8
View File
@@ -50,14 +50,6 @@ qbz-app = { path = "../qbz-app" }
qbz-audio = { path = "../qbz-audio" }
# The HiFi Wizard's frontend-agnostic core; Qt owns the presentation adapter.
qbz-dac-wizard-core = { path = "../qbz-dac-wizard-core" }
# Vulkan enumeration for the Preferred-GPU row — real adapter NAMES, MODELS and
# types, in the exact order `QT_VK_PHYSICAL_DEVICE_INDEX` counts them (both are
# `vkEnumeratePhysicalDevices`). This is the Qt analogue of what the reference
# does with `wgpu::Instance::enumerate_adapters`.
#
# `ash` is a thin FFI binding that dlopens libvulkan at RUNTIME: no Vulkan SDK
# at build time, and a box without libvulkan simply enumerates nothing.
ash = "0.38"
qbz-qobuz = { path = "../qbz-qobuz" }
# Cross-provider "Open Music Link" resolver. The crate is frontend-agnostic;
# src/link_resolver_qt.rs supplies the live Qobuz search + Qt navigation seams.
+17 -5
View File
@@ -321,6 +321,11 @@ fn build_rhi_items() {
// font through QFontDatabase before Main.qml constructs any text.
println!("cargo:rerun-if-changed=cxx/font_fallback.cpp");
cc.file("cxx/font_fallback.cpp");
// Plain helper, no QObject/moc: enumerates the exact QVulkanInstance that
// Qt Quick will give QRhi. A raw Vulkan instance can see a different order
// when hybrid-GPU implicit layers are active.
println!("cargo:rerun-if-changed=cxx/qt_vulkan_probe.cpp");
cc.file("cxx/qt_vulkan_probe.cpp");
for p in qtbuild.include_paths() {
cc.include(p);
}
@@ -337,10 +342,13 @@ fn build_rhi_items() {
let libs = qtbuild.qmake_query("QT_INSTALL_LIBS");
cc.flag(&format!("-F{}", libs.trim()));
for module in ["QtCore", "QtGui", "QtQml", "QtQuick"] {
let private = format!(
"{}/{module}.framework/Headers/{version}/{module}",
libs.trim()
);
let version_root = format!("{}/{module}.framework/Headers/{version}", libs.trim());
if Path::new(&version_root).is_dir() {
// Private Qt headers include siblings as
// <QtGui/private/foo_p.h>; that needs the version root.
cc.include(&version_root);
}
let private = format!("{version_root}/{module}");
if Path::new(&private).is_dir() {
cc.include(private);
}
@@ -348,7 +356,11 @@ fn build_rhi_items() {
} else {
let headers = qtbuild.qmake_query("QT_INSTALL_HEADERS");
for module in ["QtCore", "QtGui", "QtQml", "QtQuick"] {
let private = format!("{headers}/{module}/{version}/{module}");
let version_root = format!("{headers}/{module}/{version}");
if Path::new(&version_root).is_dir() {
cc.include(&version_root);
}
let private = format!("{version_root}/{module}");
if Path::new(&private).is_dir() {
cc.include(private);
}
+175
View File
@@ -0,0 +1,175 @@
#include <QtCore/QByteArray>
#include <QtCore/QCoreApplication>
#include <QtCore/QEventLoop>
#include <QtCore/QJsonArray>
#include <QtCore/QJsonDocument>
#include <QtCore/QJsonObject>
#include <QtCore/QTimer>
#include <QtCore/QVector>
#include <QtCore/QVersionNumber>
#include <QtGui/QGuiApplication>
#include <QtGui/qtguiglobal.h>
#include <QtQuick/QQuickWindow>
#if QT_CONFIG(vulkan) && __has_include(<vulkan/vulkan.h>)
#include <QtGui/QVulkanFunctions>
#include <private/qvulkandefaultinstance_p.h>
#endif
namespace {
QByteArray serializedDevices("[]");
#if QT_CONFIG(vulkan) && __has_include(<vulkan/vulkan.h>)
QByteArray deviceUuid(QVulkanFunctions *functions, VkPhysicalDevice device,
const QVulkanInstance *instance)
{
if (instance->apiVersion() < QVersionNumber(1, 1))
return {};
VkPhysicalDeviceIDProperties id{};
id.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_ID_PROPERTIES;
VkPhysicalDeviceProperties2 properties{};
properties.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_PROPERTIES_2;
properties.pNext = &id;
functions->vkGetPhysicalDeviceProperties2(device, &properties);
bool any = false;
for (const auto byte : id.deviceUUID)
any = any || byte != 0;
if (!any)
return {};
return QByteArray(reinterpret_cast<const char *>(id.deviceUUID), VK_UUID_SIZE).toHex();
}
#endif
} // namespace
// Enumerate through the very QVulkanInstance Qt Quick will later hand to
// QRhi. This is intentionally not a second raw vkCreateInstance: implicit
// layers (notably hybrid-GPU layers) may expose a different order depending
// on the instance extensions and platform integration. Returning Qt's exact
// order makes QT_VK_PHYSICAL_DEVICE_INDEX an identity translation instead of
// a guess.
extern "C" const char *qbz_qt_vulkan_devices_json()
{
serializedDevices = "[]";
#if QT_CONFIG(vulkan) && __has_include(<vulkan/vulkan.h>)
// QVulkanDefaultInstance needs the platform integration installed by
// QGuiApplication. Rust calls this helper after constructing it and before
// constructing the first QQuickWindow.
if (QCoreApplication::instance() == nullptr)
return serializedDevices.constData();
QVulkanInstance *instance = QVulkanDefaultInstance::instance();
if (instance == nullptr || !instance->isValid())
return serializedDevices.constData();
QVulkanFunctions *functions = instance->functions();
if (functions == nullptr)
return serializedDevices.constData();
uint32_t count = 0;
if (functions->vkEnumeratePhysicalDevices(instance->vkInstance(), &count, nullptr)
!= VK_SUCCESS || count == 0) {
return serializedDevices.constData();
}
QVector<VkPhysicalDevice> devices(static_cast<qsizetype>(count));
if (functions->vkEnumeratePhysicalDevices(instance->vkInstance(), &count, devices.data())
!= VK_SUCCESS) {
return serializedDevices.constData();
}
QJsonArray result;
for (uint32_t index = 0; index < count; ++index) {
VkPhysicalDeviceProperties properties{};
functions->vkGetPhysicalDeviceProperties(devices.at(index), &properties);
QJsonObject item;
item.insert(QStringLiteral("index"), static_cast<int>(index));
item.insert(QStringLiteral("name"),
QString::fromUtf8(properties.deviceName).trimmed());
item.insert(QStringLiteral("vendor"), static_cast<qint64>(properties.vendorID));
item.insert(QStringLiteral("device"), static_cast<qint64>(properties.deviceID));
item.insert(QStringLiteral("type"), static_cast<int>(properties.deviceType));
item.insert(QStringLiteral("uuid"),
QString::fromLatin1(deviceUuid(functions, devices.at(index), instance)));
result.append(item);
}
serializedDevices = QJsonDocument(result).toJson(QJsonDocument::Compact);
#endif
return serializedDevices.constData();
}
// Exercise the exact path the real shell needs, but inside the disposable
// preflight child: a Qt Quick Vulkan swapchain has to present actual frames to
// the active QPA compositor. Merely creating VkDevice succeeds on the owner's
// unsupported Intel-render -> NVIDIA/KWin path; the fatal failure only arrives
// when Wayland imports the first DMA-BUF. If that protocol error kills this
// process, the parent remains alive and falls back to Auto.
extern "C" int qbz_qt_vulkan_preflight_window()
{
if (qobject_cast<QGuiApplication *>(QCoreApplication::instance()) == nullptr)
return 71;
QQuickWindow window;
window.setTitle(QStringLiteral("QBZ GPU preflight"));
window.setFlags(Qt::Tool | Qt::FramelessWindowHint | Qt::WindowDoesNotAcceptFocus);
window.resize(2, 2);
window.setColor(Qt::transparent);
// Keep the native surface effectively invisible while still requiring a
// real buffer commit/import. An offscreen window would miss the exact
// Wayland failure this probe exists to catch.
window.setOpacity(0.01);
QEventLoop loop;
QTimer pulse;
QTimer settle;
QTimer deadline;
pulse.setInterval(16);
settle.setSingleShot(true);
deadline.setSingleShot(true);
int frames = 0;
int result = 76; // hard deadline / no usable presentation
QObject::connect(&pulse, &QTimer::timeout, &window, &QQuickWindow::update);
QObject::connect(&window, &QQuickWindow::frameSwapped, &loop, [&] {
++frames;
if (frames == 1)
settle.start(700);
window.update();
});
QObject::connect(&window, &QQuickWindow::sceneGraphError, &loop,
[&](QQuickWindow::SceneGraphError, const QString &) {
result = 74;
loop.quit();
});
QObject::connect(&settle, &QTimer::timeout, &loop, [&] {
// Two swaps plus a grace interval keep the Wayland connection pumping
// long enough for an asynchronous compositor import error to arrive.
result = frames >= 2 ? 0 : 75;
loop.quit();
});
QObject::connect(&deadline, &QTimer::timeout, &loop, [&] {
result = 76;
loop.quit();
});
window.show();
window.update();
pulse.start();
deadline.start(5000);
loop.exec();
pulse.stop();
settle.stop();
deadline.stop();
window.hide();
window.releaseResources();
QCoreApplication::processEvents(QEventLoop::AllEvents, 50);
return result;
}
+6 -4
View File
@@ -47,8 +47,9 @@
//! not a port.
//!
//! REPLACED, not dropped — the Qt truths that stand where a GDK/GSK row stood:
//! `Renderer (Qt)` (saved pref vs the resolved RHI api), `GPU Adapters` (the
//! Vulkan enumeration `QT_VK_PHYSICAL_DEVICE_INDEX` counts), `GPU Tier`, and the
//! `Renderer (Qt)` (saved pref vs the resolved RHI api), `GPU Adapters` (Qt's
//! own Vulkan-device order, which `QT_VK_PHYSICAL_DEVICE_INDEX` addresses),
//! `GPU Tier`, and the
//! `QSG_RHI_BACKEND` / `QT_QUICK_BACKEND` / `QT_VK_PHYSICAL_DEVICE_INDEX`
//! environment rows — which this binary SETS itself (`renderer_qt.rs:491`), so
//! they are the most load-bearing env vars it has.
@@ -795,8 +796,9 @@ fn build_graphics_rows(g: &Gathered) -> Vec<Row> {
// resolved at startup (`renderer_qt::active_api`).
row("Renderer (Qt)", &g.renderer_saved, &g.renderer_runtime, 0),
// Stands where the reference's `renderer_decision_summary()` adapter
// string does. NOT byte-identical: this is the Vulkan enumeration in
// `QT_VK_PHYSICAL_DEVICE_INDEX` order, not wgpu's adapter list.
// string does. NOT byte-identical: this is the actual Qt-owned Vulkan
// instance order addressed by `QT_VK_PHYSICAL_DEVICE_INDEX`, not
// wgpu's or an independently-created Vulkan instance's adapter list.
row("GPU Adapters", "—", &g.gpu_adapters, 0),
// Shaders + the dynamic background need this; false means the scenes
// stay hidden and reduce-motion is forced on, which is exactly the
+22 -5
View File
@@ -3268,12 +3268,28 @@ pub(crate) fn arm_hard_exit_watchdog(source: &'static str) {
fn main() {
qbz_log::install("info");
// Internal, disposable graphics child. It must branch before the normal
// instance lock, navigation crash-chain, runtime and audio thread: a GPU
// whose DMA-BUFs the active Wayland compositor rejects is expected to kill
// THIS process, while the waiting parent remains healthy and falls back to
// Auto.
if renderer_qt::gpu_preflight_child_requested() {
let app = QGuiApplication::new();
let result = renderer_qt::run_gpu_preflight_child();
drop(app);
log::logger().flush();
std::process::exit(result);
}
deep_link_qt::capture_argv();
#[cfg(target_os = "linux")]
if !single_instance_qt::acquire_or_raise() {
log::info!("[qbz-qt] another instance owns the session bus name; exiting");
return;
}
// Validate a persisted explicit GPU before constructing anything costly or
// user-visible. The child uses its own Wayland connection; fatal protocol
// errors cannot take this parent down.
renderer_qt::preflight_saved_gpu_at_boot();
// Link anchor for the hand-written QAbstractListModel. Its QML singleton
// registration itself runs at QCoreApplication startup.
local_tracks_model_qt::register_qml_model();
@@ -3367,15 +3383,16 @@ fn main() {
let _ = APP.set(runtime);
// All three must run before QGuiApplication: the renderer envs are read
// when the backend is chosen, the GPU envs when the graphics context is
// created, and the wheel policy when Flickables are constructed. Setting
// any of them afterwards is a silent no-op.
// Renderer policy stays before QGuiApplication. Scroll policy only needs
// to precede Flickable construction. Preferred GPU has a narrower safe
// slot: QGuiApplication must first install the platform integration so we
// can enumerate Qt's real QVulkanInstance, but the selection env must land
// before the first QQuickWindow/QRhi is constructed.
apply_renderer_preference();
renderer_qt::apply_gpu_preference();
apply_scroll_physics();
let mut app = QGuiApplication::new();
renderer_qt::apply_gpu_preference();
let mut engine = QQmlApplicationEngine::new();
// ── THE APP TYPEFACE, AND WHY IT IS SET RIGHT HERE ────────────────────
+693 -130
View File
@@ -188,12 +188,13 @@ fn clear_sentinel() {
}
}
/// Is a sentinel currently armed? Lets the liveness report stay quiet on the
/// runs where there is nothing to protect — notably the offscreen smoke, which
/// presents no frames by design and would otherwise log a scary warning on
/// every gate.
/// Is either graphics startup sentinel currently armed? Lets the liveness
/// report stay quiet on runs where there is nothing to protect — notably the
/// offscreen smoke, which presents no frames by design and would otherwise log
/// a scary warning on every gate.
pub fn sentinel_armed() -> bool {
sentinel_path().map(|p| p.exists()).unwrap_or(false)
|| gpu_sentinel_path().map(|p| p.exists()).unwrap_or(false)
}
/// The choice the armed sentinel was protecting, if one survived a launch.
@@ -204,10 +205,10 @@ fn armed_choice() -> Option<String> {
.filter(|s| !s.is_empty())
}
/// Called at the TOP of `apply_renderer_preference()`, before anything is
/// forced. If a previous forced launch left the sentinel armed it never
/// reached liveness, so the choice is reverted to "auto" and this launch runs
/// on Qt's default.
/// Called before GPU preflight (and again, harmlessly, at the top of
/// `apply_renderer_preference()`), before anything is forced. If a previous
/// forced launch left the sentinel armed it never reached liveness, so the
/// choice is reverted to "auto" and this launch runs on Qt's default.
///
/// Returns true when a revert happened — the caller then ignores the persisted
/// pref for this launch (it has just been rewritten to "auto" anyway).
@@ -249,11 +250,20 @@ pub fn disarm_on_liveness(frames: i32) {
// Only claim a disarm when something WAS armed. Saying "sentinel disarmed"
// on every ordinary launch trains the reader to skim past the line, which
// is precisely the run where it carries information.
if sentinel_armed() {
log::info!("[qbz-qt] renderer: startup sentinel disarmed ({frames} frames rendered)");
let renderer_armed = sentinel_path().map(|p| p.exists()).unwrap_or(false);
let gpu_armed = gpu_sentinel_path().map(|p| p.exists()).unwrap_or(false);
if renderer_armed || gpu_armed {
let protected = match (renderer_armed, gpu_armed) {
(true, true) => "renderer + GPU",
(true, false) => "renderer",
(false, true) => "GPU",
(false, false) => unreachable!(),
};
log::info!("[qbz-qt] {protected}: startup sentinel disarmed ({frames} frames rendered)");
clear_sentinel();
clear_gpu_sentinel();
} else {
log::debug!("[qbz-qt] renderer: liveness confirmed ({frames} frames), nothing armed");
log::debug!("[qbz-qt] graphics: liveness confirmed ({frames} frames), nothing armed");
}
}
@@ -262,38 +272,39 @@ pub fn disarm_on_liveness(frames: i32) {
// ===========================================================================
//
// Scoping: `qbz-nix-docs/qt-frontend/2026-08-02-gpu-selection-scoping.md`.
// Qt's own documentation is blunt: QRhi "will choose the system default GPU
// adapter … No further adapter configurability is provided at this time." So
// there is no API — only the driver's env vars, and only on some platforms.
// Qt exposes no public QRhi adapter-selection API. On Linux its supported
// escape hatch is `QT_VK_PHYSICAL_DEVICE_INDEX`, read when the Vulkan QRhi is
// created. The index is process-local and MUST be resolved afresh at boot.
//
// | platform | selectable | how |
// |---|---|---|
// | Linux (OpenGL, this port's default) | YES | `DRI_PRIME`, or the NVIDIA PRIME pair |
// | Linux | YES | Vulkan + `QT_VK_PHYSICAL_DEVICE_INDEX` |
// | Windows D3D | yes, when that port lands | `QT_D3D_ADAPTER_INDEX` |
// | macOS Metal | **NO** | QRhi hardcodes `MTLCreateSystemDefaultDevice` |
//
// So: implement Linux, HIDE the row on macOS. A row that cannot do anything is
// worse than no row — and until today this one was worse still, because it
// silently DROPPED every selection that was not "Auto"
// (`settings_qt.rs`, the old `"gpu-power"` arm persisted only index 0).
// WHY A RAW VULKAN PROBE IS WRONG. On the owner's Intel + NVIDIA hybrid, an
// independently-created VkInstance enumerated Intel=0/NVIDIA=1 while Qt's own
// QVulkanInstance enumerated NVIDIA=0/Intel=1 in the same environment. Hybrid
// implicit layers can react to instance/platform details; "both call
// vkEnumeratePhysicalDevices" does not make the indices interchangeable. The
// old code fed the raw index to Qt, selected the opposite GPU, and Wayland
// killed the connection when cross-device dmabuf import failed.
//
// CLASS KEYS, NEVER NAMES. The pref lives in the ui_prefs.json shared with the
// shipping Slint build, which accepts `"auto" | "integrated" | "discrete"` and
// falls back to Auto for anything it cannot parse. Writing an adapter NAME
// here would silently degrade the Slint side — and its wgpu/Vulkan names would
// not string-match what we read from sysfs anyway.
// `cxx/qt_vulkan_probe.cpp` enumerates the actual QVulkanDefaultInstance that
// Qt Quick later gives QRhi. It runs after QGuiApplication installs the
// platform integration and before the first QQuickWindow exists. Preferences
// store a stable Vulkan UUID beside the legacy display name; an index is never
// persisted.
/// One physical GPU, as Vulkan enumerates it.
///
/// `index` is the position in `vkEnumeratePhysicalDevices` — the SAME number
/// `QT_VK_PHYSICAL_DEVICE_INDEX` selects by, because Qt calls the same
/// function. That identity is the whole reason this row can work at all.
/// One physical GPU in this launch's Qt-owned Vulkan instance.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct GpuInfo {
/// Exact `QT_VK_PHYSICAL_DEVICE_INDEX` for this process only.
pub index: u32,
/// The model, verbatim from the driver: "NVIDIA GeForce RTX 4070 Laptop
/// GPU", "Intel(R) Arc(tm) Graphics (MTL)".
pub name: String,
pub vendor_id: u32,
pub device_id: u32,
pub identity: String,
pub discrete: bool,
}
@@ -310,26 +321,126 @@ impl GpuInfo {
}
}
/// Enumerate the real GPUs, once per process.
///
/// Empty when libvulkan is absent, no ICD is installed, or the instance cannot
/// be created — every one of which means "we cannot honour a GPU choice", so
/// the row collapses to Auto instead of offering hardware that may not exist.
/// That is the defect this replaces: a CLASS dropdown offers "Discrete GPU" on
/// a machine that has none.
#[derive(serde::Deserialize)]
struct QtGpuJson {
index: u32,
name: String,
vendor: u32,
device: u32,
#[serde(rename = "type")]
device_type: u32,
#[serde(default)]
uuid: String,
}
unsafe extern "C" {
fn qbz_qt_vulkan_devices_json() -> *const std::ffi::c_char;
fn qbz_qt_vulkan_preflight_window() -> i32;
}
const GPU_PREFLIGHT_CHILD_ENV: &str = "QBZ_INTERNAL_GPU_PREFLIGHT_CHILD";
const GPU_PREFLIGHT_IDENTITY_ENV: &str = "QBZ_INTERNAL_GPU_PREFLIGHT_IDENTITY";
const GPU_PREFLIGHT_NAME_ENV: &str = "QBZ_INTERNAL_GPU_PREFLIGHT_NAME";
const GPU_PREFLIGHT_NOT_FOUND: i32 = 41;
const GPU_PREFLIGHT_NOT_SELECTABLE: i32 = 42;
const GPU_PREFLIGHT_PARENT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(8);
/// The parent only authorizes a forced GPU after a disposable child proved
/// that this exact identity can present Qt Quick frames to the active
/// compositor. Kept in-process: a success from an older topology/driver must
/// never become a permanent allow-list.
static PREFLIGHT_APPROVED_GPU: Mutex<Option<String>> = Mutex::new(None);
static PREFLIGHT_FALLBACK_NOTICE: AtomicBool = AtomicBool::new(false);
fn stable_gpu_identity(raw: &QtGpuJson) -> String {
let uuid = raw.uuid.trim().to_ascii_lowercase();
if uuid.len() == 32
&& uuid.bytes().all(|b| b.is_ascii_hexdigit())
&& uuid.bytes().any(|b| b != b'0')
{
format!("vk:{uuid}")
} else {
format!(
"pci:{:04x}:{:04x}:{}",
raw.vendor,
raw.device,
raw.name.trim().to_ascii_lowercase()
)
}
}
/// Enumerate through Qt's own default Vulkan instance. The C++ side refuses to
/// run before QGuiApplication exists and returns `[]` on unsupported or broken
/// Vulkan setups, so inventory can never become a startup gate.
fn enumerate_qt_gpus() -> Vec<GpuInfo> {
if !cfg!(target_os = "linux") {
return Vec::new();
}
// SAFETY: the C++ function returns a pointer into a process-static
// QByteArray. This call runs on the GUI thread and copies the bytes before
// returning.
let ptr = unsafe { qbz_qt_vulkan_devices_json() };
if ptr.is_null() {
return Vec::new();
}
let json = unsafe { std::ffi::CStr::from_ptr(ptr) }.to_string_lossy();
let raw: Vec<QtGpuJson> = match serde_json::from_str(&json) {
Ok(list) => list,
Err(e) => {
log::warn!("[qbz-qt] gpu: Qt Vulkan inventory was invalid: {e}");
return Vec::new();
}
};
gpu_inventory_from_raw(raw)
}
fn gpu_inventory_from_raw(raw: Vec<QtGpuJson>) -> Vec<GpuInfo> {
let mut seen = std::collections::HashSet::new();
let mut out = Vec::with_capacity(raw.len());
for gpu in raw {
let name = gpu.name.trim().to_string();
// VkPhysicalDeviceType::CPU is lavapipe/llvmpipe, not another GPU the
// Preferred-GPU control should offer.
if name.is_empty() || gpu.device_type == 4 || !seen.insert(gpu.index) {
continue;
}
out.push(GpuInfo {
index: gpu.index,
name,
vendor_id: gpu.vendor,
device_id: gpu.device,
identity: stable_gpu_identity(&gpu),
discrete: gpu.device_type == 2,
});
}
out.sort_by_key(|gpu| gpu.index);
out
}
/// Real hardware adapters in the exact order this process's Qt Vulkan backend
/// sees them. Empty is supported: Auto can still use OpenGL or software.
pub fn gpus() -> &'static [GpuInfo] {
static GPUS: std::sync::OnceLock<Vec<GpuInfo>> = std::sync::OnceLock::new();
GPUS.get_or_init(|| {
let list = enumerate_vulkan_gpus();
let list = enumerate_qt_gpus();
if list.is_empty() {
log::info!("[qbz-qt] gpu: no Vulkan devices enumerated — the selector stays on Auto");
log::info!(
"[qbz-qt] gpu: Qt exposed no selectable Vulkan hardware — Auto remains in control"
);
} else {
for g in &list {
for gpu in &list {
log::info!(
"[qbz-qt] gpu: device {} '{}' ({})",
g.index,
g.name,
if g.discrete { "discrete" } else { "integrated" }
"[qbz-qt] gpu: Qt device {} '{}' ({}, vendor={:04x} device={:04x})",
gpu.index,
gpu.name,
if gpu.discrete {
"discrete"
} else {
"integrated"
},
gpu.vendor_id,
gpu.device_id,
);
}
}
@@ -337,77 +448,127 @@ pub fn gpus() -> &'static [GpuInfo] {
})
}
fn enumerate_vulkan_gpus() -> Vec<GpuInfo> {
use ash::vk;
// SAFETY: `Entry::load` dlopens libvulkan; every call below is a plain
// enumeration against the instance we create and destroy here. Nothing
// escapes this function but owned `String`s and plain numbers, and the
// instance is destroyed on every path.
unsafe {
let Ok(entry) = ash::Entry::load() else {
return Vec::new();
};
let app_info = vk::ApplicationInfo::default().api_version(vk::API_VERSION_1_0);
let create_info = vk::InstanceCreateInfo::default().application_info(&app_info);
let Ok(instance) = entry.create_instance(&create_info, None) else {
return Vec::new();
};
let devices = instance.enumerate_physical_devices().unwrap_or_default();
let mut out = Vec::with_capacity(devices.len());
for (i, pd) in devices.iter().enumerate() {
let props = instance.get_physical_device_properties(*pd);
let name = props
.device_name_as_c_str()
.ok()
.and_then(|s| s.to_str().ok())
.unwrap_or("")
.trim()
.to_string();
if name.is_empty() {
continue;
}
out.push(GpuInfo {
index: i as u32,
name,
discrete: props.device_type == vk::PhysicalDeviceType::DISCRETE_GPU,
});
}
instance.destroy_instance(None);
out
/// True only in the disposable subprocess spawned by
/// [`preflight_saved_gpu_at_boot`]. This is checked at the top of `main`,
/// before single-instance ownership, navigation sentinels, audio or runtime
/// construction.
pub fn gpu_preflight_child_requested() -> bool {
std::env::var_os(GPU_PREFLIGHT_CHILD_ENV)
.is_some_and(|value| value.to_string_lossy().trim() == "1")
}
/// Run the child half after its minimal QGuiApplication exists. The C++ helper
/// creates a transparent 2x2 QQuickWindow and keeps swapping long enough for
/// Wayland to accept or fatally reject its DMA-BUFs.
pub fn run_gpu_preflight_child() -> i32 {
let identity = std::env::var(GPU_PREFLIGHT_IDENTITY_ENV).unwrap_or_default();
let name = std::env::var(GPU_PREFLIGHT_NAME_ENV).unwrap_or_default();
std::env::remove_var(GPU_PREFLIGHT_CHILD_ENV);
std::env::remove_var(GPU_PREFLIGHT_IDENTITY_ENV);
std::env::remove_var(GPU_PREFLIGHT_NAME_ENV);
if !cfg!(target_os = "linux") {
return GPU_PREFLIGHT_NOT_SELECTABLE;
}
let list = gpus();
if list.len() < 2 {
log::info!("[qbz-qt] gpu preflight: fewer than two selectable devices -> Auto");
return GPU_PREFLIGHT_NOT_SELECTABLE;
}
let Some(gpu) = resolve_gpu_in(list, &name, &identity) else {
log::warn!("[qbz-qt] gpu preflight: requested device '{name}' ({identity}) is not present");
return GPU_PREFLIGHT_NOT_FOUND;
};
std::env::set_var("QSG_RHI_BACKEND", "vulkan");
std::env::set_var("QT_VK_PHYSICAL_DEVICE_INDEX", gpu.index.to_string());
log::info!(
"[qbz-qt] gpu preflight: probing '{}' ({}) at Qt index {}",
gpu.name,
gpu.identity,
gpu.index
);
// SAFETY: the helper is called on the child's GUI thread, after its sole
// QGuiApplication and before any other QQuickWindow. It owns all temporary
// Qt objects and returns only after destroying the probe window.
let result = unsafe { qbz_qt_vulkan_preflight_window() };
if result == 0 {
use std::io::Write;
let mut stdout = std::io::stdout().lock();
let _ = writeln!(stdout, "QBZ_GPU_PREFLIGHT_OK={}", gpu.identity);
let _ = stdout.flush();
log::info!(
"[qbz-qt] gpu preflight: '{}' presented frames successfully",
gpu.name
);
} else {
log::warn!(
"[qbz-qt] gpu preflight: '{}' did not prove presentation (code {result})",
gpu.name
);
}
result
}
/// The GPU whose index Vulkan would pick with no override — Qt's own default
/// is device 0. Selecting it needs no coupling at all.
fn default_gpu_index() -> u32 {
0
}
/// Resolve a persisted `gpu_power` value to a device. Accepts a NAME (what
/// this row and the reference both store) and the legacy CLASS keys the older
/// builds and the Slint app write, so a shared pref never reads as garbage.
/// Resolve a persisted GPU pair to a device. Stable identity wins; without one,
/// accept the model name older Qt builds store and the legacy class keys.
///
/// `None` = Auto, or a value that matches no present device — which is exactly
/// the case that used to let you select a GPU that does not exist.
pub fn resolve_gpu(pref: &str) -> Option<&'static GpuInfo> {
fn unique_gpu_matching(
list: &[GpuInfo],
mut predicate: impl FnMut(&GpuInfo) -> bool,
) -> Option<&GpuInfo> {
let mut matches = list.iter().filter(|gpu| predicate(gpu));
let first = matches.next()?;
matches.next().is_none().then_some(first)
}
fn resolve_gpu_in<'a>(list: &'a [GpuInfo], pref: &str, identity: &str) -> Option<&'a GpuInfo> {
let pref = pref.trim();
if pref.is_empty() || pref == "auto" {
return None;
}
let list = gpus();
if let Some(hit) = list.iter().find(|g| g.name == pref) {
let identity = identity.trim();
if !identity.is_empty() {
if let Some(hit) = unique_gpu_matching(list, |gpu| gpu.identity == identity) {
// A downgraded/older build may update `gpu_power` without knowing
// about `gpu_identity`. If the readable key clearly names another
// device (or class), treat the identity as stale. Otherwise the
// UUID remains authoritative across driver model-name changes.
let names_another = list.iter().any(|gpu| gpu.name == pref) && hit.name != pref;
let class_disagrees =
matches!(pref, "discrete" | "integrated") && (hit.discrete != (pref == "discrete"));
if !names_another && !class_disagrees {
return Some(hit);
}
} else {
// Never fall back from a missing UUID to a same-model device: that
// could be the other of two identical eGPUs.
return None;
}
}
if let Some(hit) = unique_gpu_matching(list, |gpu| gpu.name == pref) {
return Some(hit);
}
// Legacy class keys — resolve to a device that ACTUALLY EXISTS, or to
// nothing. "discrete" on a single-GPU laptop resolves to None, and that is
// the point.
match pref {
"discrete" => list.iter().find(|g| g.discrete),
"integrated" => list.iter().find(|g| !g.discrete),
"discrete" => unique_gpu_matching(list, |gpu| gpu.discrete),
"integrated" => unique_gpu_matching(list, |gpu| !gpu.discrete),
_ => None,
}
}
pub fn resolve_saved_gpu() -> Option<&'static GpuInfo> {
resolve_gpu_in(
gpus(),
&crate::settings_qt::pref_str("gpu_power", "auto"),
&crate::settings_qt::pref_str("gpu_identity", ""),
)
}
/// Can this machine actually honour a Preferred-GPU choice?
///
/// Two conditions, and BOTH are the owner's point that a control must never
@@ -420,30 +581,283 @@ pub fn gpu_selectable() -> bool {
cfg!(target_os = "linux") && gpus().len() > 1
}
/// Apply the persisted Preferred GPU. Called from `apply_renderer_preference`'s
/// slot — BEFORE `QGuiApplication`, because an env set after the graphics
/// context exists is a silent no-op (scoping trap 2).
///
/// Deliberately class-level, exactly like the reference: it nudges the driver
/// toward the integrated or the discrete GPU rather than binding one adapter.
/// Slint has the same limitation (documented F7) and so does Chromium
/// (`--force_high_performance_gpu`) — this is the ceiling of the platform, not
/// a shortcut.
pub fn apply_gpu_preference() {
if !gpu_selectable() {
return;
// A GPU override can fail before QML exists (bad adapter, presentation, or a
// cross-device dmabuf import). Keep this separate from `qt_renderer_attempt`
// so a device failure never rewrites the renderer row and vice versa.
fn gpu_sentinel_path() -> Option<std::path::PathBuf> {
Some(dirs::data_dir()?.join("qbz").join("qt_gpu_attempt"))
}
fn clear_gpu_sentinel() {
if let Some(path) = gpu_sentinel_path() {
let _ = std::fs::remove_file(path);
}
// An explicit env always wins — someone who exported one of these meant it,
// and it is the escape hatch when this row guesses wrong.
for taken in [
}
fn armed_gpu_choice() -> Option<String> {
std::fs::read_to_string(gpu_sentinel_path()?)
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
fn arm_gpu_sentinel(gpu: &GpuInfo) {
let Some(path) = gpu_sentinel_path() else {
return;
};
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let marker = format!("{}|{}|qt-index={}", gpu.identity, gpu.name, gpu.index);
match std::fs::write(&path, marker) {
Ok(()) => log::info!("[qbz-qt] gpu: startup sentinel armed for '{}'", gpu.name),
Err(e) => log::warn!("[qbz-qt] gpu: could not arm the startup sentinel: {e}"),
}
}
fn revert_if_previous_gpu_launch_died() -> bool {
let Some(choice) = armed_gpu_choice() else {
return false;
};
clear_gpu_sentinel();
log::warn!(
"[qbz-qt] gpu: the previous launch forced '{choice}' and never reached liveness — \
reverting Preferred GPU to Auto"
);
crate::settings_qt::save_gpu_preference(None);
PREFLIGHT_FALLBACK_NOTICE.store(true, Ordering::SeqCst);
true
}
fn nonempty_env(name: &str) -> bool {
std::env::var_os(name).is_some_and(|value| !value.to_string_lossy().trim().is_empty())
}
fn gpu_override_env() -> Option<&'static str> {
[
"QT_VK_PHYSICAL_DEVICE_INDEX",
"DRI_PRIME",
"__NV_PRIME_RENDER_OFFLOAD",
] {
if std::env::var_os(taken).is_some() {
log::info!("[qbz-qt] gpu: {taken} already set; leaving the choice to it");
"__VK_LAYER_NV_optimus",
"MESA_VK_DEVICE_SELECT",
"VK_DRIVER_FILES",
"VK_ICD_FILENAMES",
]
.into_iter()
.find(|name| nonempty_env(name))
}
/// Mirror `main::apply_renderer_preference` just far enough to know whether a
/// saved GPU would be ignored anyway. A software/OpenGL renderer choice wins
/// over Preferred GPU, so probing Vulkan in that case would be both wasteful
/// and misleading.
fn renderer_blocks_gpu_preflight() -> bool {
if nonempty_env("QSG_RHI_BACKEND") || nonempty_env("QT_QUICK_BACKEND") {
return true;
}
let from_env = std::env::var("QBZ_RENDERER")
.ok()
.map(|value| value.trim().to_ascii_lowercase())
.filter(|value| !value.is_empty() && value != "auto");
let choice = from_env.unwrap_or_else(|| crate::settings_qt::pref_str("renderer", "auto"));
renderer_choice_blocks_gpu(&choice)
}
fn renderer_choice_blocks_gpu(choice: &str) -> bool {
matches!(
choice.trim().to_ascii_lowercase().as_str(),
"software" | "cpu" | "soft" | "gl" | "gles" | "femtovg"
)
}
fn preflight_output_tail(output: &std::process::Output) -> String {
let text = format!(
"{}\n{}",
String::from_utf8_lossy(&output.stderr),
String::from_utf8_lossy(&output.stdout)
);
let lines: Vec<&str> = text
.lines()
.filter(|line| !line.trim().is_empty())
.collect();
let tail = lines[lines.len().saturating_sub(6)..].join(" | ");
tail.chars().take(1200).collect()
}
fn approved_identity_from_stdout(stdout: &[u8]) -> Option<String> {
String::from_utf8_lossy(stdout)
.lines()
.find_map(|line| line.strip_prefix("QBZ_GPU_PREFLIGHT_OK="))
.map(str::trim)
.filter(|identity| !identity.is_empty())
.map(str::to_string)
}
fn approved_identity_from(output: &std::process::Output) -> Option<String> {
approved_identity_from_stdout(&output.stdout)
}
fn spawn_gpu_preflight(name: &str, identity: &str) -> Result<(std::process::Output, bool), String> {
let executable = std::env::current_exe()
.map_err(|error| format!("cannot resolve current executable: {error}"))?;
let mut child = std::process::Command::new(executable)
.env(GPU_PREFLIGHT_CHILD_ENV, "1")
.env(GPU_PREFLIGHT_IDENTITY_ENV, identity)
.env(GPU_PREFLIGHT_NAME_ENV, name)
// An activation token is single-use. The invisible probe must never
// consume the token intended to focus the real main window.
.env_remove("XDG_ACTIVATION_TOKEN")
.env_remove("DESKTOP_STARTUP_ID")
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.map_err(|error| format!("cannot spawn child: {error}"))?;
let started = std::time::Instant::now();
let mut timed_out = false;
loop {
match child.try_wait() {
Ok(Some(_)) => break,
Ok(None) if started.elapsed() < GPU_PREFLIGHT_PARENT_TIMEOUT => {
std::thread::sleep(std::time::Duration::from_millis(25));
}
Ok(None) => {
timed_out = true;
let _ = child.kill();
break;
}
Err(error) => {
let _ = child.kill();
let _ = child.wait();
return Err(format!("cannot monitor child: {error}"));
}
}
}
let output = child
.wait_with_output()
.map_err(|error| format!("cannot collect child result: {error}"))?;
Ok((output, timed_out))
}
fn set_preflight_approved(identity: Option<String>) {
let mut slot = PREFLIGHT_APPROVED_GPU
.lock()
.unwrap_or_else(|error| error.into_inner());
*slot = identity;
}
fn preflight_approved(identity: &str) -> bool {
PREFLIGHT_APPROVED_GPU
.lock()
.unwrap_or_else(|error| error.into_inner())
.as_deref()
== Some(identity)
}
fn reject_saved_gpu(reason: &str) {
set_preflight_approved(None);
clear_gpu_sentinel();
crate::settings_qt::save_gpu_preference(None);
PREFLIGHT_FALLBACK_NOTICE.store(true, Ordering::SeqCst);
log::warn!(
"[qbz-qt] gpu preflight: {reason}; Preferred GPU reverted to Auto before main startup"
);
}
/// One-shot UI notice consumed after QbzShell registers its Qt-thread hop.
pub fn take_preflight_fallback_notice() -> bool {
PREFLIGHT_FALLBACK_NOTICE.swap(false, Ordering::SeqCst)
}
/// Validate an explicit saved GPU in a disposable process before this process
/// constructs its runtime, audio thread or QGuiApplication. A fatal Wayland
/// DMA-BUF import error can then kill only the child; the parent persists Auto
/// and continues through the normal safe startup.
pub fn preflight_saved_gpu_at_boot() {
set_preflight_approved(None);
if !cfg!(target_os = "linux") {
return;
}
// Renderer recovery may turn a previously forced software/OpenGL choice
// into Auto. Do it before deciding whether a Vulkan GPU probe is relevant.
let _ = revert_if_previous_launch_died();
if revert_if_previous_gpu_launch_died() {
return;
}
if let Some(name) = gpu_override_env() {
log::info!(
"[qbz-qt] gpu preflight: {name} is explicitly set; leaving external policy untouched"
);
return;
}
if renderer_blocks_gpu_preflight() {
log::info!(
"[qbz-qt] gpu preflight: explicit software/OpenGL renderer wins; no GPU probe needed"
);
return;
}
let name = crate::settings_qt::pref_str("gpu_power", "auto");
let identity = crate::settings_qt::pref_str("gpu_identity", "");
if name.trim().is_empty() || name == "auto" {
return;
}
log::info!(
"[qbz-qt] gpu preflight: validating saved device '{name}' ({identity}) in an isolated child"
);
let (output, timed_out) = match spawn_gpu_preflight(&name, &identity) {
Ok(result) => result,
Err(error) => {
reject_saved_gpu(&error);
return;
}
};
if timed_out {
reject_saved_gpu("child exceeded the 8-second watchdog");
return;
}
if output.status.success() {
if let Some(approved) = approved_identity_from(&output) {
log::info!(
"[qbz-qt] gpu preflight: presentation approved for {approved}; continuing main startup"
);
set_preflight_approved(Some(approved));
return;
}
reject_saved_gpu("child exited successfully without an identity proof");
return;
}
let code = output.status.code();
let detail = preflight_output_tail(&output);
if code == Some(GPU_PREFLIGHT_NOT_SELECTABLE) {
reject_saved_gpu("this environment exposes fewer than two selectable GPUs");
} else if code == Some(GPU_PREFLIGHT_NOT_FOUND) {
reject_saved_gpu("the saved GPU identity is no longer present");
} else if detail.is_empty() {
reject_saved_gpu(&format!("child failed with status {:?}", output.status));
} else {
reject_saved_gpu(&format!(
"child failed with status {:?}; last output: {detail}",
output.status
));
}
}
/// Apply Preferred GPU after QGuiApplication installs the platform integration
/// and before QQmlApplicationEngine can construct a QQuickWindow. This is the
/// only interval where we can enumerate Qt's actual VkInstance and still set
/// the environment QRhi consumes.
pub fn apply_gpu_preference() {
let reverted = revert_if_previous_gpu_launch_died();
// An explicit env always wins — someone who exported one of these meant it,
// including vendor/ICD selectors that can change the inventory itself.
if let Some(taken) = gpu_override_env() {
log::info!("[qbz-qt] gpu: {taken} already set; leaving the choice to it");
return;
}
// `apply_renderer_preference()` runs FIRST and may have forced a backend.
// A GPU pick needs Vulkan (see below), so it cannot also honour an explicit
@@ -458,22 +872,56 @@ pub fn apply_gpu_preference() {
);
return;
}
let pref = crate::settings_qt::pref_str("gpu_power", "auto");
let Some(gpu) = resolve_gpu(&pref) else {
if pref != "auto" && !pref.is_empty() {
log::warn!("[qbz-qt] gpu: '{pref}' matches no device present on this machine -> Auto");
}
return;
let pref = if reverted {
"auto".to_string()
} else {
crate::settings_qt::pref_str("gpu_power", "auto")
};
if gpu.index == default_gpu_index() {
// Already the default: selecting it needs no override, and forcing one
// would buy the Vulkan coupling below for nothing.
let identity = if reverted {
String::new()
} else {
crate::settings_qt::pref_str("gpu_identity", "")
};
if pref.trim().is_empty() || pref == "auto" {
clear_gpu_sentinel();
log::info!("[qbz-qt] gpu: Auto -> Qt/driver environment default (no override)");
return;
}
if !gpu_selectable() {
clear_gpu_sentinel();
log::info!(
"[qbz-qt] gpu: '{}' is device 0 (the default) -> no override",
gpu.name
"[qbz-qt] gpu: '{pref}' requested, but this environment exposes fewer than two \
selectable GPUs -> Auto"
);
return;
}
let Some(gpu) = resolve_gpu_in(gpus(), &pref, &identity) else {
clear_gpu_sentinel();
log::warn!(
"[qbz-qt] gpu: saved device '{pref}' ({identity}) is not present in Qt's inventory -> Auto"
);
return;
};
// A saved GPU is never allowed to reach the real window merely because it
// exists. The disposable child must have presented Qt Quick frames from
// this exact stable identity in the current process launch first.
if !preflight_approved(&gpu.identity) {
reject_saved_gpu(&format!(
"no successful presentation proof exists for '{}' ({})",
gpu.name, gpu.identity
));
return;
}
// Migrate model/class-only preferences once. The model stays for readable
// backward compatibility; the UUID becomes authoritative.
if pref != gpu.name || identity != gpu.identity {
crate::settings_qt::save_gpu_preference(Some(gpu));
log::info!("[qbz-qt] gpu: migrated Preferred GPU to stable identity");
}
// THE VULKAN COUPLING, and why it is the only honest route.
//
// MEASURED 2026-08-11 on the Intel-Arc + RTX-4070 hybrid (Wayland,
@@ -492,15 +940,17 @@ pub fn apply_gpu_preference() {
// is what killed the NPB Large visualiser when this row first shipped the
// PRIME envs. `QT_OPENGL=desktop` does not rescue it (measured).
//
// Vulkan has none of that problem: the same .qsb already carries SPIR-V,
// and `QT_VK_PHYSICAL_DEVICE_INDEX` counts the SAME order this module
// enumerated. So picking a non-default GPU implies the Vulkan backend —
// stated in the row's own description, never silent.
// Vulkan has none of that problem: the same .qsb already carries SPIR-V.
// Every explicit device — including Qt index 0 — forces Vulkan, otherwise
// an OpenGL default could silently use another adapter. The index below
// came from Qt's own persistent VkInstance for this launch.
arm_gpu_sentinel(gpu);
std::env::set_var("QSG_RHI_BACKEND", "vulkan");
std::env::set_var("QT_VK_PHYSICAL_DEVICE_INDEX", gpu.index.to_string());
log::info!(
"[qbz-qt] gpu: '{}' -> QSG_RHI_BACKEND=vulkan QT_VK_PHYSICAL_DEVICE_INDEX={}",
"[qbz-qt] gpu: '{}' ({}) -> QSG_RHI_BACKEND=vulkan QT_VK_PHYSICAL_DEVICE_INDEX={}",
gpu.name,
gpu.identity,
gpu.index
);
}
@@ -553,4 +1003,117 @@ mod tests {
assert!(reduce_motion(true));
GPU_TIER.store(true, Ordering::SeqCst);
}
fn raw_gpu(index: u32, name: &str, device_type: u32, uuid: &str) -> QtGpuJson {
QtGpuJson {
index,
name: name.to_string(),
vendor: 0x10de,
device: 0x2860,
device_type,
uuid: uuid.to_string(),
}
}
#[test]
fn qt_inventory_keeps_qt_indices_and_filters_cpu_adapters() {
let list = gpu_inventory_from_raw(vec![
raw_gpu(2, "lavapipe", 4, ""),
raw_gpu(1, "Intel Arc", 1, "8680557d080000000002000000000000"),
raw_gpu(0, "NVIDIA RTX", 2, "4e8f268581e1cbdcac12c7c15fa6624e"),
]);
assert_eq!(list.len(), 2);
assert_eq!(list[0].index, 0);
assert_eq!(list[0].name, "NVIDIA RTX");
assert_eq!(list[1].index, 1);
assert_eq!(list[1].name, "Intel Arc");
}
#[test]
fn stable_identity_wins_over_duplicate_model_names_and_index_changes() {
let first = GpuInfo {
index: 1,
name: "Same GPU".into(),
vendor_id: 1,
device_id: 2,
identity: "vk:first".into(),
discrete: true,
};
let wanted = GpuInfo {
index: 0,
name: "Same GPU".into(),
vendor_id: 1,
device_id: 2,
identity: "vk:wanted".into(),
discrete: true,
};
let list = vec![first, wanted];
assert_eq!(
resolve_gpu_in(&list, "Same GPU", "vk:wanted").map(|gpu| gpu.index),
Some(0)
);
assert!(resolve_gpu_in(&list, "Same GPU", "vk:missing").is_none());
assert!(
resolve_gpu_in(&list, "Same GPU", "").is_none(),
"a legacy name may not guess between identical devices"
);
assert!(
resolve_gpu_in(&list, "discrete", "").is_none(),
"a legacy class may not guess between multiple discrete GPUs"
);
}
#[test]
fn newer_readable_choice_can_replace_identity_left_by_an_older_build() {
let integrated = GpuInfo {
index: 0,
name: "Integrated".into(),
vendor_id: 1,
device_id: 1,
identity: "vk:integrated".into(),
discrete: false,
};
let discrete = GpuInfo {
index: 1,
name: "Discrete".into(),
vendor_id: 2,
device_id: 2,
identity: "vk:discrete".into(),
discrete: true,
};
let list = vec![integrated, discrete];
assert_eq!(
resolve_gpu_in(&list, "Discrete", "vk:integrated").map(|gpu| gpu.index),
Some(1)
);
}
#[test]
fn uuid_fallback_is_deterministic_when_driver_exposes_none() {
let raw = raw_gpu(0, " Example GPU ", 2, "00000000000000000000000000000000");
assert_eq!(stable_gpu_identity(&raw), "pci:10de:2860:example gpu");
}
#[test]
fn child_success_marker_requires_an_exact_nonempty_identity() {
assert_eq!(
approved_identity_from_stdout(
b"noise\nQBZ_GPU_PREFLIGHT_OK=vk:device-uuid\nmore noise\n"
)
.as_deref(),
Some("vk:device-uuid")
);
assert!(approved_identity_from_stdout(b"QBZ_GPU_PREFLIGHT_OK= \n").is_none());
assert!(approved_identity_from_stdout(b"prefix QBZ_GPU_PREFLIGHT_OK=vk:x\n").is_none());
}
#[test]
fn only_renderer_choices_that_preclude_vulkan_block_the_probe() {
for choice in ["software", "cpu", "soft", "gl", "gles", "femtovg"] {
assert!(renderer_choice_blocks_gpu(choice), "{choice}");
}
for choice in ["auto", "wgpu", "gpu", "hardware", "hw", "unknown"] {
assert!(!renderer_choice_blocks_gpu(choice), "{choice}");
}
}
}
+27 -19
View File
@@ -1321,6 +1321,20 @@ pub fn save_pref(key: &str, value: serde_json::Value) {
});
}
/// Persist Preferred GPU as one atomic pair: the readable model name retained
/// for older builds plus the stable Vulkan identity used by the Qt launcher.
/// The process-local Qt index is deliberately never written.
pub(crate) fn save_gpu_preference(gpu: Option<&crate::renderer_qt::GpuInfo>) {
let (name, identity) = gpu
.map(|gpu| (gpu.name.as_str(), gpu.identity.as_str()))
.unwrap_or(("auto", ""));
update_prefs(|doc| {
doc.insert("gpu_power".to_string(), serde_json::json!(name));
doc.insert("gpu_identity".to_string(), serde_json::json!(identity));
true
});
}
// Tray store (qbz_app::settings::tray — per-user tray_settings.db, the SAME
// file the Slint tray_settings.rs glue writes).
static TRAY: OnceLock<qbz_app::settings::tray::TraySettingsState> = OnceLock::new();
@@ -1563,11 +1577,11 @@ fn gpu_power_choice() -> (Vec<String>, i32) {
let gpus = crate::renderer_qt::gpus();
let mut opts = vec![qbz_i18n::t("Auto (recommended)")];
opts.extend(gpus.iter().map(|g| g.label()));
// Index 0 is Auto, so a device sits at its enumeration index + 1. An
// unmatched pref (a name from another machine, or a legacy class key with
// no such device) resolves to Auto.
let index = crate::renderer_qt::resolve_gpu(&pref_str("gpu_power", "auto"))
.map(|g| g.index as i32 + 1)
// Index 0 is Auto; UI positions are independent of Qt's process-local
// Vulkan indices (which can contain gaps when a CPU adapter is filtered).
let index = crate::renderer_qt::resolve_saved_gpu()
.and_then(|selected| gpus.iter().position(|gpu| gpu.identity == selected.identity))
.map(|position| position as i32 + 1)
.unwrap_or(0);
(opts, index)
}
@@ -2943,23 +2957,17 @@ pub async fn settings_select(runtime: &Arc<AppRuntime<LoggingAdapter>>, key: &st
crate::toast_qt::info(qbz_i18n::t("Renderer changed — restart QBZ to apply"));
}
"gpu-power" => {
// Index 0 is Auto; every other index is a REAL enumerated device,
// stored by NAME — the same shape the reference persists, so the
// shared ui_prefs round-trips between the two apps. The old arm
// accepted only index 0 and silently discarded the rest
// (PARITY-DEBT #83).
// Index 0 is Auto; every other position is a real Qt-enumerated
// device. Persist model + stable identity atomically, never the
// process-local Qt Vulkan index.
let gpus = crate::renderer_qt::gpus();
let value = if index <= 0 {
"auto".to_string()
let selected = if index <= 0 {
None
} else {
match gpus.get((index - 1) as usize) {
Some(g) => g.name.clone(),
// The list moved under the user (a GPU was unplugged, or
// the document is stale) — Auto rather than a wrong device.
None => "auto".to_string(),
}
gpus.get((index - 1) as usize)
};
save_pref("gpu_power", serde_json::json!(value));
save_gpu_preference(selected);
let value = selected.map(|gpu| gpu.name.as_str()).unwrap_or("auto");
log::info!("[qbz-qt] gpu_power -> {value} (restart to apply)");
crate::toast_qt::info(qbz_i18n::t("Preferred GPU changed — restart QBZ to apply"));
}
+11 -6
View File
@@ -458,9 +458,9 @@ pub mod qbz_shell {
/// with the number of frames the window actually swapped in it.
///
/// Proof of frames — not merely of a window — is what disarms the
/// startup sentinel: a backend can create a window and then never
/// present, which looks identical to a healthy start until the user
/// sees a frozen pane.
/// graphics startup sentinels: a backend or GPU can create a window and
/// then never present, which looks identical to a healthy start until
/// the user sees a frozen pane.
#[qinvokable]
fn report_frame_liveness(self: Pin<&mut QbzShell>, frames: i32);
@@ -1090,6 +1090,11 @@ impl qbz_shell::QbzShell {
} else {
start_shell_pulse();
}
if crate::renderer_qt::take_preflight_fallback_notice() {
crate::toast_qt::warning(qbz_i18n::t(
"Selected GPU is unavailable in this display session — using Auto",
));
}
}
/// The renderer probe's one-shot report (see the declaration).
@@ -1138,11 +1143,11 @@ impl qbz_shell::QbzShell {
// matters.
if crate::renderer_qt::sentinel_armed() {
log::warn!(
"[qbz-qt] renderer: the liveness window closed with NO frames rendered — \
leaving the startup sentinel armed so the next launch reverts to auto"
"[qbz-qt] graphics: the liveness window closed with NO frames rendered — \
leaving the startup sentinel armed so the next launch reverts to Auto"
);
} else {
log::debug!("[qbz-qt] renderer: no frames in the liveness window (nothing armed)");
log::debug!("[qbz-qt] graphics: no frames in the liveness window (nothing armed)");
}
return;
}