From e7ddd85960b4a97914dd5a9522f82e14ae9873fe Mon Sep 17 00:00:00 2001 From: LynithDev <61880709+LynithDev@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:20:32 +0200 Subject: [PATCH] fix: url opening, dgpu dropdown instead of switcher, invisible icons (#941) --- .../choose_which_gpu_the_game_renders_on.md | 7 + ...ow_on_linux_with_a_comma_decimal_locale.md | 5 + ...and_open_in_browser_work_again_on_linux.md | 5 + Cargo.lock | 1 + packages/oneclient_app/Cargo.toml | 1 + packages/oneclient_app/src/main.rs | 5 + packages/oneclient_app/src/platform.rs | 22 + .../src/view/app/settings/minecraft.rs | 71 ++- packages/oneclient_cluster/src/profile.rs | 18 +- packages/oneclient_core/src/game/gpu/linux.rs | 592 ++++-------------- packages/oneclient_core/src/game/gpu/mod.rs | 69 +- packages/oneclient_core/src/game/launch.rs | 4 +- packages/oneclient_core/src/game/mod.rs | 2 +- packages/oneclient_java/src/lib.rs | 2 +- packages/oneclient_java/src/platform.rs | 20 +- 15 files changed, 277 insertions(+), 547 deletions(-) create mode 100644 .changeset/choose_which_gpu_the_game_renders_on.md create mode 100644 .changeset/icons_show_on_linux_with_a_comma_decimal_locale.md create mode 100644 .changeset/open_folder_and_open_in_browser_work_again_on_linux.md diff --git a/.changeset/choose_which_gpu_the_game_renders_on.md b/.changeset/choose_which_gpu_the_game_renders_on.md new file mode 100644 index 00000000..182a4fea --- /dev/null +++ b/.changeset/choose_which_gpu_the_game_renders_on.md @@ -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. diff --git a/.changeset/icons_show_on_linux_with_a_comma_decimal_locale.md b/.changeset/icons_show_on_linux_with_a_comma_decimal_locale.md new file mode 100644 index 00000000..58890c0b --- /dev/null +++ b/.changeset/icons_show_on_linux_with_a_comma_decimal_locale.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +# Icons show on Linux systems with a comma decimal locale diff --git a/.changeset/open_folder_and_open_in_browser_work_again_on_linux.md b/.changeset/open_folder_and_open_in_browser_work_again_on_linux.md new file mode 100644 index 00000000..8115970e --- /dev/null +++ b/.changeset/open_folder_and_open_in_browser_work_again_on_linux.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +# Open folder and open in browser work again on Linux diff --git a/Cargo.lock b/Cargo.lock index 62a11a74..327101da 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4928,6 +4928,7 @@ dependencies = [ "freya-skia-safe", "freya-testing", "futures-util", + "gtk-sys", "image", "libloading 0.8.9", "mimalloc", diff --git a/packages/oneclient_app/Cargo.toml b/packages/oneclient_app/Cargo.toml index f18ed955..3577359d 100644 --- a/packages/oneclient_app/Cargo.toml +++ b/packages/oneclient_app/Cargo.toml @@ -71,6 +71,7 @@ mimalloc = "=0.1.52" [target.'cfg(target_os = "linux")'.dependencies] libloading = "=0.8.9" +gtk-sys = "=0.18.2" [target.'cfg(windows)'.dependencies] windows = { version = "=0.62.2", features = [ diff --git a/packages/oneclient_app/src/main.rs b/packages/oneclient_app/src/main.rs index 43b67b67..ff32df5f 100644 --- a/packages/oneclient_app/src/main.rs +++ b/packages/oneclient_app/src/main.rs @@ -98,6 +98,11 @@ impl App for OneClientApp { } fn main() { + #[cfg(target_os = "linux")] + unsafe { + gtk_sys::gtk_disable_setlocale(); + } + let cli = cli::parse(); let mut builder = Builder::new_multi_thread(); diff --git a/packages/oneclient_app/src/platform.rs b/packages/oneclient_app/src/platform.rs index e0d25e3d..663023c6 100644 --- a/packages/oneclient_app/src/platform.rs +++ b/packages/oneclient_app/src/platform.rs @@ -108,6 +108,28 @@ fn has_allowed_scheme(url: &str) -> bool { .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) { if let Err(err) = open::that_detached(target) { tracing::warn!("failed to open {target}: {err}"); diff --git a/packages/oneclient_app/src/view/app/settings/minecraft.rs b/packages/oneclient_app/src/view/app/settings/minecraft.rs index ee29059d..0fb71eb1 100644 --- a/packages/oneclient_app/src/view/app/settings/minecraft.rs +++ b/packages/oneclient_app/src/view/app/settings/minecraft.rs @@ -1,8 +1,8 @@ use freya::prelude::*; use oneclient_common::Patch; -#[cfg(any(target_os = "linux", windows))] -use oneclient_core::settings::SettingsOsExtra; -use oneclient_core::settings::{GameSettingsProfile, PackageUpdateMode, ProfileUpdate, Resolution}; +use oneclient_core::settings::{ + GameSettingsProfile, PackageUpdateMode, ProfileUpdate, Resolution, SettingsOsExtra, +}; use super::settings_page; use crate::components::{ @@ -65,11 +65,11 @@ impl Component for SettingsMinecraft { move || v }); - #[cfg(any(target_os = "linux", windows))] - let discrete_gpu = use_state({ - let v = profile.use_discrete_gpu(); + let gpu = use_state({ + let v = profile.gpu().map(str::to_owned); move || v }); + let gpus = use_state(oneclient_core::game::gpu::detect); let mut first = use_state(|| true); use_side_effect(move || { @@ -84,22 +84,17 @@ impl Component for SettingsMinecraft { &post_exit_command.read(), *update_mode.read(), ); - #[cfg(any(target_os = "linux", windows))] - let gpu = *discrete_gpu.read(); + let selected_gpu = gpu.read().clone(); if *first.peek() { first.set(false); return; } - #[cfg(any(target_os = "linux", windows))] - let update = with_discrete_gpu(update, gpu); + let update = with_gpu(update, selected_gpu); 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() .child(section_header("GAME")) .child(settings_row( @@ -211,34 +206,56 @@ impl Component for SettingsMinecraft { ), )); - #[cfg(any(target_os = "linux", windows))] - let page = page - .child(section_header("GRAPHICS")) - .child(settings_row( + let page = if gpus.read().is_empty() { + page + } else { + page.child(section_header("GRAPHICS")).child(settings_row( IconType::Rocket02, - "Use Discrete 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.", - resettable(toggle(discrete_gpu), discrete_gpu, discrete_gpu_default), - )); + "GPU", + "The graphics card the game renders on. System default leaves the choice to your drivers.", + resettable(gpu_field(gpu, &gpus.read()), gpu, None), + )) + }; page.into_element() } } -#[cfg(any(target_os = "linux", windows))] -fn with_discrete_gpu(mut update: ProfileUpdate, on: bool) -> ProfileUpdate { +fn with_gpu(mut update: ProfileUpdate, gpu: Option) -> ProfileUpdate { let base = crate::launcher::state() .ok() .and_then(|state| state.settings.read().global_game_settings.os_extra.clone()) .unwrap_or_default(); - update.os_extra = Patch::Set(SettingsOsExtra { - use_discrete_gpu: Some(on), - ..base - }); + update.os_extra = Patch::Set(SettingsOsExtra { gpu, ..base }); update } +fn gpu_field( + mut selected: State>, + 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 = 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)] fn build_update( fullscreen: bool, diff --git a/packages/oneclient_cluster/src/profile.rs b/packages/oneclient_cluster/src/profile.rs index b727538d..c05b030d 100644 --- a/packages/oneclient_cluster/src/profile.rs +++ b/packages/oneclient_cluster/src/profile.rs @@ -31,8 +31,8 @@ pub use oneclient_common::domain::PackageUpdateMode; pub struct SettingsOsExtra { #[cfg(target_os = "linux")] pub enable_gamemode: Option, - #[cfg(any(target_os = "linux", windows))] - pub use_discrete_gpu: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub gpu: Option, #[serde(flatten)] pub unknown: serde_json::Map, } @@ -42,8 +42,7 @@ impl Default for SettingsOsExtra { Self { #[cfg(target_os = "linux")] enable_gamemode: Some(true), - #[cfg(any(target_os = "linux", windows))] - use_discrete_gpu: Some(true), + gpu: None, unknown: serde_json::Map::new(), } } @@ -67,15 +66,8 @@ impl GameSettingsProfile { } } - pub fn use_discrete_gpu(&self) -> bool { - cfg_select! { - 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 gpu(&self) -> Option<&str> { + self.os_extra.as_ref()?.gpu.as_deref() } pub fn is_global(&self) -> bool { diff --git a/packages/oneclient_core/src/game/gpu/linux.rs b/packages/oneclient_core/src/game/gpu/linux.rs index b2b89c2c..a2ccdb85 100644 --- a/packages/oneclient_core/src/game/gpu/linux.rs +++ b/packages/oneclient_core/src/game/gpu/linux.rs @@ -1,6 +1,4 @@ -use std::path::{Path, PathBuf}; - -const INTEL_INTEGRATED: &str = "0000:00:02.0"; +use std::path::Path; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Driver { @@ -41,53 +39,25 @@ impl Driver { } #[derive(Debug, Clone, PartialEq, Eq)] -pub struct Gpu { +pub struct Card { pub pci_address: String, - pub boot_vga: Option, + pub name: String, pub driver: Driver, - pub render_node: Option, - pub discrete: bool, } -impl Gpu { - fn nvidia_proprietary(&self) -> bool { - 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() { +pub fn offload_env(card: &Card) -> Vec<(&'static str, String)> { + let mut env = if card.driver == Driver::Nvidia { vec![ ("__NV_PRIME_RENDER_OFFLOAD", "1".to_string()), ("__VK_LAYER_NV_optimus", "NVIDIA_only".to_string()), ("__GLX_VENDOR_LIBRARY_NAME", "nvidia".to_string()), ] } 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)] }; - 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())); } @@ -95,22 +65,25 @@ pub fn offload_env(gpus: &[Gpu]) -> Vec<(&'static str, String)> { } #[cfg(target_os = "linux")] -pub fn detect() -> Vec { - let mut gpus = read_pci_devices(Path::new("/sys/bus/pci/devices")); +pub fn detect() -> Vec { + 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 { - gpu.discrete = probe_discrete(gpu); - } - - gpus + read_pci_devices(Path::new("/sys/bus/pci/devices"), &ids) } -fn read_pci_devices(root: &Path) -> Vec { +fn read_pci_devices(root: &Path, ids: &str) -> Vec { let Ok(entries) = std::fs::read_dir(root) else { return Vec::new(); }; - let mut gpus = Vec::new(); + let mut cards = Vec::new(); for entry in entries.flatten() { let name = entry.file_name(); @@ -120,28 +93,30 @@ fn read_pci_devices(root: &Path) -> Vec { 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; } - 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(), - boot_vga: read_flag(&device.join("boot_vga")), - driver: Driver::from_name(read_link_name(&device.join("driver")).as_deref()), - render_node: render_node(&device.join("drm")), - discrete: false, + name: display_name(ids, vendor, model, address), + driver, }); } - gpus.sort_by(|a, b| a.pci_address.cmp(&b.pci_address)); - gpus + cards.sort_by(|a, b| a.pci_address.cmp(&b.pci_address)); + cards } -fn read_flag(path: &Path) -> Option { - Some(std::fs::read_to_string(path).ok()?.trim() == "1") -} - -fn read_class(path: &Path) -> Option { +fn read_hex(path: &Path) -> Option { let raw = std::fs::read_to_string(path).ok()?; let raw = raw.trim(); u32::from_str_radix(raw.strip_prefix("0x").unwrap_or(raw), 16).ok() @@ -152,410 +127,95 @@ fn read_link_name(path: &Path) -> Option { Some(link.file_name()?.to_str()?.to_string()) } -fn render_node(drm: &Path) -> Option { - let entries = std::fs::read_dir(drm).ok()?; - - entries.flatten().find_map(|entry| { - let name = entry.file_name(); - name.to_str()? - .starts_with("renderD") - .then(|| Path::new("/dev/dri").join(&name)) +fn has_render_node(drm: &Path) -> bool { + std::fs::read_dir(drm).is_ok_and(|entries| { + entries + .flatten() + .any(|entry| entry.file_name().to_string_lossy().starts_with("renderD")) }) } -fn pci_tag(address: &str) -> Option { - let (domain, rest) = address.split_once(':')?; - let (bus, rest) = rest.split_once(':')?; - let (device, function) = rest.split_once('.')?; +fn display_name(ids: &str, vendor: u32, model: u32, address: &str) -> String { + let brand = match vendor { + 0x10de => "NVIDIA", + 0x1002 => "AMD", + 0x8086 => "Intel", + _ => "", + }; - let widths = [(domain, 4), (bus, 2), (device, 2), (function, 1)]; - if widths - .iter() - .any(|(part, width)| part.len() != *width || !part.bytes().all(|b| b.is_ascii_hexdigit())) - { - return None; - } + let model = pci_ids_model(ids, vendor, model).map_or_else( + || format!("GPU {address}"), + |name| { + name.rsplit_once('[') + .and_then(|(_, marketing)| marketing.strip_suffix(']')) + .unwrap_or(name) + .to_string() + }, + ); - Some(format!("pci-{domain}_{bus}_{device}_{function}")) -} - -fn probe_discrete(gpu: &Gpu) -> bool { - 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), + if brand.is_empty() { + model + } else { + format!("{brand} {model}") } } -fn answered(gpu: &Gpu, probe: std::io::Result) -> bool { - probe.unwrap_or_else(|error| { - tracing::warn!( - card = gpu.pci_address, - ?error, - "no discreteness answer; assuming integrated" - ); - false - }) -} +fn pci_ids_model(ids: &str, vendor: u32, model: u32) -> Option<&str> { + let vendor = format!("{vendor:04x} "); + let model = format!("\t{model:04x} "); -#[cfg(not(target_os = "linux"))] -mod drm { - use super::Gpu; - - fn unavailable() -> std::io::Result { - Err(std::io::Error::from(std::io::ErrorKind::Unsupported)) - } - - pub fn amdgpu_is_discrete(_gpu: &Gpu) -> std::io::Result { - unavailable() - } - - pub fn xe_is_discrete(_gpu: &Gpu) -> std::io::Result { - 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> { - 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(file: &File, index: u32, data: &T) -> std::io::Result<()> { - let request = libc::_IOW::(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(file: &File, index: u32, data: &mut T) -> std::io::Result<()> { - let request = libc::_IOWR::(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 { - 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 { - 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::()); - - 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) - } + ids.lines() + .skip_while(|line| !line.starts_with(&vendor)) + .skip(1) + .take_while(|line| line.starts_with('\t') || line.starts_with('#')) + .find_map(|line| line.strip_prefix(&model)) } #[cfg(test)] mod tests { 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) -> Gpu { - let discrete = match driver { - Driver::Nvidia => true, - Driver::I915 => address != INTEL_INTEGRATED, - _ => boot_vga == Some(false), - }; - - Gpu { + fn card(address: &str, driver: Driver) -> Card { + Card { pci_address: address.to_string(), - boot_vga, + name: String::new(), 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, Vec<(&'static str, String)>); - let amd = "*radeon*,*amd*"; - - let cases: Vec = 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] - fn pci_tags_match_the_shape_mesa_builds() { - assert_eq!(pci_tag("0000:01:00.0").as_deref(), Some("pci-0000_01_00_0")); + fn offload_env_names_the_chosen_card() { assert_eq!( - pci_tag("10000:af:1f.7").as_deref(), - None, - "domain is 4 wide" + offload_env(&card("0000:01:00.0", Driver::Nvidia)), + 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()), + ], ); - assert_eq!(pci_tag("0000:1:00.0"), None, "bus is 2 wide"); - assert_eq!(pci_tag("0000:01:00"), None, "no function"); - assert_eq!(pci_tag("0000:0g:00.0"), None, "not hex"); - } - - #[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:c1:00.0", Driver::AmdGpu)), + vec![ + ("DRI_PRIME", "pci-0000_c1_00_0".to_string()), + ("VK_LOADER_DRIVERS_SELECT", "*radeon*,*amd*".to_string()), + ], ); - } - - #[test] - fn the_cards_we_can_place_without_asking_the_driver() { - 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" + assert_eq!( + offload_env(&card("0000:01:00.0", Driver::Nouveau)), + vec![("DRI_PRIME", "pci-0000_01_00_0".to_string())], + "nouveau gets no ICD filter", ); } @@ -570,10 +230,11 @@ mod tests { 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); - write(&device.join("boot_vga"), &format!("{boot_vga}\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); std::fs::create_dir_all(&target).unwrap(); @@ -581,58 +242,41 @@ mod tests { } #[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 bus = scratch.join("devices"); - write_card(&bus, INTEL_INTEGRATED, "1", "i915"); + write_card(&bus, "0000:00:02.0", "8086", "9a49", "i915"); write( - &bus.join(INTEL_INTEGRATED) + &bus.join("0000:00:02.0") .join("drm") .join("renderD128") .join("dev"), "226:128\n", ); - write_card(&bus, "0000:01:00.0", "0", "nvidia"); - - 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_card(&bus, "0000:01:00.0", "10de", "25a2", "nvidia"); + write_card(&bus, "0000:03:00.0", "1a03", "2000", "ast"); write(&bus.join("0000:01:00.1").join("class"), "0x040300\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!(gpus[0].driver, Driver::AmdGpu); + assert_eq!( + 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] fn a_missing_pci_tree_is_not_an_error() { 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()); } } diff --git a/packages/oneclient_core/src/game/gpu/mod.rs b/packages/oneclient_core/src/game/gpu/mod.rs index e6d37f06..16ee6dd2 100644 --- a/packages/oneclient_core/src/game/gpu/mod.rs +++ b/packages/oneclient_core/src/game/gpu/mod.rs @@ -1,30 +1,69 @@ #[cfg(any(target_os = "linux", test))] 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 { + 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)] -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")] { - let gpus = linux::detect(); - let env = linux::offload_env(&gpus); - - if env.is_empty() { - tracing::info!( - gpus = gpus.len(), - "discrete GPU was requested but nothing here is a valid offload target; \ - leaving the renderer alone" - ); + let cards = tokio::task::spawn_blocking(linux::detect) + .await + .unwrap_or_default(); + let Some(card) = cards.into_iter().find(|card| card.pci_address == id) else { + tracing::warn!(id, "the selected GPU is gone; leaving the renderer alone"); return; - } + }; - for (key, value) in env { - tracing::debug!(key, value, "offloading the game to the discrete GPU"); + for (key, value) in linux::offload_env(&card) { + tracing::debug!( + key, + value, + gpu = card.name, + "rendering the game on the selected GPU" + ); command.env(key, value); } } #[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; + } } diff --git a/packages/oneclient_core/src/game/launch.rs b/packages/oneclient_core/src/game/launch.rs index 90461331..ed0ea6fb 100644 --- a/packages/oneclient_core/src/game/launch.rs +++ b/packages/oneclient_core/src/game/launch.rs @@ -382,8 +382,8 @@ async fn start( let (mut command, wrapper) = base_command(&profile, &java.absolute_path); - if profile.use_discrete_gpu() { - crate::game::gpu::prefer_discrete(&mut command, &java.absolute_path).await; + if let Some(gpu) = profile.gpu() { + crate::game::gpu::select(&mut command, &java.absolute_path, gpu).await; } apply_env(&mut command, &profile); diff --git a/packages/oneclient_core/src/game/mod.rs b/packages/oneclient_core/src/game/mod.rs index 39fce0f8..4e0685d4 100644 --- a/packages/oneclient_core/src/game/mod.rs +++ b/packages/oneclient_core/src/game/mod.rs @@ -1,7 +1,7 @@ mod analytics; mod error; pub mod fabric; -mod gpu; +pub mod gpu; mod heal; mod launch; mod log_replay; diff --git a/packages/oneclient_java/src/lib.rs b/packages/oneclient_java/src/lib.rs index f64cc1b8..2cbed06e 100644 --- a/packages/oneclient_java/src/lib.rs +++ b/packages/oneclient_java/src/lib.rs @@ -18,7 +18,7 @@ pub use data::{JavaPackage, JavaRuntime, PackageArchive, java_executable_relativ pub use error::{JavaError, JavaResult}; pub use install::{install_package, is_launcher_managed}; 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::{ AvailableJava, INSTALLABLE_MAJORS, JAVA_CHOICE_DOWNLOAD, JAVA_CHOICE_FOLDER, JAVA_VENDOR_HINT, JavaService, diff --git a/packages/oneclient_java/src/platform.rs b/packages/oneclient_java/src/platform.rs index 3d09500e..eaac93e0 100644 --- a/packages/oneclient_java/src/platform.rs +++ b/packages/oneclient_java/src/platform.rs @@ -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)] -fn has_explicit_preference(data: &str) -> bool { - 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) { +pub async fn set_gpu_preference(java_path: &std::path::Path, preference: u8) { let Some(dir) = java_path.parent() else { 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 { - 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; } let added = reg_command() .args(["add", GPU_PREFERENCES_KEY, "/v"]) .arg(&path) - .args(["/t", "REG_SZ", "/d", "GpuPreference=2;", "/f"]) + .args(["/t", "REG_SZ", "/d", data.as_str(), "/f"]) .output() .await; match added { 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!( path = %path.display(), @@ -205,7 +197,7 @@ pub async fn forget_dedicated_gpu(java_path: &std::path::Path) { } #[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))] pub async fn forget_dedicated_gpu(_java_path: &std::path::Path) {}