diff --git a/docs/architecture/dxvk-enabled-resolver-clean-runner.md b/docs/architecture/dxvk-enabled-resolver-clean-runner.md new file mode 100644 index 00000000..87b6e779 --- /dev/null +++ b/docs/architecture/dxvk-enabled-resolver-clean-runner.md @@ -0,0 +1,101 @@ +# dxvk_enabled=false Contract + Runner Split (clean vs RTX Remix debug) + +Date: 2026-08-16 +Branch: `fix/dxvk-enabled-clean-runner` + +## 1. The bug: `dxvk_enabled: false` was silently ignored + +For any game launched through `steamflow-proton-11.0-purepe` with +`dxvk_enabled: false` (the default) and `d3d12_policy: Auto`, DXVK still ran. +Two independent mechanisms defeated the setting: + +1. **WINEDLLOVERRIDES pairing leak** (`src/utils.rs::build_dll_overrides`): + `d3d12_policy Auto` + a runner that bundles VKD3D-Proton (purepe does) set + `effective_vkd3d_proton = true`, and the VKD3D-Proton pairing block pushed + `d3d8/d3d9/d3d10core/d3d11=n,b` for *every* game — regardless of + `dxvk_enabled`. Those native overrides hand the game the DXVK DLLs. + +2. **Proton-script DXVK provisioning**: the purepe `proton` script installs + DXVK DLLs into the prefix `syswow64`/`system32` by default (its + `use_wined3d` flag only turns on when the compat set contains `wined3d`, + i.e. `PROTON_USE_WINED3D=1`). SteamFlow never set it, so even a game with + no DXVK override would find the provisioned DXVK d3d9.dll in the prefix. + +**Observed impact**: Alan Wake (108710) crashed with `0xC0000005` on the +renderer thread (EIP in no loaded module) right after the intro cutscene — +running on DXVK master `0a70623de9c5c69` (debug-symbol build) despite +`dxvk_enabled: false`, instead of the WineD3D path the setting implies. +Alan Wake is known (Proton issue #156) to be sensitive to the DXVK/nvapi +stack: "preset other than very low → black screen/glitchy textures, +probably nvapi is a stub". + +## 2. The fix (three coordinated changes) + +### 2.1 `dll_provider_resolver.rs` — resolver honors `dxvk_enabled` + +`resolve()` / `resolve_single()` / `get_custom_dll_path()` / +`get_runner_dll_path()` gained a `dxvk_enabled: bool` parameter (threaded +from `resolve_dll_providers.rs`, read from +`graphics_layers.dxvk_enabled`). When `false`: + +- the runner's `*/dxvk/` subdirs are never candidates — the plain builtin + dirs (`files/lib/wine/i386-windows/…`, WineD3D) are the only runner paths; +- a custom DXVK path (`custom_dxvk_path`) is ignored; +- system DXVK paths (`/usr/lib/dxvk/…`) are not listed either, so a runner + without a builtin cannot fall back to system DXVK. + +Unit tests: `test_dxvk_disabled_resolves_builtin_not_dxvk`, +`test_dxvk_disabled_ignores_custom_dxvk_path` (plus the pairing regression +test `test_build_dll_overrides_dxvk_disabled_no_d3d9_native_override` in +`utils.rs`). + +### 2.2 `utils.rs::build_dll_overrides` — no native D3D pairing without DXVK + +The VKD3D-Proton "pair D3D10/11 with native dxgi" loop now only pushes +`d3d8/d3d9/d3d10core/d3d11=n,b` when `dxvk_active`. `d3d12=n,b` + +`d3d12core=n,b` + `dxgi=n,b` remain (needed for D3D12 games regardless; with +WineD3D active the proton script installs the wined3d dxgi, so "native dxgi" +is the wined3d builtin — consistent, no null-import crash). + +### 2.3 `wine_tkg.rs` — suppress DXVK provisioning via `wined3d` compat + +When `!effective_dxvk && is_proton_game`, the compat set gets `wined3d` +inserted → `apply_proton_env_rules` emits `PROTON_USE_WINED3D=1` → the +proton script installs WineD3D builtins into the prefix **overwriting any +leftover DXVK DLLs** from an earlier launch. This is the same mechanism RE2 +(883710) already used via `proton_compat_options: ["wined3d"]`. + +## 3. Runner split: clean release vs RTX Remix debug + +The debug-optimized runner (DXVK master `0a70623` debug-symbol build, used by +the RTX Remix mod chain) was renamed to +`steamflow-proton-11.0-purepe-rtx_remix_debug`; a clean release runner was +provisioned at the original name `steamflow-proton-11.0-purepe` (same +proton-11.0-1b wine, **DXVK 3.0.2 release** instead of master). + +Registry updates: + +- `config.json` `proton_version` → `steamflow-proton-11.0-purepe` (clean) — + the default for non-Remix games. +- `config.json` `game_configs` `forced_proton_version`: + - `108710` → clean (explicit) + - `620` (Portal 2), `317400` (Portal Stories: Mel), `6910` (Deus Ex) → + `steamflow-proton-11.0-purepe-rtx_remix_debug` (they ship RTX Remix + bridges in their game dirs: `.trex/NvRemixBridge*`). +- `user_apps.json`: `620/317400/6910` `dxvk_enabled: true` — their RTX Remix + runtime is DXVK-based, so the new `dxvk_enabled=false` → WineD3D contract + must not change their effective stack (their overrides stay identical to + before the fix). `108710` gets `proton_compat_options: ["wined3d"]` + (explicit, same pattern as RE2). + +The `master_steam_prefix` registry font paths reference +`…/steamflow-proton-11.0-purepe/files/share/wine/fonts` — still valid, since +the clean runner sits at that exact path with the same layout. + +## 4. Verification + +- `cargo test --all-targets` (CARGO_PROFILE_DEV_DEBUG=0, disk-constrained) — + all green. +- Alan Wake (108710) `test-launch`: clean runner + `wined3d` path — no DXVK + log, no Remedy minidump, no access violation; game transitions past the + intro into the first level. diff --git a/src/infra/runners/wine_tkg.rs b/src/infra/runners/wine_tkg.rs index 66f8d859..cc1a1012 100644 --- a/src/infra/runners/wine_tkg.rs +++ b/src/infra/runners/wine_tkg.rs @@ -1352,6 +1352,15 @@ impl Runner for WineTkgRunner { // by SteamFlow. See docs/architecture/valve-stack-replication.md. { let mut compat = crate::runner::proton_abi::default_compat_config(ctx.app.app_id); + // dxvk_enabled=false contract: DXVK must not be provisioned into the + // prefix. The proton script installs DXVK by default; the "wined3d" + // compat option (→ PROTON_USE_WINED3D=1) makes it install WineD3D + // builtins instead, overwriting any DXVK DLLs left in syswow64 by + // an earlier launch. Same mechanism RE2 (883710) uses via its + // per-game proton_compat_options: ["wined3d"]. + if !effective_dxvk && is_proton_game { + compat.insert("wined3d".into()); + } if let Some(user_config) = &ctx.user_config { for opt in &user_config.proton_compat_options { compat.insert(opt.clone()); diff --git a/src/launch/dll_provider_resolver.rs b/src/launch/dll_provider_resolver.rs index 10caeb23..a3cd8ea0 100644 --- a/src/launch/dll_provider_resolver.rs +++ b/src/launch/dll_provider_resolver.rs @@ -81,6 +81,7 @@ impl DllProviderResolver { custom_dxvk_path: Option<&Path>, custom_vkd3d_path: Option<&Path>, custom_vkd3d_proton_path: Option<&Path>, + dxvk_enabled: bool, ) -> (Vec, ComponentScanReport) { tracing::debug!("Resolving DLL providers. ExeDir: {}, Runner: {}", game_exe_dir.display(), runner_path.display()); let runner_root = crate::utils::derive_runner_root(runner_path); @@ -137,6 +138,7 @@ impl DllProviderResolver { custom_dxvk_path, custom_vkd3d_path, custom_vkd3d_proton_path, + dxvk_enabled, )) .collect(); @@ -224,6 +226,7 @@ impl DllProviderResolver { custom_dxvk_path: Option<&Path>, custom_vkd3d_path: Option<&Path>, custom_vkd3d_proton_path: Option<&Path>, + dxvk_enabled: bool, ) -> DllResolution { let mut candidates = Vec::new(); let dll_filename = format!("{}.dll", dll_name); @@ -251,6 +254,7 @@ impl DllProviderResolver { custom_dxvk_path, custom_vkd3d_path, custom_vkd3d_proton_path, + dxvk_enabled, ) { candidates.push(DllCandidate { provider: DllProvider::Custom, @@ -260,7 +264,14 @@ impl DllProviderResolver { } // 3. Runner Priority - if let Some(path) = self.get_runner_dll_path(dll_name, runner_path, runner_components, d3d12_policy, target_arch) { + if let Some(path) = self.get_runner_dll_path( + dll_name, + runner_path, + runner_components, + d3d12_policy, + target_arch, + dxvk_enabled, + ) { candidates.push(DllCandidate { provider: DllProvider::Runner, path: path.clone(), @@ -269,12 +280,16 @@ impl DllProviderResolver { } // 3. System Priority - // For now, we use a simplified check for system paths + // For now, we use a simplified check for system paths. + // dxvk_enabled=false contract: system DXVK paths must not be listed + // either — otherwise a runner lacking a builtin would fall back to + // system DXVK, defeating the WineD3D selection. let system_paths = match dll_name { - "d3d8" | "d3d9" | "d3d10core" | "d3d11" | "dxgi" => vec![ + "d3d8" | "d3d9" | "d3d10core" | "d3d11" | "dxgi" if dxvk_enabled => vec![ "/usr/lib/dxvk/x64", "/usr/lib/x86_64-linux-gnu/dxvk", ], + "d3d8" | "d3d9" | "d3d10core" | "d3d11" | "dxgi" => vec![], "d3d12" | "d3d12core" | "libvkd3d-1" | "libvkd3d-shader-1" => vec![ "/usr/lib/vkd3d-proton/x64", "/usr/lib/x86_64-linux-gnu/vkd3d-proton", @@ -320,12 +335,19 @@ impl DllProviderResolver { custom_dxvk_path: Option<&Path>, custom_vkd3d_path: Option<&Path>, custom_vkd3d_proton_path: Option<&Path>, + dxvk_enabled: bool, ) -> Option { let dll_filename = format!("{}.dll", dll_name); let is_dxvk = matches!(dll_name, "d3d8" | "d3d9" | "d3d10core" | "d3d11" | "dxgi"); let is_vkd3d_proton = matches!(dll_name, "d3d12" | "d3d12core"); let is_vkd3d = matches!(dll_name, "libvkd3d-1" | "libvkd3d-shader-1"); + // dxvk_enabled=false contract: a custom DXVK path must NOT be used — + // the user asked for WineD3D/builtin, not for a custom DXVK build. + if is_dxvk && !dxvk_enabled { + return None; + } + let custom_root = if is_dxvk { custom_dxvk_path } else if is_vkd3d_proton { @@ -374,6 +396,7 @@ impl DllProviderResolver { components: &crate::utils::RunnerComponents, d3d12_policy: &crate::models::D3D12ProviderPolicy, target_arch: &crate::models::ExecutableArchitecture, + dxvk_enabled: bool, ) -> Option { let runner_root = crate::utils::derive_runner_root(runner_path); @@ -415,9 +438,16 @@ impl DllProviderResolver { if is_dxvk && components.dxvk.is_some() { let mut relative_paths = Vec::new(); for lib_subdir in crate::proton::COMPONENT_LIB_SUBDIRS { - relative_paths.push(format!("{}/dxvk", lib_subdir)); + if dxvk_enabled { + relative_paths.push(format!("{}/dxvk", lib_subdir)); + } for (_, arch_dir) in crate::proton::ARCH_SUBDIRS { - relative_paths.push(format!("{}/dxvk/{}", lib_subdir, arch_dir)); + if dxvk_enabled { + relative_paths.push(format!("{}/dxvk/{}", lib_subdir, arch_dir)); + } + // Plain builtin dirs (WineD3D). When dxvk_enabled=false these + // are the ONLY runner paths that qualify: the DXVK subdirs + // must never be resolved or provisioned for such a game. relative_paths.push(format!("{}/{}", lib_subdir, arch_dir)); } } @@ -574,7 +604,7 @@ mod tests { let components = crate::utils::RunnerComponents::default(); let d3d12_policy = crate::models::D3D12ProviderPolicy::Auto; let arch = crate::models::ExecutableArchitecture::X86_64; - let (resolutions, _) = resolver.resolve(&game_dir, runner_path, &components, &d3d12_policy, &arch, None, None, None); + let (resolutions, _) = resolver.resolve(&game_dir, runner_path, &components, &d3d12_policy, &arch, None, None, None, true); let d3d9_res = resolutions.iter().find(|r| r.name == "d3d9").unwrap(); assert_eq!(d3d9_res.chosen_provider, DllProvider::GameLocal); @@ -593,7 +623,7 @@ mod tests { let components = crate::utils::RunnerComponents::default(); let d3d12_policy = crate::models::D3D12ProviderPolicy::Auto; let arch = crate::models::ExecutableArchitecture::X86_64; - let (resolutions, _) = resolver.resolve(&game_dir, runner_path, &components, &d3d12_policy, &arch, None, None, None); + let (resolutions, _) = resolver.resolve(&game_dir, runner_path, &components, &d3d12_policy, &arch, None, None, None, true); for res in resolutions { if res.chosen_provider == DllProvider::System { @@ -635,17 +665,17 @@ mod tests { let arch = crate::models::ExecutableArchitecture::X86_64; // Case 1: Auto (Prefer Proton) - let (res, _) = resolver.resolve(game_dir, &runner_root, &components, &crate::models::D3D12ProviderPolicy::Auto, &arch, None, None, None); + let (res, _) = resolver.resolve(game_dir, &runner_root, &components, &crate::models::D3D12ProviderPolicy::Auto, &arch, None, None, None, true); let d3d12 = res.iter().find(|r| r.name == "d3d12").unwrap(); assert_eq!(d3d12.chosen_path.as_ref().unwrap(), &proton_dll); // Case 2: Explicit Wine - let (res, _) = resolver.resolve(game_dir, &runner_root, &components, &crate::models::D3D12ProviderPolicy::Vkd3dWine, &arch, None, None, None); + let (res, _) = resolver.resolve(game_dir, &runner_root, &components, &crate::models::D3D12ProviderPolicy::Vkd3dWine, &arch, None, None, None, true); let d3d12 = res.iter().find(|r| r.name == "d3d12").unwrap(); assert_eq!(d3d12.chosen_path.as_ref().unwrap(), &wine_dll); // Case 3: Explicit Proton - let (res, _) = resolver.resolve(game_dir, &runner_root, &components, &crate::models::D3D12ProviderPolicy::Vkd3dProton, &arch, None, None, None); + let (res, _) = resolver.resolve(game_dir, &runner_root, &components, &crate::models::D3D12ProviderPolicy::Vkd3dProton, &arch, None, None, None, true); let d3d12 = res.iter().find(|r| r.name == "d3d12").unwrap(); assert_eq!(d3d12.chosen_path.as_ref().unwrap(), &proton_dll); } @@ -661,7 +691,7 @@ mod tests { let resolver = DllProviderResolver::new(); let tmp = tempdir().unwrap(); let arch = crate::models::ExecutableArchitecture::X86_64; - let (res, _) = resolver.resolve(tmp.path(), tmp.path(), &crate::utils::RunnerComponents::default(), &crate::models::D3D12ProviderPolicy::Auto, &arch, None, None, None); + let (res, _) = resolver.resolve(tmp.path(), tmp.path(), &crate::utils::RunnerComponents::default(), &crate::models::D3D12ProviderPolicy::Auto, &arch, None, None, None, true); let d3d11 = res.iter().find(|r| r.name == "d3d11").unwrap(); assert_eq!(d3d11.chosen_provider, DllProvider::None); assert!(d3d11.fallback_reason.is_some()); @@ -693,7 +723,7 @@ mod tests { let game_dir = Path::new("/tmp/game"); let arch = crate::models::ExecutableArchitecture::X86_64; - let (res, report) = resolver.resolve(game_dir, &runner_root, &components, &crate::models::D3D12ProviderPolicy::Auto, &arch, None, None, None); + let (res, report) = resolver.resolve(game_dir, &runner_root, &components, &crate::models::D3D12ProviderPolicy::Auto, &arch, None, None, None, true); let d3d11_res = res.iter().find(|r| r.name == "d3d11").unwrap(); assert_eq!(d3d11_res.chosen_provider, DllProvider::Runner); @@ -706,4 +736,79 @@ mod tests { // Check if report scan roots include the new unified path assert!(report.scan_roots.iter().any(|p| p.to_string_lossy().contains("files/lib/wine/x86_64-windows"))); } + + #[test] + fn test_dxvk_disabled_resolves_builtin_not_dxvk() { + // Classic layout: runner ships BOTH the DXVK build (dxvk/ subdir) and + // the plain WineD3D builtin. dxvk_enabled=false must resolve to the + // builtin; dxvk_enabled=true must resolve to the DXVK build. + let tmp = tempdir().unwrap(); + let runner_root = tmp.path().to_path_buf(); + let dxvk_dir = runner_root.join("files/lib/wine/dxvk/i386-windows"); + let builtin_dir = runner_root.join("files/lib/wine/i386-windows"); + fs::create_dir_all(&dxvk_dir).unwrap(); + fs::create_dir_all(&builtin_dir).unwrap(); + let dxvk_dll = dxvk_dir.join("d3d9.dll"); + let builtin_dll = builtin_dir.join("d3d9.dll"); + fs::write(&dxvk_dll, "dxvk build").unwrap(); + fs::write(&builtin_dll, "wined3d builtin").unwrap(); + + let mut components = crate::utils::RunnerComponents::default(); + components.dxvk = Some(crate::utils::ComponentInfo { + version: "2.3".into(), + source: crate::utils::ComponentSource::BundledWithRunner, + path: None, + }); + + let resolver = DllProviderResolver::new(); + let game_dir = Path::new("/tmp/game"); + let arch = crate::models::ExecutableArchitecture::X86; + let policy = crate::models::D3D12ProviderPolicy::Auto; + + // DXVK disabled → builtin WineD3D DLL, never the dxvk/ subdir. + let (res, _) = resolver.resolve(game_dir, &runner_root, &components, &policy, &arch, None, None, None, false); + let d3d9 = res.iter().find(|r| r.name == "d3d9").unwrap(); + assert_eq!(d3d9.chosen_provider, DllProvider::Runner); + let chosen = d3d9.chosen_path.as_ref().unwrap().to_string_lossy().to_string(); + assert!(chosen.ends_with("files/lib/wine/i386-windows/d3d9.dll"), "expected builtin path, got {chosen}"); + assert!(!chosen.contains("/dxvk/"), "dxvk_enabled=false must not resolve the dxvk subdir: {chosen}"); + assert!(d3d9.candidates.iter().all(|c| !c.path.to_string_lossy().contains("/dxvk/")), + "dxvk_enabled=false must not even list dxvk candidates"); + + // DXVK enabled → DXVK build wins (regression guard). + let (res, _) = resolver.resolve(game_dir, &runner_root, &components, &policy, &arch, None, None, None, true); + let d3d9 = res.iter().find(|r| r.name == "d3d9").unwrap(); + let chosen = d3d9.chosen_path.as_ref().unwrap().to_string_lossy().to_string(); + assert!(chosen.contains("/dxvk/"), "dxvk_enabled=true should resolve the dxvk subdir, got {chosen}"); + } + + #[test] + fn test_dxvk_disabled_ignores_custom_dxvk_path() { + let tmp = tempdir().unwrap(); + // get_custom_dll_path expects arch subdirs directly under the custom + // dxvk root (i386-windows / x86_64-windows / x64 / x32 / ""). + let custom = tmp.path().join("i386-windows"); + fs::create_dir_all(&custom).unwrap(); + let custom_dll = custom.join("d3d9.dll"); + fs::write(&custom_dll, "custom dxvk").unwrap(); + + let resolver = DllProviderResolver::new(); + let game_dir = Path::new("/tmp/game"); + let runner_root = Path::new("/tmp/fake_runner"); + let arch = crate::models::ExecutableArchitecture::X86; + let policy = crate::models::D3D12ProviderPolicy::Auto; + let components = crate::utils::RunnerComponents::default(); + + // dxvk_enabled=false: the custom DXVK path must be ignored entirely. + let (res, _) = resolver.resolve(game_dir, runner_root, &components, &policy, &arch, Some(tmp.path()), None, None, false); + let d3d9 = res.iter().find(|r| r.name == "d3d9").unwrap(); + assert_ne!(d3d9.chosen_provider, DllProvider::Custom, "custom dxvk path must be ignored when dxvk_enabled=false"); + assert!(d3d9.candidates.iter().all(|c| c.provider != DllProvider::Custom)); + + // dxvk_enabled=true: custom path is honored as before. + let (res, _) = resolver.resolve(game_dir, runner_root, &components, &policy, &arch, Some(tmp.path()), None, None, true); + let d3d9 = res.iter().find(|r| r.name == "d3d9").unwrap(); + assert_eq!(d3d9.chosen_provider, DllProvider::Custom); + assert_eq!(d3d9.chosen_path.as_ref().unwrap(), &custom_dll); + } } diff --git a/src/launch/stages/resolve_dll_providers.rs b/src/launch/stages/resolve_dll_providers.rs index 91a1cfab..b38304d5 100644 --- a/src/launch/stages/resolve_dll_providers.rs +++ b/src/launch/stages/resolve_dll_providers.rs @@ -113,6 +113,10 @@ impl PipelineStage for ResolveDllProvidersStage { custom_dxvk, custom_vkd3d, custom_vkd3d_proton, + ctx.user_config + .as_ref() + .map(|c| c.graphics_layers.dxvk_enabled) + .unwrap_or(false), ); if !nvapi_enabled { diff --git a/src/ui.rs b/src/ui.rs index 0bc69aa4..398cf7bf 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -476,8 +476,10 @@ impl SteamLauncher { self.library.iter().find(|g| g.app_id == appid) } - fn poll_image_results(&mut self, ctx: &egui::Context) { + fn poll_image_results(&mut self, ctx: &egui::Context) -> bool { + let mut drained = false; while let Ok((appid, variant, result)) = self.image_rx.try_recv() { + drained = true; match result { Some(path) => { self.cover_fetch_failures.remove(&(appid, variant)); @@ -512,6 +514,7 @@ impl SteamLauncher { } self.pending_images.remove(&appid); } + drained } fn ensure_metadata_requested(&mut self, appid: AppId) { @@ -731,13 +734,15 @@ impl SteamLauncher { self.status = "Logged out".to_string(); } - fn poll_download_progress(&mut self) { + fn poll_download_progress(&mut self) -> bool { let mut finished: Vec = Vec::new(); + let mut drained = false; // Drain every active task's channel independently — each task is // strictly bound to its own AppID + game title. for (appid, task) in self.download_tasks.iter_mut() { while let Ok(progress) = task.rx.try_recv() { + drained = true; task.progress = Some(progress.clone()); let game_name = task.game_name.clone(); match progress.state { @@ -829,13 +834,16 @@ impl SteamLauncher { for appid in finished { self.download_tasks.remove(&appid); } + drained } - fn poll_play_result(&mut self) { + fn poll_play_result(&mut self) -> bool { + let mut drained = false; if let Some(rx) = &self.play_result_rx { match rx.try_recv() { Ok(message) => { + drained = true; let mut finished = true; if let Some(value) = message.strip_prefix("__RUNNING__") { finished = false; @@ -874,6 +882,7 @@ impl SteamLauncher { Err(std::sync::mpsc::TryRecvError::Empty) => {} } } + drained } fn start_install(&mut self, app_id: u32, platform: DepotPlatform, cached_vdf: Option>, filter_depots: Option>) { @@ -921,8 +930,10 @@ impl SteamLauncher { }); } - fn poll_async_ops(&mut self) { + fn poll_async_ops(&mut self) -> bool { + let mut drained = false; while let Ok(op) = self.operation_rx.try_recv() { + drained = true; match op { AsyncOp::DownloadStarted(appid, rx, state) => { // Register a new per-AppID task. If one already exists for @@ -1228,7 +1239,7 @@ impl SteamLauncher { if let Some(preferred_id) = self.launcher_config.preferred_launch_options.get(&appid) { if let Some(option) = options.iter().find(|o| &o.id == preferred_id) { self.start_launch_task(&game, option.clone(), proton_path); - return; + return drained; } } @@ -1260,6 +1271,7 @@ impl SteamLauncher { } } } + drained } fn confirmation_validation_message(&self) -> Option { @@ -3616,10 +3628,10 @@ fn scan_proton_runtimes(config: &LauncherConfig) -> (Vec, Vec) { impl eframe::App for SteamLauncher { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { - self.poll_image_results(ctx); - self.poll_download_progress(); - self.poll_play_result(); - self.poll_async_ops(); + let drained_images = self.poll_image_results(ctx); + let drained_progress = self.poll_download_progress(); + let drained_play = self.poll_play_result(); + let drained_ops = self.poll_async_ops(); egui::TopBottomPanel::top("status").show(ctx, |ui| { ui.horizontal(|ui| { @@ -4703,6 +4715,33 @@ impl eframe::App for SteamLauncher { self.draw_depot_install_selection_modal(ctx); self.draw_launch_selector_modal(ctx); self.draw_proton_remove_confirm_modal(ctx); - ctx.request_repaint(); + + // Scoped repaint policy — replaces the old unconditional + // `ctx.request_repaint()` that forced a full frame every vsync + // (~165 fps on a high-refresh display ≈ one pegged core even when + // the window sat idle). egui already repaints on input events, so + // idle frames are only needed when something async changed: + // + // 1. A channel was drained this frame (cover art landed, progress + // message, play result, async op) → render the new state now. + // 2. Active downloads → tick progress bars + the Instant-based ETA + // countdown (which changes by wall-clock even between messages). + // egui::Spinner (cover loading, account/proton/steamguard) is + // animated and self-requests repaint while visible, so those + // cover themselves and stop when they disappear. + // 3. A launch/play result is pending → poll its channel at 1 Hz so + // a game exit or login-required event is picked up promptly. + // + // With nothing pending the UI schedules no repaint and the main + // thread goes fully idle (~0% CPU) until the next input event. + if drained_images || drained_progress || drained_play || drained_ops { + ctx.request_repaint(); + } + if !self.download_tasks.is_empty() { + ctx.request_repaint_after(std::time::Duration::from_millis(250)); + } + if self.play_result_rx.is_some() { + ctx.request_repaint_after(std::time::Duration::from_secs(1)); + } } } diff --git a/src/utils.rs b/src/utils.rs index d3bf99ba..bf02d150 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1751,14 +1751,22 @@ pub fn build_dll_overrides( // does not export. Loading builtin d3d11 against native dxgi yields // null imports -> broken D3D11 device -> crash (Portal 2, RE2). // Only push when the DXVK branch (above) hasn't already done so. - for stem in &["d3d8", "d3d9", "d3d10core", "d3d11"] { - if game_has(&format!("{stem}.dll")) { - // Game ships its own copy — keep game-local priority. - continue; - } - let entry = format!("{stem}=n,b"); - if !overrides.iter().any(|o| o.starts_with(&format!("{stem}="))) { - overrides.push(entry); + // + // CRITICAL (dxvk_enabled=false contract): the d3d8/d3d9/d3d10core/ + // d3d11 "=n,b" entries hand the game the runner's DXVK DLLs (the + // proton script installs them into the prefix by default). When + // DXVK is disabled they must NOT be emitted — Wine then loads its + // builtin WineD3D DLLs, which is what dxvk_enabled=false promises. + if dxvk_active { + for stem in &["d3d8", "d3d9", "d3d10core", "d3d11"] { + if game_has(&format!("{stem}.dll")) { + // Game ships its own copy — keep game-local priority. + continue; + } + let entry = format!("{stem}=n,b"); + if !overrides.iter().any(|o| o.starts_with(&format!("{stem}="))) { + overrides.push(entry); + } } } } @@ -2486,4 +2494,34 @@ mod versions_txt_tests { assert!(!write_runner_versions_txt(root, "")); assert!(!root.join("VERSIONS.txt").exists()); } + + #[test] + fn test_build_dll_overrides_dxvk_disabled_no_d3d9_native_override() { + use crate::utils::build_dll_overrides; + + // dxvk_enabled=false + runner ships VKD3D-Proton (the purepe case that + // used to leak d3d9=n,b and activate DXVK against the user's explicit + // dxvk_enabled=false): d3d9/d3d11 must NOT get native overrides. + let off = build_dll_overrides(false, true, false, false, false, None, false, None); + assert!(!off.contains("d3d9=n"), "dxvk disabled must not emit d3d9=n,b: {off}"); + assert!(!off.contains("d3d11=n"), "dxvk disabled must not emit d3d11=n,b: {off}"); + assert!(!off.contains("d3d8=n"), "dxvk disabled must not emit d3d8=n,b: {off}"); + assert!(!off.contains("d3d10core=n"), "dxvk disabled must not emit d3d10core=n,b: {off}"); + // VKD3D-Proton pairing (d3d12 + dxgi) stays — needed for D3D12 games. + assert!(off.contains("d3d12=n,b"), "d3d12 pairing must stay: {off}"); + assert!(off.contains("dxgi=n,b"), "dxgi pairing must stay: {off}"); + + // dxvk_enabled=true + VKD3D-Proton: full DXVK pairing (regression guard). + let on = build_dll_overrides(true, true, false, false, false, None, false, None); + assert!(on.contains("d3d9=n,b"), "dxvk enabled must emit d3d9=n,b: {on}"); + assert!(on.contains("d3d11=n,b"), "dxvk enabled must emit d3d11=n,b: {on}"); + assert!(on.contains("dxgi=n,b"), "dxvk enabled must emit dxgi=n,b: {on}"); + + // Game-local d3d9 (e.g. Portal 2 RTX Remix bin/d3d9.dll) must still win. + let game = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(game.path().join("bin")).unwrap(); + std::fs::write(game.path().join("bin/d3d9.dll"), "remix runtime").unwrap(); + let local = build_dll_overrides(true, true, false, false, false, Some(game.path()), false, None); + assert!(!local.contains("d3d9=n,b"), "game-local d3d9 must not be overridden: {local}"); + } } diff --git a/tests/compat_discovery.rs b/tests/compat_discovery.rs index 417ce971..3d3658c1 100644 --- a/tests/compat_discovery.rs +++ b/tests/compat_discovery.rs @@ -164,12 +164,12 @@ fn test_architecture_aware_discovery() { let game_dir = Path::new("/tmp/game"); // Case 1: 64-bit architecture - let (res_64, _) = resolver.resolve(game_dir, &runner_root, &components, &steamflow::models::D3D12ProviderPolicy::Auto, &ExecutableArchitecture::X86_64, None, None, None); + let (res_64, _) = resolver.resolve(game_dir, &runner_root, &components, &steamflow::models::D3D12ProviderPolicy::Auto, &ExecutableArchitecture::X86_64, None, None, None, true); let d3d11_res_64 = res_64.iter().find(|r| r.name == "d3d11").unwrap(); assert_eq!(d3d11_res_64.chosen_path.as_ref().unwrap(), &d3d11_64); // Case 2: 32-bit architecture - let (res_32, _) = resolver.resolve(game_dir, &runner_root, &components, &steamflow::models::D3D12ProviderPolicy::Auto, &ExecutableArchitecture::X86, None, None, None); + let (res_32, _) = resolver.resolve(game_dir, &runner_root, &components, &steamflow::models::D3D12ProviderPolicy::Auto, &ExecutableArchitecture::X86, None, None, None, true); let d3d11_res_32 = res_32.iter().find(|r| r.name == "d3d11").unwrap(); assert_eq!(d3d11_res_32.chosen_path.as_ref().unwrap(), &d3d11_32); } diff --git a/tests/dll_override_tests.rs b/tests/dll_override_tests.rs index df189b0a..1276e4bf 100644 --- a/tests/dll_override_tests.rs +++ b/tests/dll_override_tests.rs @@ -34,16 +34,33 @@ fn test_build_dll_overrides_dxvk_active() { #[test] fn test_build_dll_overrides_vkd3d_active() { + // dxvk_enabled=false + VKD3D-Proton: D3D12 pairing only. The + // d3d8/d3d9/d3d10core/d3d11 "=n,b" entries are DXVK pairings — emitting + // them without DXVK would hand the game the runner's DXVK DLLs and defeat + // dxvk_enabled=false (with WineD3D active the proton script installs the + // wined3d dxgi, so native dxgi == wined3d builtin — no null-import crash). let overrides = build_dll_overrides(false, true, false, true, false, None, false, None); // VKD3D keys should be present assert!(overrides.contains("d3d12=n,b")); + assert!(overrides.contains("d3d12core=n,b")); // vkd3d-proton requires native dxgi for its swapchain assert!(overrides.contains("dxgi=n,b")); - // Wine's builtin d3d11/d3d10core must be paired native with dxgi: - // builtin d3d11 imports Wine-internal symbols (DXGID3D10CreateDevice) - // that native DXVK dxgi does not export -> null imports -> crash. + // WineD3D D3D9/D3D11 must NOT be forced native when DXVK is off. + assert!(!overrides.contains("d3d11=n,b")); + assert!(!overrides.contains("d3d10core=n,b")); + assert!(!overrides.contains("d3d9=n,b")); + assert!(!overrides.contains("d3d8=n,b")); +} + +#[test] +fn test_build_dll_overrides_vkd3d_active_with_dxvk() { + // dxvk_enabled=true + VKD3D-Proton: full DXVK pairing applies (native + // dxgi is DXVK's, so every D3D8-11 DLL must pair native with it). + let overrides = build_dll_overrides(true, true, false, true, false, None, false, None); + assert!(overrides.contains("d3d12=n,b")); + assert!(overrides.contains("dxgi=n,b")); assert!(overrides.contains("d3d11=n,b")); assert!(overrides.contains("d3d10core=n,b")); assert!(overrides.contains("d3d9=n,b")); diff --git a/tests/dll_resolution_report.rs b/tests/dll_resolution_report.rs index 761eff63..73f3cfe0 100644 --- a/tests/dll_resolution_report.rs +++ b/tests/dll_resolution_report.rs @@ -33,6 +33,7 @@ fn test_dll_resolution_report_includes_runner_candidates() { None, None, None, + true, ); let d3d11 = resolutions.iter().find(|r| r.name == "d3d11").unwrap();