mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-11 02:51:10 +00:00
+1








3733c50902
* Retain compositor resources when GPU completion is unconfirmed * Stop quarantined GPU callers before retries and mapped writes * Support Android hardware-buffer presentation over Wayland * Support PowerVR swapchains and linear image imports in Gamescope * Bundle verified PowerVR graphics components through existing build preparation * Detect and select the system Vulkan backend on PowerVR * Apply PowerVR Steam and game compatibility during normal launches * Show PowerVR support through existing driver and component controls * Update existing hardware guidance for system Vulkan support * Import the shared system Vulkan readiness check * Discover and propagate the actual PowerVR render node * Prune obsolete PowerVR runtimes after guest processes exit * Retain external DMA-BUF ownership after GPU quarantine * Name the PowerVR HAL selection explicitly * Isolate component UI tests from application startup * Cover the inherited FEX preset in PowerVR profile tests * Log test worker failures for CI diagnosis * Remove temporary test logging after diagnostic CI * Prefer system dependencies for the Android Vulkan loader * Capture raw stderr before test worker termination * Remove the temporary test worker crash probe * Import pinned DXVK recipe prerequisite from PowerVR PR570 Copy only the standalone build recipe, source pin, BC-format prerequisite and presentation-sync patch/check from wallentx/DroidDeck atd0f7264ea8. Source: https://github.com/Droid-Deck/DroidDeck/pull/570 Preserve that proposal attribution; no Android app, compositor, managed pack, private data or fork development history is imported. * Add opt-in low32 GPU mapping adapter and integrity probes * Add opt-in D3D9 single-sample workaround and fix HUD texel format * Document verified production game workaround and publication scope * Restrict reset diagnostics and create private output atomically * Target official runtime review and document PR570 prerequisite provenance * Fix runtime tool output isolation, mmap64 chaining and DST ambiguity * Normalize swapchain backbuffers for the opt-in D3D9 single-sample path * Experimental Samsung Xclipse support on the system Vulkan path Detect Samsung's Vulkan HAL (vulkan.samsung.so) and its sgpu render node alongside PowerVR, and run Xclipse through the same libhybris bridge: - VK_LAYER_DROIDDECK_xclipse (tools/linuxfs/xclipse-layer), built with the glibc preloads and enabled only on Xclipse: sets bionic's environ for the driver's shader compiler, avoids immutable YCbCr samplers in compute layouts, forwards the KHR calibrated-timestamp entry points to the EXT ones, and reports textureCompressionBC and BC4/BC5 filtering over the compat layer's BC emulation. - Upstream DXVK 2.6.2, pinned as a tar.gz component and selected on Xclipse: the driver has 128 bytes of push constants, DXVK 3 needs 256. - android_wlegl accepts a zero stride and passes it through unchanged: Samsung's gralloc records none for GPU-only buffers. Tested on a Galaxy A56 (Xclipse 540, driver 24.0.560): Steam Big Picture and PICO PARK (D3D9). * Xclipse layer: copy the device chain and track samplers per device - Clear textureCompressionBC in a copy of the device-create chain (every structure up to the VkPhysicalDeviceFeatures2 that enables it) instead of writing the caller's structure; a chain led by a structure of unknown size is passed on unchanged. - Track YCbCr samplers per device in a growable list, drop them on vkDestroySampler and device teardown, and fail sampler creation rather than leave one untracked when the list cannot grow. - Fail descriptor set layout creation with VK_ERROR_OUT_OF_HOST_MEMORY when a replacement sampler array cannot be allocated, instead of passing the original YCbCr samplers to the driver. * Xclipse layer: copy any device-chain structure the headers define The chain copy that clears the emulated textureCompressionBC only knew a handful of structure sizes, so a valid chain such as VkPhysicalDeviceTimelineSemaphoreFeatures -> VkPhysicalDeviceFeatures2 was passed on unchanged and the compat layer refused the bit. Size every structure that may extend VkDeviceCreateInfo from a table generated from the Vulkan registry (gen_device_chain.py, Vulkan-Headers 1.3.275, the headers the build compiles with), and copy onto the heap instead of a fixed buffer. Only a structure newer than those headers still passes the chain unchanged. Add a regression test that runs the layer between a simulated loader and a mock driver: the timeline-semaphore chain, pEnabledFeatures, an unknown structure, the caller's structures left untouched, per-device YCbCr sampler tracking with handle reuse, and the KHR calibrated-timestamp forwarding. CI installs libvulkan-dev for it. * Mali bridge (#517, by ASEbrahim) on the non-Adreno test branch Squash of Droid-Deck/DroidDeck#517 (ASEbrahim/DroidDeck mali-bridge @6b823341): the Vulkan bridge (tools/vkbridge) that runs the guest's Vulkan on the phone's own driver, with BC1-BC7, clip/cull distance, vertex-divisor and depth-clip emulation and dma-heap memory sharing for Mali. All of the bridge is ASEbrahim's work; squashed only to carry it on this test branch. Combined with #570 on this branch, a device draws through exactly one path: - Adreno: Turnip, unchanged (checked first; the bridge setting is ignored there). - PowerVR / Xclipse with the system HAL and a render node: the system-Vulkan path (#570). - Anything else with the Setup toggle on (Mali): the bridge. * Test branch: keep #570's DXVK build recipe so its pinned PowerVR components verify #602 adds output checks to tools/dxvk/build.sh; tools/powervr/fetch.py pins the exact recipe the published PowerVR DXVK was built from, so the branch carries #570's copy. * Test branch: make #570, #602 and #560/#561 pass each other's tests - tools/dxvk/build.sh: #602's copy (its output checks) and the PowerVR pin in tools/powervr/release.json moved to that file's hash; the recipe's outputs are unchanged. - droiddeck-session: the presentation block sets _bl_cef for #570's PowerVR path, and the UI block keeps main's order, defaulting to -cef-use-angle=vulkan when the block has not run. - Compositor test harnesses: AHardwareBuffer stubs for #570's AHB import, and a stub for vkp_effects_forget_image (main #597) that #561's caller harness predates. * Auto drivers for Mali and Xclipse on the non-Adreno test branch Auto now picks an open driver pair for the GPU, the way it does for Adreno: - Mali (no KGSL, /dev/mali0): the GPU's name ("Mali-G720-Immortalis MC12", from the vendor Vulkan driver or kbase's gpuinfo) picks the PanVK build: G52-G78 (Job Manager), G610/G710, G615 or G720. A Mali none of them covers (G925...), or one whose pair is not published yet, draws through the Mali bridge, switched on by Auto. - Xclipse keeps the system-driver path as Auto; the RADV pair is offered as a manual choice. - The pairs come from The412Banner/droiddeck-nonadreno-drivers (one pre-release per driver, each a DriverBundle zip). Only a device without an Adreno reads that repo. - The importers accept PanVK and RADV libraries (libvulkan_panfrost / libvulkan_radeon). - An open driver pair and the Mali bridge are alternatives: a selected pair turns the bridge off. - TU_DEBUG is only set for Turnip, on an Adreno. Every new branch is reached only when the device has no Adreno; an Adreno's detection, release sources, Auto choice and session environment are unchanged. * Auto: the one-binary PanVK kbase build for Mali v9-v13 on Android 15+ The412Banner/droiddeck-nonadreno-drivers adds panvk-mali-kbase: one PanVK build for G57/G68/G77/G78, G610/G710, G615/G715, G720/G725 and G925, whose display half needs Android 15. On Android 15+ Auto prefers it for every Mali it covers; below 15, and whenever its release is not listed, Auto falls back to the GPU generation's own pair. G52/G76 keep the Job Manager build; a G715/G725/G925 below Android 15, or any Mali no pair covers, draws through the bridge. Every pair stays selectable by hand. * Test: the G925 is in the kbase build's family now * Offer faux123's device PanVK builds (RP4 Pro, Ace, RG556) as manual pairs for their Mali * Detailed graphics logs for non-Adreno test reports - graphics-report.txt in every session folder: the graphics path the session draws with and why (Turnip, system Vulkan #570, Mali bridge #517, or an open driver pair with its release), the driver mode and choices, what Auto would pick, the GPU (family, Vulkan device name, kbase gpuinfo, Android, SoC, kernel), the GPU nodes the app can see (access, device numbers, SELinux label; nothing is opened) and the guest's graphics environment. At the end it adds the compositor's driver lines from app.log and the game launches that were logged. - vulkan-summary.txt: the session's own vulkaninfo --summary with the driver as exported, in the background with a 10 s timeout. - Per-game logs under games/<time>-<appid>/: Proton's log, DXVK's and vkd3d-proton's, Mesa's and PanVK's startup messages and the launch environment, from droiddeck-game-env. Each file is cut to 20 MB and the folder to 120 MB at the session's end. - The per-game logs and the Vulkan summary are on for a GPU that is not an Adreno, and on an Adreno only with Setup's new "Detailed graphics logs" switch. Share logs already takes the whole session folder, so all of it is in the zip, scrubbed as before. * Mali pairs: a stand-in render node, the driver's real library name, and a Mali known by its chip - Auto on Jim's Dimensity 9300+ chose the bridge: the GPU had no name (the app had not asked Vulkan yet and kbase's gpuinfo is unreadable under SELinux), so a Mali-G720 counted as a Mali no build covers. The name now falls back to the GPU the chip is known to carry (Dimensity, Helio, Kompanio, Tensor); and when nothing names it, Auto asks the vendor Vulkan driver off the main thread before it settles for the bridge. Auto logs what it decided and why. - An open Mali driver pair gets /dev/mali0 as the guest's render node, as the bridge does (bindGpuNode, BL_DRM_STANDIN): gamescope needs the node the driver reports. The session shim only answers libdrm's PRIME and GEM-close calls on it, which PanVK on kbase never makes. - A PanVK or RADV runtime half is installed as libvulkan_panfrost.so / libvulkan_radeon.so rather than under Turnip's name; Turnip keeps libvulkan_freedreno.so. - graphics-report.txt names the render node the guest got, adds the session script's driver lines and flags a failed Mali-bridge self-test at the top. * Auto takes the RADV pair on a Samsung Xclipse, with the system route as its fallback A tester's Galaxy S24 FE (Xclipse 940) boots to Steam with the RADV-Xclipse pair, where the system-driver route fails at Steam's OpenGL (glamor and GLX cannot load a driver). Auto now downloads and selects RADV on an Xclipse like the Mali pairs, and falls back to the system route only when RADV is not published. #570's reset of both drivers at each start no longer undoes an open driver pair. PowerVR (vulkan.powervr.so) keeps the system driver as before. * An Exynos with a Mali is a Mali, not an Xclipse The system route took vulkan.samsung.so as Samsung's Xclipse, but an Exynos 1280/1380 (Galaxy Tab S9 FE: Mali-G68) may name its HAL the same; with /dev/mali0 present that HAL is not the system route's, and the device is detected as the Mali it is. The chip table adds the Exynos 1380 and 1280 (s5e8835, s5e8825: Mali-G68). * Auto fetches the open drivers itself; Xclipse named as such; sound and frame-gen in the logs - Auto looks online again when its saved release list predates this device's open drivers, so a Samsung or Mali phone gets its pair without pressing refresh first. - A Samsung Xclipse is labelled "Samsung Xclipse" (not PowerVR), with its own support line. - Per-game logs add audio-streams.txt: PulseAudio's sink inputs 20 s and 60 s into the launch, so a silent game shows whether its stream reached Pulse. - graphics-report.txt states the frame-generation setting and keeps the LSFG gate, FrameGen and audio lines from app.log. * Client sound retries a briefly disconnected AAudio; kbase ioctl refusals explained in the report A Galaxy S24 FE's session had no sound at all: the DirectAudio sink's AAudio stream opened with AAUDIO_ERROR_DISCONNECTED while the output route was still changing at session start, and the plain AAudio fallback failed the same moment. The sink guard now retries DirectAudio, then the plain sink, five times over ten seconds and says which one it got. graphics-report.txt keeps the client-sink and kbase lines, and explains a KBASE_IOCTL_MEM_EXEC_INIT EPERM as the kbase driver's own answer rather than the session's sandbox. * Mali pairs draw the display on the phone's own Mali driver; Xwayland falls back without glamor - Testers' Mali pairs got the guest drawing (gamescope on the stand-in node, Zink on PanVK) but the compositor's PanVK Android half failed on Mali gralloc: it probes AHB formats 0x38/0x3b that mali_gralloc does not have, and "vkCreateImage w/ native buffer failed". Under Auto a Mali pair now leaves the display on the phone's own Vulkan driver (dma-buf, modifiers and AHB all pass the testers' checks) and runs the pair's PanVK in the guest; the Android half stays installed for a manual pick. "Pair in use" now follows the runtime half. - A session after one whose Xwayland crashed (segfault, then gamescope's X11 I/O error) starts Xwayland with XWAYLAND_NO_GLAMOR=1 on a non-Adreno device, and says so in app.log. - graphics-report.txt names the display driver the compositor used, and keeps gralloc, dma-buf, modifier and import lines from app.log and Xwayland's own lines from the session log. * Non-Adreno sound: the daemon falls back on its own; the build's commit in device.txt and the report The S24 FE's session still had no sound: its AAudio refuses DirectAudio's low-latency stream with AAUDIO_ERROR_DISCONNECTED, and the sink guard never got to retry because pactl did not reach the daemon in time ("the daemon did not answer in 15 s"). On a GPU that is not an Adreno the daemon's own start-up script now asks DirectAudio again without low latency and loads the plain AAudio sink underneath it, so a default sink exists whatever pactl does. device.txt and graphics-report.txt name the build label and source commit, so a log says which build it came from. * Non-Adreno sound: no second DirectAudio load (it hung the daemon); the plain AAudio sink underneath On the S24 FE the daemon stopped after DirectAudio's failed low-latency load: the second DirectAudio load in the same daemon never returned, so nothing after it ran and pactl never connected. The daemon now loads only the plain AAudio sink (no low latency) after DirectAudio, inside .nofail, and sets whichever exists as the default. * gamescope: explicit sync off where the kernel or the stand-in node cannot signal its timelines Adds tools/gamescope/patches/0126 (GAMESCOPE_DISABLE_EXPLICIT_SYNC=1 for the nested backends) and sets it on a GPU that is not an Adreno when the kernel is older than 6.6 (no DRM_IOCTL_SYNCOBJ_EVENTFD) or the guest's render node is Mali's stand-in. The graphics report lists it with the rest of the guest's graphics environment. * Test branch: gamescope with the explicit-sync switch (0126), pinned by sha Built by build-gamescope.yml run 38335744351 from0136fb61and published as a component release of The412Banner/droiddeck-nonadreno-drivers; the apk build fetches it by tag and checks the sha. * Sound: nothing that can block in the daemon's start-up; Steam's web compositing in software on open pairs - The S24 FE (US) daemon hung loading the plain AAudio sink from default.pa (its AAudio open blocks rather than fails), so it never finished starting. default.pa is back to DirectAudio alone inside .nofail; the fallbacks are loaded from the app once the daemon answers, each pactl bounded by its timeout (DirectAudio, then the plain sink with and without low latency). The guard waits 30 s and logs, with timestamps, how long the daemon took, whether its socket and pactl exist, and what it finally got. - With an open driver pair on a GPU that is not an Adreno, Steam's CEF composites in software (-cef-disable-gpu-compositing) with GPU raster kept: RADV on Xclipse showed stale, half-written tiles, as #570 saw on PowerVR. The session log says so. * Mali under Auto: the display always on Android's own Vulkan loader; a reboot hint for refused audio Axel's Tab S9 FE still drew its display with the PanVK Android half, set by an earlier build, and stalled at "Waiting for Steam's first frame" on Mali gralloc's format probes. Under Auto a Mali's display now goes to Android's own loader whatever was set before (DriverMenus resets it, and the compositor's driver choice ignores a pair's Android half), with no HAL path to find first. A pair counts as in use when its runtime half is. A manual pick of the PanVK display is kept. When audio.log shows AAUDIO_ERROR_DISCONNECTED, the report and the client-sink line say Android refused the output and that rebooting the phone usually fixes it. * Fix: call SessionPrefs.gpuDriverMode through its instance from Java * Compositor: log each new dma-buf format/modifier/size it imports (for colour and swizzle reports) Steam's software web compositing already covers every open driver pair, Mali included. * Open driver pairs: vkd3d-proton reports the driver's caps; buffer attach/release tracing; Mesa log - With a PanVK or RADV pair (BL_OPEN_DRIVER=1), droiddeck-game-env drops the app-wide VKD3D_FEATURE_LEVEL=12_2 and VKD3D_SHADER_MODEL=6_9 so vkd3d-proton reports what the driver has (an Unreal DX12 game crashed at start on PanVK with the overrides); a game's own setting still wins, and launch.txt records DROIDDECK_VKD3D_CAPS=driver. - The compositor traces, on a GPU that is not an Adreno, the first 400 dma-buf attaches and buffer releases by wl_buffer id and sending line, and logs any release of a buffer already released and not attached again (gamescope's "Compositor released us but we were not acquired" on G925). - Per-game logs no longer set MESA_DEBUG=silent, which kept mesa.log empty. * Tests: the dma-buf quarantine harness follows send_buffer_release's tracing macro * Mali (Venus): David Cimo's Venus route sources and recipes From DavidCimo/droiddeck-mali, branch mali-venus @ 4f838891 (prototype 13; his 12 commits on top of DroidDeck 0.3.1, GPL-3.0), by David Cimo: - tools/venus: Mesa Venus vtest patch (incl. the per-queue submit_mutex fix and the cached-memory frame readback), virglrenderer Android patch, the Mali compatibility layer (the six features DXVK needs: fill modes, multi-viewport, robustness2, clip/cull distance, BC1-BC7) and his tools. - tools/linuxfs/preload/venus.c: keeps Zink's kopper under Venus. - tools/gamescope/patches/0127 (his 0114): shm buffers uploaded on every commit. - docs/development/mali-venus.md: his design notes. - tools/venus/ci-build.sh and build-venus.yml: his build recipes as one CI job that packs the component (vtest server, ICD, layer) as venus.tzst for the apk. Co-authored-by: Dersim Abbas <derabb0620@gmail.com> * Mali (Venus) as a selectable route, by David Cimo Ports David Cimo's Venus route (DavidCimo/droiddeck-mali, mali-venus @ 4f838891) onto this branch as "Mali (Venus)", one tap in Setup next to the Mali bridge: - VenusComponent (his): stages the Venus ICD and runs virglrenderer's vtest server over the phone's own Vulkan driver; the guest gets VK_ICD_FILENAMES, VN_DEBUG=vtest, VTEST_SOCKET_NAME and MESA_VK_WSI_DEBUG=sw. - Selection rule: Venus when Setup's "Mali (Venus)" is on, on a GPU that is neither an Adreno nor a system-driver one (PowerVR, Xclipse), and the apk carries it. It then replaces the Mali bridge and any open driver pair for the session, and the display stays on the phone's own driver. Auto is unchanged (the PanVK pair). Adreno never takes any of it. - The compositor enables only the dma-buf extensions the driver has, logs a vendor driver's extension list, and gives a zero row pitch a packed row (his vk_present.c changes). - The Mali compatibility layer is staged into the runtime on non-Adreno devices only. - The component (vtest server, ICD, layer) comes from build-venus.yml as venus.tzst, published on The412Banner/droiddeck-nonadreno-drivers and pinned by sha in tools/venus/release.env. - gamescope 3.16.29-p15 adds his shm upload patch (0127) next to 0126; pinned by sha. - device.txt and graphics-report.txt name the route. Co-authored-by: Dersim Abbas <derabb0620@gmail.com> * Mali: Venus gamescope without robustness2, the bridge switch honoured and kept, clean driver switches - gamescope 0128: VK_EXT_robustness2 is optional. A Mali driver through Venus has none, and gamescope stopped at "Missing required extension: VK_EXT_robustness2" (the wayland_surface error after it was only the knock-on). Without it, unused descriptor slots point at a 1x1 texture instead of VK_NULL_HANDLE. (David Cimo's Pixel 9 driver has robustness2, so his build never needed this.) gamescope component rebuilt and pinned by sha. - Open-files limit: on a GPU that is not an Adreno the session script raises the soft limit to the hard limit for the whole session tree (a PanVK-kbase session froze on "Too many open files" after ten minutes) and graphics-report.txt shows before and after. A stopgap; the driver leak is fixed separately. - The Mali bridge switch is honoured: when on and usable it wins over a PanVK pair for the session (Venus still wins over it). Selecting a pair no longer switches the bridge off, which is why the switch did not survive an app restart (Auto re-selected the pair at start). Setup says when this phone cannot use the bridge at all. - Driver switching: picking a Mali pair by hand keeps the display on the phone's own driver, as Auto does (a PanVK Android display stalls on Mali gralloc at "Waiting for Steam's first frame", and stayed set after switching back); installing a bundle again replaces it instead of adding a "-2" copy beside the old one. - graphics-report.txt names the runtime driver's folder and libraries, and the previous session's drivers, flagged when they changed. * gamescope p16 (0128 robustness2 optional), pinned by sha; graphics-report says who chose the display driver * Bundle replace only for open non-Adreno pairs; Adreno bundles keep their numbered copies * Rebuilt open drivers reach phones: re-downloaded when their digest changes, release list hourly on non-Adreno The non-Adreno driver releases replace their assets in place (the PanVK kbase sync-file fd leak fix). A download is now remembered with its GitHub sha256, and an asset whose digest changed counts as not installed, so Auto fetches it again and the bundle replaces the old one; the release list is re-read after an hour instead of a day on a GPU that is not an Adreno. * Mali compatibility layer: multiDrawIndirect, depthClipEnable and robustness2 for DXVK on Venus A Galaxy A15 (Mali-G57) on Venus got gamescope and Steam up, but DXVK refused the GPU: "Device does not support required feature 'multiDrawIndirect'". DXVK 3.1 stops at the first missing feature, so the rest of its required list (dxvk_device_info.cpp) was checked against what the testers' Mali drivers cannot have, and covered in the same pass, on top of David Cimo's six: - multiDrawIndirect: reported, taken off the device, and an indirect draw with drawCount > 1 is recorded as single draws at offset + i * stride; maxDrawIndirectCount reported as 2^30. (drawIndirectCount is optional for DXVK and stays as the driver says.) - VK_EXT_depth_clip_enable (required): offered when the driver lacks it; depthClipEnable off in a pipeline becomes depthClampEnable, and the extension's structures never reach the driver. - VK_EXT_robustness2 missing entirely (gamescope saw the same on these phones): offered with robustBufferAccess2 (as robustBufferAccess, as before) and nullDescriptor, emulated by small stand-ins (a zeroed buffer, a 1x1 image) for VK_NULL_HANDLE buffers, image views and vertex buffers in plain, template and push descriptor writes and vertex-buffer binds. A null texel-buffer view stays null (its format is not known). The layer logs every feature it emulates per device. * Venus component22694a38(layer: multiDrawIndirect, depthClipEnable, robustness2), pinned by sha * Non-Adreno round 3: bridge images, Venus GL, shared buffers, Steam GPU drawing - vkbridge: the memory model skips lazily allocated types. Mali-G615 lists DEVICE_LOCAL|LAZILY_ALLOCATED first; taken as the device-local type it left images with no memory type (self-test "image clear/draw: setup" FAIL, gamescope "No compatible image memory type"). - Session: a bridge self-test that fails images or draws falls back to the runtime's driver pair for that session (BL_VKBRIDGE_FALLBACK_ICD); Setup and the graphics report say so. - Venus: the Mali compatibility layer runs in every process but gamescope (DROIDDECK_MALI_COMPAT=1), so Zink in Steam and Xwayland gets nullDescriptor; null texel-buffer views get stand-ins too. Without nullDescriptor the session's GL goes to llvmpipe. - gamescope 0129: a refused compute pipeline is named with its SPIR-V capabilities. - Compositor: a buffer on two surfaces at once (gamescope's planes while a notification is up) is released only when the last one lets go. - Open driver pairs: processes.txt timeline for the first three minutes; Setup "Steam interface: GPU drawing" turns off software web compositing. * Pin gamescope p17 (0129) and Venus55b30fbf* Non-Adreno round 4: Mali GPU-drawing block, gamescope YCbCr retry, GL probe, bridge draw sync - Setup's "Steam interface: GPU drawing" is hidden on Mali and a saved "on" is cleared: with PanVK it rebooted an Edge 60 (G615) as steamwebhelper started. Kept for Xclipse. - gamescope 0130: a composite pipeline refused at start-up is retried with a layout without immutable YCbCr samplers (G615 through Venus refused every composite shader; their SPIR-V has no Float16, so fp16 was not the cause). - Venus: droiddeck-glprobe opens GL through GLX the way the Steam client does, with the loader's reasons logged; when it fails the client's GL goes to llvmpipe. - vkbridge self-test: the draw check waits for the render pass's stores before copying out (a tiler read zeros without the dependency). * Pin gamescope p18 (0130) * Non-Adreno round 5: gamescope memory fallback, Venus destroy guard, OpenAL depot - gamescope 0131: DEVICE_LOCAL|HOST_VISIBLE requests fall back to host-visible memory (the bridge's unified-memory model lists none with both), with the request, type bits and memory types logged on failure; 0130's probe pipeline is kept and skipped when forced. - Venus: GAMESCOPE_NO_YCBCR_SAMPLERS=1 from the start; virglrenderer ignores the destroy of a pipeline it never created instead of marking the context fatal (vtest server exit 137 on a Mali-G615). - Steam's OpenAL depot (229020) maps to oalinst_dll, so games that list it (Hotline Miami) get OpenAL in their prefix. * Pin gamescope p19 (0131) and Venus6199a93f* Non-Adreno round 6: PowerVR PRIME on old kernels, thermal, hang-up names, bridge flippable images, Venus Zink variants - drm.c: PR #570's current PRIME ownership check (F_DUPFD_QUERY, then kcmp, then the F_SETOWN_EX owner marker): on a kernel before 6.10 with kcmp blocked, drmPrimeFDToHandle answered EOPNOTSUPP for every PowerVR import (Pixel 10, Android 17: Zink "failed drmPrimeFDToHandle", Xwayland error 7, gamescope X11 I/O error). The Mali stand-in node is kept. - A /sys/class/thermal the kernel will not list gets an empty directory, so mangoapp no longer aborts on it. - gamescope 0132: a hung-up waitable is named before the abort; 0133: the bridge's flippable images use supported-but-unexportable modifiers instead of asserting. - Venus: when Zink does not start, the loader's view is logged and Zink is retried without ZINK_DEBUG=compact, with ZINK_DESCRIPTORS=auto and without kopper before falling back to llvmpipe. * Pin gamescope p20 (0132, 0133) * Non-Adreno round 7: bridge command-buffer guard, Venus Zink trace, PowerVR mangoapp, Xclipse label - vkbridge server: every command buffer a device hands out is tracked; Begin, End (stream replay) and QueueSubmit refuse one it never allocated or has freed (VK_ERROR_DEVICE_LOST, logged) instead of passing it to the driver, where Mali's vkBeginCommandBuffer crashed the app (SIGSEGV 0x10, Steam's Zink on an Edge 60 and a Tab S11). - vkbridge server: a fault inside the driver while serving a request cuts that client off (SIGSEGV/SIGBUS guard per request) and leaves its objects alone; the app and the other clients carry on. Other faults go to the previous handler. - vkbridge server: the pipeline cache directory is copied; the JNI string it pointed at was freed (garbage in the cache path). - Venus: DROIDDECK_MALI_COMPAT_TRACE=1 makes the Mali compatibility layer log what an application queries before vkCreateDevice; a failed glprobe adds a traced run and vulkaninfo --summary under the same environment. - PowerVR: mangoapp draws with llvmpipe (Zink could not make it a swapchain and it crashed at every start). - Setup: an Xclipse GPU reads "Experimental: RADV or the system Xclipse driver", not the PowerVR line. * Pin Venuse40cd361(layer trace) * Non-Adreno round 8: bridge sharing fallback and crash detail, Venus diagnostics and 1.2 gate, compositor disconnect reasons, RADV upstream - Mali bridge: the self-test fails "memory sharing" when the server offers no host-visible memory type (no sharing method passed, RP4 Pro G77); the session then falls back to the PanVK pair like a failed draw, and Setup says the bridge cannot share memory with that driver. Without a pair to fall back on, the log says so. - Mali bridge: a guarded driver crash now logs the sub-command it was in, the fault address and pc (library + offset via dladdr), the command buffer's level, begin flags, pool flags and state, and the last 32 replayed commands. Begin on an executable buffer from a pool without RESET_COMMAND_BUFFER_BIT is logged once (implicit reset). - Venus: vulkan-summary.txt gets the device extension list and key features (full vulkaninfo in vulkan-full.txt); a failed GL probe runs once with VN_DEBUG=init,result, ZINK_DEBUG=mem, MESA_LOG_LEVEL=debug and the layer trace into glprobe-trace.txt. The Mali compatibility layer's trace covers every pre-device query (devices, memory, queue families, formats, image formats, external handles) and names the extensions. - Venus: off when the phone's driver reports Vulkan below 1.2 (gamescope skips such a device); Setup and the graphics report say why and the session uses the PanVK pair. - Compositor: libwayland's own disconnect reasons go to wayland.log, and a client's buffer may grow to 1 MB before a slow reader is dropped. A session that ended without teardown records Android's exit reasons. - Drivers: RADV-Xclipse upstream (radv-xclipse-upstream) is a manual choice for Xclipse; Auto keeps radv-xclipse. * Tests: the terminal-callers harness stubs the Quest layer and Cardboard modules and Android's system properties * Pin gamescope p21 (main's patches + 0120-0133) and Venusa4d47cf3(layer trace) * Display driver Auto: Quest's patched Turnip first; the system driver only for a Mali, other GPUs keep the generic fallback * Open driver pairs: the installed build's version, its upload date, and an Update when the release is rebuilt in place - The release check records each asset's GitHub id and updated_at; a download records them (with the digest) for an open non-Adreno pair. - Components > GPU drivers: an installed open pair shows its manifest version ("android <tag> + linux <commit>"), one not yet installed shows "built YYYY-MM-DD". A different digest, asset id or upload time on the same tag marks the installed pair "Update available" with an Update action: the pair is downloaded again and replaced in place, and stays selected only if it was. Adreno and DD-Turnip pairs are unchanged. - graphics-report.txt: the pair release line gives the installed and the listed upload dates and flags a newer build; an exact bundle id match is preferred (RADV-Xclipse-upstream is not RADV-Xclipse). * Session preload: drop oom_score_adj raises so lmkd does not treat games as cached apps steam-launch-wrapper starts every game with --oom-score-adjust 900; on Android 900 is CACHED_APP_MIN_ADJ, so lmkd kills the running game first when memory runs short (and on Linux 6.1 the raise becomes the process's permanent minimum). A process in the app cannot go below the score it inherits, so the writes that succeed from inside are raises: every write-open of /proc/<pid|self|thread-self>[/task/<tid>]/oom_score_adj (or oom_adj), through open/openat or fopen, goes to /dev/null, logged once. Based on the oom_score_adj redirect in the preload's opens.c by jdmg28 (https://github.com/jdmg28/DroidDeck-Mali/commit/6be00e00) * Display: why presenting failed on the session-ended screen, and the display driver that really loaded - The compositor records why it cannot present (no Vulkan driver loaded, vkCreateInstance/vkCreateDevice failed, no GPU or graphics queue, no swapchain on the screen with the driver's reason, device lost, GPU work unconfirmed). Each is a DD_PRESENT_FAULT line in wayland.log, read back over JNI or from wayland.log; the session-ended screen shows it instead of a black screen, and graphics-report.txt gets "!! DISPLAY FAILED". A first frame or a working swapchain clears it. - After the display device is created, the driver that answered is logged from VkPhysicalDeviceDriverProperties (device, driverName, driverInfo, driver id, Vulkan version) and reported in graphics-report.txt. When a driver pair's display half was requested but a vendor driver answered (adrenotools falls back to the system driver when the requested one does not load), it says so. Based on DD_PRESENT_FAULT in vk_present.c / waylandcomp_jni.c / SessionActivity by jdmg28 (https://github.com/jdmg28/DroidDeck-Mali/blob/arena/cd8ab18a-droiddeck-mali/app/src/main/cpp/waylandcomp/src/vk_present.c) Based on the adrenotools silent-fallback finding by ryanistr (https://github.com/ryanistr/DroidDeck-Mali/blob/main/docs/development/panvk-mali-g77-bringup.md) * tests: steam presentation test sets 0.5.0's steam_client * Non-Adreno: find every driver pair, Venus before the bridge, fix the bridge's begin info From the logs testers sent sincec9ebb247: - The app read the drivers repo's newest 15 releases only; the component releases (gamescope, Venus) pushed every PanVK pair and RADV-Xclipse past them, so Auto found no pair on any Mali and switched the Mali bridge on. The non-Adreno repo is now read in one page of 100. - Auto's stand-in for a missing pair: Mali (Venus) where the apk carries it (Steam runs on it from the G57 to the G1-Ultra), the bridge otherwise, nothing on a driver below Vulkan 1.2. It no longer clears the Linux driver, does nothing when the list could not be read, records that it chose the route, and turns it off again once a pair is installed. A bridge that earlier builds left on under Auto is taken as Auto's. - Mali bridge: vkBeginCommandBuffer sent its begin info without the count the server decodes, so Mali got a zeroed begin info with stale flags and crashed at 0x10 (Pixel 8 Pro G715, Lenovo G615, Xiaomi G925, Tab S11). - Mali-G1 series (MT6993, arch v14) known by its chip; its label says Venus, not the bridge. - A session Android killed for memory is said so on the next start (the S24 FE "crashes" were low-memory kills, not driver faults). - The in-app updater never installs an official (Adreno-only) build over this test build. * Venus: destroy the device's object mutex after the objects that lock it Bionic aborts on locking a destroyed mutex ("pthread_mutex_lock called on a destroyed mutex", Tab S9 FE G68, Pixel 9a), so a client that exited holding objects took the render worker down. From BattleTempest's DroidDeck-Pixel (kevinbryant551-ux/DroidDeck-Pixel, GPL-3.0). * release check: a session helper may be extracted to another runtime path droiddeck-steam-taskset (#570) goes to usr/local/libexec/droiddeck-steam/taskset, where PATH puts it in front of Steam's own taskset for the client only; 0.5.0's check wanted every droiddeck-* helper at its own path. * Venus component93038b7d(destroyed-mutex fix) * Update MaliBridge to v0.5.1 (#1135) * Mali Auto: never leave a session on the runtime's Turnip Logs from a Retroid Pocket 4 Pro (G77), Galaxy S21 Ultra (G78), Tab S11 Ultra (G925) and Xiaomi G610 showed Auto ending on the runtime's Turnip, where gamescope finds no GPU ("failed to find physical device"): after a pair's trial rolled back, when the pair is on the bad list, and when the driver list could not be read. In each case Auto now switches on its stand-in (Venus, or the bridge) when no open driver, Venus or bridge is set. Under Auto the Mali display also leaves any imported driver, not only a pair's PanVK half (a lone PanVK_G720 import gave a black screen). * Mali bridge: MaliBridge 224b6e36 (v0.5.3 source, by ASEbrahim) From c65062b6 (v0.5.1). v0.5.2/v0.5.3: Steam mode on older Mali drivers (G77/G710/G610/G57) gets past "starting display and audio", OpenGL 4.6 on most Malis through dynamic-rendering and vertex-format emulation, Dimensity 9500 texture fixes, async pipeline compile, RE2 flicker on G925, DirectX on the Pixel 8 family. JNI entry and session env names are unchanged. * gamescope 0134: linear when the backend offers no modifier; Venus on Mesa 26.2.5 0134: CVulkanTexture::BInit asserted !modifiers.empty() when the nested Wayland backend offered no modifier for a flippable image's format (NieR Replicant on a Pixel 9a under Venus, session exit 134). It now tries linear and logs it. Venus ICD: mesa-26.2.5 (venus TLS teardown from an unloaded driver, virtio-gpu devices without 3D skipped); virglrenderer stays on 1.3.0. * gamescope p22-nonadreno (0134 + main's 0116 cursor fix; build run 40159674290) * CI: build the non-Adreno additions before the preload check * build.gradle: drop verifyHandModel (main moved the hand models into the tracking pack) * Venus component venus-proto13-e24ed8ab (Mesa 26.2.5; build run 40161447324) * CI: fetch the Mali (Venus) component again (lost when build.yml took main's jobs) Builda7a1ebf1(run 40161734101) had no assets/venus or libvirgl_test_server.so, so VenusComponent.available() was false and Auto's stand-in would have been the bridge. * Auto: the Mali bridge stands in on a Vulkan 1.1 Mali driver Auto refused both Venus and the bridge when the phone's driver reports Vulkan below 1.2, so on the G77, G78 and G610 (r32p1/r38, Vulkan 1.1) a rolled-back or missing pair left the runtime's Turnip: "failed to find physical device" (Retroid Pocket 4 Pro after the JM pair's trial, Xiaomi 13T). Only Venus needs 1.2; the bridge raises the driver for gamescope and runs on these phones. A Venus switch that cannot start on such a driver no longer counts as a route. * Tests: stub main's g_release_unpaced in the DMA-BUF quarantine probe The probe copies release_buffer out of compositor.c; since the merge it reads g_release_unpaced, which the harness did not declare (setUpClass failed to compile). * Update MaliBridge to v0.5.4 (ASEbrahim) 48477fe6 (tag v0.5.4): Dimensity 9500 green squares and red-tinted textures, Balatro and other OpenGL text, fewer crashes when memory runs low, one screen update per game frame, picture kept after a long background pause (SIGHUP no longer stops the server), wrong colours after changing graphics settings on the G720, more in the session report. The app-side changes of his DroidDeck Mali app (Steam menus setting, per-game setup, Send logs, updates) are not part of the MaliBridge repository and are not in this build. * gamescope 0135: linear when the driver rejects every offered modifier An r32p1 Mali-G610 (Vulkan 1.1) behind the Mali bridge answered both of the backend's modifiers for AR24 with VK_ERROR_FORMAT_NOT_SUPPORTED, so the first flippable image failed and gamescope asserted in vulkan_create_flat_texture (Xiaomi 13T). Linear, when offered, is tried anyway; the bridge makes such images without the handle type and exports them through a dma-heap. If the image cannot be made, BInit fails as before. * gamescope: pin the non-Adreno p23 build (0135) * Tests: the terminal-callers harness stubs take_shm's game-frame detection Main's take_shm now reads surface damage, parent/role/mapped and the HUD state to spot game frames; the harness declares them so the copied function compiles (setUpClass failed in Build APK run 40505286127). No damage, so it stays off. * gamescope: number the test branch's patches 0920-0935 Main now has its own 0121-0125 (window override walk, host-event wake, commit fences, done at import, HUD frame times), which sorted in between this branch's 0120-0135. The branch's sixteen patches move to 0920-0935 with the same last two digits, so they apply after main's set and stay clear of its next numbers. Applying all 38 to gamescope 3.16.29 in the old order and the new one gives the same tree (checked with patch -p1, as build-in-arch.sh does). PATCHES.md and the one code comment that named 0130 follow the new numbers. * check_session_assets: helpers count in usr/local/bin or the Steam taskset folder only The merge kept this branch's pattern (any usr/ target), which main's new test rejects: a helper extracted into a subfolder of usr/local/bin must not count. The pattern now takes usr/local/bin under any name, as on main, plus usr/local/libexec/droiddeck-steam for droiddeck-steam-taskset. Every helper in the overlay is still found extracted in SessionFiles.kt. * gamescope: pin 3.16.29 p24 (non-Adreno) Built from this branch @d13b9d88 (run 41119284267): main a5b035b2's patches plus this branch's, now 0920-0935. All 38 applied, gamescope's tests 2/2. sha256 1d1088d4. * Update MaliBridge to v0.5.4.2 (ASEbrahim) 95a1e600 (tag v0.5.4.2), was v0.5.4 (48477fe6). For the bridge: memory sharing without the handle type where that is all the self-test passed (unflagged buffers, client/memory.c, server_memory.c), the older-Mali (G52-G77) game-mode start fixes, the r38p1 beta geometry-shader erratum (gs_hide), present timeouts and an API cap per process. Client, server and the in-app library all build from the submodule with MaliBridge's own scripts, which did not change; the protocol only gained fields an older server sends as 0. * Session end: the compositor fault query no longer fails the hint when the native library is absent presentationFault() (6778a224) called WaylandCompositor.compositorFault() directly. Main's new SessionEndHintTest runs sessionEndHint() in Robolectric, where libdroiddeckwayland cannot load, so all six cases ended in UnsatisfiedLinkError (Build APK run 41125749537). The call is now wrapped like runtimeDamageHint()'s, and without an answer the hint reads wayland.log as before. * test: stub the GPU busy reader in the caller harness * fix: release build is not debuggable, Mali uses the system display driver, staging failure ends its own session * fix: address non-Adreno review findings * fix: preserve current gamescope and GPU warning behavior * fix: retain graphics failure hints and launch diagnostics * fix: identify non-Adreno GPUs in driver failure hints * test: harnesses follow the external client barrier and effects upscaling changes * fix: client sources return to foreign ownership after the overlay draws --------- Co-authored-by: Leask Wong <i@leaskh.com> Co-authored-by: wallentx <william.allentx@gmail.com> Co-authored-by: Stefan Zijlstra <6730062+stefanz92@users.noreply.github.com> Co-authored-by: William Allen <wallentx@users.noreply.github.com> Co-authored-by: The412Banner <the412banner@users.noreply.github.com> Co-authored-by: Dersim Abbas <derabb0620@gmail.com> Co-authored-by: root <root@localhost.localdomain> Co-authored-by: ASEbrahim <ahmadsmain@gmail.com> Co-authored-by: The412Banner <the412banner@gmail.com> Co-authored-by: The412Banner <205237651+The412Banner@users.noreply.github.com>
2574 lines
156 KiB
Python
2574 lines
156 KiB
Python
import hashlib
|
|
import io
|
|
import json
|
|
import os
|
|
import shutil
|
|
from pathlib import Path
|
|
import runpy
|
|
import shlex
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
from unittest import mock
|
|
import unittest
|
|
from unittest.mock import patch
|
|
|
|
BIN = Path(__file__).resolve().parents[1] / "linuxfs/overlay/usr/local/bin"
|
|
MODULE = runpy.run_path(str(BIN / "droiddeck-game-env"))
|
|
COMPAT = runpy.run_path(str(BIN / "steam-compatibility"))
|
|
STATE = tempfile.TemporaryDirectory()
|
|
COMPAT["main"].__globals__["COMPAT_DIR"] = STATE.name
|
|
|
|
|
|
class SupergiantDotnetReporterTest(unittest.TestCase):
|
|
def test_allows_the_builtin_mscoree_for_supergiant_reporter(self):
|
|
game = mock.Mock(files={"basecampbugreporter.native.dll"})
|
|
self.assertEqual(
|
|
({"WINEDLLOVERRIDES": "mscoree=n,b"}, [], "Supergiant: allow the builtin mscoree"),
|
|
MODULE["supergiant_dotnet_reporter"](game),
|
|
)
|
|
|
|
def test_ignores_games_without_the_supergiant_reporter(self):
|
|
game = mock.Mock(files={"enginewin64s.dll"})
|
|
self.assertIsNone(MODULE["supergiant_dotnet_reporter"](game))
|
|
|
|
|
|
def mapping_of(text):
|
|
tokens = COMPAT["tokenize"](text)
|
|
return COMPAT["read_mapping"](tokens, COMPAT["mapping_block"](tokens))
|
|
|
|
|
|
def config_text(entries):
|
|
mapping = "".join('"%s" { "name" "%s" "config" "" "priority" "%s" }' % (app, name, "75" if app == "0" else "250") for app, name in entries)
|
|
return '"InstallConfigStore" { "Software" { "Valve" { "Steam" { "CompatToolMapping" { %s } } } } }' % mapping
|
|
|
|
|
|
class RenderPathDefaultsTest(unittest.TestCase):
|
|
DRIVER = "/drivers/DD-Turnip_v0.4.0/icd.json"
|
|
COMMAND = ["proton", "waitforexitandrun", "/games/Spintires/SpinTires.exe"]
|
|
|
|
def apply(self, env=None, command=None, model="Adreno722", library="libvulkan_freedreno.so"):
|
|
env = dict({"VK_DRIVER_FILES": self.DRIVER} if env is None else env)
|
|
manifest = json.dumps({"ICD": {"library_path": library}})
|
|
with mock.patch.object(Path, "read_text", return_value=model), \
|
|
mock.patch.object(Path, "is_file", return_value=False), \
|
|
mock.patch("builtins.open", mock.mock_open(read_data=manifest)):
|
|
note = MODULE["render_path_defaults"](command or self.COMMAND, env)
|
|
return env, note
|
|
|
|
def test_tested_render_path_keeps_other_options(self):
|
|
env, note = self.apply({"VK_DRIVER_FILES": self.DRIVER, "DXVK_CONFIG": "d3d9.maxFrameLatency = 2; ", "CUSTOM": "kept"})
|
|
self.assertEqual(env["DXVK_CONFIG"], "d3d9.maxFrameLatency = 2; dxvk.tilerMode = False")
|
|
self.assertEqual(env["CUSTOM"], "kept")
|
|
self.assertIsNotNone(note)
|
|
self.assertEqual(self.apply()[0]["DXVK_CONFIG"], "dxvk.tilerMode = False")
|
|
self.assertIsNotNone(self.apply({"VK_ICD_FILENAMES": self.DRIVER})[1])
|
|
|
|
def test_other_games_devices_and_drivers_keep_their_environment(self):
|
|
for changes in (
|
|
{"command": ["proton", "waitforexitandrun", "/games/MudRunner.exe"]},
|
|
{"command": ["proton", "run", self.COMMAND[2]]},
|
|
{"command": ["proton"]},
|
|
{"model": "Adreno740"},
|
|
{"model": "Mali-G715"},
|
|
{"library": "libvulkan.so"},
|
|
{"env": {}},
|
|
{"env": {"VK_DRIVER_FILES": "/drivers/new-turnip/icd.json"}},
|
|
{"env": {"VK_DRIVER_FILES": "", "VK_ICD_FILENAMES": self.DRIVER}},
|
|
):
|
|
with self.subTest(changes=changes):
|
|
env, note = self.apply(**changes)
|
|
self.assertIsNone(note)
|
|
self.assertNotIn("DXVK_CONFIG", env)
|
|
|
|
def test_explicit_environment_and_config_files_take_precedence(self):
|
|
for value in ("dxvk.tilerMode = Auto", "d3d9.maxFrameLatency = 1; dxvk.tilerMode=True", "dxvk.tilerMode = False"):
|
|
env, note = self.apply({"VK_DRIVER_FILES": self.DRIVER, "DXVK_CONFIG": value})
|
|
self.assertIsNone(note)
|
|
self.assertEqual(env["DXVK_CONFIG"], value)
|
|
env, note = self.apply({"VK_DRIVER_FILES": self.DRIVER, "DXVK_CONFIG_FILE": "/games/custom.conf"})
|
|
self.assertIsNone(note)
|
|
self.assertNotIn("DXVK_CONFIG", env)
|
|
with mock.patch.object(Path, "is_file", return_value=True):
|
|
env = {"VK_DRIVER_FILES": self.DRIVER}
|
|
self.assertIsNone(MODULE["render_path_defaults"](self.COMMAND, env))
|
|
self.assertNotIn("DXVK_CONFIG", env)
|
|
|
|
def test_unreadable_or_invalid_driver_metadata_leaves_launch_unchanged(self):
|
|
for data in ("not-json", "{}", '{"ICD":{"library_path":null}}'):
|
|
with mock.patch.object(Path, "read_text", return_value="Adreno722"), \
|
|
mock.patch.object(Path, "is_file", return_value=False), \
|
|
mock.patch("builtins.open", mock.mock_open(read_data=data)):
|
|
env = {"VK_DRIVER_FILES": self.DRIVER}
|
|
self.assertIsNone(MODULE["render_path_defaults"](self.COMMAND, env))
|
|
self.assertNotIn("DXVK_CONFIG", env)
|
|
with mock.patch.object(Path, "read_text", side_effect=PermissionError), \
|
|
mock.patch.object(Path, "is_file", return_value=False):
|
|
self.assertIsNone(MODULE["render_path_defaults"](self.COMMAND, {"VK_DRIVER_FILES": self.DRIVER}))
|
|
|
|
|
|
class DirtTelemetryFixTest(unittest.TestCase):
|
|
SHOWDOWN_ON = '<x><motion enabled="true" ip="127.0.0.1" port="20777" delay="1" extradata="3" /></x>'
|
|
SHOWDOWN_DEFAULT = '<x><motion enabled="true" ip="dbox" port="20777" delay="1" extradata="0" /></x>'
|
|
|
|
def setUp(self):
|
|
self.tmp = tempfile.TemporaryDirectory()
|
|
root = Path(self.tmp.name)
|
|
self.prefix = root / "compatdata/2848447563"
|
|
self.proton = root / "Proton/proton"
|
|
self.own = root / "Proton/files/lib/wine/i386-windows/ws2_32.dll"
|
|
self.own.parent.mkdir(parents=True)
|
|
self.own.write_bytes(b"MZ wine ws2_32")
|
|
self.proton.write_text("")
|
|
self.target = self.prefix / "pfx/drive_c/windows/syswow64/ws2_32.dll"
|
|
self.target.parent.mkdir(parents=True)
|
|
self.target.symlink_to(self.own)
|
|
self.stand_in = root / "droiddeck-dirt/ws2_32.dll"
|
|
self.stand_in.parent.mkdir()
|
|
self.stand_in.write_bytes(b"MZ droiddeck-dirt-ws2_32 stand-in")
|
|
self.patch = mock.patch.dict(MODULE["dirt_ws2_wanted"].__globals__, {"DIRT_WS2": str(self.stand_in)})
|
|
self.patch.start()
|
|
|
|
def tearDown(self):
|
|
self.patch.stop()
|
|
self.tmp.cleanup()
|
|
|
|
def config(self, folder, text):
|
|
path = self.prefix / "pfx/drive_c/users/steamuser/Documents/My Games" / folder / "hardwaresettings/hardware_settings_config.xml"
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(text)
|
|
|
|
def command(self, exe="showdown.exe"):
|
|
return [str(self.proton), "waitforexitandrun", "/root/Storage/games/DiRT Showdown/" + exe]
|
|
|
|
def env(self):
|
|
return {"STEAM_COMPAT_DATA_PATH": str(self.prefix)}
|
|
|
|
def test_telemetry_counts_as_on_when_the_game_sends_somewhere(self):
|
|
self.config("DiRT Showdown", self.SHOWDOWN_DEFAULT)
|
|
self.assertFalse(MODULE["dirt_telemetry_on"](self.prefix, "dirtshowdown"))
|
|
self.config("DiRT Showdown", self.SHOWDOWN_ON)
|
|
self.assertTrue(MODULE["dirt_telemetry_on"](self.prefix, "dirtshowdown"))
|
|
self.config("DiRT Showdown", self.SHOWDOWN_ON.replace('enabled="true"', 'enabled="false"'))
|
|
self.assertFalse(MODULE["dirt_telemetry_on"](self.prefix, "dirtshowdown"))
|
|
self.config("DiRT3", '<x><motion enabled="true" ip="192.168.1.20" port="20777" /></x>')
|
|
self.assertTrue(MODULE["dirt_telemetry_on"](self.prefix, "dirt3"))
|
|
|
|
def test_stand_in_is_wanted_only_for_the_dirt_games_with_telemetry_on_and_a_proton_without_the_fix(self):
|
|
wanted = MODULE["dirt_ws2_wanted"]
|
|
self.config("DiRT Showdown", self.SHOWDOWN_DEFAULT)
|
|
self.assertIsNone(wanted(self.command(), self.env()))
|
|
self.config("DiRT Showdown", self.SHOWDOWN_ON)
|
|
self.assertEqual(wanted(self.command(), self.env()), str(self.prefix))
|
|
self.assertEqual(wanted(self.command("Showdown_AVX.exe"), self.env()), str(self.prefix))
|
|
self.assertIsNone(wanted(self.command("DiRTRally2.exe"), self.env()))
|
|
self.own.write_bytes(b"MZ wine ws2_32 " + "showdown_avx.exe".encode("utf-16-le"))
|
|
self.assertIsNone(wanted(self.command(), self.env()))
|
|
|
|
def test_swap_keeps_the_prefix_link_and_restore_puts_it_back(self):
|
|
MODULE["swap_dirt_ws2"](self.prefix)
|
|
saved = self.target.with_name("ws2_32.dll.droiddeck-original")
|
|
self.assertFalse(self.target.is_symlink())
|
|
self.assertEqual(self.target.read_bytes(), self.stand_in.read_bytes())
|
|
self.assertEqual(os.readlink(saved), str(self.own))
|
|
self.assertEqual(self.own.read_bytes(), b"MZ wine ws2_32")
|
|
MODULE["swap_dirt_ws2"](self.prefix)
|
|
self.assertEqual(os.readlink(saved), str(self.own))
|
|
self.assertTrue(MODULE["restore_dirt_ws2"](self.prefix, self.proton))
|
|
self.assertEqual(os.readlink(self.target), str(self.own))
|
|
self.assertFalse(os.path.lexists(saved))
|
|
self.assertFalse(MODULE["restore_dirt_ws2"](self.prefix, self.proton))
|
|
|
|
def test_restore_keeps_a_file_proton_put_back_meanwhile(self):
|
|
MODULE["swap_dirt_ws2"](self.prefix)
|
|
newer = self.own.with_name("ws2_32-newer.dll")
|
|
newer.write_bytes(b"MZ newer wine ws2_32")
|
|
self.target.unlink()
|
|
self.target.symlink_to(newer)
|
|
self.assertFalse(MODULE["restore_dirt_ws2"](self.prefix, self.proton))
|
|
self.assertEqual(os.readlink(self.target), str(newer))
|
|
self.assertFalse(os.path.lexists(self.target.with_name("ws2_32.dll.droiddeck-original")))
|
|
|
|
def test_after_a_proton_switch_the_running_protons_file_goes_back(self):
|
|
MODULE["swap_dirt_ws2"](self.prefix)
|
|
other = Path(self.tmp.name) / "GE-Proton/proton"
|
|
other_own = other.parent / "files/lib/wine/i386-windows/ws2_32.dll"
|
|
other_own.parent.mkdir(parents=True)
|
|
other_own.write_bytes(b"MZ GE wine ws2_32")
|
|
other.write_text("")
|
|
self.assertTrue(MODULE["restore_dirt_ws2"](self.prefix, other))
|
|
self.assertEqual(os.readlink(self.target), str(other_own))
|
|
self.assertFalse(os.path.lexists(self.target.with_name("ws2_32.dll.droiddeck-original")))
|
|
|
|
def test_restore_follows_a_proton_reached_through_a_linked_folder(self):
|
|
MODULE["swap_dirt_ws2"](self.prefix)
|
|
pack = Path(self.tmp.name) / "pack"
|
|
(pack / "files/lib/wine").mkdir(parents=True)
|
|
(pack / "files/lib/wine/i386-windows").symlink_to(self.own.parent)
|
|
(pack / "proton").write_text("")
|
|
self.assertTrue(MODULE["restore_dirt_ws2"](self.prefix, pack / "proton"))
|
|
self.assertEqual(os.readlink(self.target), str(self.own))
|
|
|
|
def test_a_stand_in_left_without_the_saved_link_is_replaced_by_protons_own_file(self):
|
|
self.target.unlink()
|
|
self.target.write_bytes(self.stand_in.read_bytes())
|
|
self.assertTrue(MODULE["restore_dirt_ws2"](self.prefix, self.proton))
|
|
self.assertEqual(os.readlink(self.target), str(self.own))
|
|
|
|
def test_launch_swaps_the_stand_in_in_for_the_game_and_back_afterwards(self):
|
|
self.config("DiRT Showdown", self.SHOWDOWN_ON)
|
|
self.proton.write_text("#!" + sys.executable + "\nimport os, sys\n"
|
|
"target = os.path.join(os.environ['STEAM_COMPAT_DATA_PATH'], 'pfx/drive_c/windows/syswow64/ws2_32.dll')\n"
|
|
"print(os.environ.get('WINEDLLOVERRIDES', ''))\n"
|
|
"print(os.path.islink(target), b'droiddeck-dirt-ws2_32' in open(target, 'rb').read())\n")
|
|
self.proton.chmod(0o755)
|
|
runner = Path(self.tmp.name) / "run.py"
|
|
runner.write_text("import runpy, sys\nmain = runpy.run_path(sys.argv[1])['main']\nmain.__globals__['DIRT_WS2'] = sys.argv[2]\n"
|
|
"sys.argv = [sys.argv[1]] + sys.argv[3:]\nsys.exit(main())\n")
|
|
home = Path(self.tmp.name) / "home"
|
|
home.mkdir()
|
|
result = subprocess.run([sys.executable, str(runner), str(BIN / "droiddeck-game-env"), str(self.stand_in), *self.command()],
|
|
env={**os.environ, "HOME": str(home), "STEAM_COMPAT_DATA_PATH": str(self.prefix)}, text=True, capture_output=True)
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
overrides, state = result.stdout.splitlines()[-2:]
|
|
self.assertIn("ws2_32=n,b", overrides.split(";"))
|
|
self.assertEqual(state, "False True")
|
|
self.assertIn("DiRT telemetry fix: ws2_32 stand-in", result.stderr)
|
|
self.assertEqual(os.readlink(self.target), str(self.own))
|
|
self.assertFalse(os.path.lexists(self.target.with_name("ws2_32.dll.droiddeck-original")))
|
|
|
|
def test_a_proton_that_cannot_start_leaves_the_prefix_as_it_was(self):
|
|
self.config("DiRT Showdown", self.SHOWDOWN_ON)
|
|
self.proton.write_text("not a program")
|
|
self.proton.chmod(0o644)
|
|
runner = Path(self.tmp.name) / "run.py"
|
|
runner.write_text("import runpy, sys\nmain = runpy.run_path(sys.argv[1])['main']\nmain.__globals__['DIRT_WS2'] = sys.argv[2]\n"
|
|
"sys.argv = [sys.argv[1]] + sys.argv[3:]\nsys.exit(main())\n")
|
|
home = Path(self.tmp.name) / "home"
|
|
home.mkdir()
|
|
result = subprocess.run([sys.executable, str(runner), str(BIN / "droiddeck-game-env"), str(self.stand_in), *self.command()],
|
|
env={**os.environ, "HOME": str(home), "STEAM_COMPAT_DATA_PATH": str(self.prefix)}, text=True, capture_output=True)
|
|
self.assertNotEqual(result.returncode, 0)
|
|
self.assertIn("DiRT telemetry fix: ws2_32 stand-in", result.stderr)
|
|
self.assertEqual(os.readlink(self.target), str(self.own))
|
|
self.assertFalse(os.path.lexists(self.target.with_name("ws2_32.dll.droiddeck-original")))
|
|
|
|
def test_launch_with_telemetry_off_leaves_the_prefix_alone(self):
|
|
self.config("DiRT Showdown", self.SHOWDOWN_DEFAULT)
|
|
self.proton.write_text("#!" + sys.executable + "\nimport os\nprint(os.environ.get('WINEDLLOVERRIDES', ''))\n")
|
|
self.proton.chmod(0o755)
|
|
home = Path(self.tmp.name) / "home"
|
|
home.mkdir()
|
|
result = subprocess.run([sys.executable, str(BIN / "droiddeck-game-env"), *self.command()],
|
|
env={**os.environ, "HOME": str(home), "STEAM_COMPAT_DATA_PATH": str(self.prefix)}, text=True, capture_output=True)
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
self.assertNotIn("ws2_32=n,b", result.stdout)
|
|
self.assertEqual(os.readlink(self.target), str(self.own))
|
|
|
|
def test_a_failed_put_back_does_not_end_the_launch_with_an_error(self):
|
|
def fail():
|
|
raise OSError(28, "No space left on device")
|
|
stderr = io.StringIO()
|
|
with mock.patch("sys.stderr", stderr):
|
|
code = MODULE["start"]([sys.executable, "-c", "raise SystemExit(0)"], dict(os.environ), None, fail)
|
|
self.assertEqual(code, 0)
|
|
self.assertIn("not put back", stderr.getvalue())
|
|
|
|
def test_a_half_done_swap_is_put_back(self):
|
|
saved = self.target.with_name("ws2_32.dll.droiddeck-original")
|
|
os.replace(self.target, saved)
|
|
self.assertTrue(MODULE["restore_dirt_ws2"](self.prefix, self.proton))
|
|
self.assertEqual(os.readlink(self.target), str(self.own))
|
|
self.assertFalse(os.path.lexists(saved))
|
|
|
|
def test_a_native_ws2_32_goes_back_as_it_was_after_a_proton_switch(self):
|
|
self.target.unlink()
|
|
self.target.write_bytes(b"MZ a mod's own ws2_32")
|
|
MODULE["swap_dirt_ws2"](self.prefix)
|
|
other = Path(self.tmp.name) / "GE-Proton/proton"
|
|
(other.parent / "files/lib/wine/i386-windows").mkdir(parents=True)
|
|
(other.parent / "files/lib/wine/i386-windows/ws2_32.dll").write_bytes(b"MZ GE wine ws2_32")
|
|
other.write_text("")
|
|
self.assertTrue(MODULE["restore_dirt_ws2"](self.prefix, other))
|
|
self.assertFalse(self.target.is_symlink())
|
|
self.assertEqual(self.target.read_bytes(), b"MZ a mod's own ws2_32")
|
|
|
|
def test_a_link_to_a_mods_ws2_32_goes_back_as_it_was(self):
|
|
mod = Path(self.tmp.name) / "mods/ws2_32.dll"
|
|
mod.parent.mkdir()
|
|
mod.write_bytes(b"MZ a mod's ws2_32")
|
|
self.target.unlink()
|
|
self.target.symlink_to(mod)
|
|
MODULE["swap_dirt_ws2"](self.prefix)
|
|
other = Path(self.tmp.name) / "GE-Proton/proton"
|
|
(other.parent / "files/lib/wine/i386-windows").mkdir(parents=True)
|
|
(other.parent / "files/lib/wine/i386-windows/ws2_32.dll").write_bytes(b"MZ GE wine ws2_32")
|
|
other.write_text("")
|
|
self.assertTrue(MODULE["restore_dirt_ws2"](self.prefix, other))
|
|
self.assertEqual(os.readlink(self.target), str(mod))
|
|
|
|
def test_start_gives_the_signal_handlers_back(self):
|
|
import signal
|
|
before = {s: signal.getsignal(s) for s in (signal.SIGTERM, signal.SIGINT, signal.SIGHUP)}
|
|
MODULE["start"]([sys.executable, "-c", "pass"], dict(os.environ), None, lambda: None)
|
|
self.assertEqual(before, {s: signal.getsignal(s) for s in before})
|
|
|
|
def test_start_puts_the_file_back_after_the_game_exits(self):
|
|
calls = []
|
|
code = MODULE["start"]([sys.executable, "-c", "raise SystemExit(3)"], dict(os.environ), None, lambda: calls.append("restored"))
|
|
self.assertEqual(code, 3)
|
|
self.assertEqual(calls, ["restored"])
|
|
with self.assertRaises(OSError):
|
|
MODULE["start"]([str(Path(self.tmp.name) / "missing")], dict(os.environ), None, lambda: calls.append("again"))
|
|
self.assertEqual(calls, ["restored"])
|
|
|
|
|
|
class EgoCorePlanTest(unittest.TestCase):
|
|
|
|
@staticmethod
|
|
def rule(op, value, worker_map):
|
|
return (' <restrict_if name="cores" op="%s" value="%d">\r\n <restrict_add>\r\n'
|
|
' <threadStrategy parallelUpdateRender="true" workerMapFile="system/%s" updateProcessor="2" />\r\n'
|
|
' </restrict_add>\r\n </restrict_if>\r\n' % (op, value, worker_map))
|
|
|
|
def setUp(self):
|
|
self.tmp = tempfile.TemporaryDirectory()
|
|
self.addCleanup(self.tmp.cleanup)
|
|
self.system = Path(self.tmp.name) / "DiRT 3/system"
|
|
self.system.mkdir(parents=True)
|
|
cpu = "".join(self.rule("int_eq", n, "workerMap%dCore.xml" % n) for n in (2, 3, 4, 6)) + self.rule("int_ge", 8, "workerMap8Core.xml")
|
|
self.shipped = ('<?xml version="1.0" encoding="UTF-8" ?>\r\n<hardware_settings_restrictions>\r\n <cpu>\r\n'
|
|
' <restrict_info>\r\n' + cpu +
|
|
' <restrict_else>\r\n <restrict_add>\r\n'
|
|
' <threadStrategy parallelUpdateRender="false" workerMapFile="system/workerMap.xml" />\r\n'
|
|
' </restrict_add>\r\n </restrict_else>\r\n </restrict_info>\r\n </cpu>\r\n'
|
|
' <audio_card>\r\n' + self.rule("int_eq", 5, "audio.xml") + ' </audio_card>\r\n'
|
|
'</hardware_settings_restrictions>').encode("latin-1")
|
|
self.path = self.system / "hardware_settings_restrictions.xml"
|
|
self.path.write_bytes(self.shipped)
|
|
for name in ("workerMap.xml", "workerMap2Core.xml", "workerMap3Core.xml", "workerMap4Core.xml", "workerMap6Core.xml", "workerMap8Core.xml"):
|
|
(self.system / name).write_text("<workerMap />")
|
|
self.command = ["/proton", "waitforexitandrun", str(self.system.parent / "dirt3_game.exe")]
|
|
self.saved = self.path.with_name(self.path.name + ".droiddeck-original")
|
|
|
|
def plan(self, topology, command=None):
|
|
env = {} if topology is None else {"WINE_CPU_TOPOLOGY": topology}
|
|
return MODULE["ego_core_plan"](command or self.command, env)
|
|
|
|
def test_five_cores_get_a_copy_of_the_four_core_plan(self):
|
|
note = self.plan("5:7,3,4,5,6")
|
|
self.assertIn("5 cores", note)
|
|
self.assertIn("4-core plan", note)
|
|
text = self.path.read_bytes()
|
|
four = self.rule("int_eq", 4, "workerMap4Core.xml").encode("latin-1")
|
|
five = self.rule("int_eq", 5, "workerMap4Core.xml").encode("latin-1")
|
|
self.assertEqual(text, self.shipped.replace(four, four + five))
|
|
self.assertNotIn(b"\n", text.replace(b"\r\n", b""))
|
|
self.assertEqual(self.saved.read_bytes(), self.shipped)
|
|
self.assertEqual(sorted(p.name for p in self.system.glob("*droiddeck-tmp*")), [])
|
|
self.assertIsNone(self.plan("5:7,3,4,5,6"))
|
|
self.assertEqual(self.path.read_bytes(), text)
|
|
self.assertEqual(self.saved.read_bytes(), self.shipped)
|
|
|
|
def test_seven_cores_get_the_six_core_plan(self):
|
|
self.assertIn("6-core plan", self.plan("7:7,1,2,3,4,5,6"))
|
|
self.assertIn(b'value="7">\r\n <restrict_add>\r\n <threadStrategy parallelUpdateRender="true" '
|
|
b'workerMapFile="system/workerMap6Core.xml"', self.path.read_bytes())
|
|
|
|
def test_counts_with_a_plan_are_left_alone(self):
|
|
for topology in ("2:7,6", "4:7,3,4,5", "6:7,3,4,5,6,2", "8:7,2,3,4,5,6,0,1", "12:0,1,2,3,4,5,6,7,8,9,10,11"):
|
|
self.assertIsNone(self.plan(topology), topology)
|
|
self.assertEqual(self.path.read_bytes(), self.shipped)
|
|
self.assertFalse(os.path.lexists(self.saved))
|
|
|
|
def test_nothing_changes_without_a_core_count_a_restrictions_file_a_smaller_plan_or_its_worker_map(self):
|
|
self.assertIsNone(self.plan(None))
|
|
self.assertIsNone(self.plan(""))
|
|
self.assertIsNone(self.plan("x:1"))
|
|
self.assertIsNone(self.plan("\u00b2:7"))
|
|
self.assertIsNone(self.plan("1:7"))
|
|
self.assertIsNone(self.plan("5:7,3,4,5,6", ["/proton", "waitforexitandrun"]))
|
|
self.assertIsNone(self.plan("5:7,3,4,5,6", ["/proton", "waitforexitandrun", "/elsewhere/game.exe"]))
|
|
(self.system / "workerMap4Core.xml").unlink()
|
|
self.assertIsNone(self.plan("5:7,3,4,5,6"))
|
|
self.assertEqual(self.path.read_bytes(), self.shipped)
|
|
self.assertFalse(os.path.lexists(self.saved))
|
|
|
|
def test_an_unknown_comparison_counts_as_a_plan(self):
|
|
self.path.write_bytes(self.shipped.replace(b'op="int_ge" value="8"', b'op="int_between" value="8"'))
|
|
self.assertIsNone(self.plan("5:7,3,4,5,6"))
|
|
|
|
def test_main_adds_the_plan_before_the_game_starts(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
probe = Path(tmp) / "probe"
|
|
probe.write_text('#!/bin/sh\ngrep -c \'value="5"\' "%s"\n' % self.path)
|
|
probe.chmod(0o755)
|
|
env = {k: v for k, v in os.environ.items() if not k.startswith(("WINE", "PROTON", "DROIDDECK", "BL_", "STEAM"))}
|
|
env.update(HOME=tmp, STEAM_COMPAT_DATA_PATH="/compatdata/3791535942", WINE_CPU_TOPOLOGY="5:7,3,4,5,6")
|
|
result = subprocess.run([sys.executable, str(BIN / "droiddeck-game-env"), str(probe), "waitforexitandrun", self.command[2]],
|
|
env=env, text=True, capture_output=True, check=True)
|
|
self.assertEqual(result.stdout.strip(), "2", result.stderr)
|
|
self.assertIn("added a copy of its 4-core plan", result.stderr)
|
|
|
|
|
|
class SystemGraphicsLaunchTest(unittest.TestCase):
|
|
def test_start_configures_ubisoft_with_proton_before_wrapping_the_game(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
root = Path(tmp)
|
|
runtime, _ = self.runtime(root)
|
|
prefix = root / "42" / "pfx"
|
|
(prefix / MODULE["UBISOFT_LAUNCHER"]).mkdir(parents=True)
|
|
(prefix / "user.reg").write_text("WINE REGISTRY Version 2\n")
|
|
env = {"DROIDDECK_POWERVR_RUNTIME": str(runtime), "STEAM_COMPAT_DATA_PATH": str(prefix.parent)}
|
|
command = ["/selected/proton", "waitforexitandrun", "game.exe"]
|
|
with mock.patch("subprocess.run") as run, mock.patch("os.execvpe") as execute:
|
|
MODULE["start"](command, env, None)
|
|
self.assertEqual(run.call_count, 2)
|
|
for call in run.call_args_list:
|
|
self.assertEqual(call.args[0][:3], ["/selected/proton", "runinprefix", "reg.exe"])
|
|
self.assertEqual(execute.call_args.args[:2], (str(runtime / "run-proton.py"),
|
|
[str(runtime / "run-proton.py"), *command]))
|
|
|
|
def runtime(self, root):
|
|
runtime = root / "system_vulkan" / ("a" * 64)
|
|
files = ["run-proton.py", "lib/libVkLayer_hybris_compat.so",
|
|
"wsi/libVkLayer_window_system_integration.so",
|
|
"wsi/VkLayer_window_system_integration.json"]
|
|
files += ["dxvk/%s/%s.dll" % (arch, name) for arch in ("x32", "x64")
|
|
for name in ("d3d8", "d3d9", "d3d10core", "d3d11", "dxgi")]
|
|
for name in files:
|
|
target = runtime / name
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
target.write_bytes(b"component")
|
|
(runtime / "run-proton.py").chmod(0o755)
|
|
return runtime, files
|
|
|
|
def test_other_gpus_and_non_game_calls_keep_the_original_launch(self):
|
|
command = ["/proton", "waitforexitandrun", "game.exe"]
|
|
base = {"STEAM_COMPAT_DATA_PATH": "/compatdata/42", "VK_LAYER_PATH": "original"}
|
|
selected = dict(base, DROIDDECK_POWERVR_RUNTIME="/not-installed")
|
|
cases = [(command, base), (command, dict(selected, STEAM_COMPAT_DATA_PATH="/compatdata/0")),
|
|
(command, dict(selected, STEAM_COMPAT_DATA_PATH="/compatdata/invalid")),
|
|
(["/proton", "run", "iscriptevaluator.exe"], selected),
|
|
(["/proton", "getcompatpath", "x"], selected), (command[:2], selected)]
|
|
for args, env in cases:
|
|
with self.subTest(args=args, env=env):
|
|
actual, inherited = MODULE["system_graphics_launch"](args, env)
|
|
self.assertIs(actual, args)
|
|
self.assertIs(inherited, env)
|
|
|
|
def test_system_graphics_follow_game_configuration_and_preserve_other_keys(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
runtime, _ = self.runtime(Path(tmp))
|
|
base = {"DROIDDECK_POWERVR_RUNTIME": str(runtime), "STEAM_COMPAT_DATA_PATH": "/compatdata/42"}
|
|
config = {"version": 1, "games": {"42": {
|
|
"PROTON_USE_WINED3D": "1", "DISABLE_WSI_LAYER": "1",
|
|
"ENABLE_VK_LAYER_VALVE_steam_fossilize_1": "1", "HYBRIS_BC_TEXTURES": "0",
|
|
"VK_LAYER_PATH": "old", "VK_INSTANCE_LAYERS": "old", "ENABLE_GAMESCOPE_WSI": "1",
|
|
"FEX_TSOENABLED": "1", "WINESERVER": "/selected/wineserver", "CUSTOM": "literal $value",
|
|
}}}
|
|
env = MODULE["apply_config"](base, config, "42")
|
|
command = ["/selected/proton", "waitforexitandrun", "game with spaces.exe"]
|
|
launch, result = MODULE["system_graphics_launch"](command, env)
|
|
self.assertEqual(launch, [str(runtime / "run-proton.py"), *command])
|
|
self.assertEqual(result["HYBRIS_BC_TEXTURES"], "dxvk")
|
|
self.assertEqual(result["VK_LAYER_PATH"], f"{runtime}/wsi:{runtime}/lib")
|
|
self.assertEqual(result["VK_INSTANCE_LAYERS"], "VK_LAYER_window_system_integration:VK_LAYER_HYBRIS_compat")
|
|
self.assertEqual(result["VK_LOADER_LAYERS_DISABLE"], "*gamescope*")
|
|
self.assertEqual(result["ENABLE_GAMESCOPE_WSI"], "0")
|
|
self.assertEqual(result["DISABLE_VK_LAYER_VALVE_steam_fossilize_1"], "1")
|
|
for key in ("PROTON_USE_WINED3D", "DISABLE_WSI_LAYER", "ENABLE_VK_LAYER_VALVE_steam_fossilize_1"):
|
|
self.assertNotIn(key, result)
|
|
self.assertIn(key, env)
|
|
for key in ("FEX_TSOENABLED", "WINESERVER", "CUSTOM"):
|
|
self.assertEqual(result[key], env[key])
|
|
|
|
def test_xclipse_selection_adds_its_layers_layer_path_and_dxvk_payload(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
runtime, _ = self.runtime(Path(tmp))
|
|
env = {"DROIDDECK_POWERVR_RUNTIME": str(runtime), "STEAM_COMPAT_DATA_PATH": "/compatdata/42",
|
|
"DROIDDECK_HYBRIS_LAYERS": "VK_LAYER_DROIDDECK_xclipse:VK_LAYER_HYBRIS_compat",
|
|
"DROIDDECK_HYBRIS_LAYER_PATH": "/usr/local/lib/droiddeck-xclipse",
|
|
"DROIDDECK_HYBRIS_DXVK": "dxvk-2.6.2"}
|
|
command = ["/proton", "waitforexitandrun", "game.exe"]
|
|
# The selected payload is required like the default one.
|
|
with self.assertRaises(MODULE["SystemGraphicsError"]):
|
|
MODULE["system_graphics_launch"](command, env)
|
|
for arch in ("x32", "x64"):
|
|
for name in ("d3d8", "d3d9", "d3d10core", "d3d11", "dxgi"):
|
|
target = runtime / "dxvk-2.6.2" / arch / (name + ".dll")
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
target.write_bytes(b"component")
|
|
launch, result = MODULE["system_graphics_launch"](command, env)
|
|
self.assertEqual(launch, [str(runtime / "run-proton.py"), *command])
|
|
self.assertEqual(result["VK_INSTANCE_LAYERS"],
|
|
"VK_LAYER_window_system_integration:VK_LAYER_DROIDDECK_xclipse:VK_LAYER_HYBRIS_compat")
|
|
self.assertEqual(result["VK_LAYER_PATH"], f"{runtime}/wsi:{runtime}/lib:/usr/local/lib/droiddeck-xclipse")
|
|
self.assertEqual(result["DROIDDECK_HYBRIS_DXVK"], "dxvk-2.6.2")
|
|
for name in ("../dxvk", "other", "dxvk/x"):
|
|
with self.subTest(name=name), self.assertRaises(MODULE["SystemGraphicsError"]):
|
|
MODULE["system_graphics_launch"](command, dict(env, DROIDDECK_HYBRIS_DXVK=name))
|
|
|
|
def test_incomplete_runtime_fails_before_any_proton_exec(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
runtime, files = self.runtime(Path(tmp))
|
|
env = {"DROIDDECK_POWERVR_RUNTIME": str(runtime), "STEAM_COMPAT_DATA_PATH": "/compatdata/42"}
|
|
command = ["/proton", "waitforexitandrun", "game.exe"]
|
|
for name in files:
|
|
target = runtime / name
|
|
original = target.read_bytes()
|
|
for missing in (False, True):
|
|
with self.subTest(name=name, missing=missing):
|
|
target.unlink() if missing else target.write_bytes(b"")
|
|
with patch.object(os, "execvpe") as execute:
|
|
with self.assertRaises(MODULE["SystemGraphicsError"]):
|
|
MODULE["exec_proton"](command, env)
|
|
execute.assert_not_called()
|
|
target.write_bytes(original)
|
|
if name == "run-proton.py":
|
|
target.chmod(0o755)
|
|
(runtime / "run-proton.py").chmod(0o644)
|
|
with self.assertRaises(MODULE["SystemGraphicsError"]):
|
|
MODULE["system_graphics_launch"](command, env)
|
|
with self.assertRaises(MODULE["SystemGraphicsError"]):
|
|
MODULE["system_graphics_launch"](command, dict(env, DROIDDECK_POWERVR_RUNTIME="relative"))
|
|
|
|
def test_wrapper_receives_the_sync_selected_proton(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
root = Path(tmp)
|
|
runtime, _ = self.runtime(root)
|
|
env = {"HOME": tmp, "DROIDDECK_POWERVR_RUNTIME": str(runtime),
|
|
"STEAM_COMPAT_DATA_PATH": "/compatdata/42"}
|
|
command = ["/stock/proton", "waitforexitandrun", "game.exe"]
|
|
selected = ["/sync-pack/proton", *command[1:]]
|
|
selected_env = dict(env, WINESERVER="/sync-pack/wineserver", BL_SYNC_PACK="selected")
|
|
main = MODULE["main"]
|
|
with patch.dict(os.environ, env, clear=True), patch.object(sys, "argv", ["game-env", *command]), \
|
|
patch.dict(main.__globals__, {"load_sync": lambda: {},
|
|
"select_sync": lambda *args: (selected, selected_env),
|
|
"win_components": lambda *args: ""}), \
|
|
patch.object(os, "execvpe", side_effect=SystemExit(23)) as execute:
|
|
with self.assertRaises(SystemExit) as stopped:
|
|
main()
|
|
self.assertEqual(stopped.exception.code, 23)
|
|
execute.assert_called_once()
|
|
path, args, actual_env = execute.call_args.args
|
|
self.assertEqual(path, str(runtime / "run-proton.py"))
|
|
self.assertEqual(args, [path, *selected])
|
|
self.assertEqual(actual_env["WINESERVER"], "/sync-pack/wineserver")
|
|
self.assertEqual(actual_env["BL_SYNC_PACK"], "selected")
|
|
|
|
|
|
class GameDebugLoggingTest(unittest.TestCase):
|
|
def test_maintenance_precision_survives_run_with_performance_game_settings(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
home = Path(tmp)
|
|
config = home / ".config/droiddeck/game-environment.json"
|
|
config.parent.mkdir(parents=True)
|
|
config.write_text(json.dumps({"version": 1, "shared": {"FEX_X87REDUCEDPRECISION": "1"},
|
|
"games": {"42": {"FEX_X87REDUCEDPRECISION": "1"}}}))
|
|
probe = home / "proton"
|
|
probe.write_text("#!" + sys.executable + "\nimport os\nprint(os.environ['FEX_X87REDUCEDPRECISION'])\n")
|
|
probe.chmod(0o755)
|
|
base = {**os.environ, "HOME": tmp, "STEAM_COMPAT_DATA_PATH": str(home / "compatdata/42")}
|
|
maintenance = subprocess.run([sys.executable, str(BIN / "droiddeck-game-env"), str(probe), "run"],
|
|
env={**base, "DROIDDECK_GOG_MAINTENANCE": "1", "FEX_X87REDUCEDPRECISION": "0"}, text=True, capture_output=True, check=True)
|
|
game = subprocess.run([sys.executable, str(BIN / "droiddeck-game-env"), str(probe), "waitforexitandrun"],
|
|
env={**base, "FEX_X87REDUCEDPRECISION": "1"}, text=True, capture_output=True, check=True)
|
|
self.assertEqual("0", maintenance.stdout.strip())
|
|
self.assertEqual("1", game.stdout.strip())
|
|
|
|
def test_real_game_launch_without_config_file_keeps_the_base_environment(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
home = Path(tmp)
|
|
probe = home / "proton"
|
|
probe.write_text("#!" + sys.executable + "\nimport os\nprint(os.environ['PROTON_LOG'])\n")
|
|
probe.chmod(0o755)
|
|
result = subprocess.run(
|
|
[sys.executable, str(BIN / "droiddeck-game-env"), str(probe), "waitforexitandrun"],
|
|
env={**os.environ, "HOME": tmp, "STEAM_COMPAT_DATA_PATH": str(home / "compatdata/42"),
|
|
"BL_DEBUG_DIR": tmp, "PROTON_LOG": "0"},
|
|
text=True, capture_output=True, check=True,
|
|
)
|
|
self.assertEqual(result.stdout.strip(), "0")
|
|
|
|
def test_game_debug_logs_override_only_diagnostic_values_and_keep_raw_settings(self):
|
|
with tempfile.TemporaryDirectory(prefix="game logs '") as debug_dir:
|
|
inherited = {"BL_DEBUG_DIR": debug_dir, "PROTON_LOG": "0", "WINEDEBUG": "-all,+relay",
|
|
"PROTON_LOG_DIR": "/old", "PROTON_CRASH_REPORT_DIR": "/old-crashes",
|
|
"DXVK_LOG_PATH": "/old-dxvk", "DXVK_LOG_LEVEL": "none", "VKD3D_DEBUG": "none",
|
|
"CUSTOM": "$(touch never); 'quoted' = literal", "DXVK_CONFIG": "dxvk.tearFree = True"}
|
|
config = {"version": 1, "gameDebugLogs": True,
|
|
"shared": {"PROTON_LOG": "0", "BL_DEBUG_DIR": "/profile-redirect"},
|
|
"games": {"42": {"PROTON_LOG_DIR": None, "DXVK_LOG_PATH": "/game-redirect"}}}
|
|
normal = MODULE["apply_config"](inherited, config, "42")
|
|
debug = MODULE["apply_game_debug_logs"](normal, config, inherited["BL_DEBUG_DIR"])
|
|
self.assertEqual(debug["PROTON_LOG"], "1")
|
|
for key in ("PROTON_LOG_DIR", "PROTON_CRASH_REPORT_DIR", "DXVK_LOG_PATH"):
|
|
self.assertEqual(debug[key], debug_dir)
|
|
self.assertEqual(debug["DXVK_LOG_LEVEL"], "debug")
|
|
self.assertEqual(debug["VKD3D_DEBUG"], "info")
|
|
self.assertIn("+relay", debug["WINEDEBUG"].split(","))
|
|
self.assertTrue(debug["WINEDEBUG"].endswith("+loaddll,+mscoree"))
|
|
for key in ("CUSTOM", "DXVK_CONFIG", "BL_DEBUG_DIR"):
|
|
self.assertEqual(debug[key], normal[key])
|
|
self.assertEqual(normal["PROTON_LOG"], "0")
|
|
self.assertEqual(inherited["PROTON_LOG_DIR"], "/old")
|
|
self.assertIs(MODULE["apply_game_debug_logs"](normal, {**config, "gameDebugLogs": False}, debug_dir), normal)
|
|
self.assertEqual(MODULE["apply_game_debug_logs"](debug, config, debug_dir), debug)
|
|
|
|
def test_wine_debug_channels_are_formatted_deduplicated_and_diagnostics_win(self):
|
|
formatted = MODULE["game_debug_channels"](
|
|
" +relay, trace-vulkan, -all, -seh, +relay, trace+vulkan, garbage, $(touch never) ",
|
|
"+seh,warn-foo,warn+foo,+relay",
|
|
)
|
|
self.assertEqual(formatted, "-all,trace+vulkan,warn+foo,+relay," +
|
|
"+timestamp,+pid,+tid,+seh,+unwind,+threadname,+debugstr,+loaddll,+mscoree")
|
|
self.assertEqual(MODULE["game_debug_channels"](formatted, "1"), formatted)
|
|
self.assertNotIn("-seh", formatted)
|
|
for value in ("x\0,+relay", "+" + "x" * 8192, 1, None):
|
|
self.assertEqual(MODULE["game_debug_channels"](value), MODULE["game_debug_channels"]())
|
|
oversized = ",".join("+channel_" + str(i) for i in range(550))
|
|
other = ",".join("+other_" + str(i) for i in range(550))
|
|
self.assertLessEqual(len(MODULE["game_debug_channels"](oversized, other)), 8192)
|
|
self.assertTrue(MODULE["game_debug_channels"](oversized, other).endswith("+loaddll,+mscoree"))
|
|
|
|
def test_game_debug_logs_require_opt_in_and_a_real_session_log_directory(self):
|
|
env = {"PROTON_LOG": "user", "CUSTOM": "keep"}
|
|
for config in ({}, {"version": 1}, {"version": 1, "gameDebugLogs": False},
|
|
{"version": 1, "gameDebugLogs": "true"}, {"version": 2, "gameDebugLogs": True}):
|
|
self.assertIs(MODULE["apply_game_debug_logs"](env, config, "/"), env)
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
not_a_directory = Path(tmp) / "file"
|
|
not_a_directory.write_text("file")
|
|
for path in (None, "", "relative", "bad\0path", str(not_a_directory), str(Path(tmp) / "missing")):
|
|
with self.subTest(path=path):
|
|
self.assertIs(MODULE["apply_game_debug_logs"](env, {"version": 1, "gameDebugLogs": True}, path), env)
|
|
|
|
def test_game_debug_toggle_is_live_preserves_command_and_excludes_probes(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
home = Path(tmp)
|
|
config_path = home / ".config/droiddeck/game-environment.json"
|
|
config_path.parent.mkdir(parents=True)
|
|
debug_dir = home / "session logs '$literal; directory'"
|
|
debug_dir.mkdir()
|
|
probe = home / "fake proton"
|
|
probe.write_text("#!" + sys.executable + "\nimport json, os, sys\n"
|
|
"print(json.dumps({'env': dict(os.environ), 'argv': sys.argv[1:]}))\n")
|
|
probe.chmod(0o755)
|
|
literal = "$(touch " + str(home / "never-created") + "); 'literal'=value"
|
|
config = {"version": 1, "gameDebugLogs": True,
|
|
"shared": {"PROTON_LOG": "0", "WINEDEBUG": "-all,+relay", "CUSTOM": literal,
|
|
"BL_DEBUG_DIR": "/profile-must-not-redirect"}}
|
|
env = {**os.environ, "HOME": tmp, "STEAM_COMPAT_DATA_PATH": str(home / "compatdata/42"),
|
|
"BL_DEBUG_DIR": str(debug_dir), "PROTON_LOG": "0", "WINEDEBUG": "-all"}
|
|
command = [str(probe), "waitforexitandrun", "game 'with quotes'.exe", "argument with spaces", "$(literal)", "%command%"]
|
|
|
|
def launch(arguments, environment):
|
|
result = subprocess.run([sys.executable, str(BIN / "droiddeck-game-env"), *arguments],
|
|
env=environment, text=True, capture_output=True, check=True)
|
|
return json.loads(result.stdout)
|
|
|
|
for enabled in (True, False, True):
|
|
config_path.write_text(json.dumps({**config, "gameDebugLogs": enabled}))
|
|
result = launch(command, env)
|
|
self.assertEqual(result["argv"], command[1:])
|
|
self.assertEqual(result["env"]["CUSTOM"], literal)
|
|
self.assertEqual(result["env"]["PROTON_LOG"], "1" if enabled else "0")
|
|
if enabled:
|
|
self.assertEqual(result["env"]["PROTON_LOG_DIR"], str(debug_dir))
|
|
self.assertIn("+relay", result["env"]["WINEDEBUG"].split(","))
|
|
self.assertIn("+seh", result["env"]["WINEDEBUG"].split(","))
|
|
else:
|
|
self.assertEqual(result["env"]["WINEDEBUG"], "-all,+relay")
|
|
for verb, prefix in (("run", "42"), ("waitforexitandrun", "0"), ("waitforexitandrun", "not-a-game")):
|
|
result = launch([str(probe), verb], {**env, "STEAM_COMPAT_DATA_PATH": str(home / "compatdata" / prefix)})
|
|
self.assertEqual(result["env"]["PROTON_LOG"], "0")
|
|
self.assertEqual(result["env"]["WINEDEBUG"], "-all")
|
|
result = launch(command, {**env, "STEAM_COMPAT_DATA_PATH": str(home / "compatdata/-1")})
|
|
self.assertEqual(result["env"]["PROTON_LOG"], "1")
|
|
self.assertFalse((home / "never-created").exists())
|
|
self.assertEqual(json.loads(config_path.read_text())["shared"], config["shared"])
|
|
|
|
|
|
class ReadConfigMappingTest(unittest.TestCase):
|
|
def test_missing_parent_or_unbalanced_vdf_is_not_treated_as_absent_choices(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
config = Path(tmp) / "config.vdf"
|
|
config.write_text('"InstallConfigStore" { "Software" { "Other" {} } }')
|
|
self.assertIsNone(COMPAT["read_config_mapping"](str(config)))
|
|
config.write_text('"InstallConfigStore" { "Software" { "Valve" { "Steam" {} }')
|
|
self.assertIsNone(COMPAT["read_config_mapping"](str(config)))
|
|
|
|
def test_valid_steam_parent_without_compat_mapping_has_no_per_app_choices(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
config = Path(tmp) / "config.vdf"
|
|
config.write_text('"InstallConfigStore" { "Software" { "Valve" { "Steam" {} } } }')
|
|
self.assertEqual(COMPAT["read_config_mapping"](str(config)), {})
|
|
|
|
|
|
class GameEnvironmentTest(unittest.TestCase):
|
|
def test_optional_xbox_loader_failure_keeps_stock_launch(self):
|
|
from unittest.mock import patch
|
|
for error in (FileNotFoundError('missing'), RuntimeError('module initialization failed')):
|
|
calls = []
|
|
with patch.dict(os.environ, {}, clear=True), patch('sys.argv', ['droiddeck-game-env', '/stock', 'run']), \
|
|
patch('runpy.run_path', side_effect=error), patch('os.execvpe', side_effect=lambda *args: calls.append(args)), \
|
|
patch.dict(MODULE['main'].__globals__, load_sync=lambda: None, select_sync=lambda command, env, sync: (command, env)):
|
|
MODULE['main']()
|
|
self.assertEqual(calls[0][0:2], ('/stock', ['/stock', 'run']))
|
|
|
|
|
|
def test_directaudio_capture_follows_the_session_microphone_setting(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
home = Path(tmp)
|
|
probe = home / "probe"
|
|
probe.write_text("#!" + sys.executable + "\nimport os\nprint(os.environ.get('AUDIO_DIRECT_MIC', 'unset'))\n")
|
|
probe.chmod(0o755)
|
|
config = home / ".config/droiddeck/game-environment.json"
|
|
config.parent.mkdir(parents=True)
|
|
for enabled, override, expected in (("1", None, "1"), ("0", "1", "0"), ("1", "0", "0")):
|
|
with self.subTest(enabled=enabled, override=override):
|
|
profile = {} if override is None else {
|
|
"AUDIO_DIRECT_MIC": override, "BL_MIC_ENABLED": override,
|
|
}
|
|
config.write_text(json.dumps({"version": 1, "games": {"42": profile}}))
|
|
env = dict(os.environ, HOME=tmp, BL_MIC_ENABLED=enabled,
|
|
AUDIO_DIRECT_MIC="1", STEAM_COMPAT_DATA_PATH="/compatdata/42")
|
|
result = subprocess.run([sys.executable, str(BIN / "droiddeck-game-env"),
|
|
str(probe), "waitforexitandrun"],
|
|
env=env, text=True, capture_output=True, check=True)
|
|
self.assertEqual(result.stdout.strip(), expected)
|
|
|
|
def test_game_profile_directaudio_settings_reach_the_driver(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
home = Path(tmp)
|
|
probe = home / "probe"
|
|
probe.write_text("#!" + sys.executable + "\nimport os\nprint(' '.join(sorted(k + '=' + v for k, v in os.environ.items() if k.startswith('BANNER_AUDIO_DIRECT_'))))\n")
|
|
probe.chmod(0o755)
|
|
config = home / ".config/droiddeck/game-environment.json"
|
|
config.parent.mkdir(parents=True)
|
|
config.write_text(json.dumps({"version": 1, "games": {"42": {"AUDIO_DIRECT_MS": "80"}}}))
|
|
base = {key: value for key, value in os.environ.items() if "AUDIO_DIRECT_" not in key}
|
|
base.update(HOME=tmp, BL_MIC_ENABLED="0", STEAM_COMPAT_DATA_PATH="/compatdata/42",
|
|
AUDIO_DIRECT_MS="60", AUDIO_DIRECT_PERIOD_MS="20", AUDIO_DIRECT_MIC="1",
|
|
BANNER_AUDIO_DIRECT_MS="60", BANNER_AUDIO_DIRECT_MIC="1")
|
|
for driver, expected in (("directaudio,pulse", "BANNER_AUDIO_DIRECT_MIC=0 BANNER_AUDIO_DIRECT_MS=80 BANNER_AUDIO_DIRECT_PERIOD_MS=20"),
|
|
("pulse", "BANNER_AUDIO_DIRECT_MIC=1 BANNER_AUDIO_DIRECT_MS=60")):
|
|
with self.subTest(driver=driver):
|
|
result = subprocess.run([sys.executable, str(BIN / "droiddeck-game-env"), str(probe), "waitforexitandrun"],
|
|
env=dict(base, WINE_AUDIO_DRIVER=driver), text=True, capture_output=True, check=True)
|
|
self.assertEqual(result.stdout.strip(), expected)
|
|
|
|
def test_desktop_games_restart_on_the_gpu_with_steams_preloads_handed_back(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
home = Path(tmp)
|
|
gpu = home / "gpu"
|
|
gpu.write_text("#!/bin/bash\necho \"gpu LD_PRELOAD=${LD_PRELOAD-unset}\"\nexport BL_DESKTOP_GPU_CHILD=1\nexec \"$@\"\n")
|
|
gpu.chmod(0o755)
|
|
launcher = home / "launcher"
|
|
setup = COMPAT["BL_DESKTOP_GPU_SETUP"].replace("/usr/local/bin/droiddeck-gpu", str(gpu))
|
|
launcher.write_text("#!/bin/bash\nverb=$1; shift\n" + setup + "echo \"game $verb $* LD_PRELOAD=${LD_PRELOAD-unset} saved=${BL_GPU_SAVED_LD_PRELOAD-unset}\"\n")
|
|
launcher.chmod(0o755)
|
|
base = {key: value for key, value in os.environ.items() if not key.startswith("BL_")}
|
|
base["LD_PRELOAD"] = "overlay.so"
|
|
for extra, args, expected in (
|
|
({"BL_DESKTOP": "1"}, ["waitforexitandrun", "game.exe"],
|
|
["gpu LD_PRELOAD=unset", "game waitforexitandrun game.exe LD_PRELOAD=overlay.so saved=unset"]),
|
|
({"BL_DESKTOP": "1", "BL_DESKTOP_GPU": "Off"}, ["waitforexitandrun", "game.exe"],
|
|
["game waitforexitandrun game.exe LD_PRELOAD=overlay.so saved=unset"]),
|
|
({"BL_DESKTOP": "1"}, ["run", "setup.exe"],
|
|
["game run setup.exe LD_PRELOAD=overlay.so saved=unset"]),
|
|
({"BL_DESKTOP": "1"}, ["waitforexitandrun", "iscriptevaluator.exe"],
|
|
["game waitforexitandrun iscriptevaluator.exe LD_PRELOAD=overlay.so saved=unset"]),
|
|
({}, ["waitforexitandrun", "game.exe"],
|
|
["game waitforexitandrun game.exe LD_PRELOAD=overlay.so saved=unset"]),
|
|
):
|
|
with self.subTest(extra=extra, args=args):
|
|
result = subprocess.run([str(launcher)] + args, env=dict(base, **extra),
|
|
text=True, capture_output=True, check=True)
|
|
self.assertEqual(result.stdout.splitlines(), expected)
|
|
|
|
def test_the_install_script_evaluator_gets_its_scripts_marked_first(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
root = Path(tmp)
|
|
script = root / "legacycompat/evaluatorscript_409710.vdf"
|
|
script.parent.mkdir(parents=True)
|
|
script.write_text("{}")
|
|
calls = []
|
|
run = MODULE["mark_install_scripts"].__globals__["subprocess"].run
|
|
MODULE["mark_install_scripts"].__globals__["subprocess"].run = lambda args, **kw: calls.append((args, kw["env"]))
|
|
try:
|
|
env = {"STEAM_COMPAT_CLIENT_INSTALL_PATH": str(root)}
|
|
MODULE["mark_install_scripts"](["/proton", "run", str(root / "legacycompat/iscriptevaluator.exe"), "legacycompat\\evaluatorscript_409710.vdf"], env)
|
|
MODULE["mark_install_scripts"](["/proton", "waitforexitandrun", "/game/Game.exe"], env)
|
|
finally:
|
|
MODULE["mark_install_scripts"].__globals__["subprocess"].run = run
|
|
self.assertEqual(len(calls), 1)
|
|
self.assertEqual(calls[0][0][1], str(root))
|
|
self.assertEqual(calls[0][1]["DROIDDECK_SEED_SCRIPTS"], str(script))
|
|
|
|
SCRIPT = ('"evaluatorscript" { "0" { "appid" "228980" "compat_installscript" { "run process" { '
|
|
'"x64 14.28.29334.0" { "hasrunkey" "HKEY_LOCAL_MACHINE\\\\Software\\\\Valve\\\\Steam\\\\Apps\\\\CommonRedist\\\\vcredist\\\\2019" '
|
|
'"process 1" "%INSTALLDIR%\\\\x64.cmd" "requirement_os" { "Is64BitWindows" "1" } } '
|
|
'"Setup" { "process 1" "%INSTALLDIR%\\\\setup.exe" } } } } }')
|
|
REDIST = "[Software\\\\Wow6432Node\\\\Valve\\\\Steam\\\\Apps\\\\CommonRedist\\\\vcredist\\\\2019] 1\n\"x64 14.28.29334.0\"=dword:00000001\n"
|
|
SETUP = "[Software\\\\Valve\\\\Steam\\\\Apps\\\\228980] 1\n\"Setup\"=dword:00000001\n"
|
|
|
|
def test_install_script_run_keys_default_to_the_app_key(self):
|
|
keys = MODULE["run_keys"](MODULE["read_vdf"](self.SCRIPT))
|
|
self.assertEqual(keys, {
|
|
("hkey_local_machine\\software\\valve\\steam\\apps\\commonredist\\vcredist\\2019", "x64 14.28.29334.0"),
|
|
("hkey_local_machine\\software\\valve\\steam\\apps\\228980", "setup"),
|
|
})
|
|
with self.assertRaises(ValueError):
|
|
MODULE["read_vdf"]('"a" { "b" }')
|
|
|
|
def test_install_scripts_count_as_done_only_when_recorded_and_still_marked(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
script = Path(tmp) / "evaluatorscript_1.vdf"
|
|
script.write_text(self.SCRIPT)
|
|
prefix = Path(tmp) / "compat/pfx"
|
|
env = {"STEAM_COMPAT_DATA_PATH": str(prefix.parent)}
|
|
done = MODULE["install_scripts_done"]
|
|
self.assertFalse(done([str(script)], env))
|
|
prefix.mkdir(parents=True)
|
|
MODULE["record_install_scripts"](MODULE["scripts_digest"]([str(script)]), env)
|
|
self.assertFalse(done([str(script)], env))
|
|
(prefix / "system.reg").write_text(self.REDIST)
|
|
self.assertFalse(done([str(script)], env))
|
|
(prefix / "system.reg").write_text(self.REDIST + self.SETUP)
|
|
self.assertTrue(done([str(script)], env))
|
|
script.write_text(self.SCRIPT.replace("x64.cmd", "x64-update.cmd"))
|
|
self.assertFalse(done([str(script)], env))
|
|
script.write_text('"InstallScript" { "Registry" { "HKEY_LOCAL_MACHINE\\\\Software\\\\Game" { "string" { "Path" "%INSTALLDIR%" } } } }')
|
|
MODULE["record_install_scripts"](MODULE["scripts_digest"]([str(script)]), env)
|
|
self.assertFalse(done([str(script)], env))
|
|
|
|
def test_the_evaluator_is_skipped_once_it_finished_the_same_scripts(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
root = Path(tmp)
|
|
(root / "legacycompat").mkdir()
|
|
evaluator = root / "legacycompat/iscriptevaluator.exe"
|
|
evaluator.write_bytes(b"evaluator")
|
|
script = root / "legacycompat/evaluatorscript_1.vdf"
|
|
prefix = root / "compatdata/1/pfx"
|
|
prefix.mkdir(parents=True)
|
|
(prefix / "system.reg").write_text(self.REDIST + self.SETUP)
|
|
runs = root / "runs"
|
|
proton = root / "proton"
|
|
proton.write_text("#!/bin/sh\necho run >> %s\nexit ${EXIT_CODE:-0}\n" % shlex.quote(str(runs)))
|
|
proton.chmod(0o755)
|
|
|
|
def evaluate(exit_code="0"):
|
|
script.write_text(self.SCRIPT if exit_code != "changed" else self.SCRIPT.replace("setup.exe", "setup-update.exe"))
|
|
env = dict(os.environ, HOME=tmp, STEAM_COMPAT_CLIENT_INSTALL_PATH=str(root),
|
|
STEAM_COMPAT_DATA_PATH=str(prefix.parent), EXIT_CODE="0" if exit_code == "changed" else exit_code)
|
|
result = subprocess.run([sys.executable, str(BIN / "droiddeck-game-env"), str(proton), "run",
|
|
str(evaluator), "legacycompat\\evaluatorscript_1.vdf"],
|
|
env=env, text=True, capture_output=True)
|
|
return result.returncode, len(runs.read_text().splitlines()) if runs.exists() else 0
|
|
|
|
self.assertEqual(evaluate("3"), (3, 1))
|
|
self.assertFalse((prefix / ".droiddeck-install-scripts").exists())
|
|
self.assertEqual(evaluate(), (0, 2))
|
|
self.assertEqual(evaluate(), (0, 2))
|
|
self.assertEqual(evaluate("changed"), (0, 3))
|
|
|
|
def test_the_mali_bridge_caps_the_vkd3d_feature_level(self):
|
|
bridge = {"BL_VKBRIDGE": "1", "VKD3D_FEATURE_LEVEL": "12_2"}
|
|
self.assertIn("capped", MODULE["bridge_feature_level"](bridge))
|
|
self.assertEqual(bridge["VKD3D_FEATURE_LEVEL"], "12_0")
|
|
for env in ({"BL_VKBRIDGE": "1", "VKD3D_FEATURE_LEVEL": "11_1"}, {"VKD3D_FEATURE_LEVEL": "12_2"}, {"BL_VKBRIDGE": "1"}):
|
|
before = dict(env)
|
|
self.assertIsNone(MODULE["bridge_feature_level"](env))
|
|
self.assertEqual(env, before)
|
|
|
|
def test_profile_precedence_and_unset(self):
|
|
env = {"KEEP": "inherited", "REMOVE": "inherited", "CUSTOM": "launch option"}
|
|
config = {"version": 1, "shared": {"CUSTOM": "shared"}, "games": {"42": {"REMOVE": None, "CUSTOM": "game", "EMPTY": ""}}}
|
|
self.assertEqual(MODULE["apply_config"](env, config, "42"), {"KEEP": "inherited", "CUSTOM": "game", "EMPTY": ""})
|
|
self.assertEqual(env["REMOVE"], "inherited")
|
|
self.assertEqual(MODULE["apply_config"](env, config, "43")["CUSTOM"], "shared")
|
|
|
|
def test_games_get_their_own_turnip_debug_and_a_game_profile_still_wins(self):
|
|
gpu = MODULE["game_gpu"]
|
|
self.assertEqual(gpu({"TU_DEBUG": "sysmem", "DROIDDECK_GAME_TU_DEBUG": "", "KEEP": "1"}), {"KEEP": "1"})
|
|
self.assertEqual(gpu({"TU_DEBUG": "sysmem", "DROIDDECK_GAME_TU_DEBUG": "sysmem"}), {"TU_DEBUG": "sysmem"})
|
|
self.assertEqual(gpu({"DROIDDECK_GAME_TU_DEBUG": "nolrz"}), {"TU_DEBUG": "nolrz"})
|
|
self.assertEqual(gpu({"TU_DEBUG": "sysmem"}), {"TU_DEBUG": "sysmem"})
|
|
config = {"version": 1, "games": {"42": {"TU_DEBUG": "sysmem"}}}
|
|
env = gpu({"TU_DEBUG": "sysmem", "DROIDDECK_GAME_TU_DEBUG": ""})
|
|
self.assertEqual(MODULE["apply_config"](env, config, "42")["TU_DEBUG"], "sysmem")
|
|
self.assertNotIn("TU_DEBUG", MODULE["apply_config"](env, config, "43"))
|
|
|
|
def test_the_vram_cap_comes_before_the_games_own_dxvk_settings(self):
|
|
cap = MODULE["vram_cap"]
|
|
self.assertEqual(cap({"BL_VRAM_MB": "4096"})["DXVK_CONFIG"], "dxgi.maxDeviceMemory = 4096")
|
|
self.assertEqual(cap({"BL_VRAM_MB": "2048", "DXVK_CONFIG": "dxvk.maxFrameRate = 60;"})["DXVK_CONFIG"],
|
|
"dxgi.maxDeviceMemory = 2048; dxvk.maxFrameRate = 60")
|
|
for off in ({}, {"BL_VRAM_MB": "0"}, {"BL_VRAM_MB": "lots"}, {"BL_VRAM_MB": "²"}, {"BL_VRAM_MB": "-1"}):
|
|
self.assertNotIn("DXVK_CONFIG", cap(off))
|
|
self.assertNotIn("DROIDDECK_VRAM_CAP", cap(off))
|
|
self.assertNotIn("DROIDDECK_VRAM_CAP_MB", cap(off))
|
|
|
|
def test_the_vram_cap_also_turns_on_the_vulkan_layer_that_holds_the_game_to_it(self):
|
|
env = MODULE["vram_cap"]({"BL_VRAM_MB": "3072", "DXVK_CONFIG": "dxgi.maxDeviceMemory = 16384"})
|
|
self.assertEqual((env["DROIDDECK_VRAM_CAP"], env["DROIDDECK_VRAM_CAP_MB"]), ("1", "3072"))
|
|
|
|
def test_a_games_wine_sync_choice_also_reaches_its_setup_runs(self):
|
|
env = {"BL_SYNC": "1", "BL_SYNC_FALLBACK": "1", "BL_FSYNC_FIRST": "0", "OTHER": "kept"}
|
|
config = {"version": 1, "games": {"42": {"BL_SYNC": "0", "BL_FSYNC_FIRST": "1", "PROTON_NO_ESYNC": "1", "FEX_TSOENABLED": "1"}}}
|
|
chosen = MODULE["apply_config"](env, MODULE["sync_config"](config, "42"), "42")
|
|
self.assertEqual(chosen, {"BL_SYNC": "0", "BL_SYNC_FALLBACK": "1", "BL_FSYNC_FIRST": "1", "PROTON_NO_ESYNC": "1", "OTHER": "kept"})
|
|
|
|
def test_component_profiles_override_general_and_restore_it_for_the_next_game(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
root = Path(tmp)
|
|
home = root / "home"
|
|
component_dir = home / ".local/share/droiddeck-components"
|
|
base = root / "proton"
|
|
target = base / "files/lib/wine/dxvk/aarch64-windows/d3d11.dll"
|
|
leftover = target.with_name("d3d8.dll")
|
|
component_dir.mkdir(parents=True)
|
|
target.parent.mkdir(parents=True)
|
|
(base / "version").write_text("1 build-1\n")
|
|
general = root / "general.dll"
|
|
game = root / "game.dll"
|
|
general.write_text("general")
|
|
game.write_text("game")
|
|
target.write_text("old")
|
|
leftover.write_text("old")
|
|
digest = lambda path: hashlib.sha256(path.read_bytes()).hexdigest()
|
|
(component_dir / "desired.tsv").write_text(
|
|
"*\tproton\tbuild-1\tdxvk\tfiles/lib/wine/dxvk/aarch64-windows/d3d11.dll\t%s\t%s\n"
|
|
"42\tproton\tbuild-1\tdxvk\tfiles/lib/wine/dxvk/aarch64-windows/d3d11.dll\t%s\t%s\n"
|
|
"42\tproton\tbuild-1\tdxvk\tfiles/lib/wine/dxvk/aarch64-windows/d3d8.dll\t-\t-\n"
|
|
% (digest(general), general, digest(game), game)
|
|
)
|
|
script = COMPAT["BL_COMPONENTS_SETUP"] + '\nverb=waitforexitandrun; bl_components "$1"\n'
|
|
env = dict(os.environ, HOME=str(home), STEAM_COMPAT_DATA_PATH="/steam/compatdata/42")
|
|
subprocess.run(["bash", "-c", script, "test", str(base)], env=env, check=True)
|
|
self.assertEqual(target.read_text(), "game")
|
|
self.assertFalse(leftover.exists())
|
|
|
|
env["STEAM_COMPAT_DATA_PATH"] = "/steam/compatdata/43"
|
|
subprocess.run(["bash", "-c", script, "test", str(base)], env=env, check=True)
|
|
self.assertEqual(target.read_text(), "general")
|
|
|
|
def test_system_powervr_preserves_dxvk_profiles_without_applying_them(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
root = Path(tmp)
|
|
home = root / "home"
|
|
component_dir = home / ".local/share/droiddeck-components"
|
|
component_dir.mkdir(parents=True)
|
|
base = root / "proton"
|
|
paths = {
|
|
"dxvk": "files/lib/wine/dxvk/aarch64-windows/d3d11.dll",
|
|
"fex": "files/lib/wine/aarch64-windows/libarm64ecfex.dll",
|
|
"vkd3d": "files/lib/wine/vkd3d-proton/aarch64-windows/d3d12.dll",
|
|
}
|
|
rows = []
|
|
for comp, rel in paths.items():
|
|
target = base / rel
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
target.write_text("bundled " + comp)
|
|
for scope in ("*", "42"):
|
|
source = root / (comp + ("-general" if scope == "*" else "-game"))
|
|
source.write_text(source.name)
|
|
rows.append("%s\tproton\tbuild-1\t%s\t%s\t%s\t%s\n" % (
|
|
scope, comp, rel, hashlib.sha256(source.read_bytes()).hexdigest(), source,
|
|
))
|
|
leftover = (base / paths["dxvk"]).with_name("d3d8.dll")
|
|
leftover.write_text("bundled d3d8")
|
|
rows.append("42\tproton\tbuild-1\tdxvk\t%s\t-\t-\n" % leftover.relative_to(base))
|
|
(base / "version").write_text("1 build-1\n")
|
|
selection = component_dir / "desired.tsv"
|
|
selection.write_text("".join(rows))
|
|
saved = selection.read_bytes()
|
|
script = COMPAT["BL_COMPONENTS_SETUP"] + '\nverb=waitforexitandrun; bl_components "$1"\n'
|
|
env = dict(os.environ, HOME=str(home), STEAM_COMPAT_DATA_PATH="/steam/compatdata/42",
|
|
DROIDDECK_POWERVR_RUNTIME="/system_vulkan/runtime")
|
|
|
|
subprocess.run(["bash", "-c", script, "test", str(base)], env=env, check=True)
|
|
|
|
self.assertEqual((base / paths["dxvk"]).read_text(), "bundled dxvk")
|
|
self.assertEqual(leftover.read_text(), "bundled d3d8")
|
|
for comp in ("fex", "vkd3d"):
|
|
self.assertEqual((base / paths[comp]).read_text(), comp + "-game")
|
|
self.assertEqual(selection.read_bytes(), saved)
|
|
|
|
# A subsequent manual-driver session still applies the saved per-game DXVK choice.
|
|
env.pop("DROIDDECK_POWERVR_RUNTIME")
|
|
subprocess.run(["bash", "-c", script, "test", str(base)], env=env, check=True)
|
|
self.assertEqual((base / paths["dxvk"]).read_text(), "dxvk-game")
|
|
self.assertFalse(leftover.exists())
|
|
self.assertEqual(selection.read_bytes(), saved)
|
|
|
|
def test_component_scope_matches_environment_scope_for_shortcut_ids(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
root = Path(tmp)
|
|
component_dir = root / ".local/share/droiddeck-components"
|
|
base = root / "proton"
|
|
target = base / "files/lib/wine/dxvk/aarch64-windows/d3d11.dll"
|
|
component_dir.mkdir(parents=True)
|
|
target.parent.mkdir(parents=True)
|
|
(base / "version").write_text("1 build-1\n")
|
|
rows = []
|
|
for scope in ("*", "42", "4294967295", "2147483648"):
|
|
source = root / ("default" if scope == "*" else scope)
|
|
source.write_text(scope)
|
|
rows.append("%s\tproton\tbuild-1\tdxvk\t%s\t%s\t%s\n" % (
|
|
scope, target.relative_to(base),
|
|
hashlib.sha256(source.read_bytes()).hexdigest(), source,
|
|
))
|
|
(component_dir / "desired.tsv").write_text("".join(rows))
|
|
script = COMPAT["BL_COMPONENTS_SETUP"] + '\nverb=waitforexitandrun; bl_components "$1"\n'
|
|
for value in (
|
|
"42", "00042", "43", "-1", "-0001", "4294967295", "-2147483648",
|
|
"2147483648", "-1///", "0", "-0", "000", "-2147483649", "4294967296",
|
|
"999999999999999999999999999", "not-an-id", "1+1", "",
|
|
):
|
|
with self.subTest(prefix=value):
|
|
env = dict(os.environ, HOME=str(root), STEAM_COMPAT_DATA_PATH="/steam/compatdata/" + value)
|
|
target.write_text("untouched")
|
|
subprocess.run(["bash", "-c", script, "test", str(base)], env=env, check=True, capture_output=True)
|
|
scope = MODULE["game_id"](env)
|
|
expected = "untouched" if scope is None else scope if scope in {"42", "4294967295", "2147483648"} else "*"
|
|
self.assertEqual(target.read_text(), expected)
|
|
|
|
def test_engine_fixes_follow_the_games_files(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
game = Path(tmp) / "Godot Game"
|
|
(game / "data_game_windows_x86_64").mkdir(parents=True)
|
|
for name in ("Game.exe", "Game.pck", "libsentry.windows.release.x86_64.dll", "data_game_windows_x86_64/coreclr.dll"):
|
|
(game / name).touch()
|
|
env, extra, notes = MODULE["engine_fixes"](str(game / "Game.exe"), [])
|
|
self.assertEqual(env["FEX_TSOENABLED"], "1")
|
|
self.assertNotIn("FEX_MULTIBLOCK", env)
|
|
self.assertEqual(env["WINEDLLOVERRIDES"], "libsentry.windows.release.x86_64=d")
|
|
self.assertEqual(extra, ["--rendering-driver", "vulkan"])
|
|
self.assertEqual(MODULE["engine_fixes"](str(game / "Game.exe"), ["--rendering-driver", "opengl3"])[1], [])
|
|
script = Path(tmp) / "GDScript Game"
|
|
script.mkdir()
|
|
for name in ("Game.exe", "Game.pck", "libsentry.windows.release.x86_64.dll"):
|
|
(script / name).touch()
|
|
env, extra, notes = MODULE["engine_fixes"](str(script / "Game.exe"), [])
|
|
self.assertNotIn("WINEDLLOVERRIDES", env)
|
|
self.assertEqual(extra, ["--rendering-driver", "vulkan"])
|
|
other = Path(tmp) / "Other"
|
|
other.mkdir()
|
|
(other / "Other.exe").touch()
|
|
self.assertEqual(MODULE["engine_fixes"](str(other / "Other.exe"), []), ({}, [], []))
|
|
|
|
def test_easy_anti_cheat_games_start_offline_only_when_opted_in(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
game = Path(tmp) / "ELDEN RING/Game"
|
|
(game / "EasyAntiCheat").mkdir(parents=True)
|
|
for name in ("start_protected_game.exe", "eldenring.exe"):
|
|
(game / name).touch()
|
|
(game / "EasyAntiCheat/settings.json").write_text('\ufeff{ "executable" : "eldenring.exe" }', encoding="utf-8")
|
|
command = ["proton", "waitforexitandrun", str(game / "start_protected_game.exe"), "-arg"]
|
|
offline = MODULE["offline_without_anticheat"]
|
|
self.assertEqual(offline(command, {}), command)
|
|
self.assertEqual(offline(command, {"DROIDDECK_ANTICHEAT_OFFLINE": "0"}), command)
|
|
self.assertEqual(offline(command, {"DROIDDECK_ANTICHEAT_OFFLINE": "1"}),
|
|
["proton", "waitforexitandrun", str(game / "eldenring.exe"), "-arg"])
|
|
(game / "EasyAntiCheat/settings.json").write_text('{ "executable" : "../../escape.exe" }')
|
|
(Path(tmp) / "escape.exe").touch()
|
|
self.assertEqual(offline(command, {"DROIDDECK_ANTICHEAT_OFFLINE": "1"}), command)
|
|
(game / "EasyAntiCheat/settings.json").write_text('{ "executable" : "missing.exe" }')
|
|
self.assertEqual(offline(command, {"DROIDDECK_ANTICHEAT_OFFLINE": "1"}), command)
|
|
(game / "EasyAntiCheat/settings.json").write_text('{ "executable" : "link/escape.exe" }')
|
|
outside = Path(tmp) / "outside"
|
|
outside.mkdir()
|
|
(outside / "escape.exe").touch()
|
|
(game / "link").symlink_to(outside, target_is_directory=True)
|
|
self.assertEqual(offline(command, {"DROIDDECK_ANTICHEAT_OFFLINE": "1"}), command)
|
|
(game / "EasyAntiCheat/settings.json").write_text('{ "executable" : "eldenring.exe" }')
|
|
plain = ["proton", "waitforexitandrun", str(game / "eldenring.exe")]
|
|
self.assertEqual(offline(plain, {"DROIDDECK_ANTICHEAT_OFFLINE": "1"}), plain)
|
|
self.assertEqual(offline(["proton", "run", command[2]],
|
|
{"DROIDDECK_ANTICHEAT_OFFLINE": "1"}),
|
|
["proton", "run", command[2]])
|
|
|
|
def test_easy_anti_cheat_forwarding_through_entrypoint(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
home = Path(tmp)
|
|
game = home / "ELDEN RING/Game"
|
|
(game / "EasyAntiCheat").mkdir(parents=True)
|
|
bootstrapper = game / "start_protected_game.exe"
|
|
target = game / "eldenring.exe"
|
|
bootstrapper.touch()
|
|
target.touch()
|
|
(game / "EasyAntiCheat/settings.json").write_text(json.dumps({"executable": "eldenring.exe"}))
|
|
proton = home / "fake-proton"
|
|
proton.write_text("#!" + sys.executable + "\nimport json, sys\nprint(json.dumps(sys.argv[1:]))\n")
|
|
proton.chmod(0o755)
|
|
config = home / ".config/droiddeck/game-environment.json"
|
|
config.parent.mkdir(parents=True)
|
|
env = dict(os.environ, HOME=tmp, STEAM_COMPAT_DATA_PATH="/compatdata/42")
|
|
env.pop("DROIDDECK_ANTICHEAT_OFFLINE", None)
|
|
for setting, expected in (("1", target), ("0", bootstrapper), (None, bootstrapper)):
|
|
with self.subTest(setting=setting):
|
|
values = {} if setting is None else {"DROIDDECK_ANTICHEAT_OFFLINE": setting}
|
|
config.write_text(json.dumps({"version": 1, "games": {"42": values}}))
|
|
result = subprocess.run([sys.executable, str(BIN / "droiddeck-game-env"),
|
|
str(proton), "waitforexitandrun", str(bootstrapper), "-arg"],
|
|
env=env, text=True, capture_output=True, check=True)
|
|
self.assertEqual(json.loads(result.stdout.strip()),
|
|
["waitforexitandrun", str(expected), "-arg"])
|
|
|
|
def test_deep_rock_gdk_avoids_the_raytracing_pipeline_crash(self):
|
|
game = "/game/FSD/Binaries/WinGDK/FSD-WinGDK-Shipping.exe"
|
|
env, extra, notes = MODULE["engine_fixes"](game, [])
|
|
self.assertEqual(extra, ["-noraytracing", "-ini:Engine:[/Script/Engine.RendererSettings]:r.RayTracing=False"])
|
|
self.assertEqual(env, {})
|
|
self.assertTrue(notes)
|
|
for choice in ("-raytracing", "-noraytracing"):
|
|
self.assertEqual(MODULE["engine_fixes"](game, [choice]), ({}, [], []))
|
|
self.assertEqual(MODULE["engine_fixes"]("/game/FSD-Win64-Shipping.exe", []), ({}, [], []))
|
|
self.assertEqual(MODULE["engine_fixes"]("/game/Other-WinGDK-Shipping.exe", []), ({}, [], []))
|
|
|
|
def test_arkham_city_gets_full_x87_precision_over_the_preset(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
city = Path(tmp) / "Batman Arkham City GOTY/Binaries/Win32"
|
|
city.mkdir(parents=True)
|
|
for name in ("BatmanAC.exe", "BmLauncher.exe"):
|
|
(city / name).touch()
|
|
for exe in ("BatmanAC.exe", "BmLauncher.exe"):
|
|
env, extra, notes = MODULE["engine_fixes"](str(city / exe), [])
|
|
self.assertEqual((env, extra), ({"FEX_X87REDUCEDPRECISION": "0"}, []))
|
|
config = {"version": 1, "shared": {"FEX_X87REDUCEDPRECISION": "1"}}
|
|
self.assertEqual(MODULE["apply_config"]({}, config, "200260", env)["FEX_X87REDUCEDPRECISION"], "0")
|
|
config["games"] = {"200260": {"FEX_X87REDUCEDPRECISION": "1"}}
|
|
self.assertEqual(MODULE["apply_config"]({}, config, "200260", env)["FEX_X87REDUCEDPRECISION"], "1")
|
|
asylum = Path(tmp) / "Batman Arkham Asylum GOTY/Binaries"
|
|
asylum.mkdir(parents=True)
|
|
for name in ("ShippingPC-BmGame.exe", "BmLauncher.exe"):
|
|
(asylum / name).touch()
|
|
self.assertEqual(MODULE["engine_fixes"](str(asylum / "BmLauncher.exe"), []), ({}, [], []))
|
|
|
|
def test_d3dgear_capture_hook_beside_the_game_is_turned_off(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
game = Path(tmp) / "The Crew 2"
|
|
game.mkdir()
|
|
for name in ("TheCrew2.exe", "TheCrew2_BE.exe", "d3dgear64.dll"):
|
|
(game / name).touch()
|
|
env, extra, notes = MODULE["engine_fixes"](str(game / "TheCrew2.exe"), [])
|
|
self.assertEqual((env, extra), ({"WINEDLLOVERRIDES": "d3dgear64=d"}, []))
|
|
self.assertTrue(notes)
|
|
(game / "d3dgear64.dll").rename(game / "D3DGear64.DLL")
|
|
self.assertEqual(MODULE["engine_fixes"](str(game / "TheCrew2.exe"), [])[0], {"WINEDLLOVERRIDES": "d3dgear64=d"})
|
|
(game / "D3DGear64.DLL").unlink()
|
|
self.assertEqual(MODULE["engine_fixes"](str(game / "TheCrew2.exe"), []), ({}, [], []))
|
|
|
|
def test_doom_2016_keeps_the_swapchain_image_count_it_asks_for(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
game = Path(tmp) / "DOOM (2016)"
|
|
game.mkdir()
|
|
for name in ("DOOMx64.exe", "DOOMx64vk.exe", "Galaxy64.dll"):
|
|
(game / name).touch()
|
|
images = {
|
|
"GAMESCOPE_WSI_MIN_IMAGE_COUNT": None,
|
|
"GAMESCOPE_WSI_ENSURE_MIN_IMAGE_COUNT": None,
|
|
"vk_x11_strict_image_count": "true",
|
|
}
|
|
for exe in ("DOOMx64.exe", "DOOMx64vk.exe"):
|
|
env, extra, notes = MODULE["engine_fixes"](str(game / exe), [])
|
|
self.assertEqual((env, extra), (images, []))
|
|
self.assertTrue(notes)
|
|
launched = {"GAMESCOPE_WSI_MIN_IMAGE_COUNT": "8", "GAMESCOPE_WSI_ENSURE_MIN_IMAGE_COUNT": "1"}
|
|
result = MODULE["apply_config"](launched, {"version": 1}, "3566086475", env)
|
|
self.assertNotIn("GAMESCOPE_WSI_MIN_IMAGE_COUNT", result)
|
|
self.assertNotIn("GAMESCOPE_WSI_ENSURE_MIN_IMAGE_COUNT", result)
|
|
self.assertEqual(result["vk_x11_strict_image_count"], "true")
|
|
config = {"version": 1, "games": {"3566086475": {"GAMESCOPE_WSI_MIN_IMAGE_COUNT": "3"}}}
|
|
self.assertEqual(MODULE["apply_config"](launched, config, "3566086475", env)["GAMESCOPE_WSI_MIN_IMAGE_COUNT"], "3")
|
|
eternal = Path(tmp) / "DOOMEternal"
|
|
eternal.mkdir()
|
|
(eternal / "DOOMEternalx64vk.exe").touch()
|
|
self.assertEqual(MODULE["engine_fixes"](str(eternal / "DOOMEternalx64vk.exe"), []), ({}, [], []))
|
|
other = Path(tmp) / "Other"
|
|
other.mkdir()
|
|
for name in ("Other.exe", "DOOMx64vk.exe"):
|
|
(other / name).touch()
|
|
self.assertEqual(MODULE["engine_fixes"](str(other / "Other.exe"), []), ({}, [], []))
|
|
|
|
def test_dll_overrides_from_several_fixes_are_all_kept(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
for name in ("Game.exe", "Game.pck", "GodotSharp.dll", "libsentry.windows.release.x86_64.dll", "d3dgear64.dll"):
|
|
(Path(tmp) / name).touch()
|
|
env = MODULE["engine_fixes"](str(Path(tmp) / "Game.exe"), [])[0]
|
|
self.assertEqual(env["WINEDLLOVERRIDES"], "libsentry.windows.release.x86_64=d;d3dgear64=d")
|
|
|
|
def test_black_ops_iii_and_dark_souls_remastered_skip_protonfixes(self):
|
|
for exe in ("/game/Call of Duty Black Ops III/BlackOps3.exe", "/game/DARK SOULS REMASTERED/DarkSoulsRemastered.exe"):
|
|
env, extra, notes = MODULE["engine_fixes"](exe, [])
|
|
self.assertEqual((env, extra), ({"PROTONFIXES_DISABLE": "1"}, []))
|
|
self.assertTrue(notes)
|
|
self.assertEqual(MODULE["engine_fixes"]("/game/Game/DarkSoulsII.exe", []), ({}, [], []))
|
|
|
|
def test_little_nightmares_keeps_its_render_thread(self):
|
|
env, extra, notes = MODULE["engine_fixes"]("/game/Little Nightmares/Atlas/Binaries/Win64/LittleNightmares.exe", [])
|
|
self.assertEqual((env, extra), ({"PROTONFIXES_DISABLE": "1"}, []))
|
|
self.assertTrue(notes)
|
|
self.assertEqual(MODULE["engine_fixes"]("/game/Little Nightmares II/Moonlight/Binaries/Win64/LittleNightmaresII.exe", []), ({}, [], []))
|
|
|
|
def test_game_cores_are_parsed_from_lists_and_ranges(self):
|
|
parse = MODULE["parse_cores"]
|
|
self.assertEqual(parse("0-3,6"), {0, 1, 2, 3, 6})
|
|
self.assertEqual(parse(" 4, 5 "), {4, 5})
|
|
for bad in ("", "a", "3-1", "1,,2", "-1", "1-"):
|
|
self.assertIsNone(parse(bad), bad)
|
|
|
|
def test_a_profiles_game_cores_pin_the_game_within_the_online_cores(self):
|
|
calls = []
|
|
os_mod = MODULE["os"]
|
|
with mock.patch.dict(MODULE["game_cores"].__globals__, {"online_cores": lambda: {0, 1, 2, 3, 4, 5, 6, 7}}), \
|
|
mock.patch.object(os_mod, "sched_setaffinity", side_effect=lambda pid, cores: calls.append(set(cores)), create=True):
|
|
MODULE["game_cores"]({"DROIDDECK_GAME_CORES": "0-2,9"})
|
|
MODULE["game_cores"]({"DROIDDECK_GAME_CORES": "junk"})
|
|
MODULE["game_cores"]({"DROIDDECK_GAME_CORES": "12-13"})
|
|
MODULE["game_cores"]({})
|
|
self.assertEqual(calls, [{0, 1, 2}])
|
|
self.assertEqual(MODULE["parse_cores"]("0,2-7"), {0, 2, 3, 4, 5, 6, 7})
|
|
|
|
def test_windows_games_see_only_the_cores_they_are_pinned_to(self):
|
|
os_mod = MODULE["os"]
|
|
online = {"online_cores": lambda: {0, 1, 2, 3, 4, 5, 6, 7}}
|
|
cases = [
|
|
({"BL_GAME_CPUS": "6,7"}, {6, 7}, "2:6,7", "2:6,7"),
|
|
({"DROIDDECK_GAME_CORES": "0-2"}, {0, 1, 2}, "3:0,1,2", "3:0,1,2"),
|
|
({"BL_GAME_CPUS": "0-7"}, set(range(8)), None, None),
|
|
({}, {6, 7}, None, None),
|
|
({"BL_GAME_CPUS": "6,7", "WINE_CPU_TOPOLOGY": "4:0,1,2,3"}, {6, 7}, "4:0,1,2,3", "4:0,1,2,3"),
|
|
({"WINE_CPU_TOPOLOGY": "2:0,1"}, set(range(8)), "2:0,1", "2:0,1"),
|
|
({"BL_GAME_CPUS": "6,7", "WINE_CPU_TOPOLOGY": ""}, {6, 7}, "", None),
|
|
({"BL_GAME_CPUS": "6,7", "PROTON_CPU_TOPOLOGY": "1:7"}, {6, 7}, None, "1:7"),
|
|
({"BL_GAME_CPUS": "9"}, {9}, None, None),
|
|
]
|
|
for env, affinity, expected, proton in cases:
|
|
env["DROIDDECK_FASTEST_CORES"] = "0"
|
|
with mock.patch.dict(MODULE["cpu_topology"].__globals__, online), \
|
|
mock.patch.object(os_mod, "sched_getaffinity", return_value=affinity, create=True):
|
|
MODULE["cpu_topology"](env)
|
|
self.assertEqual(env.get("WINE_CPU_TOPOLOGY"), expected, env)
|
|
self.assertEqual(env.get("PROTON_CPU_TOPOLOGY"), proton, env)
|
|
|
|
def test_only_games_get_the_larger_swapchain(self):
|
|
game = {"STEAM_COMPAT_DATA_PATH": "/steam/compatdata/945360"}
|
|
self.assertTrue(MODULE["game_images"](game))
|
|
self.assertEqual(game["GAMESCOPE_WSI_MIN_IMAGE_COUNT"], "8")
|
|
self.assertEqual(game["GAMESCOPE_WSI_ENSURE_MIN_IMAGE_COUNT"], "1")
|
|
own = {"STEAM_COMPAT_DATA_PATH": "/steam/compatdata/945360", "GAMESCOPE_WSI_MIN_IMAGE_COUNT": "3"}
|
|
MODULE["game_images"](own)
|
|
self.assertEqual(own["GAMESCOPE_WSI_MIN_IMAGE_COUNT"], "3")
|
|
for env in ({}, {"STEAM_COMPAT_DATA_PATH": "/steam/compatdata/0"}):
|
|
self.assertFalse(MODULE["game_images"](env))
|
|
self.assertNotIn("GAMESCOPE_WSI_MIN_IMAGE_COUNT", env)
|
|
|
|
def test_opengl_games_do_not_wait_for_the_screen_refresh(self):
|
|
cases = [
|
|
({}, "0"),
|
|
({"STEAM_COMPAT_DATA_PATH": "/steam/compatdata/-686078602"}, "0"),
|
|
({"vblank_mode": "2"}, "2"),
|
|
({"vblank_mode": ""}, ""),
|
|
({"STEAM_COMPAT_DATA_PATH": "/steam/compatdata/0"}, None),
|
|
({"STEAM_COMPAT_DATA_PATH": ""}, None),
|
|
]
|
|
for env, expected in cases:
|
|
env.setdefault("STEAM_COMPAT_DATA_PATH", "/steam/compatdata/3608888694")
|
|
added = expected == "0" and "vblank_mode" not in env
|
|
self.assertEqual(MODULE["gl_vsync"](env), added, env)
|
|
self.assertEqual(env.get("vblank_mode"), expected, env)
|
|
said = "OpenGL does not wait for the screen refresh"
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
probe = Path(tmp) / "probe"
|
|
probe.write_text("#!" + sys.executable + "\nimport os\nprint(os.environ.get('vblank_mode', 'unset'))\n")
|
|
probe.chmod(0o755)
|
|
config = Path(tmp) / ".config/droiddeck/game-environment.json"
|
|
config.parent.mkdir(parents=True)
|
|
for inherited, edits, expected in (({}, None, "0"), ({"vblank_mode": "3"}, None, "3"),
|
|
({}, {"vblank_mode": None}, "unset"), ({}, {"vblank_mode": "2"}, "2"),
|
|
({"vblank_mode": "3"}, {"vblank_mode": None}, "unset")):
|
|
with self.subTest(inherited=inherited, edits=edits):
|
|
config.write_text(json.dumps({"version": 1, "games": {"42": edits or {}}}))
|
|
env = {name: value for name, value in os.environ.items() if name != "vblank_mode"}
|
|
env.update(HOME=tmp, STEAM_COMPAT_DATA_PATH="/compatdata/42", **inherited)
|
|
result = subprocess.run([sys.executable, str(BIN / "droiddeck-game-env"), str(probe), "waitforexitandrun"],
|
|
env=env, text=True, capture_output=True, check=True)
|
|
self.assertEqual(result.stdout.strip(), expected)
|
|
self.assertEqual(said in result.stderr, expected == "0" and not inherited, result.stderr)
|
|
|
|
def test_teardown_keeps_its_own_vsync_unless_the_player_chose(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
probe = Path(tmp) / "probe"
|
|
probe.write_text("#!" + sys.executable + "\nimport os\nprint(os.environ.get('vblank_mode', 'unset'))\n")
|
|
probe.chmod(0o755)
|
|
game = Path(tmp) / "Teardown/teardown.exe"
|
|
game.parent.mkdir()
|
|
game.write_bytes(b"MZ")
|
|
config = Path(tmp) / ".config/droiddeck/game-environment.json"
|
|
config.parent.mkdir(parents=True)
|
|
for inherited, edits, shared, expected in (({}, None, None, "2"), ({"vblank_mode": "0"}, None, None, "0"),
|
|
({}, {"vblank_mode": "1"}, None, "1"),
|
|
({}, None, {"vblank_mode": "1"}, "1")):
|
|
with self.subTest(inherited=inherited, edits=edits, shared=shared):
|
|
config.write_text(json.dumps({"version": 1, "shared": shared or {}, "games": {"1167630": edits or {}}}))
|
|
env = {name: value for name, value in os.environ.items() if name != "vblank_mode"}
|
|
env.update(HOME=tmp, STEAM_COMPAT_DATA_PATH="/compatdata/1167630", **inherited)
|
|
result = subprocess.run([sys.executable, str(BIN / "droiddeck-game-env"), str(probe), "waitforexitandrun", str(game)],
|
|
env=env, text=True, capture_output=True, check=True)
|
|
self.assertEqual(result.stdout.strip(), expected, result.stderr)
|
|
|
|
def test_windows_cpu_0_is_the_fastest_core(self):
|
|
os_mod = MODULE["os"]
|
|
pocket_fit = {0: 379, 1: 379, 2: 923, 3: 923, 4: 923, 5: 867, 6: 867, 7: 1024}
|
|
every = set(range(8))
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
proton = Path(tmp) / "proton"
|
|
proton.write_text('default_cpu_limit = {\n "19900" : "16", # Far Cry 2\n'
|
|
' "2229830" : "1", # Command & Conquer\n "11440" : "4", # DiRT\n'
|
|
' "1038250" : "P", # DIRT 5\n "618970" : 4, # Outcast, written as Proton 10 does\n'
|
|
'}\n\nother = {\n "620" : "2",\n}\n')
|
|
command = [str(proton), "waitforexitandrun", "/game/game.exe"]
|
|
cases = [
|
|
({}, every, pocket_fit, "8:7,2,3,4,5,6,0,1", "8:7,2,3,4,5,6,0,1"),
|
|
({"BL_GAME_CPUS": "6,7"}, {6, 7}, pocket_fit, "2:7,6", "2:7,6"),
|
|
({"DROIDDECK_GAME_CORES": "0-2"}, {0, 1, 2}, pocket_fit, "3:2,0,1", "3:2,0,1"),
|
|
({"BL_GAME_CPUS": "0-7"}, every, pocket_fit, "8:7,2,3,4,5,6,0,1", "8:7,2,3,4,5,6,0,1"),
|
|
({"WINE_CPU_TOPOLOGY": "2:0,1"}, every, pocket_fit, "2:0,1", "2:0,1"),
|
|
({"WINE_CPU_TOPOLOGY": ""}, every, pocket_fit, "", None),
|
|
({"PROTON_CPU_TOPOLOGY": "1:0"}, every, pocket_fit, None, "1:0"),
|
|
({"DROIDDECK_FASTEST_CORES": "0"}, every, pocket_fit, None, None),
|
|
({"SteamGameId": "2229830"}, every, pocket_fit, "1:7", "1:7"),
|
|
({"SteamGameId": "11440"}, every, pocket_fit, "4:7,2,3,4", "4:7,2,3,4"),
|
|
({"SteamGameId": "19900"}, every, pocket_fit, "8:7,2,3,4,5,6,0,1", "8:7,2,3,4,5,6,0,1"),
|
|
({"SteamGameId": "1038250"}, every, pocket_fit, None, None),
|
|
({"SteamGameId": "618970"}, every, pocket_fit, "4:7,2,3,4", "4:7,2,3,4"),
|
|
({"SteamGameId": "620"}, every, pocket_fit, "8:7,2,3,4,5,6,0,1", "8:7,2,3,4,5,6,0,1"),
|
|
({"SteamGameId": "2229830", "BL_GAME_CPUS": "0,1"}, {0, 1}, pocket_fit, "2:0,1", "2:0,1"),
|
|
({"SteamGameId": "2229830", "DROIDDECK_GAME_CORES": "12-13"}, every, pocket_fit, "1:7", "1:7"),
|
|
({"SteamGameId": "11440", "BL_GAME_CPUS": "0-7"}, every, pocket_fit, "4:7,2,3,4", "4:7,2,3,4"),
|
|
({}, every, {core: 100 for core in every}, None, None),
|
|
({}, every, None, None, None),
|
|
({"SteamGameId": "11440"}, every, None, "4:0,1,2,3", "4:0,1,2,3"),
|
|
({"BL_GAME_CPUS": "9"}, {9}, pocket_fit, None, None),
|
|
]
|
|
cases += [
|
|
({"STEAM_COMPAT_DATA_PATH": "/steam/compatdata/0"}, every, pocket_fit, None, None),
|
|
({"STEAM_COMPAT_DATA_PATH": ""}, every, pocket_fit, None, None),
|
|
({"STEAM_COMPAT_DATA_PATH": "/steam/compatdata/0", "BL_GAME_CPUS": "6,7"}, {6, 7}, pocket_fit, "2:6,7", "2:6,7"),
|
|
]
|
|
for env, affinity, speeds, expected, proton_topology in cases:
|
|
env.setdefault("STEAM_COMPAT_DATA_PATH", "/steam/compatdata/3608888694")
|
|
with mock.patch.dict(MODULE["cpu_topology"].__globals__, {"online_cores": lambda: every}), \
|
|
mock.patch.dict(MODULE["fastest_first"].__globals__, {"core_speeds": lambda cores, speeds=speeds: speeds}), \
|
|
mock.patch.object(os_mod, "sched_getaffinity", return_value=affinity, create=True):
|
|
MODULE["cpu_topology"](env, command)
|
|
self.assertEqual(env.get("WINE_CPU_TOPOLOGY"), expected, env)
|
|
self.assertEqual(env.get("PROTON_CPU_TOPOLOGY"), proton_topology, env)
|
|
self.assertIsNone(MODULE["proton_cpu_limit"](["/missing/proton"], {"SteamGameId": "11440"}))
|
|
self.assertIsNone(MODULE["proton_cpu_limit"](command, {}))
|
|
self.assertEqual(MODULE["proton_cpu_limit"](command, {"SteamGameId": "1038250"}), "P")
|
|
self.assertEqual(MODULE["proton_cpu_limit"](command, {"SteamGameId": "618970"}), 4)
|
|
|
|
def test_core_speeds_read_capacity_then_top_clock(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
for core, capacity, clock in ((0, "379", "2265600"), (1, None, "3302400")):
|
|
folder = Path(tmp) / ("cpu%d" % core) / "cpufreq"
|
|
folder.mkdir(parents=True)
|
|
(folder / "cpuinfo_max_freq").write_text(clock + "\n")
|
|
if capacity:
|
|
(folder.parent / "cpu_capacity").write_text(capacity + "\n")
|
|
speeds = MODULE["core_speeds"]
|
|
self.assertEqual(speeds([0], tmp), {0: 379})
|
|
self.assertEqual(speeds([0, 1], tmp), {0: 2265600, 1: 3302400})
|
|
self.assertIsNone(speeds([0, 2], tmp))
|
|
|
|
def test_titles_with_former_feature_level_workarounds_keep_detected_capabilities(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
root = Path(tmp)
|
|
names = (
|
|
"StellarBlade/SB.exe", "StellarBlade/.droiddeck-launch.bat",
|
|
"StellarBlade/SB/Binaries/Win64/SB-Win64-Shipping.exe",
|
|
"Caravan/CaravanSandWitch.exe", "Caravan/.droiddeck-launch.bat",
|
|
"Caravan/CaravanSandWitch/Binaries/Win64/CaravanSandWitch-Win64-Shipping.exe",
|
|
"RoboCop/RoboCop.exe", "RoboCop/RoboCop/Binaries/Win64/RoboCop-Win64-Shipping.exe",
|
|
"ReSkate/Skate.exe", "ReSkate/ReSkate.dll", "ReSkate/ReSkateLauncher.exe",
|
|
"Cyberpunk/REDprelauncher.exe", "Cyberpunk/bin/x64/Cyberpunk2077.exe",
|
|
)
|
|
for name in names:
|
|
p = root / name
|
|
p.parent.mkdir(parents=True, exist_ok=True)
|
|
p.touch()
|
|
for name in names:
|
|
if name.endswith(".dll"):
|
|
continue
|
|
env, extra, notes = MODULE["engine_fixes"](str(root / name), [])
|
|
self.assertNotIn("VKD3D_FEATURE_LEVEL", env, name)
|
|
self.assertNotIn("VKD3D_SHADER_MODEL", env, name)
|
|
for values in ({}, {"VKD3D_FEATURE_LEVEL": "12_2", "VKD3D_SHADER_MODEL": "6_9"}):
|
|
config = {"version": 1, "shared": values}
|
|
self.assertEqual(MODULE["apply_config"]({}, config, "42", env), values, name)
|
|
config = {"version": 1, "shared": {}, "games": {"42": values}}
|
|
self.assertEqual(MODULE["apply_config"]({}, config, "42", env), values, name)
|
|
|
|
def test_unity_players_keep_tso_over_the_preset(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
def game(*names):
|
|
for name in names:
|
|
(Path(tmp) / name).parent.mkdir(parents=True, exist_ok=True)
|
|
(Path(tmp) / name).touch()
|
|
return str(Path(tmp) / names[0])
|
|
|
|
il2cpp = game("Megabonk/Megabonk.x86_64", "Megabonk/UnityPlayer.so", "Megabonk/GameAssembly.so")
|
|
mono = game("Mono/Game.x86_64", "Mono/UnityPlayer.so")
|
|
windows = game("Crab Game/Crab Game.exe", "Crab Game/UnityPlayer.dll", "Crab Game/GameAssembly.dll")
|
|
windows_mono = game("RoR2/Risk of Rain 2.exe", "RoR2/UnityPlayer.dll", "RoR2/MonoBleedingEdge/mono-2.0-bdwgc.dll")
|
|
for exe in (il2cpp, mono, windows, windows_mono):
|
|
env, extra, notes = MODULE["engine_fixes"](exe, [])
|
|
self.assertEqual((env, extra, notes), ({"FEX_TSOENABLED": "1"}, [], ["Unity: FEX with TSO"]))
|
|
performance = {"version": 1, "shared": {"FEX_TSOENABLED": "0", "FEX_VECTORTSOENABLED": "0"}}
|
|
result = MODULE["apply_config"]({}, performance, "3405340", env)
|
|
self.assertEqual((result["FEX_TSOENABLED"], result["FEX_VECTORTSOENABLED"]), ("1", "0"))
|
|
performance["games"] = {"3405340": {"FEX_TSOENABLED": "0"}}
|
|
self.assertEqual(MODULE["apply_config"]({}, performance, "3405340", env)["FEX_TSOENABLED"], "0")
|
|
|
|
def test_a_new_fix_only_needs_registering(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
(Path(tmp) / "Game.exe").touch()
|
|
(Path(tmp) / "engine.dll").touch()
|
|
check = lambda game: ({"ENGINE": "1"}, ["--flag"], "engine") if "engine.dll" in game.files else None
|
|
MODULE["FIXES"].append(check)
|
|
try:
|
|
self.assertEqual(MODULE["engine_fixes"](str(Path(tmp) / "Game.exe"), []), ({"ENGINE": "1"}, ["--flag"], ["engine"]))
|
|
finally:
|
|
MODULE["FIXES"].remove(check)
|
|
|
|
def test_engine_fixes_sit_between_shared_and_game_profiles(self):
|
|
env = {"WINEDLLOVERRIDES": "dxgi=n"}
|
|
fixes = {"FEX_MULTIBLOCK": "0", "WINEDLLOVERRIDES": "libsentry=d"}
|
|
config = {"version": 1, "shared": {"FEX_MULTIBLOCK": "1"}, "games": {"42": {"FEX_TSOENABLED": "0"}}}
|
|
result = MODULE["apply_config"](env, config, "42", {**fixes, "FEX_TSOENABLED": "1"})
|
|
self.assertEqual(result["FEX_MULTIBLOCK"], "0")
|
|
self.assertEqual(result["FEX_TSOENABLED"], "0")
|
|
self.assertEqual(result["WINEDLLOVERRIDES"], "dxgi=n;libsentry=d")
|
|
|
|
def test_invalid_configuration_is_atomic(self):
|
|
env = {"ORIGINAL": "unchanged"}
|
|
for entries in ({"A": "ok", "BAD=KEY": "x"}, {"A": "bad\0value"}, {"A": 1}):
|
|
with self.assertRaises(ValueError):
|
|
MODULE["apply_config"](env, {"version": 1, "shared": entries}, "42")
|
|
self.assertEqual(env, {"ORIGINAL": "unchanged"})
|
|
|
|
def test_texture_filtering_is_appended_to_dxvk_config(self):
|
|
options = "d3d9.samplerAnisotropy = 16; d3d11.samplerAnisotropy = 16"
|
|
config = {"version": 1, "shared": {}, "games": {}, "dxvkConfig": options}
|
|
self.assertEqual(MODULE["apply_config"]({}, config, "42")["DXVK_CONFIG"], options)
|
|
own = {"DXVK_CONFIG": "dxvk.maxFrameRate = 60; "}
|
|
self.assertEqual(MODULE["apply_config"](own, config, "42")["DXVK_CONFIG"], "dxvk.maxFrameRate = 60; " + options)
|
|
shared = {"version": 1, "shared": {"DXVK_CONFIG": "dxvk.tearFree = True"}, "games": {}, "dxvkConfig": options}
|
|
self.assertEqual(MODULE["apply_config"]({}, shared, "42")["DXVK_CONFIG"], "dxvk.tearFree = True; " + options)
|
|
self.assertNotIn("DXVK_CONFIG", MODULE["apply_config"]({}, {**config, "dxvkConfig": ""}, "42"))
|
|
for bad in (1, "a\0b", "x" * 8193):
|
|
with self.assertRaises(ValueError):
|
|
MODULE["apply_config"]({}, {**config, "dxvkConfig": bad}, "42")
|
|
|
|
def test_game_texture_filtering_overrides_default_without_leaking(self):
|
|
default = "d3d9.samplerAnisotropy = 16"
|
|
game = "d3d9.samplerAnisotropy = 4"
|
|
config = {
|
|
"version": 1,
|
|
"shared": {},
|
|
"games": {},
|
|
"dxvkConfig": default,
|
|
"dxvkConfigGames": {"42": game, "43": ""},
|
|
}
|
|
self.assertEqual(MODULE["apply_config"]({}, config, "42")["DXVK_CONFIG"], game)
|
|
self.assertNotIn("DXVK_CONFIG", MODULE["apply_config"]({}, config, "43"))
|
|
self.assertEqual(MODULE["apply_config"]({}, config, "44")["DXVK_CONFIG"], default)
|
|
with self.assertRaises(ValueError):
|
|
MODULE["apply_config"]({}, {**config, "dxvkConfigGames": []}, "42")
|
|
|
|
def test_game_logs_only_when_asked_and_only_for_game_launches(self):
|
|
game_logs = MODULE["game_logs"]
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
command = ["/proton", "waitforexitandrun", "/games/Hades/Hades.exe"]
|
|
env = {"BL_DEBUG_DIR": tmp, "VK_DRIVER_FILES": "/icd.json"}
|
|
self.assertEqual(game_logs(command, env, "1145360", now=0), env)
|
|
on = dict(env, BL_GAME_LOGS="1")
|
|
self.assertEqual(game_logs(["/proton", "run", "x.exe"], on, "1145360", now=0), on)
|
|
result = game_logs(command, on, "1145360", now=0)
|
|
folder = Path(result["PROTON_LOG_DIR"])
|
|
self.assertEqual(folder.parent, Path(tmp) / "games")
|
|
self.assertTrue(folder.name.endswith("-1145360"))
|
|
self.assertEqual(result["DXVK_LOG_PATH"], str(folder))
|
|
self.assertEqual(result["PROTON_LOG"], "1")
|
|
launch = (folder / "launch.txt").read_text()
|
|
self.assertIn("VK_DRIVER_FILES=/icd.json", launch)
|
|
self.assertNotIn("PROTON_LOG", env)
|
|
named = game_logs(command, on, None, now=0)
|
|
self.assertTrue(Path(named["PROTON_LOG_DIR"]).name.endswith("-Hades"))
|
|
|
|
def test_rapid_game_launches_keep_separate_diagnostics(self):
|
|
with tempfile.TemporaryDirectory() as tmp, mock.patch("subprocess.Popen"):
|
|
env = {"BL_GAME_LOGS": "1", "BL_DEBUG_DIR": tmp}
|
|
first = MODULE["game_logs"](["/proton", "waitforexitandrun", "first.exe"], env, "42", now=0)
|
|
second = MODULE["game_logs"](["/proton", "waitforexitandrun", "second.exe"], env, "42", now=0)
|
|
self.assertNotEqual(first["PROTON_LOG_DIR"], second["PROTON_LOG_DIR"])
|
|
self.assertIn("exe: first.exe", (Path(first["PROTON_LOG_DIR"]) / "launch.txt").read_text())
|
|
self.assertIn("exe: second.exe", (Path(second["PROTON_LOG_DIR"]) / "launch.txt").read_text())
|
|
|
|
def test_open_driver_pairs_report_the_drivers_own_vkd3d_caps(self):
|
|
apply = MODULE["apply_config"]
|
|
config = {"version": 1, "shared": {"VKD3D_FEATURE_LEVEL": "12_2", "VKD3D_SHADER_MODEL": "6_9"},
|
|
"games": {"42": {"VKD3D_SHADER_MODEL": "6_6"}}}
|
|
adreno = apply({}, config, "1")
|
|
self.assertEqual(adreno["VKD3D_FEATURE_LEVEL"], "12_2")
|
|
pair = apply({"BL_OPEN_DRIVER": "1"}, config, "1")
|
|
self.assertNotIn("VKD3D_FEATURE_LEVEL", pair)
|
|
self.assertNotIn("VKD3D_SHADER_MODEL", pair)
|
|
self.assertEqual(pair["DROIDDECK_VKD3D_CAPS"], "driver")
|
|
own = apply({"BL_OPEN_DRIVER": "1"}, config, "42")
|
|
self.assertEqual(own["VKD3D_SHADER_MODEL"], "6_6")
|
|
self.assertNotIn("VKD3D_FEATURE_LEVEL", own)
|
|
|
|
def test_game_ids_and_probes(self):
|
|
for prefix in ("", "/compatdata/0", "/compatdata/0-123", "/compatdata/nope", "/compatdata/4294967296"):
|
|
self.assertIsNone(MODULE["game_id"]({"STEAM_COMPAT_DATA_PATH": prefix}))
|
|
self.assertEqual(MODULE["game_id"]({"STEAM_COMPAT_DATA_PATH": "/a/compatdata/42/"}), "42")
|
|
self.assertEqual(MODULE["game_id"]({"STEAM_COMPAT_DATA_PATH": "/a/compatdata/-1"}), "4294967295")
|
|
|
|
def test_launch_reads_updates_preserves_argv_and_does_not_execute_values(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
home = Path(tmp)
|
|
config = home / ".config/droiddeck/game-environment.json"
|
|
config.parent.mkdir(parents=True)
|
|
probe = home / "fake-proton"
|
|
probe.write_text("#!/usr/bin/python3\nimport json, os, sys\nprint(json.dumps([os.environ.get('CUSTOM'), sys.argv[1:]]))\n")
|
|
probe.chmod(0o755)
|
|
env = {**os.environ, "HOME": tmp, "STEAM_COMPAT_DATA_PATH": "/compatdata/42"}
|
|
for value in ("first value", "$(touch " + str(home / "injected") + "); 'literal'=value"):
|
|
config.write_text(json.dumps({"version": 1, "shared": {"CUSTOM": value}}))
|
|
result = subprocess.check_output([sys.executable, str(BIN / "droiddeck-game-env"), str(probe), "waitforexitandrun", "path with spaces", "a=b"], env=env, text=True)
|
|
self.assertEqual(json.loads(result), [value, ["waitforexitandrun", "path with spaces", "a=b"]])
|
|
self.assertFalse((home / "injected").exists())
|
|
for verb, prefix in (("run", "/compatdata/42"), ("waitforexitandrun", "/compatdata/0")):
|
|
output = subprocess.check_output([sys.executable, str(BIN / "droiddeck-game-env"), str(probe), verb], env={**env, "STEAM_COMPAT_DATA_PATH": prefix, "CUSTOM": "original"}, text=True)
|
|
self.assertEqual(json.loads(output)[0], "original")
|
|
config.write_text("{broken")
|
|
result = subprocess.run([sys.executable, str(BIN / "droiddeck-game-env"), str(probe), "waitforexitandrun"], env={**env, "CUSTOM": "original"}, text=True, capture_output=True, check=True)
|
|
self.assertEqual(json.loads(result.stdout)[0], "original")
|
|
|
|
def test_generated_valve_and_third_party_launchers_apply_configuration(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
home = Path(tmp)
|
|
steam = home / "Steam"
|
|
tools = steam / "compatibilitytools.d"
|
|
depot = steam / "steamapps/common" / COMPAT["SOURCES"][0]
|
|
(depot / "files/bin-arm64").mkdir(parents=True)
|
|
extra = tools / "custom-proton"
|
|
extra.mkdir(parents=True)
|
|
for base in (depot, extra):
|
|
proton = base / "proton"
|
|
proton.write_text("#!/usr/bin/python3\nimport json, os, sys\nprint(json.dumps([os.environ['CUSTOM'], sys.argv[1:]]))\nsys.exit(7)\n")
|
|
proton.chmod(0o755)
|
|
(extra / "toolmanifest.vdf").write_text('"manifest" { "commandline" "/proton %verb%" }')
|
|
COMPAT["build_tool"](str(tools / COMPAT["TOOL"]), str(depot))
|
|
COMPAT["adopt_extras"](str(tools))
|
|
config = home / ".config/droiddeck/game-environment.json"
|
|
config.parent.mkdir(parents=True)
|
|
config.write_text(json.dumps({"version": 1, "shared": {"CUSTOM": "shared"}, "games": {"42": {"CUSTOM": "specific"}}}))
|
|
wrappers = (tools / COMPAT["TOOL"] / COMPAT["LAUNCHER"], extra / COMPAT["EXTRA_WRAPPER"])
|
|
for wrapper in wrappers:
|
|
wrapper.write_text(wrapper.read_text().replace("/usr/local/bin/droiddeck-game-env", str(BIN / "droiddeck-game-env")))
|
|
result = subprocess.run([str(wrapper), "waitforexitandrun", "game with spaces.exe"],
|
|
env={"PATH": os.defpath, "HOME": tmp, "STEAM_COMPAT_DATA_PATH": "/compatdata/42", "STEAM_COMPAT_CLIENT_INSTALL_PATH": str(steam)},
|
|
text=True, capture_output=True)
|
|
self.assertEqual(result.returncode, 7, result.stderr)
|
|
self.assertEqual(json.loads(result.stdout), ["specific", ["waitforexitandrun", "game with spaces.exe"]])
|
|
|
|
def test_default_is_compatible_tool_and_labels_mark_it(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
home = Path(tmp)
|
|
label = home / "label"
|
|
label.write_text("Compatible\n")
|
|
COMPAT["display_name"].__globals__["LABEL_FILE"] = str(label)
|
|
tools = home / "compatibilitytools.d"
|
|
extra = tools / "proton-cachyos-11"
|
|
extra.mkdir(parents=True)
|
|
(extra / "proton").write_text("")
|
|
(extra / "toolmanifest.vdf").write_text('"manifest" { "commandline" "/proton %verb%" }')
|
|
vdf = tools / COMPAT["TOOL"] / "compatibilitytool.vdf"
|
|
COMPAT["build_tool"](str(tools / COMPAT["TOOL"]), None)
|
|
self.assertIn('"display_name" "Proton ARM64 (Compatible)"', vdf.read_text())
|
|
for source in COMPAT["SOURCES"]:
|
|
COMPAT["build_tool"](str(tools / COMPAT["TOOL"]), str(home / "steamapps/common" / source))
|
|
self.assertIn('"display_name" "%s ARM64 (Compatible)"' % source.replace(" (ARM64)", ""), vdf.read_text())
|
|
protect = COMPAT["adopt_extras"](str(tools))
|
|
self.assertIn('"display_name" "proton-cachyos-11 (Compatible)"', (extra / "compatibilitytool.vdf").read_text())
|
|
config = home / "config.vdf"
|
|
mapping = "".join('"%s" { "name" "%s" "config" "" "priority" "%s" }' % entry for entry in (
|
|
("0", "GE-Proton10-25", "75"), ("42", "proton_experimental_arm64", "250"), ("43", "proton-cachyos-11", "250")))
|
|
config.write_text('"InstallConfigStore" { "Software" { "Valve" { "Steam" { "CompatToolMapping" { %s } } } } }' % mapping)
|
|
COMPAT["register_default"](str(config), ["44"], protect=protect)
|
|
tokens = COMPAT["tokenize"](config.read_text())
|
|
names = {tokens[i - 1].strip('"'): tokens[i + 2].strip('"') for i in range(1, len(tokens) - 2) if tokens[i] == "{" and tokens[i + 1] == '"name"'}
|
|
self.assertEqual(names, {"0": COMPAT["TOOL"], "42": COMPAT["TOOL"], "43": "proton-cachyos-11", "44": COMPAT["TOOL"]})
|
|
|
|
def test_existing_install_moves_to_new_tool_name(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
home = Path(tmp)
|
|
(home / "label").write_text("Compatible\n")
|
|
COMPAT["main"].__globals__["LABEL_FILE"] = str(home / "label")
|
|
steam = home / "Steam"
|
|
depot = steam / "steamapps/common" / COMPAT["SOURCES"][0]
|
|
(depot / "files/bin-arm64").mkdir(parents=True)
|
|
(steam / "steamapps/appmanifest_42.acf").write_text("")
|
|
legacy = steam / "compatibilitytools.d" / COMPAT["LEGACY_TOOL"]
|
|
legacy.mkdir(parents=True)
|
|
for name in COMPAT["OWN_FILES"]:
|
|
(legacy / name).write_text("old")
|
|
(steam / "config").mkdir()
|
|
config = steam / "config/config.vdf"
|
|
mapping = "".join('"%s" { "name" "%s" "config" "" "priority" "%s" }' % (app, COMPAT["LEGACY_TOOL"], priority)
|
|
for app, priority in (("0", "75"), ("42", "250")))
|
|
config.write_text('"InstallConfigStore" { "Software" { "Valve" { "Steam" { "CompatToolMapping" { %s } } } } }' % mapping)
|
|
argv = sys.argv
|
|
try:
|
|
sys.argv = ["steam-compatibility", str(steam)]
|
|
COMPAT["main"]()
|
|
migrated = config.read_text()
|
|
COMPAT["main"]()
|
|
finally:
|
|
sys.argv = argv
|
|
self.assertFalse(legacy.exists())
|
|
self.assertIn('"display_name" "Proton Experimental ARM64 (Compatible)"',
|
|
(steam / "compatibilitytools.d" / COMPAT["TOOL"] / "compatibilitytool.vdf").read_text())
|
|
self.assertNotIn(COMPAT["LEGACY_TOOL"], migrated)
|
|
self.assertEqual(migrated.count('"%s"' % COMPAT["TOOL"]), 2)
|
|
self.assertEqual(config.read_text(), migrated)
|
|
|
|
def test_launchers_and_adopted_protons_from_before_the_rename_move_over(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
home = Path(tmp)
|
|
(home / "label").write_text("Compatible\n")
|
|
COMPAT["main"].__globals__["LABEL_FILE"] = str(home / "label")
|
|
tools = home / "compatibilitytools.d"
|
|
own = tools / COMPAT["TOOL"]
|
|
own.mkdir(parents=True)
|
|
for name in ("toolmanifest.vdf", "compatibilitytool.vdf", COMPAT["LEGACY_LAUNCHER"]):
|
|
(own / name).write_text("old")
|
|
spare = tools / COMPAT["TOOL_11"]
|
|
spare.mkdir()
|
|
for name in ("toolmanifest.vdf", "compatibilitytool.vdf", COMPAT["LEGACY_LAUNCHER"]):
|
|
(spare / name).write_text("old")
|
|
extra = tools / "GE-Proton11-7"
|
|
extra.mkdir()
|
|
(extra / "proton").write_text("")
|
|
(extra / "toolmanifest.vdf").write_text('"manifest" { "commandline" "/%s %%verb%%" }' % COMPAT["LEGACY_EXTRA_WRAPPER"])
|
|
(extra / ("toolmanifest.vdf" + COMPAT["LEGACY_ORIGINAL_SUFFIX"])).write_text('"manifest" { "commandline" "/proton %verb%" }')
|
|
(extra / COMPAT["LEGACY_EXTRA_WRAPPER"]).write_text("old")
|
|
COMPAT["build_tool"](str(own), None)
|
|
COMPAT["remove_tool"](str(spare))
|
|
self.assertEqual(COMPAT["adopt_extras"](str(tools)), {"GE-Proton11-7": "GE-Proton11-7"})
|
|
self.assertEqual(sorted(os.listdir(own)), sorted(COMPAT["OWN_FILES"]))
|
|
self.assertFalse(spare.exists())
|
|
self.assertEqual(sorted(os.listdir(extra)), sorted(["proton", "toolmanifest.vdf", "compatibilitytool.vdf",
|
|
COMPAT["EXTRA_WRAPPER"], "toolmanifest.vdf" + COMPAT["ORIGINAL_SUFFIX"]]))
|
|
self.assertIn("/%s %%verb%%" % COMPAT["EXTRA_WRAPPER"], (extra / "toolmanifest.vdf").read_text())
|
|
self.assertIn('"/proton %verb%"', (extra / ("toolmanifest.vdf" + COMPAT["ORIGINAL_SUFFIX"])).read_text())
|
|
self.assertIn('"$here/proton"', (extra / COMPAT["EXTRA_WRAPPER"]).read_text())
|
|
self.assertIn("GE-Proton11-7", [entry["name"] for entry in COMPAT["tool_catalog"](str(home), None)])
|
|
|
|
def test_droiddeck_proton_is_adopted_and_labelled_like_any_other_extra(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
home = Path(tmp)
|
|
(home / "label").write_text("Compatible\n")
|
|
COMPAT["display_name"].__globals__["LABEL_FILE"] = str(home / "label")
|
|
tools = home / "compatibilitytools.d"
|
|
extra = tools / "DroidDeck-Proton 11-7.5"
|
|
extra.mkdir(parents=True)
|
|
(extra / "proton").write_text("")
|
|
(extra / "toolmanifest.vdf").write_text('"manifest" { "version" "2" "commandline" "/proton %verb%" "require_tool_appid" "4185400" }')
|
|
(extra / "compatibilitytool.vdf").write_text(
|
|
'"compatibilitytools" { "compat_tools" { "DroidDeck-Proton-11-7.5" { "install_path" "." '
|
|
'"display_name" "DroidDeck-Proton 11-7.5" "from_oslist" "windows" "to_oslist" "linux" } } }')
|
|
COMPAT["build_tool"](str(tools / COMPAT["TOOL"]), None)
|
|
protect = COMPAT["adopt_extras"](str(tools))
|
|
self.assertEqual(protect, {"DroidDeck-Proton-11-7.5": "DroidDeck-Proton 11-7.5"})
|
|
compat = (extra / "compatibilitytool.vdf").read_text()
|
|
self.assertIn('"DroidDeck-Proton-11-7.5"', compat)
|
|
self.assertIn('"display_name" "DroidDeck-Proton 11-7.5 (Compatible)"', compat)
|
|
self.assertNotIn("require_tool_appid", (extra / "toolmanifest.vdf").read_text())
|
|
self.assertIn('"$here/proton"', (extra / COMPAT["EXTRA_WRAPPER"]).read_text())
|
|
catalog = COMPAT["tool_catalog"](str(home), None)
|
|
self.assertIn({"name": "DroidDeck-Proton-11-7.5", "display": "DroidDeck-Proton 11-7.5 (Compatible)",
|
|
"dir": "DroidDeck-Proton 11-7.5", "valve": False}, catalog)
|
|
config = home / "config.vdf"
|
|
config.write_text(config_text([("0", COMPAT["TOOL"]), ("42", "DroidDeck-Proton-11-7.5")]))
|
|
COMPAT["register_default"](str(config), ["42", "43"], protect=protect)
|
|
self.assertEqual(mapping_of(config.read_text()), {"0": COMPAT["TOOL"], "42": "DroidDeck-Proton-11-7.5", "43": COMPAT["TOOL"]})
|
|
changes, _ = COMPAT["plan_mapping"]({"0": COMPAT["TOOL"], "42": "DroidDeck-Proton-11-7.5"}, ["42"], [COMPAT["TOOL"]], COMPAT["TOOL"], {})
|
|
self.assertEqual(changes, {"42": COMPAT["TOOL"]})
|
|
|
|
def test_both_proton_wrappers_call_environment_launcher(self):
|
|
self.assertIn('bl_directaudio "$depot" "$@"', COMPAT["LAUNCHER_SH"])
|
|
self.assertIn('bl_directaudio "$here" "$@"', COMPAT["EXTRA_WRAPPER_SH"] % "proton")
|
|
for script in (COMPAT["LAUNCHER_SH"], COMPAT["EXTRA_WRAPPER_SH"] % "proton"):
|
|
subprocess.run(["bash", "-n"], input=script, text=True, check=True)
|
|
self.assertIn('bl_run ${BL_TASKSET:-} /usr/local/bin/droiddeck-game-env', script)
|
|
|
|
def test_both_proton_wrappers_answer_steams_driver_query_without_wine(self):
|
|
scripts = (COMPAT["launcher_sh"](), COMPAT["extra_wrapper_sh"]("proton"))
|
|
query = "/root/.local/share/Steam/steamrtarm64/../bin/d3ddriverquery64.exe"
|
|
probe = "/root/.local/share/Steam/steamapps/compatdata/0"
|
|
for script in scripts:
|
|
subprocess.run(["bash", "-n"], input=script, text=True, check=True)
|
|
self.assertLess(script.index('case "$verb:${1##*/}:'), script.index("bl_components "))
|
|
for args, prefix in (([query, "-d3d12"], probe), ([query], probe + "/"),
|
|
([query.replace("64.exe", ".exe")], probe)):
|
|
result = subprocess.run(["bash", "-s", "run"] + args, input=script, text=True, capture_output=True,
|
|
env={"PATH": os.defpath, "HOME": "/nonexistent", "STEAM_COMPAT_DATA_PATH": prefix},
|
|
timeout=10)
|
|
self.assertEqual(result.returncode, 1, result.stderr)
|
|
self.assertEqual(result.stdout, "")
|
|
self.assertIn("D3D driver query is not run", result.stderr)
|
|
skip = COMPAT["BL_DRIVER_QUERY_SKIP"]
|
|
for verb, args, prefix in (
|
|
("getcompatpath", [query], probe),
|
|
("run", [query], "/root/.local/share/Steam/steamapps/compatdata/2276548447"),
|
|
("run", [query], None),
|
|
("waitforexitandrun", ["/games/AIO/AIO-Graphics-Test-64bit.exe", "--cube", "dx11"], probe),
|
|
("run", ["/games/Tool/tool.exe", query], probe),
|
|
("run", ["/games/d3ddriverquery64.exe.bak"], probe)):
|
|
env = {"PATH": os.defpath}
|
|
if prefix is not None:
|
|
env["STEAM_COMPAT_DATA_PATH"] = prefix
|
|
out = subprocess.run(["bash", "-c", "verb=" + verb + "\n" + skip + "echo continued", "_"] + args,
|
|
env=env, capture_output=True, text=True, check=True).stdout
|
|
self.assertEqual(out, "continued\n", (verb, args, prefix))
|
|
|
|
def test_wrappers_preload_the_session_library_before_the_input_shim(self):
|
|
overlay = "/root/.local/share/Steam/ubuntu12_64/gameoverlayrenderer.so"
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
for name in ("libblsession.so", "libfakeinput.so"):
|
|
(Path(tmp) / name).write_bytes(b"")
|
|
for script in (COMPAT["LAUNCHER_SH"], COMPAT["EXTRA_WRAPPER_SH"] % "proton"):
|
|
start = script.index("for fake in ")
|
|
block = script[start:script.index("\ndone\n", start) + 6].replace("/usr/local/lib/", tmp + "/")
|
|
for inherited, expected in (
|
|
(None, ["libblsession.so", "libfakeinput.so"]),
|
|
(overlay, ["libblsession.so", "libfakeinput.so", overlay]),
|
|
(tmp + "/libfakeinput.so:" + overlay, ["libblsession.so", "libfakeinput.so", overlay]),
|
|
(tmp + "/libblsession.so:" + tmp + "/libfakeinput.so", ["libblsession.so", "libfakeinput.so"])):
|
|
env = {"PATH": os.defpath}
|
|
if inherited is not None:
|
|
env["LD_PRELOAD"] = inherited
|
|
out = subprocess.run(["bash", "-c", block + 'printf %s "$LD_PRELOAD"'], env=env,
|
|
capture_output=True, text=True, check=True).stdout
|
|
self.assertEqual([entry.replace(tmp + "/", "") for entry in out.split(":")], expected, inherited)
|
|
|
|
|
|
class ProtonDefaultTest(unittest.TestCase):
|
|
TOOL = COMPAT["TOOL"]
|
|
CATALOG = [
|
|
{"name": COMPAT["TOOL"], "display": "Proton Experimental ARM64", "dir": "Proton Experimental (ARM64)", "valve": True},
|
|
{"name": COMPAT["TOOL_11"], "display": "Proton 11.0 ARM64", "dir": "Proton 11.0 (ARM64)", "valve": True},
|
|
{"name": "GE-Proton11-7", "display": "GE-Proton11-7", "dir": "GE-Proton11-7", "valve": False},
|
|
]
|
|
NAMES = [t["name"] for t in CATALOG]
|
|
|
|
def plan(self, current, installed, default, auto):
|
|
return COMPAT["plan_mapping"](current, installed, self.NAMES, default, auto)
|
|
|
|
def test_new_installs_and_valve_picks_follow_the_default(self):
|
|
changes, auto = self.plan({"0": self.TOOL, "10": "proton_experimental", "11": "proton_11_arm64", "12": "GE-Proton11-7", "13": "steamlinuxruntime"},
|
|
["10", "11", "12", "13", "14"], "GE-Proton11-7", {})
|
|
self.assertEqual(changes, {"0": "GE-Proton11-7", "10": "GE-Proton11-7", "11": "GE-Proton11-7", "14": "GE-Proton11-7"})
|
|
self.assertEqual(auto, {"10": "GE-Proton11-7", "11": "GE-Proton11-7", "14": "GE-Proton11-7"})
|
|
|
|
def test_windows_only_titles_still_follow_the_default(self):
|
|
changes, auto = COMPAT["plan_mapping"](
|
|
{"0": self.TOOL}, ["42"], self.NAMES, self.TOOL, {})
|
|
self.assertEqual(changes, {"42": self.TOOL})
|
|
self.assertEqual(auto, {"42": self.TOOL})
|
|
|
|
def test_native_arm64_default_clears_only_our_auto_mapping(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
config = Path(tmp) / "config.vdf"
|
|
config.write_text(config_text([("0", self.TOOL), ("42", self.TOOL)]))
|
|
(Path(tmp) / "state.json").write_text(json.dumps({
|
|
"default": self.TOOL,
|
|
"auto": {"42": self.TOOL},
|
|
}))
|
|
globals_ = COMPAT["register_default"].__globals__
|
|
original_native_targets = globals_["native_targets"]
|
|
globals_["native_targets"] = lambda _root, _apps: {"42": None}
|
|
try:
|
|
COMPAT["register_default"](
|
|
str(config), ["42"], catalog=self.CATALOG, directory=tmp)
|
|
first = config.read_bytes()
|
|
self.assertEqual(mapping_of(first.decode()), {"0": self.TOOL})
|
|
self.assertEqual(COMPAT["tokenize"](first.decode()),
|
|
COMPAT["tokenize"](config_text([("0", self.TOOL)])))
|
|
self.assertEqual(json.loads((Path(tmp) / "state.json").read_text())["auto"], {})
|
|
|
|
COMPAT["register_default"](
|
|
str(config), ["42"], catalog=self.CATALOG, directory=tmp)
|
|
self.assertEqual(config.read_bytes(), first)
|
|
finally:
|
|
globals_["native_targets"] = original_native_targets
|
|
|
|
def test_saved_native_choice_survives_valve_pick_without_appinfo(self):
|
|
app = "452280"
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
root = Path(tmp)
|
|
steam = root / "Steam"
|
|
config = steam / "config/config.vdf"
|
|
config.parent.mkdir(parents=True)
|
|
(steam / "steamapps").mkdir()
|
|
(steam / "steamapps" / ("appmanifest_%s.acf" % app)).write_text("")
|
|
config.write_text(config_text([("0", self.TOOL)]))
|
|
(root / "state.json").write_text(json.dumps({
|
|
"default": self.TOOL,
|
|
"auto": {},
|
|
"native_choices": [app],
|
|
}))
|
|
|
|
globals_ = COMPAT["register_default"].__globals__
|
|
original_native_targets = globals_["native_targets"]
|
|
globals_["native_targets"] = lambda _root, _apps: {}
|
|
try:
|
|
COMPAT["register_default"](
|
|
str(config), [], catalog=self.CATALOG, directory=tmp, picks=[app])
|
|
self.assertEqual(mapping_of(config.read_text()), {"0": self.TOOL})
|
|
state = json.loads((root / "state.json").read_text())
|
|
self.assertEqual(state["auto"], {})
|
|
self.assertEqual(state["native_choices"], [app])
|
|
finally:
|
|
globals_["native_targets"] = original_native_targets
|
|
|
|
def test_shortcuts_scanner_keeps_native_choice_for_unrelated_app_list(self):
|
|
app, added_shortcut = "452280", "3537741571"
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
root = Path(tmp)
|
|
steam = root / "Steam"
|
|
config = steam / "config/config.vdf"
|
|
config.parent.mkdir(parents=True)
|
|
(steam / "steamapps").mkdir()
|
|
(steam / "steamapps" / ("appmanifest_%s.acf" % app)).write_text("")
|
|
config.write_text(config_text([("0", self.TOOL)]))
|
|
(root / "state.json").write_text(json.dumps({
|
|
"default": self.TOOL,
|
|
"auto": {},
|
|
"native_choices": [app],
|
|
}))
|
|
|
|
globals_ = COMPAT["register_default"].__globals__
|
|
original_native_targets = globals_["native_targets"]
|
|
globals_["native_targets"] = lambda _root, wanted: {
|
|
app: COMPAT["NATIVE_CHOICE"]
|
|
} if app in wanted else {}
|
|
try:
|
|
COMPAT["register_default"](
|
|
str(config), [app], catalog=self.CATALOG, directory=tmp)
|
|
self.assertEqual(json.loads((root / "state.json").read_text())["native_choices"], [app])
|
|
|
|
COMPAT["register_default"](
|
|
str(config), [added_shortcut], catalog=self.CATALOG, directory=tmp)
|
|
state = json.loads((root / "state.json").read_text())
|
|
self.assertEqual(state["native_choices"], [app])
|
|
self.assertEqual(state["auto"], {added_shortcut: self.TOOL})
|
|
self.assertEqual(mapping_of(config.read_text()), {
|
|
"0": self.TOOL,
|
|
added_shortcut: self.TOOL,
|
|
})
|
|
finally:
|
|
globals_["native_targets"] = original_native_targets
|
|
|
|
def test_a_listed_title_with_a_bundled_arm64_linux_build_defaults_to_fex(self):
|
|
globals_ = COMPAT["native_targets"].__globals__
|
|
original_platforms = globals_["appinfo_platforms"]
|
|
globals_["appinfo_platforms"] = lambda _path, wanted: {
|
|
app: ({"linux", "windows"}, "64", "game") for app in ("4385780", "42") if app in wanted
|
|
}
|
|
try:
|
|
native = COMPAT["native_targets"]("/nonexistent", ["4385780", "42"])
|
|
finally:
|
|
globals_["appinfo_platforms"] = original_platforms
|
|
self.assertEqual(native["4385780"], COMPAT["FEX_TOOL"])
|
|
self.assertIs(native["42"], COMPAT["NATIVE_CHOICE"])
|
|
|
|
changes, auto = COMPAT["plan_mapping"](
|
|
{"0": self.TOOL}, ["4385780"], self.NAMES, self.TOOL, {}, native)
|
|
self.assertEqual(changes, {"4385780": COMPAT["FEX_TOOL"]})
|
|
self.assertEqual(auto, {"4385780": COMPAT["FEX_TOOL"]})
|
|
|
|
for choice in (self.TOOL, "GE-Proton11-7"):
|
|
changes, auto = COMPAT["plan_mapping"](
|
|
{"0": self.TOOL, "4385780": choice}, ["4385780"], self.NAMES, self.TOOL, {}, native)
|
|
self.assertEqual(changes, {})
|
|
self.assertEqual(auto, {})
|
|
|
|
def test_a_cleared_fex_default_for_a_listed_title_is_kept_as_a_native_choice(self):
|
|
app = "4385780"
|
|
fex = COMPAT["FEX_TOOL"]
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
steam = Path(tmp) / "Steam"
|
|
config = steam / "config/config.vdf"
|
|
config.parent.mkdir(parents=True)
|
|
config.write_text(config_text([("0", self.TOOL)]))
|
|
(Path(tmp) / "state.json").write_text(json.dumps({"default": self.TOOL, "auto": {}}))
|
|
|
|
globals_ = COMPAT["native_targets"].__globals__
|
|
original_platforms = globals_["appinfo_platforms"]
|
|
globals_["appinfo_platforms"] = lambda _path, wanted: {
|
|
app: ({"linux", "windows"}, "64", "game")
|
|
} if app in wanted else {}
|
|
try:
|
|
def run():
|
|
COMPAT["register_default"](str(config), [app], catalog=self.CATALOG, directory=tmp)
|
|
return json.loads((Path(tmp) / "state.json").read_text())
|
|
|
|
state = run()
|
|
self.assertEqual(mapping_of(config.read_text()), {"0": self.TOOL, app: fex})
|
|
self.assertEqual(state["auto"], {app: fex})
|
|
|
|
config.write_text(config_text([("0", self.TOOL), (app, "")]))
|
|
state = run()
|
|
cleared = config.read_bytes()
|
|
self.assertEqual(mapping_of(cleared.decode()), {"0": self.TOOL, app: ""})
|
|
self.assertEqual(state["auto"], {})
|
|
self.assertEqual(state["native_choices"], [app])
|
|
run()
|
|
self.assertEqual(config.read_bytes(), cleared)
|
|
|
|
config.write_text(config_text([("0", self.TOOL), (app, fex)]))
|
|
state = run()
|
|
self.assertEqual(mapping_of(config.read_text()), {"0": self.TOOL, app: fex})
|
|
self.assertEqual(state["native_choices"], [])
|
|
finally:
|
|
globals_["appinfo_platforms"] = original_platforms
|
|
|
|
def test_factorio_preserves_an_observed_native_choice_despite_ambiguous_metadata(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
steam = Path(tmp) / "Steam"
|
|
config = steam / "config/config.vdf"
|
|
config.parent.mkdir(parents=True)
|
|
config.write_text(config_text([("0", self.TOOL)]))
|
|
state = {"default": self.TOOL, "auto": {}}
|
|
(Path(tmp) / "state.json").write_text(json.dumps(state))
|
|
|
|
globals_ = COMPAT["native_targets"].__globals__
|
|
original_platforms = globals_["appinfo_platforms"]
|
|
globals_["appinfo_platforms"] = lambda _path, wanted: {
|
|
"427520": ({"linux", "macos", "windows"}, "", "game")
|
|
} if "427520" in wanted else {}
|
|
try:
|
|
native = COMPAT["native_targets"](str(steam), ["427520"])
|
|
self.assertIs(native["427520"], COMPAT["NATIVE_CHOICE"])
|
|
|
|
changes, auto = COMPAT["plan_mapping"](
|
|
{"0": self.TOOL}, ["427520"], self.NAMES, self.TOOL, {}, native)
|
|
self.assertEqual(changes, {"427520": self.TOOL})
|
|
self.assertEqual(auto, {"427520": self.TOOL})
|
|
|
|
COMPAT["register_default"](
|
|
str(config), ["427520"], catalog=self.CATALOG, directory=tmp)
|
|
self.assertEqual(mapping_of(config.read_text()), {"0": self.TOOL, "427520": self.TOOL})
|
|
|
|
state["auto"] = {"427520": self.TOOL}
|
|
(Path(tmp) / "state.json").write_text(json.dumps(state))
|
|
config.write_text(config_text([("0", self.TOOL)]))
|
|
COMPAT["register_default"](
|
|
str(config), ["427520"], catalog=self.CATALOG, directory=tmp)
|
|
first = config.read_bytes()
|
|
self.assertEqual(mapping_of(first.decode()), {"0": self.TOOL})
|
|
self.assertEqual(COMPAT["tokenize"](first.decode()),
|
|
COMPAT["tokenize"](config_text([("0", self.TOOL)])))
|
|
self.assertEqual(json.loads((Path(tmp) / "state.json").read_text())["auto"], {})
|
|
self.assertEqual(json.loads((Path(tmp) / "state.json").read_text())["native_choices"], ["427520"])
|
|
|
|
COMPAT["register_default"](
|
|
str(config), ["427520"], catalog=self.CATALOG, directory=tmp)
|
|
self.assertEqual(config.read_bytes(), first)
|
|
|
|
config.write_text(config_text([("0", self.TOOL), ("427520", "")]))
|
|
COMPAT["register_default"](
|
|
str(config), ["427520"], catalog=self.CATALOG, directory=tmp)
|
|
native_choice = config.read_bytes()
|
|
self.assertEqual(mapping_of(native_choice.decode()), {"0": self.TOOL, "427520": ""})
|
|
self.assertEqual(json.loads((Path(tmp) / "state.json").read_text())["auto"], {})
|
|
self.assertEqual(json.loads((Path(tmp) / "state.json").read_text())["native_choices"], ["427520"])
|
|
COMPAT["register_default"](
|
|
str(config), ["427520"], catalog=self.CATALOG, directory=tmp)
|
|
self.assertEqual(config.read_bytes(), native_choice)
|
|
|
|
for choice in (COMPAT["FEX_TOOL"], COMPAT["TOOL_11"], "GE-Proton11-7"):
|
|
config.write_text(config_text([("0", self.TOOL), ("427520", choice)]))
|
|
COMPAT["register_default"](
|
|
str(config), ["427520"], catalog=self.CATALOG, directory=tmp)
|
|
self.assertEqual(mapping_of(config.read_text()), {"0": self.TOOL, "427520": choice})
|
|
self.assertEqual(json.loads((Path(tmp) / "state.json").read_text())["native_choices"], [])
|
|
|
|
state = json.loads((Path(tmp) / "state.json").read_text())
|
|
state["native_choices"] = ["427520"]
|
|
state["auto"] = {}
|
|
(Path(tmp) / "state.json").write_text(json.dumps(state))
|
|
config.write_text(config_text([("0", self.TOOL), ("427520", "proton_experimental")]))
|
|
COMPAT["register_default"](
|
|
str(config), ["427520"], catalog=self.CATALOG, directory=tmp)
|
|
self.assertEqual(mapping_of(config.read_text()), {"0": self.TOOL, "427520": self.TOOL})
|
|
saved = json.loads((Path(tmp) / "state.json").read_text())
|
|
self.assertEqual(saved["native_choices"], [])
|
|
self.assertEqual(saved["auto"], {"427520": self.TOOL})
|
|
finally:
|
|
globals_["appinfo_platforms"] = original_platforms
|
|
|
|
def test_native_arm64_preserves_a_proton_mapping_without_managed_state(self):
|
|
changes, auto = COMPAT["plan_mapping"](
|
|
{"0": self.TOOL, "42": self.TOOL}, ["42"], self.NAMES, self.TOOL,
|
|
{"42": self.TOOL}, {"42": None}, managed=())
|
|
self.assertEqual(changes, {})
|
|
self.assertEqual(auto, {})
|
|
|
|
def test_native_arm64_keeps_a_chosen_valve_proton_through_its_wrapper(self):
|
|
changes, auto = COMPAT["plan_mapping"](
|
|
{"0": self.TOOL, "42": "proton_experimental"}, ["42"], self.NAMES,
|
|
self.TOOL, {}, {"42": None})
|
|
self.assertEqual(changes, {"42": self.TOOL})
|
|
self.assertEqual(auto, {})
|
|
|
|
changes, auto = COMPAT["plan_mapping"](
|
|
{"0": self.TOOL, "42": self.TOOL}, ["42"], self.NAMES,
|
|
self.TOOL, {}, {"42": None})
|
|
self.assertEqual(changes, {})
|
|
self.assertEqual(auto, {})
|
|
|
|
def test_native_arm64_keeps_explicit_runtime_fex_and_proton_profiles(self):
|
|
fex = COMPAT["FEX_TOOL"]
|
|
current = {
|
|
"0": self.TOOL,
|
|
"42": COMPAT["TOOL_11"],
|
|
"43": "GE-Proton11-7",
|
|
"44": "steamlinuxruntime_arm64",
|
|
"45": fex,
|
|
"46": "",
|
|
}
|
|
changes, auto = COMPAT["plan_mapping"](
|
|
current, ["42", "43", "44", "45", "46"], self.NAMES, self.TOOL, {},
|
|
{app: None for app in ("42", "43", "44", "45", "46")},
|
|
overrides={"46": "GE-Proton11-7"},
|
|
)
|
|
self.assertEqual(changes, {"46": "GE-Proton11-7"})
|
|
self.assertEqual(auto, {})
|
|
|
|
def test_titles_following_the_default_move_with_it_and_picks_stay(self):
|
|
current = {"0": self.TOOL, "10": self.TOOL, "11": "GE-Proton11-7", "12": self.TOOL}
|
|
auto = {"10": self.TOOL, "11": self.TOOL, "12": self.TOOL}
|
|
changes, auto = self.plan(current, ["10", "11", "12"], COMPAT["TOOL_11"], auto)
|
|
self.assertEqual(changes, {"0": COMPAT["TOOL_11"], "10": COMPAT["TOOL_11"], "12": COMPAT["TOOL_11"]})
|
|
self.assertEqual(auto, {"10": COMPAT["TOOL_11"], "12": COMPAT["TOOL_11"]})
|
|
|
|
def test_a_removed_tool_falls_back_to_the_default(self):
|
|
changes, auto = self.plan({"0": self.TOOL, "10": "GE-Proton10-1", "11": "droiddeck-proton-12-arm64"}, ["10", "11"], self.TOOL, {"10": "GE-Proton10-1"})
|
|
self.assertEqual(changes, {"10": self.TOOL, "11": self.TOOL})
|
|
self.assertEqual(auto, {"10": self.TOOL, "11": self.TOOL})
|
|
|
|
def test_uninstalled_titles_are_forgotten(self):
|
|
changes, auto = self.plan({"0": self.TOOL}, [], self.TOOL, {"10": self.TOOL})
|
|
self.assertEqual((changes, auto), ({}, {}))
|
|
|
|
def test_per_game_proton_override_and_inherit_follow_steam_mappings(self):
|
|
current = {"0": self.TOOL, "10": self.TOOL, "11": "GE-Proton11-7", "12": self.TOOL}
|
|
overrides = {"10": "GE-Proton11-7", "11": None}
|
|
changes, auto = COMPAT["plan_mapping"](
|
|
current, ["10", "11", "12"], self.NAMES, COMPAT["TOOL_11"],
|
|
{"10": self.TOOL, "12": self.TOOL}, overrides=overrides)
|
|
self.assertEqual(changes, {"0": COMPAT["TOOL_11"], "10": "GE-Proton11-7", "11": COMPAT["TOOL_11"], "12": COMPAT["TOOL_11"]})
|
|
self.assertEqual(auto, {"11": COMPAT["TOOL_11"], "12": COMPAT["TOOL_11"]})
|
|
|
|
def test_per_game_requests_resolve_identity_and_defer_a_new_live_tool(self):
|
|
requests = {
|
|
"10": {"valve": False, "dir": "GE-Proton11-7"},
|
|
"11": None,
|
|
"12": {"valve": True, "dir": "Proton 11.0 (ARM64)"},
|
|
"0": None,
|
|
}
|
|
self.assertEqual(
|
|
COMPAT["requested_games"](requests, self.CATALOG),
|
|
{"10": "GE-Proton11-7", "11": None, "12": COMPAT["TOOL_11"]},
|
|
)
|
|
self.assertEqual(
|
|
COMPAT["requested_games"](requests, self.CATALOG, [self.TOOL, COMPAT["TOOL_11"]]),
|
|
{"10": COMPAT["DEFERRED_GAME_TOOL"], "11": None, "12": COMPAT["TOOL_11"]},
|
|
)
|
|
|
|
def test_missing_per_game_proton_is_preserved_during_default_registration(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
config = Path(tmp) / "config.vdf"
|
|
config.write_text(config_text([
|
|
("0", self.TOOL),
|
|
("10", "GE-Proton9-1"),
|
|
("11", self.TOOL),
|
|
]))
|
|
(Path(tmp) / "games.json").write_text(json.dumps({
|
|
"version": 1,
|
|
"games": {
|
|
"10": {"valve": False, "dir": "GE-Proton9-1"},
|
|
},
|
|
}))
|
|
|
|
COMPAT["register_default"](
|
|
str(config), ["10", "11"], default=COMPAT["TOOL_11"],
|
|
catalog=self.CATALOG, directory=tmp,
|
|
)
|
|
|
|
self.assertEqual(mapping_of(config.read_text()), {
|
|
"0": COMPAT["TOOL_11"],
|
|
"10": "GE-Proton9-1",
|
|
"11": COMPAT["TOOL_11"],
|
|
})
|
|
|
|
def test_the_last_side_to_change_wins(self):
|
|
state = {"default": self.TOOL, "applied": 5}
|
|
default, state = COMPAT["choose_default"]("GE-Proton11-7", state, {"seq": 5, "dir": "Proton 11.0 (ARM64)", "valve": True}, self.CATALOG, now=100)
|
|
self.assertEqual((default, state["source"], state["seq"]), ("GE-Proton11-7", "steam", 100))
|
|
default, state = COMPAT["choose_default"]("GE-Proton11-7", state, {"seq": 6, "dir": "Proton 11.0 (ARM64)", "valve": True}, self.CATALOG, now=200)
|
|
self.assertEqual((default, state["source"], state["seq"], state["applied"]), (COMPAT["TOOL_11"], "app", 200, 6))
|
|
default, state = COMPAT["choose_default"]("GE-Proton11-7", state, {"seq": 6}, self.CATALOG, adopt_steam=False, now=300)
|
|
self.assertEqual((default, state["seq"]), (COMPAT["TOOL_11"], 200))
|
|
default, state = COMPAT["choose_default"]("proton_experimental", state, {"seq": 6}, self.CATALOG, now=300)
|
|
self.assertEqual(default, COMPAT["TOOL_11"])
|
|
|
|
def test_a_request_for_a_missing_tool_is_dropped(self):
|
|
default, state = COMPAT["choose_default"](self.TOOL, {"default": self.TOOL}, {"seq": 9, "dir": "GE-Proton9-1", "valve": False}, self.CATALOG, now=1)
|
|
self.assertEqual((default, state["applied"]), (self.TOOL, 9))
|
|
default, state = COMPAT["choose_default"]("GE-Proton11-7", state, {"seq": 9, "dir": "GE-Proton9-1", "valve": False}, self.CATALOG, now=2)
|
|
self.assertEqual(default, "GE-Proton11-7")
|
|
|
|
def test_settle_before_the_client_starts(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
config = Path(tmp) / "config.vdf"
|
|
config.write_text(config_text([("0", self.TOOL), ("10", "proton_experimental"), ("11", "GE-Proton11-7")]))
|
|
(Path(tmp) / "request.json").write_text(json.dumps({"seq": 3, "dir": "Proton 11.0 (ARM64)", "valve": True}))
|
|
default = COMPAT["register_default"](str(config), ["10", "11", "12"], catalog=self.CATALOG, directory=tmp, settle=True)
|
|
self.assertEqual(default, COMPAT["TOOL_11"])
|
|
self.assertEqual(mapping_of(config.read_text()), {"0": COMPAT["TOOL_11"], "10": COMPAT["TOOL_11"], "11": "GE-Proton11-7", "12": COMPAT["TOOL_11"]})
|
|
state = json.loads((Path(tmp) / "state.json").read_text())
|
|
self.assertEqual((state["applied"], state["source"], state["tools"]), (3, "app", self.CATALOG))
|
|
config.write_text(config_text([("0", "GE-Proton11-7"), ("10", COMPAT["TOOL_11"]), ("11", "GE-Proton11-7"), ("12", COMPAT["TOOL_11"])]))
|
|
default = COMPAT["register_default"](str(config), ["10", "11", "12"], catalog=self.CATALOG, directory=tmp, settle=True)
|
|
self.assertEqual(default, "GE-Proton11-7")
|
|
self.assertEqual(mapping_of(config.read_text())["10"], "GE-Proton11-7")
|
|
self.assertEqual(json.loads((Path(tmp) / "state.json").read_text())["source"], "steam")
|
|
config.write_text(config_text([("0", "proton_experimental")]))
|
|
default = COMPAT["register_default"](str(config), ["10"], catalog=self.CATALOG, directory=tmp)
|
|
self.assertEqual(mapping_of(config.read_text()), {"0": "GE-Proton11-7", "10": "GE-Proton11-7"})
|
|
|
|
def test_the_shortcut_writer_keeps_the_chosen_default(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
steam = Path(tmp) / "Steam"
|
|
for name in COMPAT["SOURCES"]:
|
|
(steam / "steamapps/common" / name / "files/bin-arm64").mkdir(parents=True)
|
|
(steam / "config").mkdir()
|
|
config = steam / "config/config.vdf"
|
|
config.write_text(config_text([("0", COMPAT["TOOL_11"]), ("10", COMPAT["TOOL_11"]), ("3044416433", COMPAT["TOOL_11"])]))
|
|
(Path(tmp) / "state.json").write_text(json.dumps({"default": COMPAT["TOOL_11"], "auto": {"10": COMPAT["TOOL_11"], "3044416433": COMPAT["TOOL_11"]}}))
|
|
COMPAT["register_default"](str(config), apps=["3044416433", "3212965118"], protect={}, directory=tmp)
|
|
self.assertEqual(mapping_of(config.read_text()), {"0": COMPAT["TOOL_11"], "10": COMPAT["TOOL_11"], "3044416433": COMPAT["TOOL_11"], "3212965118": COMPAT["TOOL_11"]})
|
|
|
|
def test_each_valve_depot_gets_its_own_tool(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
steam = Path(tmp) / "Steam"
|
|
for name in COMPAT["SOURCES"]:
|
|
(steam / "steamapps/common" / name / "files/bin-arm64").mkdir(parents=True)
|
|
self.assertEqual(COMPAT["find_source"](str(steam), COMPAT["OWN_TOOLS"][1][1]), str(steam / "steamapps/common/Proton 11.0 (ARM64)"))
|
|
self.assertEqual(COMPAT["find_source"](str(steam)), str(steam / "steamapps/common/Proton Experimental (ARM64)"))
|
|
self.assertEqual([(t["name"], t["dir"]) for t in COMPAT["tool_catalog"](str(steam), {})],
|
|
[(COMPAT["TOOL"], "Proton Experimental (ARM64)"), (COMPAT["TOOL_11"], "Proton 11.0 (ARM64)")])
|
|
for name, sources in COMPAT["OWN_TOOLS"]:
|
|
COMPAT["build_tool"](str(steam / "compatibilitytools.d" / name), COMPAT["find_source"](str(steam), sources), sources, name)
|
|
self.assertEqual(COMPAT["adopt_extras"](str(steam / "compatibilitytools.d")), {})
|
|
self.assertEqual(sorted(os.listdir(steam / "compatibilitytools.d" / COMPAT["TOOL_11"])), sorted(COMPAT["OWN_FILES"]))
|
|
launcher = COMPAT["launcher_sh"](COMPAT["OWN_TOOLS"][1][1])
|
|
self.assertIn('for name in "Proton 11.0 (ARM64)"; do', launcher)
|
|
self.assertIn("${major%%.*}", launcher)
|
|
|
|
|
|
|
|
class DirectAudioPrefixTest(unittest.TestCase):
|
|
def setUp(self):
|
|
self.tmp = tempfile.TemporaryDirectory()
|
|
self.addCleanup(self.tmp.cleanup)
|
|
base = Path(self.tmp.name)
|
|
self.tool = base / "tool"
|
|
(self.tool / "files/bin-arm64").mkdir(parents=True)
|
|
wine = self.tool / "files/bin-arm64/wine"
|
|
wine.write_text("#!/bin/sh\necho wine-11.0-4c8f2e1 '(Staging)'\n")
|
|
wine.chmod(0o755)
|
|
(self.tool / "files/lib/wine/aarch64-unix").mkdir(parents=True)
|
|
self.winepulse = self.tool / "files/lib/wine/aarch64-unix/winepulse.so"
|
|
self.winepulse.write_bytes(b"\x7fELF classic wine 11 pulse driver")
|
|
self.sets = base / "directaudio"
|
|
for set_name in ("linux-wine11", "linux-wine11-systhread"):
|
|
for arch in ("aarch64-windows", "i386-windows", "aarch64-unix"):
|
|
(self.sets / set_name / "lib/wine" / arch).mkdir(parents=True)
|
|
(self.sets / set_name / "lib/wine/aarch64-windows/winedirectaudio.drv").write_bytes(b"arm64x-" + set_name.encode())
|
|
(self.sets / set_name / "lib/wine/i386-windows/winedirectaudio.drv").write_bytes(b"i386")
|
|
self.audio = self.sets / "linux-wine11"
|
|
self.compat = base / "compat"
|
|
self.windows = self.compat / "pfx/drive_c/windows"
|
|
for folder in ("system32", "syswow64"):
|
|
(self.windows / folder).mkdir(parents=True)
|
|
self.reg = self.compat / "pfx/user.reg"
|
|
|
|
def run_setup(self, verb="waitforexitandrun", target="game.exe", cwd=None, extra=None):
|
|
script = COMPAT["BL_DIRECTAUDIO_SETUP"] + '\nverb=$2\nbl_directaudio "$1" "$3"\necho "WINEDLLPATH=$WINEDLLPATH"\necho "WINE_AUDIO_DRIVER=${WINE_AUDIO_DRIVER:-}"\nenv | grep "^BANNER_AUDIO_DIRECT_" | sort\n'
|
|
steam = self.compat.parent / "Steam"
|
|
env = dict(os.environ, BL_DIRECTAUDIO=str(self.sets), STEAM_COMPAT_DATA_PATH=str(self.compat),
|
|
STEAM_COMPAT_CLIENT_INSTALL_PATH=str(steam), **(extra or {}))
|
|
env.pop("WINEDLLPATH", None)
|
|
env.pop("WINE_AUDIO_DRIVER", None)
|
|
return subprocess.run(["bash", "-c", script, "bash", str(self.tool), verb, target], cwd=cwd, env=env,
|
|
capture_output=True, text=True, check=True)
|
|
|
|
def audio_values(self):
|
|
found, current = [], None
|
|
for line in self.reg.read_text().splitlines():
|
|
if line.startswith("["):
|
|
current = line.rsplit("] ", 1)[0] + "]"
|
|
elif line.startswith('"Audio"='):
|
|
found.append((current, line))
|
|
return found
|
|
|
|
def link(self, folder):
|
|
path = self.windows / folder / "winedirectaudio.drv"
|
|
return os.readlink(path) if path.is_symlink() else None
|
|
|
|
def test_steam_installscript_evaluator_does_not_change_prefix_audio(self):
|
|
legacycompat = self.compat.parent / "Steam/legacycompat"
|
|
legacycompat.mkdir(parents=True)
|
|
(legacycompat / "iscriptevaluator.exe").write_bytes(b"Steam install-script evaluator")
|
|
self.reg.write_text("WINE REGISTRY Version 2\n\n#arch=win64\n")
|
|
|
|
result = self.run_setup(target="iscriptevaluator.exe", cwd=legacycompat)
|
|
|
|
self.assertIn("DirectAudio skipped for Steam's install-script evaluator", result.stderr)
|
|
self.assertIn("WINEDLLPATH=\n", result.stdout)
|
|
self.assertIn("WINE_AUDIO_DRIVER=\n", result.stdout)
|
|
self.assertEqual(self.reg.read_text(), "WINE REGISTRY Version 2\n\n#arch=win64\n")
|
|
self.assertIsNone(self.link("system32"))
|
|
self.assertIsNone(self.link("syswow64"))
|
|
|
|
def test_same_basename_outside_steam_legacycompat_keeps_normal_setup(self):
|
|
legacycompat = self.compat.parent / "Steam/legacycompat"
|
|
legacycompat.mkdir(parents=True)
|
|
(legacycompat / "iscriptevaluator.exe").write_bytes(b"Steam install-script evaluator")
|
|
other = self.compat.parent / "other"
|
|
other.mkdir()
|
|
(other / "iscriptevaluator.exe").write_bytes(b"unrelated executable")
|
|
self.reg.write_text("WINE REGISTRY Version 2\n\n#arch=win64\n")
|
|
|
|
result = self.run_setup(target="iscriptevaluator.exe", cwd=other)
|
|
|
|
self.assertIn("DirectAudio selected", result.stderr)
|
|
self.assertEqual(self.link("system32"), str(self.audio / "lib/wine/aarch64-windows/winedirectaudio.drv"))
|
|
self.assertEqual(self.audio_values(), [("[Software\\\\Wine\\\\Drivers]", '"Audio"="directaudio,pulse"')])
|
|
def test_audio_direct_settings_reach_the_driver_under_its_own_names(self):
|
|
self.reg.write_text("WINE REGISTRY Version 2\n\n#arch=win64\n")
|
|
result = self.run_setup(extra={"AUDIO_DIRECT_MS": "60", "AUDIO_DIRECT_PERIOD_MS": "20",
|
|
"AUDIO_DIRECT_RELAY": "/run/relay.sock", "OTHER_AUDIO_DIRECT_MS": "5"})
|
|
lines = [line for line in result.stdout.splitlines() if line.startswith("BANNER_")]
|
|
self.assertEqual(lines, ["BANNER_AUDIO_DIRECT_MS=60", "BANNER_AUDIO_DIRECT_PERIOD_MS=20",
|
|
"BANNER_AUDIO_DIRECT_RELAY=/run/relay.sock"])
|
|
|
|
def test_audio_direct_settings_are_not_handed_on_when_directaudio_stays_off(self):
|
|
legacycompat = self.compat.parent / "Steam/legacycompat"
|
|
legacycompat.mkdir(parents=True)
|
|
(legacycompat / "iscriptevaluator.exe").write_bytes(b"Steam install-script evaluator")
|
|
result = self.run_setup(target="iscriptevaluator.exe", cwd=legacycompat, extra={"AUDIO_DIRECT_MS": "60"})
|
|
self.assertNotIn("BANNER_AUDIO_DIRECT_MS", result.stdout)
|
|
|
|
def test_non_launch_verbs_preserve_audio_configuration(self):
|
|
for verb in ("getcompatpath", "getnativepath", "destroyprefix"):
|
|
with self.subTest(verb=verb):
|
|
self.reg.write_text('WINE REGISTRY Version 2\n\n[Software\\\\Wine\\\\Drivers] 1\n"Audio"="pulse"\n')
|
|
before = self.reg.read_bytes()
|
|
result = self.run_setup(verb)
|
|
self.assertEqual(self.reg.read_bytes(), before)
|
|
self.assertIsNone(self.link("system32"))
|
|
self.assertIsNone(self.link("syswow64"))
|
|
self.assertIn("WINEDLLPATH=\n", result.stdout)
|
|
self.assertIn("WINE_AUDIO_DRIVER=\n", result.stdout)
|
|
|
|
def test_other_windows_launch_verbs_still_set_up_audio(self):
|
|
self.reg.write_text("WINE REGISTRY Version 2\n")
|
|
for verb in ("run", "runinprefix"):
|
|
with self.subTest(verb=verb):
|
|
result = self.run_setup(verb)
|
|
self.assertIn("DirectAudio ready", result.stderr)
|
|
self.assertIn("WINE_AUDIO_DRIVER=directaudio,pulse\n", result.stdout)
|
|
self.assertEqual(self.link("system32"), str(self.audio / "lib/wine/aarch64-windows/winedirectaudio.drv"))
|
|
|
|
def test_the_driver_is_placed_and_selected_the_way_wine_reads_it(self):
|
|
self.reg.write_text("WINE REGISTRY Version 2\n;; All keys relative to \\\\User\\\\S-1-5-21-0-0-0-1000\n\n#arch=win64\n")
|
|
result = self.run_setup()
|
|
self.assertIn("DirectAudio selected", result.stderr)
|
|
self.assertIn("WINEDLLPATH=%s/lib/wine" % self.audio, result.stdout)
|
|
self.assertIn("WINE_AUDIO_DRIVER=directaudio,pulse\n", result.stdout)
|
|
self.assertEqual(self.link("system32"), str(self.audio / "lib/wine/aarch64-windows/winedirectaudio.drv"))
|
|
self.assertEqual(self.link("syswow64"), str(self.audio / "lib/wine/i386-windows/winedirectaudio.drv"))
|
|
self.assertEqual((self.windows / "system32/winedirectaudio.drv").read_bytes(), b"arm64x-linux-wine11")
|
|
self.assertEqual(self.audio_values(), [("[Software\\\\Wine\\\\Drivers]", '"Audio"="directaudio,pulse"')])
|
|
before = self.reg.read_text()
|
|
self.assertNotIn("DirectAudio selected", self.run_setup().stderr)
|
|
self.assertEqual(self.reg.read_text(), before)
|
|
self.assertEqual(sorted(os.listdir(self.windows / "system32")), ["winedirectaudio.drv"])
|
|
|
|
def test_without_a_prefix_it_waits_for_the_next_launch(self):
|
|
result = self.run_setup()
|
|
self.assertIn("takes effect the next time this game starts", result.stderr)
|
|
self.assertEqual(self.link("system32"), None)
|
|
|
|
def test_the_system_thread_set_is_picked_from_winepulse_not_the_version(self):
|
|
self.reg.write_text("WINE REGISTRY Version 2\n\n#arch=win64\n")
|
|
self.winepulse.write_bytes(b"\x7fELF cachyos pulse driver PsCreateSystemThread NtSetInformationThread")
|
|
result = self.run_setup()
|
|
systhread = self.sets / "linux-wine11-systhread"
|
|
self.assertIn("DirectAudio ready (Wine 11, linux-wine11-systhread)", result.stderr)
|
|
self.assertIn("WINEDLLPATH=%s/lib/wine" % systhread, result.stdout)
|
|
self.assertEqual(self.link("system32"), str(systhread / "lib/wine/aarch64-windows/winedirectaudio.drv"))
|
|
self.assertEqual((self.windows / "system32/winedirectaudio.drv").read_bytes(), b"arm64x-linux-wine11-systhread")
|
|
|
|
def test_no_set_for_the_interface_leaves_protons_own_audio(self):
|
|
self.reg.write_text("WINE REGISTRY Version 2\n\n#arch=win64\n")
|
|
self.winepulse.write_bytes(b"\x7fELF PsCreateSystemThread")
|
|
shutil.rmtree(self.sets / "linux-wine11-systhread")
|
|
result = self.run_setup()
|
|
self.assertIn("no driver set for the linux-wine11-systhread interface", result.stderr)
|
|
self.assertIn("WINEDLLPATH=\n", result.stdout)
|
|
self.assertIn("WINE_AUDIO_DRIVER=\n", result.stdout)
|
|
self.assertFalse((self.windows / "system32/winedirectaudio.drv").exists())
|
|
|
|
def test_older_selections_are_upgraded(self):
|
|
self.reg.write_text('WINE REGISTRY Version 2\n\n[SoftwareWineDrivers] 1790995967\n#time=1dd52e24550e980\n"Audio"="directaudio"\n'
|
|
'\n[Software\\\\Wine\\\\Drivers] 1790995986\n#time=1dd52e250b31ec4\n"Audio"="directaudio"\n')
|
|
self.assertIn("DirectAudio selected", self.run_setup().stderr)
|
|
self.assertEqual(self.audio_values(), [("[SoftwareWineDrivers]", '"Audio"="directaudio"'),
|
|
("[Software\\\\Wine\\\\Drivers]", '"Audio"="directaudio"'),
|
|
("[Software\\\\Wine\\\\Drivers]", '"Audio"="directaudio,pulse"')])
|
|
self.assertNotIn("DirectAudio selected", self.run_setup().stderr)
|
|
|
|
def test_a_copied_driver_is_replaced_by_the_staged_one(self):
|
|
self.reg.write_text("WINE REGISTRY Version 2\n")
|
|
(self.windows / "system32/winedirectaudio.drv").write_bytes(b"stale copy")
|
|
self.run_setup()
|
|
self.assertEqual(self.link("system32"), str(self.audio / "lib/wine/aarch64-windows/winedirectaudio.drv"))
|
|
|
|
def test_nothing_is_selected_without_the_driver_in_the_prefix(self):
|
|
self.reg.write_text("WINE REGISTRY Version 2\n")
|
|
shutil.rmtree(self.windows)
|
|
self.assertIn("could not be placed", self.run_setup().stderr)
|
|
self.assertEqual(self.audio_values(), [])
|
|
(self.windows / "system32").mkdir(parents=True)
|
|
(self.audio / "lib/wine/aarch64-windows/winedirectaudio.drv").unlink()
|
|
self.assertIn("could not be placed", self.run_setup().stderr)
|
|
self.assertEqual(self.audio_values(), [])
|
|
|
|
def test_a_new_prefix_waits_for_the_next_launch(self):
|
|
shutil.rmtree(self.compat / "pfx")
|
|
self.assertIn("no prefix yet", self.run_setup().stderr)
|
|
self.assertFalse(self.reg.exists())
|
|
|
|
def test_another_wine_major_is_left_alone(self):
|
|
(self.tool / "files/bin-arm64/wine").write_text("#!/bin/sh\necho wine-10.0\n")
|
|
self.reg.write_text("WINE REGISTRY Version 2\n")
|
|
result = self.run_setup()
|
|
self.assertIn("left off", result.stderr)
|
|
self.assertIn("WINEDLLPATH=\n", result.stdout)
|
|
self.assertEqual(self.audio_values(), [])
|
|
self.assertIsNone(self.link("system32"))
|
|
|
|
|
|
class ProjectZomboidJvmTest(unittest.TestCase):
|
|
ARGS = ["-Djava.awt.headless=true", "-Xmx3072m", "-XX:+UseZGC", "-XX:-CreateCoredumpOnCrash"]
|
|
|
|
def launch(self, tmp, args=None, stem="ProjectZomboid64"):
|
|
settings = Path(tmp) / (stem + ".json")
|
|
settings.write_text(json.dumps({"mainClass": "zombie/gameStates/MainScreenState", "vmArgs": args or self.ARGS}))
|
|
command = ["proton", "waitforexitandrun", str(Path(tmp) / (stem + ".exe"))]
|
|
return settings, command
|
|
|
|
def test_zgc_becomes_g1_and_the_c1_jit_flag_is_added_once(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
settings, command = self.launch(tmp)
|
|
MODULE["project_zomboid_jvm"](command)
|
|
data = json.loads(settings.read_text())
|
|
self.assertEqual(data["vmArgs"], ["-Djava.awt.headless=true", "-Xmx3072m", "-XX:-CreateCoredumpOnCrash",
|
|
"-XX:+UseG1GC", "-XX:TieredStopAtLevel=1"])
|
|
self.assertEqual(data["mainClass"], "zombie/gameStates/MainScreenState")
|
|
before = settings.read_text()
|
|
MODULE["project_zomboid_jvm"](command)
|
|
self.assertEqual(settings.read_text(), before)
|
|
|
|
def test_other_collectors_and_windows_specific_lists_are_rewritten(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
settings, command = self.launch(tmp)
|
|
settings.write_text(json.dumps({"vmArgs": ["-XX:+UseParallelGC", "-Xmx1g"],
|
|
"windows": {"10.0.17134": {"vmArgs": ["-XX:+UseZGC", "-XX:+UseG1GC"]}}}))
|
|
MODULE["project_zomboid_jvm"](command)
|
|
data = json.loads(settings.read_text())
|
|
flags = ["-XX:+UseG1GC", "-XX:TieredStopAtLevel=1"]
|
|
self.assertEqual(data["vmArgs"], ["-Xmx1g"] + flags)
|
|
self.assertEqual(data["windows"]["10.0.17134"]["vmArgs"], flags)
|
|
|
|
def test_other_games_and_unreadable_files_are_left_alone(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
settings, command = self.launch(tmp, stem="Other")
|
|
before = settings.read_text()
|
|
MODULE["project_zomboid_jvm"](command)
|
|
self.assertEqual(settings.read_text(), before)
|
|
settings.write_text("{")
|
|
MODULE["project_zomboid_jvm"](command[:2] + [str(Path(tmp) / "Other.exe")])
|
|
(Path(tmp) / "ProjectZomboid64.json").write_text("{")
|
|
MODULE["project_zomboid_jvm"](command[:2] + [str(Path(tmp) / "ProjectZomboid64.exe")])
|
|
self.assertEqual((Path(tmp) / "ProjectZomboid64.json").read_text(), "{")
|
|
|
|
|
|
class UbisoftOverlayTest(unittest.TestCase):
|
|
SETTINGS = (
|
|
"language:\n code: en-US\n"
|
|
"overlay:\n enabled: true\n forceunhookgame: false\n fps_enabled: false\n warning_enabled: true\n"
|
|
"patchTrigger:\n source: USER_RESTART\n"
|
|
)
|
|
|
|
def test_the_overlay_and_its_prompt_are_turned_off_and_the_rest_is_kept(self):
|
|
got = MODULE["overlay_off"](self.SETTINGS)
|
|
self.assertEqual(got, self.SETTINGS.replace("enabled: true", "enabled: false"))
|
|
self.assertEqual(MODULE["overlay_off"](got), got)
|
|
|
|
def test_missing_keys_or_section_are_added(self):
|
|
self.assertEqual(MODULE["overlay_off"]("overlay:\n fps_enabled: true\nmisc:\n a: 1\n"),
|
|
"overlay:\n fps_enabled: true\n enabled: false\n warning_enabled: false\nmisc:\n a: 1\n")
|
|
self.assertEqual(MODULE["overlay_off"](""), "overlay:\n enabled: false\n warning_enabled: false\n")
|
|
|
|
def test_blank_lines_inside_the_overlay_section_do_not_end_it(self):
|
|
text = "overlay:\n enabled: true\n\n warning_enabled: true\n\nmisc:\n a: 1\n"
|
|
self.assertEqual(MODULE["overlay_off"](text), text.replace("true", "false"))
|
|
self.assertEqual(MODULE["overlay_off"]("overlay:\n fps_enabled: true\n\nmisc:\n a: 1\n"),
|
|
"overlay:\n fps_enabled: true\n enabled: false\n warning_enabled: false\n\nmisc:\n a: 1\n")
|
|
|
|
def test_an_annotated_overlay_header_is_the_same_section(self):
|
|
self.assertEqual(MODULE["overlay_off"]("overlay: # in-game\n enabled: true\n"),
|
|
"overlay: # in-game\n enabled: false\n warning_enabled: false\n")
|
|
|
|
def test_only_prefixes_with_ubisoft_connect_are_touched(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
prefix = Path(tmp) / "447040/pfx"
|
|
env = {"STEAM_COMPAT_DATA_PATH": str(prefix.parent)}
|
|
settings = prefix / MODULE["UBISOFT_SETTINGS"]
|
|
prefix.mkdir(parents=True)
|
|
MODULE["ubisoft_overlay_off"](env)
|
|
self.assertFalse(settings.exists())
|
|
(prefix / MODULE["UBISOFT_LAUNCHER"]).mkdir(parents=True)
|
|
MODULE["ubisoft_overlay_off"](env)
|
|
self.assertEqual(settings.read_text(), "overlay:\n enabled: false\n warning_enabled: false\n")
|
|
settings.write_text(self.SETTINGS)
|
|
settings.chmod(0o644)
|
|
MODULE["ubisoft_overlay_off"](env)
|
|
self.assertEqual(settings.read_text(), self.SETTINGS.replace("enabled: true", "enabled: false"))
|
|
self.assertEqual(settings.stat().st_mode & 0o777, 0o644)
|
|
self.assertEqual(sorted(p.name for p in settings.parent.iterdir()), ["settings.yaml"])
|
|
|
|
|
|
class UbisoftCompositionTest(unittest.TestCase):
|
|
def test_writes_through_wine_without_replacing_a_live_servers_registry(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
prefix = Path(tmp) / "pfx"
|
|
(prefix / MODULE["UBISOFT_LAUNCHER"]).mkdir(parents=True)
|
|
registry = prefix / "user.reg"
|
|
registry.write_text("registry last saved by Wine\n")
|
|
env = {"STEAM_COMPAT_DATA_PATH": tmp, "WINESERVER": "/selected/wineserver"}
|
|
keys = []
|
|
|
|
def write_registry(argv, **kwargs):
|
|
self.assertEqual(argv[:4], ["/selected/proton", "runinprefix", "reg.exe", "add"])
|
|
self.assertEqual(argv[5:], ["/v", "dcomp", "/t", "REG_SZ", "/d", "", "/f"])
|
|
self.assertIs(kwargs["env"], env)
|
|
self.assertTrue(kwargs["check"])
|
|
keys.append(argv[4])
|
|
registry.write_text("newer state saved by the active Wine server\n")
|
|
return subprocess.CompletedProcess(argv, 0)
|
|
|
|
with mock.patch("subprocess.run", side_effect=write_registry) as run:
|
|
MODULE["ubisoft_composition_off"]("/selected/proton", env)
|
|
self.assertEqual(run.call_count, 2)
|
|
self.assertEqual(registry.read_text(), "newer state saved by the active Wine server\n")
|
|
self.assertEqual(keys, [r"HKCU\Software\Wine\AppDefaults\%s\DllOverrides" % program
|
|
for program in ("upc.exe", "UplayWebCore.exe")])
|
|
self.assertEqual(list(prefix.glob(".ubisoft-*.reg")), [])
|
|
|
|
def test_other_prefixes_and_uninitialized_registries_are_untouched(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
prefix = Path(tmp) / "pfx"
|
|
prefix.mkdir()
|
|
registry = prefix / "user.reg"
|
|
registry.write_text("WINE REGISTRY Version 2\n")
|
|
env = {"STEAM_COMPAT_DATA_PATH": tmp}
|
|
with mock.patch("subprocess.run") as run:
|
|
MODULE["ubisoft_composition_off"]("proton", env)
|
|
self.assertEqual(registry.read_text(), "WINE REGISTRY Version 2\n")
|
|
registry.unlink()
|
|
(prefix / MODULE["UBISOFT_LAUNCHER"]).mkdir(parents=True)
|
|
MODULE["ubisoft_composition_off"]("proton", env)
|
|
self.assertFalse(registry.exists())
|
|
run.assert_not_called()
|
|
|
|
def test_failed_registry_write_keeps_the_game_launch_available(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
prefix = Path(tmp) / "pfx"
|
|
(prefix / MODULE["UBISOFT_LAUNCHER"]).mkdir(parents=True)
|
|
(prefix / "user.reg").write_text("WINE REGISTRY Version 2\n")
|
|
for failed_write in (0, 1):
|
|
stderr = io.StringIO()
|
|
results = [subprocess.CompletedProcess([], 0)] * failed_write
|
|
results.append(subprocess.CalledProcessError(1, "proton"))
|
|
with mock.patch("subprocess.run", side_effect=results) as run, mock.patch("sys.stderr", stderr):
|
|
MODULE["ubisoft_composition_off"]("proton", {"STEAM_COMPAT_DATA_PATH": tmp})
|
|
self.assertEqual(run.call_count, failed_write + 1)
|
|
self.assertIn("configuration failed", stderr.getvalue())
|
|
self.assertNotIn("uses CEF", stderr.getvalue())
|
|
self.assertEqual(list(prefix.glob(".ubisoft-*.reg")), [])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|
|
|
|
|
|
class ForceSsbsTest(unittest.TestCase):
|
|
def test_libssbs_is_preloaded_only_when_asked_and_only_into_arm64_proton(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
arm = Path(tmp) / "arm"
|
|
(arm / "files/lib/wine/aarch64-unix").mkdir(parents=True)
|
|
x86 = Path(tmp) / "x86"
|
|
(x86 / "files/lib/wine/x86_64-unix").mkdir(parents=True)
|
|
lib = Path(tmp) / "libssbs.so"
|
|
lib.write_bytes(b"\x7fELF")
|
|
g = MODULE["force_ssbs"].__globals__
|
|
old = g["SSBS_LIB"]
|
|
g["SSBS_LIB"] = str(lib)
|
|
try:
|
|
env = {"LD_PRELOAD": "/usr/local/lib/libblsession.so"}
|
|
self.assertEqual(MODULE["force_ssbs"]([str(arm / "proton"), "waitforexitandrun"], env), env)
|
|
on = dict(env, DROIDDECK_FORCE_SSBS="1")
|
|
got = MODULE["force_ssbs"]([str(arm / "proton"), "waitforexitandrun"], on)
|
|
self.assertEqual(got["LD_PRELOAD"], "/usr/local/lib/libblsession.so:" + str(lib))
|
|
self.assertEqual(MODULE["force_ssbs"]([str(arm / "proton")], got)["LD_PRELOAD"], got["LD_PRELOAD"])
|
|
self.assertEqual(MODULE["force_ssbs"]([str(x86 / "proton")], on), on)
|
|
finally:
|
|
g["SSBS_LIB"] = old
|