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-testing",
"futures-util",
"gtk-sys",
"image",
"libloading 0.8.9",
"mimalloc",

View file

@ -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 = [

View file

@ -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();

View file

@ -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}");

View file

@ -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<String>) -> 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<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)]
fn build_update(
fullscreen: bool,

View file

@ -31,8 +31,8 @@ pub use oneclient_common::domain::PackageUpdateMode;
pub struct SettingsOsExtra {
#[cfg(target_os = "linux")]
pub enable_gamemode: Option<bool>,
#[cfg(any(target_os = "linux", windows))]
pub use_discrete_gpu: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub gpu: Option<String>,
#[serde(flatten)]
pub unknown: serde_json::Map<String, serde_json::Value>,
}
@ -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 {

View file

@ -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<bool>,
pub name: String,
pub driver: Driver,
pub render_node: Option<PathBuf>,
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<Gpu> {
let mut gpus = read_pci_devices(Path::new("/sys/bus/pci/devices"));
pub fn detect() -> Vec<Card> {
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);
read_pci_devices(Path::new("/sys/bus/pci/devices"), &ids)
}
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 {
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<Gpu> {
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<bool> {
Some(std::fs::read_to_string(path).ok()?.trim() == "1")
}
fn read_class(path: &Path) -> Option<u32> {
fn read_hex(path: &Path) -> Option<u32> {
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<String> {
Some(link.file_name()?.to_str()?.to_string())
}
fn render_node(drm: &Path) -> Option<PathBuf> {
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<String> {
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;
}
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>) -> bool {
probe.unwrap_or_else(|error| {
tracing::warn!(
card = gpu.pci_address,
?error,
"no discreteness answer; assuming integrated"
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()
},
);
false
})
}
#[cfg(not(target_os = "linux"))]
mod drm {
use super::Gpu;
fn unavailable() -> std::io::Result<bool> {
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(())
if brand.is_empty() {
model
} else {
Err(std::io::Error::last_os_error())
format!("{brand} {model}")
}
}
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 pci_ids_model(ids: &str, vendor: u32, model: u32) -> Option<&str> {
let vendor = format!("{vendor:04x} ");
let model = format!("\t{model:04x} ");
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)
}
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<bool>) -> 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)> {
#[test]
fn offload_env_names_the_chosen_card() {
assert_eq!(
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()),
]
}
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]
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"
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()),
],
);
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"
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());
}
}

View file

@ -1,30 +1,69 @@
#[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;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Gpu {
pub id: String,
pub name: String,
}
for (key, value) in env {
tracing::debug!(key, value, "offloading the game to the discrete GPU");
#[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)]
pub async fn select(command: &mut tokio::process::Command, java_path: &str, id: &str) {
#[cfg(target_os = "linux")]
{
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 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;
}
}

View file

@ -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);

View file

@ -1,7 +1,7 @@
mod analytics;
mod error;
pub mod fabric;
mod gpu;
pub mod gpu;
mod heal;
mod launch;
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 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,

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)]
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) {}