fix(qt): preflight preferred GPU presentation
cx
This commit is contained in:
17 files changed
+975
-187
No files matched your search
Generated
-10
@@ -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, чтобы применить"
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user