From 8a7fa4f120c545cd6faf18daf6180d8ba701a3cd Mon Sep 17 00:00:00 2001 From: weter11 Date: Sun, 16 Aug 2026 07:28:07 -0200 Subject: [PATCH 1/2] =?UTF-8?q?perf(ui):=20drop=20unconditional=20per-fram?= =?UTF-8?q?e=20repaint=20=E2=80=94=20idle=20window=20goes=20to=20~0%=20CPU?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit update() ended with an unconditional ctx.request_repaint(), forcing egui to render a full frame every vsync. On a 165 Hz display that pegs the UI thread (~65% of one core idle; 353% under unthrottled Xvfb) even when the window sits untouched. Replace it with a scoped repaint policy. All four channel drains in update() now report whether they drained anything, and repaints are only requested when the UI actually has new state to show: - any drained message (cover art, download progress, play result, async op) -> render immediately - active downloads -> request_repaint_after(250 ms) so the passive ProgressBar and the Instant-based ETA countdown keep ticking between channel messages (egui::Spinner self-repaints while visible, so cover loading / account / proton / steamguard spinners cover themselves) - pending play result -> request_repaint_after(1 s) so a game exit or login-required event is picked up while the window is idle With nothing pending the UI schedules no repaint and the main thread blocks in the event loop until the next input event (which egui repaints on itself). Measured idle (10 s samples, Xvfb :99, fresh config, no Steam session): before: 353% of one core (UI thread), 331% whole-process (top-style) after: 0% of one core (UI thread), 1.6% whole-process Input wake: 40 XTEST key events -> 288% burst, then settles back to 0%. Same binary pair, same display, only the repaint policy differs. --- src/ui.rs | 59 +++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 49 insertions(+), 10 deletions(-) diff --git a/src/ui.rs b/src/ui.rs index 0bc69aa..398cf7b 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)); + } } } From 4a73c50eacebba93fd880ffdb65dedfc34a5025a Mon Sep 17 00:00:00 2001 From: weter11 Date: Sun, 16 Aug 2026 09:28:31 -0200 Subject: [PATCH 2/2] fix(launch): honor dxvk_enabled=false end-to-end (resolver, overrides, wined3d provisioning) dxvk_enabled=false was silently ignored: the VKD3D-Proton pairing in build_dll_overrides pushed d3d8/d3d9/d3d10core/d3d11=n,b for every game on a runner bundling vkd3d-proton (purepe), and the proton script provisioned DXVK into the prefix because PROTON_USE_WINED3D was never set. Alan Wake (108710) thus ran DXVK master despite dxvk_enabled=false and crashed post-intro. - dll_provider_resolver: dxvk_enabled=false now excludes dxvk/ subdirs, custom_dxvk_path and system dxvk paths; d3d8-11 resolve to the runner's WineD3D builtins. +2 unit tests. - utils::build_dll_overrides: d3d8/9/10core/11=n,b pairing only when dxvk_active (d3d12/dxgi pairing stays for D3D12). +1 unit test, updated integration tests to the new contract. - wine_tkg: !effective_dxvk && Proton -> inject 'wined3d' compat so the proton script installs WineD3D builtins over any DXVK DLLs in the prefix. - docs: dxvk-enabled-resolver-clean-runner.md (bug analysis, fix, runner split clean-vs-rtx_remix_debug, registry changes). cargo test --all-targets: all green. --- .../dxvk-enabled-resolver-clean-runner.md | 101 ++++++++++++++ src/infra/runners/wine_tkg.rs | 9 ++ src/launch/dll_provider_resolver.rs | 129 ++++++++++++++++-- src/launch/stages/resolve_dll_providers.rs | 4 + src/utils.rs | 54 ++++++-- tests/compat_discovery.rs | 4 +- tests/dll_override_tests.rs | 23 +++- tests/dll_resolution_report.rs | 1 + 8 files changed, 300 insertions(+), 25 deletions(-) create mode 100644 docs/architecture/dxvk-enabled-resolver-clean-runner.md 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 0000000..87b6e77 --- /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 66f8d85..cc1a101 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 10caeb2..a3cd8ea 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 91a1cfa..b38304d 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/utils.rs b/src/utils.rs index d3bf99b..bf02d15 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 417ce97..3d3658c 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 df189b0..1276e4b 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 761eff6..73f3cfe 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();