fix: url opening, dgpu dropdown instead of switcher, invisible icons (#941)

This commit is contained in:
LynithDev 2026-10-05 23:20:32 +02:00 • committed by GitHub
parent abf708fdb4
commit e7ddd85960
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 277 additions and 547 deletions

View file

@ -0,0 +1,7 @@
---
default: minor
---
# Choose which GPU the game renders on
The discrete GPU toggle is now a picker. Linux lists each graphics card by name, Windows offers integrated or dedicated.

View file

@ -0,0 +1,5 @@
---
default: patch
---
# Icons show on Linux systems with a comma decimal locale

View file

@ -0,0 +1,5 @@
---
default: patch
---
# Open folder and open in browser work again on Linux

1
Cargo.lock generated
View file

@ -4928,6 +4928,7 @@ dependencies = [
"freya-skia-safe", "freya-skia-safe",
"freya-testing", "freya-testing",
"futures-util", "futures-util",
"gtk-sys",
"image", "image",
"libloading 0.8.9", "libloading 0.8.9",
"mimalloc", "mimalloc",

View file

@ -71,6 +71,7 @@ mimalloc = "=0.1.52"
[target.'cfg(target_os = "linux")'.dependencies] [target.'cfg(target_os = "linux")'.dependencies]
libloading = "=0.8.9" libloading = "=0.8.9"
gtk-sys = "=0.18.2"
[target.'cfg(windows)'.dependencies] [target.'cfg(windows)'.dependencies]
windows = { version = "=0.62.2", features = [ windows = { version = "=0.62.2", features = [

View file

@ -98,6 +98,11 @@ impl App for OneClientApp {
} }
fn main() { fn main() {
#[cfg(target_os = "linux")]
unsafe {
gtk_sys::gtk_disable_setlocale();
}
let cli = cli::parse(); let cli = cli::parse();
let mut builder = Builder::new_multi_thread(); let mut builder = Builder::new_multi_thread();

View file

@ -108,6 +108,28 @@ fn has_allowed_scheme(url: &str) -> bool {
.any(|allowed| scheme.eq_ignore_ascii_case(allowed)) .any(|allowed| scheme.eq_ignore_ascii_case(allowed))
} }
#[cfg(target_os = "linux")]
fn open_target(target: &str) {
use std::process::Stdio;
let spawned = open::commands(target).into_iter().find_map(|mut command| {
command
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.ok()
});
match spawned {
Some(mut child) => {
std::thread::spawn(move || child.wait());
}
None => tracing::warn!("failed to open {target}"),
}
}
#[cfg(not(target_os = "linux"))]
fn open_target(target: &str) { fn open_target(target: &str) {
if let Err(err) = open::that_detached(target) { if let Err(err) = open::that_detached(target) {
tracing::warn!("failed to open {target}: {err}"); tracing::warn!("failed to open {target}: {err}");

View file

@ -1,8 +1,8 @@
use freya::prelude::*; use freya::prelude::*;
use oneclient_common::Patch; use oneclient_common::Patch;
#[cfg(any(target_os = "linux", windows))] use oneclient_core::settings::{
use oneclient_core::settings::SettingsOsExtra; GameSettingsProfile, PackageUpdateMode, ProfileUpdate, Resolution, SettingsOsExtra,
use oneclient_core::settings::{GameSettingsProfile, PackageUpdateMode, ProfileUpdate, Resolution}; };
use super::settings_page; use super::settings_page;
use crate::components::{ use crate::components::{
@ -65,11 +65,11 @@ impl Component for SettingsMinecraft {
move || v move || v
}); });
#[cfg(any(target_os = "linux", windows))] let gpu = use_state({
let discrete_gpu = use_state({ let v = profile.gpu().map(str::to_owned);
let v = profile.use_discrete_gpu();
move || v move || v
}); });
let gpus = use_state(oneclient_core::game::gpu::detect);
let mut first = use_state(|| true); let mut first = use_state(|| true);
use_side_effect(move || { use_side_effect(move || {
@ -84,22 +84,17 @@ impl Component for SettingsMinecraft {
&post_exit_command.read(), &post_exit_command.read(),
*update_mode.read(), *update_mode.read(),
); );
#[cfg(any(target_os = "linux", windows))] let selected_gpu = gpu.read().clone();
let gpu = *discrete_gpu.read();
if *first.peek() { if *first.peek() {
first.set(false); first.set(false);
return; return;
} }
#[cfg(any(target_os = "linux", windows))] let update = with_gpu(update, selected_gpu);
let update = with_discrete_gpu(update, gpu);
dispatch.update_global_profile(update); dispatch.update_global_profile(update);
}); });
#[cfg(any(target_os = "linux", windows))]
let discrete_gpu_default = SettingsOsExtra::default().use_discrete_gpu.unwrap_or(true);
let page = settings_page() let page = settings_page()
.child(section_header("GAME")) .child(section_header("GAME"))
.child(settings_row( .child(settings_row(
@ -211,34 +206,56 @@ impl Component for SettingsMinecraft {
), ),
)); ));
#[cfg(any(target_os = "linux", windows))] let page = if gpus.read().is_empty() {
let page = page page
.child(section_header("GRAPHICS")) } else {
.child(settings_row( page.child(section_header("GRAPHICS")).child(settings_row(
IconType::Rocket02, IconType::Rocket02,
"Use Discrete GPU", "GPU",
"Render the game on the dedicated graphics card. Does nothing on a machine with only one GPU, and draws noticeably more power on a laptop.", "The graphics card the game renders on. System default leaves the choice to your drivers.",
resettable(toggle(discrete_gpu), discrete_gpu, discrete_gpu_default), resettable(gpu_field(gpu, &gpus.read()), gpu, None),
)); ))
};
page.into_element() page.into_element()
} }
} }
#[cfg(any(target_os = "linux", windows))] fn with_gpu(mut update: ProfileUpdate, gpu: Option<String>) -> ProfileUpdate {
fn with_discrete_gpu(mut update: ProfileUpdate, on: bool) -> ProfileUpdate {
let base = crate::launcher::state() let base = crate::launcher::state()
.ok() .ok()
.and_then(|state| state.settings.read().global_game_settings.os_extra.clone()) .and_then(|state| state.settings.read().global_game_settings.os_extra.clone())
.unwrap_or_default(); .unwrap_or_default();
update.os_extra = Patch::Set(SettingsOsExtra { update.os_extra = Patch::Set(SettingsOsExtra { gpu, ..base });
use_discrete_gpu: Some(on),
..base
});
update update
} }
fn gpu_field(
mut selected: State<Option<String>>,
gpus: &[oneclient_core::game::gpu::Gpu],
) -> impl IntoElement {
const SYSTEM_DEFAULT: &str = "System default";
let current = selected
.read()
.as_deref()
.and_then(|id| gpus.iter().find(|gpu| gpu.id == id))
.map_or(SYSTEM_DEFAULT.to_string(), |gpu| gpu.name.clone());
let options = std::iter::once(SYSTEM_DEFAULT.to_string())
.chain(gpus.iter().map(|gpu| gpu.name.clone()))
.collect();
let ids: Vec<String> = gpus.iter().map(|gpu| gpu.id.clone()).collect();
Dropdown::new(current, options)
.width(Size::px(220.))
.height(Size::px(34.))
.on_select(move |idx: usize| {
selected.set(idx.checked_sub(1).and_then(|i| ids.get(i).cloned()));
})
}
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
fn build_update( fn build_update(
fullscreen: bool, fullscreen: bool,

View file

@ -31,8 +31,8 @@ pub use oneclient_common::domain::PackageUpdateMode;
pub struct SettingsOsExtra { pub struct SettingsOsExtra {
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
pub enable_gamemode: Option<bool>, pub enable_gamemode: Option<bool>,
#[cfg(any(target_os = "linux", windows))] #[serde(skip_serializing_if = "Option::is_none")]
pub use_discrete_gpu: Option<bool>, pub gpu: Option<String>,
#[serde(flatten)] #[serde(flatten)]
pub unknown: serde_json::Map<String, serde_json::Value>, pub unknown: serde_json::Map<String, serde_json::Value>,
} }
@ -42,8 +42,7 @@ impl Default for SettingsOsExtra {
Self { Self {
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
enable_gamemode: Some(true), enable_gamemode: Some(true),
#[cfg(any(target_os = "linux", windows))] gpu: None,
use_discrete_gpu: Some(true),
unknown: serde_json::Map::new(), unknown: serde_json::Map::new(),
} }
} }
@ -67,15 +66,8 @@ impl GameSettingsProfile {
} }
} }
pub fn use_discrete_gpu(&self) -> bool { pub fn gpu(&self) -> Option<&str> {
cfg_select! { self.os_extra.as_ref()?.gpu.as_deref()
any(target_os = "linux", target_os = "windows") => self
.os_extra
.as_ref()
.and_then(|extra| extra.use_discrete_gpu)
.unwrap_or(true),
_ => false
}
} }
pub fn is_global(&self) -> bool { pub fn is_global(&self) -> bool {

View file

@ -1,6 +1,4 @@
use std::path::{Path, PathBuf}; use std::path::Path;
const INTEL_INTEGRATED: &str = "0000:00:02.0";
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Driver { pub enum Driver {
@ -41,53 +39,25 @@ impl Driver {
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct Gpu { pub struct Card {
pub pci_address: String, pub pci_address: String,
pub boot_vga: Option<bool>, pub name: String,
pub driver: Driver, pub driver: Driver,
pub render_node: Option<PathBuf>,
pub discrete: bool,
} }
impl Gpu { pub fn offload_env(card: &Card) -> Vec<(&'static str, String)> {
fn nvidia_proprietary(&self) -> bool { let mut env = if card.driver == Driver::Nvidia {
self.driver == Driver::Nvidia
}
fn can_render(&self) -> bool {
self.render_node.is_some() || self.nvidia_proprietary()
}
fn priority(&self) -> u32 {
u32::from(self.discrete) * 2 + u32::from(self.boot_vga == Some(true))
}
}
pub fn offload_env(gpus: &[Gpu]) -> Vec<(&'static str, String)> {
let Some(best) = gpus
.iter()
.filter(|gpu| gpu.can_render())
.max_by_key(|gpu| gpu.priority())
else {
return Vec::new();
};
if !best.discrete || best.boot_vga == Some(true) {
return Vec::new();
}
let mut env = if best.nvidia_proprietary() {
vec![ vec![
("__NV_PRIME_RENDER_OFFLOAD", "1".to_string()), ("__NV_PRIME_RENDER_OFFLOAD", "1".to_string()),
("__VK_LAYER_NV_optimus", "NVIDIA_only".to_string()), ("__VK_LAYER_NV_optimus", "NVIDIA_only".to_string()),
("__GLX_VENDOR_LIBRARY_NAME", "nvidia".to_string()), ("__GLX_VENDOR_LIBRARY_NAME", "nvidia".to_string()),
] ]
} else { } else {
let prime = pci_tag(&best.pci_address).unwrap_or_else(|| "1".to_string()); let prime = format!("pci-{}", card.pci_address.replace([':', '.'], "_"));
vec![("DRI_PRIME", prime)] vec![("DRI_PRIME", prime)]
}; };
if let Some(icd) = best.driver.vulkan_icd() { if let Some(icd) = card.driver.vulkan_icd() {
env.push(("VK_LOADER_DRIVERS_SELECT", icd.to_string())); env.push(("VK_LOADER_DRIVERS_SELECT", icd.to_string()));
} }
@ -95,22 +65,25 @@ pub fn offload_env(gpus: &[Gpu]) -> Vec<(&'static str, String)> {
} }
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
pub fn detect() -> Vec<Gpu> { pub fn detect() -> Vec<Card> {
let mut gpus = read_pci_devices(Path::new("/sys/bus/pci/devices")); let ids = [
"/usr/share/hwdata/pci.ids",
"/usr/share/misc/pci.ids",
"/usr/share/pci.ids",
]
.iter()
.find_map(|path| std::fs::read_to_string(path).ok())
.unwrap_or_default();
for gpu in &mut gpus { read_pci_devices(Path::new("/sys/bus/pci/devices"), &ids)
gpu.discrete = probe_discrete(gpu);
}
gpus
} }
fn read_pci_devices(root: &Path) -> Vec<Gpu> { fn read_pci_devices(root: &Path, ids: &str) -> Vec<Card> {
let Ok(entries) = std::fs::read_dir(root) else { let Ok(entries) = std::fs::read_dir(root) else {
return Vec::new(); return Vec::new();
}; };
let mut gpus = Vec::new(); let mut cards = Vec::new();
for entry in entries.flatten() { for entry in entries.flatten() {
let name = entry.file_name(); let name = entry.file_name();
@ -120,28 +93,30 @@ fn read_pci_devices(root: &Path) -> Vec<Gpu> {
let device = entry.path(); let device = entry.path();
if read_class(&device.join("class")).is_none_or(|class| class >> 16 != 0x03) { if read_hex(&device.join("class")).is_none_or(|class| class >> 16 != 0x03) {
continue; continue;
} }
gpus.push(Gpu { let driver = Driver::from_name(read_link_name(&device.join("driver")).as_deref());
if driver != Driver::Nvidia && !has_render_node(&device.join("drm")) {
continue;
}
let vendor = read_hex(&device.join("vendor")).unwrap_or(0);
let model = read_hex(&device.join("device")).unwrap_or(0);
cards.push(Card {
pci_address: address.to_string(), pci_address: address.to_string(),
boot_vga: read_flag(&device.join("boot_vga")), name: display_name(ids, vendor, model, address),
driver: Driver::from_name(read_link_name(&device.join("driver")).as_deref()), driver,
render_node: render_node(&device.join("drm")),
discrete: false,
}); });
} }
gpus.sort_by(|a, b| a.pci_address.cmp(&b.pci_address)); cards.sort_by(|a, b| a.pci_address.cmp(&b.pci_address));
gpus cards
} }
fn read_flag(path: &Path) -> Option<bool> { fn read_hex(path: &Path) -> Option<u32> {
Some(std::fs::read_to_string(path).ok()?.trim() == "1")
}
fn read_class(path: &Path) -> Option<u32> {
let raw = std::fs::read_to_string(path).ok()?; let raw = std::fs::read_to_string(path).ok()?;
let raw = raw.trim(); let raw = raw.trim();
u32::from_str_radix(raw.strip_prefix("0x").unwrap_or(raw), 16).ok() u32::from_str_radix(raw.strip_prefix("0x").unwrap_or(raw), 16).ok()
@ -152,410 +127,95 @@ fn read_link_name(path: &Path) -> Option<String> {
Some(link.file_name()?.to_str()?.to_string()) Some(link.file_name()?.to_str()?.to_string())
} }
fn render_node(drm: &Path) -> Option<PathBuf> { fn has_render_node(drm: &Path) -> bool {
let entries = std::fs::read_dir(drm).ok()?; std::fs::read_dir(drm).is_ok_and(|entries| {
entries
entries.flatten().find_map(|entry| { .flatten()
let name = entry.file_name(); .any(|entry| entry.file_name().to_string_lossy().starts_with("renderD"))
name.to_str()?
.starts_with("renderD")
.then(|| Path::new("/dev/dri").join(&name))
}) })
} }
fn pci_tag(address: &str) -> Option<String> { fn display_name(ids: &str, vendor: u32, model: u32, address: &str) -> String {
let (domain, rest) = address.split_once(':')?; let brand = match vendor {
let (bus, rest) = rest.split_once(':')?; 0x10de => "NVIDIA",
let (device, function) = rest.split_once('.')?; 0x1002 => "AMD",
0x8086 => "Intel",
_ => "",
};
let widths = [(domain, 4), (bus, 2), (device, 2), (function, 1)]; let model = pci_ids_model(ids, vendor, model).map_or_else(
if widths || format!("GPU {address}"),
.iter() |name| {
.any(|(part, width)| part.len() != *width || !part.bytes().all(|b| b.is_ascii_hexdigit())) name.rsplit_once('[')
{ .and_then(|(_, marketing)| marketing.strip_suffix(']'))
return None; .unwrap_or(name)
} .to_string()
},
);
Some(format!("pci-{domain}_{bus}_{device}_{function}")) if brand.is_empty() {
} model
} else {
fn probe_discrete(gpu: &Gpu) -> bool { format!("{brand} {model}")
match gpu.driver {
Driver::Nvidia => true,
Driver::AmdGpu => answered(gpu, drm::amdgpu_is_discrete(gpu)),
Driver::Xe => answered(gpu, drm::xe_is_discrete(gpu)),
Driver::I915 => gpu.pci_address != INTEL_INTEGRATED,
Driver::Nouveau | Driver::Radeon | Driver::Other => gpu.boot_vga == Some(false),
} }
} }
fn answered(gpu: &Gpu, probe: std::io::Result<bool>) -> bool { fn pci_ids_model(ids: &str, vendor: u32, model: u32) -> Option<&str> {
probe.unwrap_or_else(|error| { let vendor = format!("{vendor:04x} ");
tracing::warn!( let model = format!("\t{model:04x} ");
card = gpu.pci_address,
?error,
"no discreteness answer; assuming integrated"
);
false
})
}
#[cfg(not(target_os = "linux"))] ids.lines()
mod drm { .skip_while(|line| !line.starts_with(&vendor))
use super::Gpu; .skip(1)
.take_while(|line| line.starts_with('\t') || line.starts_with('#'))
fn unavailable() -> std::io::Result<bool> { .find_map(|line| line.strip_prefix(&model))
Err(std::io::Error::from(std::io::ErrorKind::Unsupported))
}
pub fn amdgpu_is_discrete(_gpu: &Gpu) -> std::io::Result<bool> {
unavailable()
}
pub fn xe_is_discrete(_gpu: &Gpu) -> std::io::Result<bool> {
unavailable()
}
}
#[cfg(target_os = "linux")]
mod drm {
use std::fs::File;
use std::os::fd::AsRawFd;
use super::Gpu;
const DRM_IOCTL_BASE: u32 = b'd' as u32;
const DRM_COMMAND_BASE: u32 = 0x40;
fn open(gpu: &Gpu) -> std::io::Result<Option<File>> {
let Some(node) = &gpu.render_node else {
return Ok(None);
};
std::fs::OpenOptions::new()
.read(true)
.write(true)
.open(node)
.map(Some)
}
fn check(result: i32) -> std::io::Result<()> {
if result == 0 {
Ok(())
} else {
Err(std::io::Error::last_os_error())
}
}
fn command_write<T>(file: &File, index: u32, data: &T) -> std::io::Result<()> {
let request = libc::_IOW::<T>(DRM_IOCTL_BASE, DRM_COMMAND_BASE + index);
check(unsafe { libc::ioctl(file.as_raw_fd(), request, std::ptr::from_ref(data)) })
}
fn command_read_write<T>(file: &File, index: u32, data: &mut T) -> std::io::Result<()> {
let request = libc::_IOWR::<T>(DRM_IOCTL_BASE, DRM_COMMAND_BASE + index);
check(unsafe { libc::ioctl(file.as_raw_fd(), request, std::ptr::from_mut(data)) })
}
pub fn amdgpu_is_discrete(gpu: &Gpu) -> std::io::Result<bool> {
let Some(file) = open(gpu)? else {
return Ok(false);
};
#[repr(C)]
struct InfoDevice {
_before_ids_flags: [u32; 34],
ids_flags: u64,
}
#[repr(C)]
struct Info {
return_pointer: u64,
return_size: u32,
query: u32,
_query_args: [u32; 4],
}
let mut result = InfoDevice {
_before_ids_flags: [0; 34],
ids_flags: 0,
};
const AMDGPU_INFO_DEV_INFO: u32 = 0x16;
let query = Info {
return_pointer: std::ptr::from_mut(&mut result) as u64,
return_size: size_of_val(&result) as u32,
query: AMDGPU_INFO_DEV_INFO,
_query_args: [0; 4],
};
const DRM_AMDGPU_INFO: u32 = 0x05;
command_write(&file, DRM_AMDGPU_INFO, &query)?;
const AMDGPU_IDS_FLAGS_FUSION: u64 = 0x1;
Ok(result.ids_flags & AMDGPU_IDS_FLAGS_FUSION == 0)
}
pub fn xe_is_discrete(gpu: &Gpu) -> std::io::Result<bool> {
let Some(file) = open(gpu)? else {
return Ok(false);
};
#[repr(C)]
struct DeviceQuery {
extensions: u64,
query: u32,
size: u32,
data: u64,
_reserved: [u64; 2],
}
const DRM_XE_DEVICE_QUERY: u32 = 0x00;
const DRM_XE_DEVICE_QUERY_CONFIG: u32 = 0x2;
let mut query = DeviceQuery {
extensions: 0,
query: DRM_XE_DEVICE_QUERY_CONFIG,
size: 0,
data: 0,
_reserved: [0; 2],
};
command_read_write(&file, DRM_XE_DEVICE_QUERY, &mut query)?;
const INFO_OFFSET: usize = 1;
const CONFIG_FLAGS: usize = 1;
let words = (query.size as usize).div_ceil(size_of::<u64>());
if words <= INFO_OFFSET + CONFIG_FLAGS {
tracing::warn!(size = query.size, "xe config too short to hold its flags");
return Ok(false);
}
let mut data = vec![0_u64; words];
query.data = data.as_mut_ptr() as u64;
command_read_write(&file, DRM_XE_DEVICE_QUERY, &mut query)?;
const DRM_XE_QUERY_CONFIG_FLAG_HAS_VRAM: u64 = 1 << 0;
Ok(data[INFO_OFFSET + CONFIG_FLAGS] & DRM_XE_QUERY_CONFIG_FLAG_HAS_VRAM != 0)
}
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
const AMD_INTEGRATED: &str = "0000:0c:00.0"; const IDS: &str = "\
# comment
10de NVIDIA Corporation
\t25a2 GA107M [GeForce RTX 3050 Mobile]
\t\t1043 1e1f GA107M [GeForce RTX 3050 Mobile]
\t1234 Plain Name
1002 Advanced Micro Devices, Inc. [AMD/ATI]
\t25a2 Not The Nvidia Card
8086 Intel Corporation
";
fn gpu(address: &str, driver: Driver, boot_vga: Option<bool>) -> Gpu { fn card(address: &str, driver: Driver) -> Card {
let discrete = match driver { Card {
Driver::Nvidia => true,
Driver::I915 => address != INTEL_INTEGRATED,
_ => boot_vga == Some(false),
};
Gpu {
pci_address: address.to_string(), pci_address: address.to_string(),
boot_vga, name: String::new(),
driver, driver,
render_node: Some(PathBuf::from("/dev/dri/renderD128")),
discrete,
}
}
fn nvidia_offload() -> Vec<(&'static str, String)> {
vec![
("__NV_PRIME_RENDER_OFFLOAD", "1".to_string()),
("__VK_LAYER_NV_optimus", "NVIDIA_only".to_string()),
("__GLX_VENDOR_LIBRARY_NAME", "nvidia".to_string()),
("VK_LOADER_DRIVERS_SELECT", "*nvidia*".to_string()),
]
}
fn dri_prime(card: &str, icd: &str) -> Vec<(&'static str, String)> {
vec![
("DRI_PRIME", card.to_string()),
("VK_LOADER_DRIVERS_SELECT", icd.to_string()),
]
}
#[test]
fn offload_env_names_the_right_card_for_every_pairing() {
let igpu = || gpu(INTEL_INTEGRATED, Driver::I915, Some(true));
let dgpu = || gpu("0000:01:00.0", Driver::Nvidia, Some(false));
type Case = (&'static str, Vec<Gpu>, Vec<(&'static str, String)>);
let amd = "*radeon*,*amd*";
let cases: Vec<Case> = vec![
("no cards at all", vec![], vec![]),
(
"a lone amd card",
vec![gpu("0000:01:00.0", Driver::AmdGpu, Some(true))],
vec![],
),
(
"a lone nvidia card",
vec![gpu("0000:01:00.0", Driver::Nvidia, Some(true))],
vec![],
),
("a lone intel card", vec![igpu()], vec![]),
(
"an all-amd hybrid never names nvidia",
vec![
gpu(AMD_INTEGRATED, Driver::AmdGpu, Some(true)),
gpu("0000:01:00.0", Driver::AmdGpu, Some(false)),
],
dri_prime("pci-0000_01_00_0", amd),
),
(
"an intel + amd hybrid never names nvidia",
vec![igpu(), gpu("0000:01:00.0", Driver::AmdGpu, Some(false))],
dri_prime("pci-0000_01_00_0", amd),
),
(
"an optimus laptop gets the nvidia variables and no DRI_PRIME",
vec![igpu(), dgpu()],
nvidia_offload(),
),
(
"the same laptop on nouveau gets DRI_PRIME and no ICD filter",
vec![
igpu(),
Gpu {
driver: Driver::Nouveau,
..dgpu()
},
],
vec![("DRI_PRIME", "pci-0000_01_00_0".to_string())],
),
(
"an nvidia card without a render node is still a target",
vec![
igpu(),
Gpu {
render_node: None,
..dgpu()
},
],
nvidia_offload(),
),
(
"an unbound card is never the target",
vec![
igpu(),
Gpu {
driver: Driver::Other,
render_node: None,
..dgpu()
},
],
vec![],
),
(
"a bmc display adapter is skipped for the real card behind it",
vec![
igpu(),
Gpu {
render_node: None,
..gpu("0000:03:00.0", Driver::Other, Some(false))
},
gpu("0000:c1:00.0", Driver::AmdGpu, Some(false)),
],
dri_prime("pci-0000_c1_00_0", amd),
),
(
"the discrete card wins over a renderable card at a lower address",
vec![
igpu(),
Gpu {
discrete: false,
..gpu("0000:03:00.0", Driver::AmdGpu, Some(false))
},
gpu("0000:c1:00.0", Driver::AmdGpu, Some(false)),
],
dri_prime("pci-0000_c1_00_0", amd),
),
(
"nothing happens when the discrete card already drives the display",
vec![
gpu("0000:01:00.0", Driver::Nvidia, Some(true)),
gpu(INTEL_INTEGRATED, Driver::I915, Some(false)),
],
vec![],
),
(
"the driver still finds the discrete card when nothing publishes boot_vga",
vec![
gpu(INTEL_INTEGRATED, Driver::I915, None),
gpu("0000:01:00.0", Driver::Nvidia, None),
],
nvidia_offload(),
),
(
"a malformed pci address falls back to the ordinal",
vec![
gpu(AMD_INTEGRATED, Driver::AmdGpu, Some(true)),
gpu("not-an-address", Driver::AmdGpu, Some(false)),
],
dri_prime("1", amd),
),
];
for (name, gpus, expected) in cases {
assert_eq!(offload_env(&gpus), expected, "{name}");
} }
} }
#[test] #[test]
fn pci_tags_match_the_shape_mesa_builds() { fn offload_env_names_the_chosen_card() {
assert_eq!(pci_tag("0000:01:00.0").as_deref(), Some("pci-0000_01_00_0"));
assert_eq!( assert_eq!(
pci_tag("10000:af:1f.7").as_deref(), offload_env(&card("0000:01:00.0", Driver::Nvidia)),
None, vec![
"domain is 4 wide" ("__NV_PRIME_RENDER_OFFLOAD", "1".to_string()),
("__VK_LAYER_NV_optimus", "NVIDIA_only".to_string()),
("__GLX_VENDOR_LIBRARY_NAME", "nvidia".to_string()),
("VK_LOADER_DRIVERS_SELECT", "*nvidia*".to_string()),
],
); );
assert_eq!(pci_tag("0000:1:00.0"), None, "bus is 2 wide"); assert_eq!(
assert_eq!(pci_tag("0000:01:00"), None, "no function"); offload_env(&card("0000:c1:00.0", Driver::AmdGpu)),
assert_eq!(pci_tag("0000:0g:00.0"), None, "not hex"); vec![
} ("DRI_PRIME", "pci-0000_c1_00_0".to_string()),
("VK_LOADER_DRIVERS_SELECT", "*radeon*,*amd*".to_string()),
#[test] ],
fn a_driver_that_will_not_answer_is_never_called_discrete() {
let mut apu = gpu(AMD_INTEGRATED, Driver::AmdGpu, Some(false));
apu.discrete = probe_discrete(&apu);
assert!(!apu.discrete);
let mut dgpu = gpu("0000:03:00.0", Driver::AmdGpu, Some(true));
dgpu.discrete = probe_discrete(&dgpu);
assert!(!dgpu.discrete);
assert!(
offload_env(&[apu, dgpu]).is_empty(),
"knowing nothing, leave the renderer alone"
); );
} assert_eq!(
offload_env(&card("0000:01:00.0", Driver::Nouveau)),
#[test] vec![("DRI_PRIME", "pci-0000_01_00_0".to_string())],
fn the_cards_we_can_place_without_asking_the_driver() { "nouveau gets no ICD filter",
assert!(probe_discrete(&gpu(
"0000:01:00.0",
Driver::Nvidia,
Some(false)
)));
assert!(!probe_discrete(&gpu(
INTEL_INTEGRATED,
Driver::I915,
Some(true)
)));
assert!(
probe_discrete(&gpu("0000:03:00.0", Driver::I915, Some(false))),
"an arc board does not sit at the integrated address"
);
assert!(
probe_discrete(&gpu("0000:01:00.0", Driver::Nouveau, Some(false))),
"no query, so boot_vga is all we have"
); );
} }
@ -570,10 +230,11 @@ mod tests {
std::fs::write(path, contents).unwrap(); std::fs::write(path, contents).unwrap();
} }
fn write_card(bus: &Path, address: &str, boot_vga: &str, driver: &str) { fn write_card(bus: &Path, address: &str, vendor: &str, model: &str, driver: &str) {
let device = bus.join(address); let device = bus.join(address);
write(&device.join("boot_vga"), &format!("{boot_vga}\n"));
write(&device.join("class"), "0x030000\n"); write(&device.join("class"), "0x030000\n");
write(&device.join("vendor"), &format!("0x{vendor}\n"));
write(&device.join("device"), &format!("0x{model}\n"));
let target = bus.join("drivers").join(driver); let target = bus.join("drivers").join(driver);
std::fs::create_dir_all(&target).unwrap(); std::fs::create_dir_all(&target).unwrap();
@ -581,58 +242,41 @@ mod tests {
} }
#[test] #[test]
fn reads_boot_flag_driver_and_render_node_out_of_a_sysfs_tree() { fn lists_the_cards_that_can_render_out_of_a_sysfs_tree() {
let scratch = polyio::testing::ScratchDir::new("gpu-sysfs"); let scratch = polyio::testing::ScratchDir::new("gpu-sysfs");
let bus = scratch.join("devices"); let bus = scratch.join("devices");
write_card(&bus, INTEL_INTEGRATED, "1", "i915"); write_card(&bus, "0000:00:02.0", "8086", "9a49", "i915");
write( write(
&bus.join(INTEL_INTEGRATED) &bus.join("0000:00:02.0")
.join("drm") .join("drm")
.join("renderD128") .join("renderD128")
.join("dev"), .join("dev"),
"226:128\n", "226:128\n",
); );
write_card(&bus, "0000:01:00.0", "0", "nvidia"); write_card(&bus, "0000:01:00.0", "10de", "25a2", "nvidia");
write_card(&bus, "0000:03:00.0", "1a03", "2000", "ast");
let gpus = read_pci_devices(&bus);
assert_eq!(gpus.len(), 2);
assert_eq!(gpus[0].pci_address, INTEL_INTEGRATED);
assert_eq!(gpus[0].boot_vga, Some(true));
assert_eq!(gpus[0].driver, Driver::I915);
assert_eq!(
gpus[0].render_node,
Some(PathBuf::from("/dev/dri/renderD128")),
"the node the ioctl probes open"
);
assert_eq!(gpus[1].boot_vga, Some(false));
assert_eq!(gpus[1].driver, Driver::Nvidia);
assert_eq!(gpus[1].render_node, None, "no nvidia-drm in this tree");
assert!(gpus.iter().all(|gpu| !gpu.discrete));
}
#[test]
fn skips_everything_on_the_bus_that_is_not_a_display_controller() {
let scratch = polyio::testing::ScratchDir::new("gpu-bus");
let bus = scratch.join("devices");
write_card(&bus, "0000:01:00.0", "1", "amdgpu");
write(&bus.join("0000:01:00.1").join("class"), "0x040300\n"); write(&bus.join("0000:01:00.1").join("class"), "0x040300\n");
write(&bus.join("0000:02:00.0").join("class"), "0x020000\n"); write(&bus.join("0000:02:00.0").join("class"), "0x020000\n");
std::fs::create_dir_all(bus.join("0000:03:00.0")).unwrap(); std::fs::create_dir_all(bus.join("0000:04:00.0")).unwrap();
let gpus = read_pci_devices(&bus); let gpus = read_pci_devices(&bus, IDS);
assert_eq!(gpus.len(), 1, "one card, counted once"); assert_eq!(
assert_eq!(gpus[0].driver, Driver::AmdGpu); gpus.len(),
2,
"no bmc adapter, audio, nic or classless device"
);
assert_eq!(gpus[0].pci_address, "0000:00:02.0");
assert_eq!(gpus[0].driver, Driver::I915);
assert_eq!(gpus[1].driver, Driver::Nvidia, "needs no render node");
assert_eq!(gpus[1].name, "NVIDIA GeForce RTX 3050 Mobile");
} }
#[test] #[test]
fn a_missing_pci_tree_is_not_an_error() { fn a_missing_pci_tree_is_not_an_error() {
let scratch = polyio::testing::ScratchDir::new("gpu-missing"); let scratch = polyio::testing::ScratchDir::new("gpu-missing");
assert!(read_pci_devices(&scratch.join("nope")).is_empty()); assert!(read_pci_devices(&scratch.join("nope"), "").is_empty());
} }
} }

View file

@ -1,30 +1,69 @@
#[cfg(any(target_os = "linux", test))] #[cfg(any(target_os = "linux", test))]
mod linux; mod linux;
use tokio::process::Command; #[derive(Debug, Clone, PartialEq, Eq)]
pub struct Gpu {
pub id: String,
pub name: String,
}
#[cfg(windows)]
const WINDOWS_PREFERENCES: [(&str, &str, u8); 2] = [
("integrated", "Integrated GPU", 1),
("dedicated", "Dedicated GPU", 2),
];
pub fn detect() -> Vec<Gpu> {
cfg_select! {
target_os = "linux" => linux::detect()
.into_iter()
.map(|card| Gpu {
id: card.pci_address,
name: card.name,
})
.collect(),
windows => WINDOWS_PREFERENCES
.iter()
.map(|(id, name, _)| Gpu {
id: (*id).to_string(),
name: (*name).to_string(),
})
.collect(),
_ => Vec::new(),
}
}
#[allow(unused_variables)] #[allow(unused_variables)]
pub async fn prefer_discrete(command: &mut Command, java_path: &str) { pub async fn select(command: &mut tokio::process::Command, java_path: &str, id: &str) {
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
{ {
let gpus = linux::detect(); let cards = tokio::task::spawn_blocking(linux::detect)
let env = linux::offload_env(&gpus); .await
.unwrap_or_default();
if env.is_empty() { let Some(card) = cards.into_iter().find(|card| card.pci_address == id) else {
tracing::info!( tracing::warn!(id, "the selected GPU is gone; leaving the renderer alone");
gpus = gpus.len(),
"discrete GPU was requested but nothing here is a valid offload target; \
leaving the renderer alone"
);
return; return;
} };
for (key, value) in env { for (key, value) in linux::offload_env(&card) {
tracing::debug!(key, value, "offloading the game to the discrete GPU"); tracing::debug!(
key,
value,
gpu = card.name,
"rendering the game on the selected GPU"
);
command.env(key, value); command.env(key, value);
} }
} }
#[cfg(windows)] #[cfg(windows)]
oneclient_java::prefer_dedicated_gpu(std::path::Path::new(java_path)).await; {
let Some((_, _, preference)) = WINDOWS_PREFERENCES.iter().find(|(known, ..)| *known == id)
else {
tracing::warn!(id, "unknown GPU preference; leaving the renderer alone");
return;
};
oneclient_java::set_gpu_preference(std::path::Path::new(java_path), *preference).await;
}
} }

View file

@ -382,8 +382,8 @@ async fn start(
let (mut command, wrapper) = base_command(&profile, &java.absolute_path); let (mut command, wrapper) = base_command(&profile, &java.absolute_path);
if profile.use_discrete_gpu() { if let Some(gpu) = profile.gpu() {
crate::game::gpu::prefer_discrete(&mut command, &java.absolute_path).await; crate::game::gpu::select(&mut command, &java.absolute_path, gpu).await;
} }
apply_env(&mut command, &profile); apply_env(&mut command, &profile);

View file

@ -1,7 +1,7 @@
mod analytics; mod analytics;
mod error; mod error;
pub mod fabric; pub mod fabric;
mod gpu; pub mod gpu;
mod heal; mod heal;
mod launch; mod launch;
mod log_replay; mod log_replay;

View file

@ -18,7 +18,7 @@ pub use data::{JavaPackage, JavaRuntime, PackageArchive, java_executable_relativ
pub use error::{JavaError, JavaResult}; pub use error::{JavaError, JavaResult};
pub use install::{install_package, is_launcher_managed}; pub use install::{install_package, is_launcher_managed};
pub use locate::{LocatedJava, best_for_major, locate_java}; pub use locate::{LocatedJava, best_for_major, locate_java};
pub use platform::{HostArch, HostOs, HostTarget, prefer_dedicated_gpu}; pub use platform::{HostArch, HostOs, HostTarget, set_gpu_preference};
pub use service::{ pub use service::{
AvailableJava, INSTALLABLE_MAJORS, JAVA_CHOICE_DOWNLOAD, JAVA_CHOICE_FOLDER, JAVA_VENDOR_HINT, AvailableJava, INSTALLABLE_MAJORS, JAVA_CHOICE_DOWNLOAD, JAVA_CHOICE_FOLDER, JAVA_VENDOR_HINT,
JavaService, JavaService,

View file

@ -110,17 +110,8 @@ fn registered_value<'a>(query: &'a str, name: &str) -> Option<&'a str> {
}) })
} }
/// Windows writes `GpuPreference=0;` ("let Windows decide") for any app added
/// through Settings, so only 1 or 2 is a choice worth leaving alone
#[cfg(windows)] #[cfg(windows)]
fn has_explicit_preference(data: &str) -> bool { pub async fn set_gpu_preference(java_path: &std::path::Path, preference: u8) {
data.split(';')
.find_map(|part| part.trim().strip_prefix("GpuPreference="))
.is_some_and(|preference| matches!(preference.trim(), "1" | "2"))
}
#[cfg(windows)]
pub async fn prefer_dedicated_gpu(java_path: &std::path::Path) {
let Some(dir) = java_path.parent() else { let Some(dir) = java_path.parent() else {
return; return;
}; };
@ -150,21 +141,22 @@ pub async fn prefer_dedicated_gpu(java_path: &std::path::Path) {
} }
}; };
let data = format!("GpuPreference={preference};");
for path in targets { for path in targets {
if registered_value(&query, &path.to_string_lossy()).is_some_and(has_explicit_preference) { if registered_value(&query, &path.to_string_lossy()) == Some(data.as_str()) {
continue; continue;
} }
let added = reg_command() let added = reg_command()
.args(["add", GPU_PREFERENCES_KEY, "/v"]) .args(["add", GPU_PREFERENCES_KEY, "/v"])
.arg(&path) .arg(&path)
.args(["/t", "REG_SZ", "/d", "GpuPreference=2;", "/f"]) .args(["/t", "REG_SZ", "/d", data.as_str(), "/f"])
.output() .output()
.await; .await;
match added { match added {
Ok(out) if out.status.success() => { Ok(out) if out.status.success() => {
tracing::info!(path = %path.display(), "registered JVM for high-performance GPU"); tracing::info!(path = %path.display(), preference, "registered JVM GPU preference");
} }
Ok(out) => tracing::warn!( Ok(out) => tracing::warn!(
path = %path.display(), path = %path.display(),
@ -205,7 +197,7 @@ pub async fn forget_dedicated_gpu(java_path: &std::path::Path) {
} }
#[cfg(not(windows))] #[cfg(not(windows))]
pub async fn prefer_dedicated_gpu(_java_path: &std::path::Path) {} pub async fn set_gpu_preference(_java_path: &std::path::Path, _preference: u8) {}
#[cfg(not(windows))] #[cfg(not(windows))]
pub async fn forget_dedicated_gpu(_java_path: &std::path::Path) {} pub async fn forget_dedicated_gpu(_java_path: &std::path::Path) {}