From 54980fa73a678dc271e12bd517546cf291a396b6 Mon Sep 17 00:00:00 2001 From: LynithDev <61880709+LynithDev@users.noreply.github.com> Date: Sun, 13 Sep 2026 01:36:47 +0200 Subject: [PATCH] fix: linux use dedicated GPU setting (#816) * forgot to add this * fix complex type warning in test * fix(linux): ask the driver whether a card is discrete --- Cargo.lock | 1 + Cargo.toml | 1 + .../src/view/app/settings/minecraft.rs | 37 +- packages/oneclient_cluster/src/profile.rs | 59 +- packages/oneclient_core/Cargo.toml | 3 + packages/oneclient_core/src/game/gpu.rs | 362 ----------- packages/oneclient_core/src/game/gpu/linux.rs | 601 ++++++++++++++++++ packages/oneclient_core/src/game/gpu/mod.rs | 30 + packages/oneclient_core/src/game/launch.rs | 46 +- packages/oneclient_core/src/game/mod.rs | 1 - .../oneclient_core/src/settings/launcher.rs | 2 - 11 files changed, 677 insertions(+), 466 deletions(-) delete mode 100644 packages/oneclient_core/src/game/gpu.rs create mode 100644 packages/oneclient_core/src/game/gpu/linux.rs create mode 100644 packages/oneclient_core/src/game/gpu/mod.rs diff --git a/Cargo.lock b/Cargo.lock index d0e64284..df1e523a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4619,6 +4619,7 @@ dependencies = [ "image", "indicatif", "interfrost", + "libc", "md-5 0.10.6", "oneclient_auth", "oneclient_cluster", diff --git a/Cargo.toml b/Cargo.toml index 36e5d3c6..aeaba27f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -113,6 +113,7 @@ dunce = {version = "=1.0.5"} htmd = {version = "=0.5.4"} indicatif = {version = "=0.18.4", features = ["tokio"]} junction = {version = "=2.0.0"} +libc = {version = "=0.2.189"} notify = {version = "=8.2.0"} parking_lot = {version = "=0.12.5"} pulldown-cmark = {version = "=0.13.4", default-features = false} diff --git a/packages/oneclient_app/src/view/app/settings/minecraft.rs b/packages/oneclient_app/src/view/app/settings/minecraft.rs index 51d32bce..a1f0e559 100644 --- a/packages/oneclient_app/src/view/app/settings/minecraft.rs +++ b/packages/oneclient_app/src/view/app/settings/minecraft.rs @@ -1,24 +1,19 @@ use freya::prelude::*; use oneclient_common::Patch; use oneclient_core::settings::{PackageUpdateMode, ProfileUpdate, Resolution}; -#[cfg(windows)] -use oneclient_core::settings::LauncherSettings; +#[cfg(any(target_os = "linux", windows))] +use oneclient_core::settings::SettingsOsExtra; use super::settings_page; use crate::components::{ Dropdown, Icon, IconType, TextInput, memory_field, toggle, validate_number, }; -#[cfg(windows)] +#[cfg(any(target_os = "linux", windows))] use crate::components::toggle_controlled; use crate::hooks::{use_dispatch, use_settings_snapshot}; use crate::theme::colors; use crate::view::app::settings::{section_header, settings_row}; -#[cfg(target_os = "linux")] -use crate::components::toggle_controlled; -#[cfg(target_os = "linux")] -use oneclient_core::settings::SettingsOsExtra; - #[derive(PartialEq)] pub struct SettingsMinecraft; @@ -151,15 +146,7 @@ impl Component for SettingsMinecraft { .width(Size::px(220.)), )); - #[cfg(windows)] - let page = page.child(settings_row( - IconType::Rocket02, - "Prefer Dedicated GPU", - "Ask Windows to run Java on the high-performance GPU.", - discrete_gpu_field(settings, dispatch.clone()), - )); - - #[cfg(target_os = "linux")] + #[cfg(any(target_os = "linux", windows))] let page = page .child(section_header("GRAPHICS")) .child(discrete_gpu_row(profile.os_extra.clone(), dispatch)); @@ -168,7 +155,7 @@ impl Component for SettingsMinecraft { } } -#[cfg(target_os = "linux")] +#[cfg(any(target_os = "linux", windows))] fn discrete_gpu_row( os_extra: Option, dispatch: crate::Actions, @@ -231,20 +218,6 @@ fn build_update( } } -/// A launcher setting rather than a profile field, so it bypasses [`build_update`] -#[cfg(windows)] -fn discrete_gpu_field(settings: LauncherSettings, dispatch: crate::Actions) -> impl IntoElement { - let on = settings.use_discrete_gpu; - let on_toggle: EventHandler<()> = (move |()| { - let mut next = settings.clone(); - next.use_discrete_gpu = !on; - dispatch.set_settings(next); - }) - .into(); - - toggle_controlled(on, on_toggle) -} - /// Dispatched separately from [`build_update`] which debounces keystrokes a dropdown has no intermediate states fn update_mode_field( selected: PackageUpdateMode, diff --git a/packages/oneclient_cluster/src/profile.rs b/packages/oneclient_cluster/src/profile.rs index 07d7c86c..14c3b8c6 100644 --- a/packages/oneclient_cluster/src/profile.rs +++ b/packages/oneclient_cluster/src/profile.rs @@ -26,37 +26,25 @@ pub struct GameSettingsProfile { pub use oneclient_common::Resolution; pub use oneclient_common::domain::PackageUpdateMode; -cfg_select! { - target_os = "linux" => { - #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] - #[serde(default)] - pub struct SettingsOsExtra { - pub enable_gamemode: Option, - pub use_discrete_gpu: Option, - /// Keys this build has no field for, kept verbatim so a profile edited - /// on one OS does not clear another OS's settings - #[serde(flatten)] - pub unknown: serde_json::Map, - } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct SettingsOsExtra { + #[cfg(target_os = "linux")] + pub enable_gamemode: Option, + #[cfg(any(target_os = "linux", windows))] + pub use_discrete_gpu: Option, + #[serde(flatten)] + pub unknown: serde_json::Map, +} - impl Default for SettingsOsExtra { - fn default() -> Self { - Self { - enable_gamemode: Some(true), - use_discrete_gpu: Some(false), - unknown: serde_json::Map::new(), - } - } - } - } - _ => { - #[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] - #[serde(default)] - pub struct SettingsOsExtra { - /// Keys this build has no field for, kept verbatim so a profile edited - /// on one OS does not clear another OS's settings - #[serde(flatten)] - pub unknown: serde_json::Map, +impl Default for SettingsOsExtra { + fn default() -> Self { + Self { + #[cfg(target_os = "linux")] + enable_gamemode: Some(true), + #[cfg(any(target_os = "linux", windows))] + use_discrete_gpu: Some(true), + unknown: serde_json::Map::new(), } } } @@ -79,6 +67,17 @@ 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(false), + _ => false + } + } + pub fn is_global(&self) -> bool { self.name == GLOBAL_PROFILE_NAME } diff --git a/packages/oneclient_core/Cargo.toml b/packages/oneclient_core/Cargo.toml index 51262cd5..5763f432 100644 --- a/packages/oneclient_core/Cargo.toml +++ b/packages/oneclient_core/Cargo.toml @@ -92,3 +92,6 @@ sysinfo.workspace = true trash.workspace = true url.workspace = true uuid.workspace = true + +[target.'cfg(target_os = "linux")'.dependencies] +libc.workspace = true diff --git a/packages/oneclient_core/src/game/gpu.rs b/packages/oneclient_core/src/game/gpu.rs deleted file mode 100644 index 52e1bf00..00000000 --- a/packages/oneclient_core/src/game/gpu.rs +++ /dev/null @@ -1,362 +0,0 @@ -use std::path::Path; - -const VENDOR_NVIDIA: u16 = 0x10de; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Gpu { - pub index: u32, - pub vendor_id: u16, - /// The card the firmware booted with - /// `None` on the non-PCI platforms that never publish the attribute - pub boot_vga: Option, - pub pci_address: Option, - /// `device/class`; display controllers are `0x03xxxx` - pub class: Option, - pub has_render_node: bool, -} - -impl Gpu { - /// A server's BMC display adapter is a display controller with no render node, and naming it in `DRI_PRIME` drops the game to software rendering - fn can_render(&self) -> bool { - self.has_render_node && self.class.is_none_or(|class| class >> 16 == 0x03) - } -} - -/// Empty whenever offload does not apply -pub fn offload_env(gpus: &[Gpu]) -> Vec<(&'static str, String)> { - if gpus.len() < 2 { - return Vec::new(); - } - - let Some(boot) = gpus.iter().find(|gpu| gpu.boot_vga == Some(true)) else { - return Vec::new(); - }; - - if boot.vendor_id == VENDOR_NVIDIA { - return Vec::new(); - } - - let Some(target) = gpus - .iter() - .find(|gpu| gpu.boot_vga != Some(true) && gpu.can_render()) - else { - return Vec::new(); - }; - - // `prime-run` sets only the NVIDIA variables; `DRI_PRIME` alongside them names a - // card the proprietary driver owns and steers any Mesa path still in play onto nouveau - if target.vendor_id == VENDOR_NVIDIA { - return vec![ - ("__NV_PRIME_RENDER_OFFLOAD", "1".to_string()), - ("__VK_LAYER_NV_optimus", "NVIDIA_only".to_string()), - ("__GLX_VENDOR_LIBRARY_NAME", "nvidia".to_string()), - ]; - } - - let prime = target - .pci_address - .as_deref() - .and_then(pci_tag) - .unwrap_or_else(|| "1".to_string()); - - vec![("DRI_PRIME", prime)] -} - -#[cfg(target_os = "linux")] -pub fn detect() -> Vec { - read_drm_devices(Path::new("/sys/class/drm")) -} - -fn read_drm_devices(root: &Path) -> Vec { - let Ok(entries) = std::fs::read_dir(root) else { - return Vec::new(); - }; - - let mut gpus = Vec::new(); - - for entry in entries.flatten() { - let name = entry.file_name(); - let Some(name) = name.to_str() else { - continue; - }; - - // `card0` is a device; `card0-HDMI-A-1` is a connector hanging off it - let Some(index) = name.strip_prefix("card").and_then(|n| n.parse::().ok()) else { - continue; - }; - - let device = entry.path().join("device"); - let Some(vendor_id) = read_hex(&device.join("vendor")) else { - continue; - }; - - gpus.push(Gpu { - index, - vendor_id, - boot_vga: read_flag(&device.join("boot_vga")), - pci_address: std::fs::read_link(&device) - .ok() - .and_then(|link| link.file_name()?.to_str().map(str::to_string)), - class: read_class(&device.join("class")), - has_render_node: has_render_node(&device.join("drm")), - }); - } - - gpus.sort_by_key(|gpu| gpu.index); - gpus -} - -fn read_hex(path: &Path) -> Option { - let raw = std::fs::read_to_string(path).ok()?; - let raw = raw.trim(); - u16::from_str_radix(raw.strip_prefix("0x").unwrap_or(raw), 16).ok() -} - -fn read_flag(path: &Path) -> Option { - Some(std::fs::read_to_string(path).ok()?.trim() == "1") -} - -/// sysfs spells the PCI class as `0x030000` -fn read_class(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() -} - -/// The `drm` directory on the device holds its own `cardN` plus a `renderD*` whenever the card can be rendered on -fn has_render_node(drm: &Path) -> bool { - let Ok(entries) = std::fs::read_dir(drm) else { - return false; - }; - - entries.flatten().any(|entry| { - entry - .file_name() - .to_str() - .is_some_and(|name| name.starts_with("renderD")) - }) -} - -/// Mesa builds its tag as `pci-%04x_%02x_%02x_%1u`, which is the sysfs `0000:01:00.0` spelling with the separators swapped -fn pci_tag(address: &str) -> Option { - let (domain, rest) = address.split_once(':')?; - let (bus, rest) = rest.split_once(':')?; - let (device, function) = rest.split_once('.')?; - - 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; - } - - Some(format!("pci-{domain}_{bus}_{device}_{function}")) -} - -#[cfg(test)] -mod tests { - use super::*; - - const NVIDIA: u16 = 0x10de; - const AMD: u16 = 0x1002; - const INTEL: u16 = 0x8086; - const ASPEED: u16 = 0x1a03; - - fn gpu(index: u32, vendor_id: u16, boot_vga: Option) -> Gpu { - Gpu { - index, - vendor_id, - boot_vga, - pci_address: None, - class: Some(0x03_0000), - has_render_node: true, - } - } - - #[test] - fn a_lone_card_is_never_offloaded_from() { - for vendor in [AMD, NVIDIA, INTEL] { - assert!(offload_env(&[gpu(0, vendor, Some(true))]).is_empty()); - } - assert!(offload_env(&[]).is_empty()); - } - - #[test] - fn an_all_amd_hybrid_never_names_nvidia() { - let env = offload_env(&[gpu(0, AMD, Some(true)), gpu(1, AMD, Some(false))]); - - assert_eq!(env, vec![("DRI_PRIME", "1".to_string())]); - } - - #[test] - fn an_intel_amd_hybrid_never_names_nvidia() { - let env = offload_env(&[gpu(0, INTEL, Some(true)), gpu(1, AMD, Some(false))]); - - assert_eq!(env, vec![("DRI_PRIME", "1".to_string())]); - } - - #[test] - fn an_optimus_laptop_gets_the_nvidia_variables() { - let env = offload_env(&[gpu(0, INTEL, Some(true)), gpu(1, NVIDIA, Some(false))]); - - assert_eq!( - env, - vec![ - ("__NV_PRIME_RENDER_OFFLOAD", "1".to_string()), - ("__VK_LAYER_NV_optimus", "NVIDIA_only".to_string()), - ("__GLX_VENDOR_LIBRARY_NAME", "nvidia".to_string()), - ] - ); - assert!( - !env.iter().any(|(key, _)| *key == "DRI_PRIME"), - "the proprietary driver owns the card DRI_PRIME would name" - ); - } - - #[test] - fn a_bmc_display_adapter_is_never_the_offload_target() { - // Workstation: iGPU on the display, the BMC's ASPEED at a lower card index - // than the real dGPU, and no render node on the BMC - let mut bmc = gpu(1, ASPEED, Some(false)); - bmc.has_render_node = false; - - let mut dgpu = gpu(2, AMD, Some(false)); - dgpu.pci_address = Some("0000:c1:00.0".to_string()); - - let env = offload_env(&[gpu(0, INTEL, Some(true)), bmc, dgpu]); - - assert_eq!(env, vec![("DRI_PRIME", "pci-0000_c1_00_0".to_string())]); - } - - #[test] - fn a_card_that_is_not_a_display_controller_is_never_the_offload_target() { - let mut compute = gpu(1, AMD, Some(false)); - compute.class = Some(0x12_0000); - - assert!(offload_env(&[gpu(0, INTEL, Some(true)), compute]).is_empty()); - } - - #[test] - fn nothing_happens_when_the_discrete_card_already_drives_the_display() { - // Desktop with the monitor on the NVIDIA card and the iGPU still on. - // `DRI_PRIME` would move Mesa onto the integrated one - let env = offload_env(&[gpu(0, NVIDIA, Some(true)), gpu(1, INTEL, Some(false))]); - - assert!(env.is_empty()); - } - - #[test] - fn nothing_happens_when_no_card_claims_to_be_the_boot_gpu() { - let env = offload_env(&[gpu(0, INTEL, None), gpu(1, NVIDIA, None)]); - - assert!(env.is_empty()); - } - - #[test] - fn the_target_card_is_named_by_pci_address_when_sysfs_offers_one() { - let mut target = gpu(1, AMD, Some(false)); - target.pci_address = Some("0000:01:00.0".to_string()); - - let env = offload_env(&[gpu(0, INTEL, Some(true)), target]); - - assert_eq!(env[0], ("DRI_PRIME", "pci-0000_01_00_0".to_string())); - } - - #[test] - fn a_malformed_pci_address_falls_back_to_the_ordinal() { - let mut target = gpu(1, AMD, Some(false)); - target.pci_address = Some("not-an-address".to_string()); - - let env = offload_env(&[gpu(0, AMD, Some(true)), target]); - - assert_eq!(env[0], ("DRI_PRIME", "1".to_string())); - } - - #[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")); - assert_eq!(pci_tag("10000:af:1f.7").as_deref(), None, "domain is 4 wide"); - 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"); - } - - fn write(path: &Path, contents: &str) { - std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - std::fs::write(path, contents).unwrap(); - } - - #[test] - fn reads_vendor_boot_flag_and_address_out_of_a_sysfs_tree() { - let scratch = polyio::testing::ScratchDir::new("gpu-sysfs"); - let drm = scratch.join("drm"); - - // sysfs spells these with a trailing newline - write(&drm.join("card0").join("device").join("vendor"), "0x8086\n"); - write(&drm.join("card0").join("device").join("boot_vga"), "1\n"); - write(&drm.join("card0").join("device").join("class"), "0x030000\n"); - write( - &drm.join("card0") - .join("device") - .join("drm") - .join("renderD128") - .join("dev"), - "226:128\n", - ); - write(&drm.join("card1").join("device").join("vendor"), "0x10de\n"); - write(&drm.join("card1").join("device").join("boot_vga"), "0\n"); - write(&drm.join("card1").join("device").join("class"), "0x030000\n"); - write( - &drm.join("card1") - .join("device") - .join("drm") - .join("renderD129") - .join("dev"), - "226:129\n", - ); - - let gpus = read_drm_devices(&drm); - - assert_eq!(gpus.len(), 2); - assert_eq!(gpus[0].index, 0); - assert_eq!(gpus[0].vendor_id, INTEL); - assert_eq!(gpus[0].boot_vga, Some(true)); - assert_eq!(gpus[1].vendor_id, NVIDIA); - assert_eq!(gpus[1].boot_vga, Some(false)); - assert_eq!(gpus[1].class, Some(0x03_0000)); - assert!(gpus[1].has_render_node); - - // and the whole point: this pair earns the NVIDIA variables - assert_eq!(offload_env(&gpus).len(), 3); - } - - #[test] - fn skips_connectors_and_anything_without_a_vendor() { - let scratch = polyio::testing::ScratchDir::new("gpu-connectors"); - let drm = scratch.join("drm"); - - write(&drm.join("card0").join("device").join("vendor"), "0x1002\n"); - // a connector, not a device - write( - &drm.join("card0-HDMI-A-1").join("device").join("vendor"), - "0x1002\n", - ); - // renderD128 sits alongside the cards and is the same device again - write(&drm.join("renderD128").join("device").join("vendor"), "0x1002\n"); - // present but unreadable as a device - std::fs::create_dir_all(drm.join("card9")).unwrap(); - - let gpus = read_drm_devices(&drm); - - assert_eq!(gpus.len(), 1, "one card, counted once"); - assert_eq!(gpus[0].vendor_id, AMD); - } - - #[test] - fn a_missing_drm_tree_is_not_an_error() { - let scratch = polyio::testing::ScratchDir::new("gpu-missing"); - - assert!(read_drm_devices(&scratch.join("nope")).is_empty()); - } -} diff --git a/packages/oneclient_core/src/game/gpu/linux.rs b/packages/oneclient_core/src/game/gpu/linux.rs new file mode 100644 index 00000000..7734a33c --- /dev/null +++ b/packages/oneclient_core/src/game/gpu/linux.rs @@ -0,0 +1,601 @@ +use std::path::{Path, PathBuf}; + +const INTEL_INTEGRATED: &str = "0000:00:02.0"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Driver { + AmdGpu, + Radeon, + Nouveau, + Nvidia, + I915, + Xe, + Other, +} + +impl Driver { + fn from_name(name: Option<&str>) -> Self { + match name { + Some("amdgpu") => Self::AmdGpu, + Some("radeon") => Self::Radeon, + Some("nouveau") => Self::Nouveau, + Some("nvidia") => Self::Nvidia, + Some("i915") => Self::I915, + Some("xe") => Self::Xe, + Some(other) => { + tracing::warn!(driver = other, "unknown graphics driver"); + Self::Other + } + None => Self::Other, + } + } + + fn vulkan_icd(self) -> Option<&'static str> { + match self { + Self::AmdGpu | Self::Radeon => Some("*radeon*,*amd*"), + Self::Nvidia => Some("*nvidia*"), + Self::I915 | Self::Xe => Some("*intel*"), + Self::Nouveau | Self::Other => None, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Gpu { + pub pci_address: String, + pub boot_vga: Option, + 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() { + 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()); + vec![("DRI_PRIME", prime)] + }; + + if let Some(icd) = best.driver.vulkan_icd() { + env.push(("VK_LOADER_DRIVERS_SELECT", icd.to_string())); + } + + env +} + +#[cfg(target_os = "linux")] +pub fn detect() -> Vec { + let mut gpus = read_pci_devices(Path::new("/sys/bus/pci/devices")); + + for gpu in &mut gpus { + gpu.discrete = probe_discrete(gpu); + } + + gpus +} + +fn read_pci_devices(root: &Path) -> Vec { + let Ok(entries) = std::fs::read_dir(root) else { + return Vec::new(); + }; + + let mut gpus = Vec::new(); + + for entry in entries.flatten() { + let name = entry.file_name(); + let Some(address) = name.to_str() else { + continue; + }; + + let device = entry.path(); + + if read_class(&device.join("class")).is_none_or(|class| class >> 16 != 0x03) { + continue; + } + + gpus.push(Gpu { + 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, + }); + } + + gpus.sort_by(|a, b| a.pci_address.cmp(&b.pci_address)); + gpus +} + +fn read_flag(path: &Path) -> Option { + Some(std::fs::read_to_string(path).ok()?.trim() == "1") +} + +fn read_class(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() +} + +fn read_link_name(path: &Path) -> Option { + let link = std::fs::read_link(path).ok()?; + 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 pci_tag(address: &str) -> Option { + let (domain, rest) = address.split_once(':')?; + let (bus, rest) = rest.split_once(':')?; + let (device, function) = rest.split_once('.')?; + + 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; + } + + 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), + } +} + +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 + }) +} + +#[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) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const AMD_INTEGRATED: &str = "0000:0c:00.0"; + + 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 { + pci_address: address.to_string(), + boot_vga, + 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")); + assert_eq!(pci_tag("10000:af:1f.7").as_deref(), None, "domain is 4 wide"); + 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" + ); + } + + #[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" + ); + } + + #[test] + fn an_unknown_module_name_is_not_fatal() { + assert_eq!(Driver::from_name(Some("asahi")), Driver::Other); + assert_eq!(Driver::from_name(None), Driver::Other); + } + + fn write(path: &Path, contents: &str) { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, contents).unwrap(); + } + + fn write_card(bus: &Path, address: &str, boot_vga: &str, driver: &str) { + let device = bus.join(address); + write(&device.join("boot_vga"), &format!("{boot_vga}\n")); + write(&device.join("class"), "0x030000\n"); + + let target = bus.join("drivers").join(driver); + std::fs::create_dir_all(&target).unwrap(); + std::os::unix::fs::symlink(&target, device.join("driver")).unwrap(); + } + + #[test] + fn reads_boot_flag_driver_and_render_node_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( + &bus.join(INTEL_INTEGRATED) + .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(&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(); + + let gpus = read_pci_devices(&bus); + + assert_eq!(gpus.len(), 1, "one card, counted once"); + assert_eq!(gpus[0].driver, Driver::AmdGpu); + } + + #[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()); + } +} diff --git a/packages/oneclient_core/src/game/gpu/mod.rs b/packages/oneclient_core/src/game/gpu/mod.rs new file mode 100644 index 00000000..e6d37f06 --- /dev/null +++ b/packages/oneclient_core/src/game/gpu/mod.rs @@ -0,0 +1,30 @@ +#[cfg(any(target_os = "linux", test))] +mod linux; + +use tokio::process::Command; + +#[allow(unused_variables)] +pub async fn prefer_discrete(command: &mut Command, java_path: &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" + ); + return; + } + + for (key, value) in env { + tracing::debug!(key, value, "offloading the game to the discrete GPU"); + command.env(key, value); + } + } + + #[cfg(windows)] + oneclient_java::prefer_dedicated_gpu(std::path::Path::new(java_path)).await; +} diff --git a/packages/oneclient_core/src/game/launch.rs b/packages/oneclient_core/src/game/launch.rs index 5504d88a..01ac1539 100644 --- a/packages/oneclient_core/src/game/launch.rs +++ b/packages/oneclient_core/src/game/launch.rs @@ -341,14 +341,15 @@ async fn start( ); tracing::debug!(cluster_id, ?jvm_args, main_class = %version_info.main_class, "jvm arguments"); - let use_discrete_gpu = state.settings.read().use_discrete_gpu; - if use_discrete_gpu { - oneclient_java::prefer_dedicated_gpu(std::path::Path::new(&java.absolute_path)).await; + let mut command = base_command(&profile, &java.absolute_path); + + if profile.use_discrete_gpu() { + crate::game::gpu::prefer_discrete(&mut command, &java.absolute_path).await; } - let mut command = base_command(&profile, &java.absolute_path); - apply_env(&mut command, &profile); - command + apply_env(&mut command, &profile); + + command .args(jvm_args) .arg(&version_info.main_class) .args(mc_args) @@ -683,9 +684,6 @@ fn base_command(profile: &GameSettingsProfile, java_path: &str) -> Command { fn apply_env(command: &mut Command, profile: &GameSettingsProfile) { command.env_remove("_JAVA_OPTIONS"); - #[cfg(target_os = "linux")] - apply_discrete_gpu(command, profile); - if let Some(env) = &profile.launch_env { for pair in env.split_whitespace() { if let Some((key, value)) = pair.split_once('=') { @@ -695,36 +693,6 @@ fn apply_env(command: &mut Command, profile: &GameSettingsProfile) { } } -#[cfg(target_os = "linux")] -fn apply_discrete_gpu(command: &mut Command, profile: &GameSettingsProfile) { - let requested = profile - .os_extra - .as_ref() - .and_then(|extra| extra.use_discrete_gpu) - .unwrap_or(false); - - if !requested { - return; - } - - let gpus = crate::game::gpu::detect(); - let env = crate::game::gpu::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" - ); - return; - } - - for (key, value) in env { - tracing::debug!(key, value, "offloading the game to the discrete GPU"); - command.env(key, value); - } -} - #[tracing::instrument(skip(cwd), fields(hook), level = "debug")] async fn run_hook(hook: Option<&str>, cwd: &Path) { let Some(hook) = hook.map(str::trim).filter(|h| !h.is_empty()) else { diff --git a/packages/oneclient_core/src/game/mod.rs b/packages/oneclient_core/src/game/mod.rs index 83b0894a..d9c59cdb 100644 --- a/packages/oneclient_core/src/game/mod.rs +++ b/packages/oneclient_core/src/game/mod.rs @@ -1,7 +1,6 @@ mod analytics; mod error; pub mod fabric; -#[cfg(any(target_os = "linux", test))] mod gpu; mod heal; mod launch; diff --git a/packages/oneclient_core/src/settings/launcher.rs b/packages/oneclient_core/src/settings/launcher.rs index 48c7276b..57a2c61f 100644 --- a/packages/oneclient_core/src/settings/launcher.rs +++ b/packages/oneclient_core/src/settings/launcher.rs @@ -42,7 +42,6 @@ pub struct LauncherSettings { pub auto_update: bool, pub crash_reporting: bool, pub enable_gamemode: bool, - pub use_discrete_gpu: bool, pub discord_enabled: bool, pub max_concurrent_requests: usize, pub global_game_settings: GameSettingsProfile, @@ -86,7 +85,6 @@ impl Default for LauncherSettings { crash_reporting: true, discord_enabled: true, enable_gamemode: false, - use_discrete_gpu: false, max_concurrent_requests: 25, global_game_settings: GameSettingsProfile::default_global_profile(), allow_parallel_running_clusters: false,