Playing D2R on the OneXPlayer 2 Pro with Arch Linux

D2R has already been out for four years. I used to play it on the Nintendo Switch, but recently I noticed a discount on the PC version, so I bought a copy for PC. Thanks to Wine, Valve, and the Steam Deck, playing games on Linux has become much easier. There are many articles about how to play D2R on a Steam Deck. However, since I’m using KWin with Wayland, I had a few issues to overcome.

Battle.net

Unlike Diablo IV, we can't run D2R directly through Steam. We need to launch Battle.net first. There are two issues here.

  1. we can't add non-steam game in Big-Picture mode.
  2. `Battle.net` has recently updated its server certificate, which is now signed by a new root certificate. To communicate with the server properly, we should use Proton 10 or later. Otherwise, running `Battle.net` will show BLZBNTBNA00000005 error.

There are two important points to note:

  1. If the Battle.net installer is not run with Proton 10 or later, switching to Proton 10 afterward will not resolve the issue — a complete installation is required.
  2. Before installing D2R, you should change the installation path in the Battle.net settings to a location outside the Steam folder. So that we don't need to install D2R again if we remove and install `Battle.net` again.

Windowed of Battle.net

Battle.net may be run in windowed mode, a titlebar and frame is added by KWin. It will make the Battle.net running in a wrong resolution, and the play button won't be shown. We can add a KWin Window Rule to force No titlebar and frame and the minimum size of the window.

Controller

D2R works out of the box with mouse and keyboard. But if you want to play it with a controller, you should enable Steam Input. After the press any button screen is passed, D2R can be controlled by a controller. We can touch the screen, switch the controller to mouse mode, or map a button of the controller to send a input event using keyd(There is no extra key in OneXPlayer 2 Pro controller. The keyboard mode switch key is hardware controlled, and the system can't read that key event.) to pass the press any button screen. It looks like D2R will show press A button screen instead of press any button screen if we don't attach any mouse. We can use a controller to pass that screen.

Enable Steam Input

Steam Input uses uinput to handle the event from controllers. By default, the user running steam doesn't have enough permission. We need to install game-devices-udev` to change the permission of the device.

In Arch Linux, there is an AUR package, install it by running: paru -S game-devices-udev, change paru to the aur helper you are using.

Cursor in Inventory UI with Controller

In the inventory ui, the cursor might be not moved correctly. The position of the cursor is moved, but the cursor surface is not. We can run D2R with gamescope to solve this problem.

  1. Install gamescope. Because my steam is installed by flatpak, so I should install gamescope by flatpak too: flatpak --user install org.freedesktop.Platform.VulkanLayer.gamescope, choose the same version of org.freedesktop.Platform needed by steam.
  2. Add the bin folder of gamescope to PATH of steam in flatpak settings, the bin folder is /usr/lib/extensions/vulkan/gamescope/bin.
  3. Set the launch option
# Ghost editor replace -- with –
gamescope --hide-cursor-delay -1 -- %command%

The cursor surface might not be shown at first, we can move the mouse to make it appear.

Gamescope Error Popups

Launching D2R, there are two gamescope error popup windows: CreateSwapchainKHR and QueuePresentKHR. We can just click OK to pass them. Don't click Cancel, otherwise, D2R will display a black screen. When I disable WSI layer with ENABLE_GAMESCOPE_WSI=0, those popups are gone. The launch option becomes: ENABLE_GAMESCOPE_WSI=0 gamescope --hide-cursor-delay -1 – %command%. When WSI layer is disabled, the cursor in inventory ui becomes laggy.

Unsolved

Steam Freeze

This is an issue of steam. If steam lost-focus(may) or the screen is locked(must), steam will freeze. the game might lose control or be frozen. I'm not sure whether disabling Steam Input will prevent Steam freeze from affecting the game. I'm also not looking into the lost-focus issue — it seems to be related to the KWin compositor's behavior during the game, such as when pressing Alt+Tab.

It looks like even I don't open steam, D2R may freeze too. Not sure if `Battle.net` launcher is the cause in this case.

What a heartbeat moment — playing D2R Hardcore mode and experiencing an unexpected freeze!

Running D2R without Opening Steam

I think I can run D2R directly to avoid the steam freeze problem. I used pstree -plas $PID to find out the parent process running Battle.net, and used cat /proc/$PID/environ | tr '\0' '\n' to print all its environment variables. I kept almost all variables starting with STEAM. After that, I can run D2R with Proton directly.

Controller Mapping

I found that the controller works out of box opening outside of steam. It looks like steam disables my controller using a SDL environment variable. I am a Nintendo controller guy. Using Xbox controller layout is bit inconvenient for me. Thanks for SDL, I can use a Nintendo controller layout. Here is my controller mapping edited via AntiMicroX:

SDL_GAMECONTROLLERCONFIG="030081b85e0400008e020000080100001118654,Atari Xbox 360 Game Controller,platform:Linux,a:b1,b:b0,x:b3,y:b2,back:b6,start:b7,guide:b8,leftshoulder:b4,rightshoulder:b5,leftstick:b9,rightstick:b10,leftx:a0,lefty:a1,rightx:a3,righty:a4,lefttrigger:a2,righttrigger:a5,dpup:h0.1,dpleft:h0.8,dpdown:h0.4,dpright:h0.2,"

Gamescope

To my surprise, if I run D2R with gamescope nothing will show but the Battle.net is actual running. Comparing the processes between steam-run and direct-run, I found the proton process executed by gamescope will create a child process and exit in direct-run. So, gamescope thinks there is no application running. I finally wrote a script to get the flatpak instance id with (--instance-id-fd) and monitor that instance id with (flatpak ps) to keep the process running.

Then comes the second surprise. Unlike steam-run, all windows (Battle.net launcher and D2R) are running inside a window. Actually, the direct-run behavior is what I would expect based on my understanding. It is what a nested compositor should do. But the scale of the cursor is wrong and I must manually set the resolution. I really want the steam-run behavior. Comparing the environment variables, I found Display is set to :0 in steam-run and Display is set to :1. So, all windows of steam-run is drawing directly by Kwin. But why does the cursor in the inventory ui affects?

During the search, I found that gamescope actually doesn't work with the builtin Proton. I should install com.valvesoftware.Steam.CompatibilityTool.Proton-GE. After that, all windows of steam-run will run inside the window of gamescope. And those error popups disappear. But the cursor in the inventory ui doesn't move. After adding `--force-grab-cursor`, the cursor moves but it twinkles.

Scripts

Here are my scripts:

  • run-steam-battlenet
#! /usr/bin/env bash

set_envs() {
    set -a
    STEAM_BASE_FOLDER=$HOME/.var/app/com.valvesoftware.Steam/.local/share/Steam
    STEAM_CLIENT_CONFIG_FILE=$HOME/.var/app/com.valvesoftware.Steam/.local/share/steam.cfg
    STEAM_COMPAT_APP_ID=0
    STEAM_COMPAT_CLIENT_INSTALL_PATH=$HOME/.var/app/com.valvesoftware.Steam/.local/share/Steam
    STEAM_COMPAT_DATA_PATH=$HOME/.var/app/com.valvesoftware.Steam/.local/share/Steam/steamapps/compatdata/$EX_STEAM_GAME_ID
    STEAM_COMPAT_LIBRARY_PATHS=$HOME/.var/app/com.valvesoftware.Steam/.local/share/Steam/steamapps
    STEAM_COMPAT_MEDIA_PATH=$HOME/.var/app/com.valvesoftware.Steam/.local/share/Steam/steamapps/shadercache/$EX_STEAM_GAME_ID/fozmediav1
    STEAM_COMPAT_MOUNTS="$HOME/.var/app/com.valvesoftware.Steam/.local/share/Steam/steamapps/common/Proton - Experimental":$HOME/.var/app/com.valvesoftware.Steam/.local/share/Steam/steamapps/common/SteamLinuxRuntime_sniper
    STEAM_COMPAT_PROTON=1
    STEAM_COMPAT_TOOL_PATHS="$HOME/.var/app/com.valvesoftware.Steam/.local/share/Steam/steamapps/common/Proton - Experimental":$HOME/.var/app/com.valvesoftware.Steam/.local/share/Steam/steamapps/common/SteamLinuxRuntime_sniper
    STEAM_COMPAT_TRANSCODED_MEDIA_PATH=$HOME/.var/app/com.valvesoftware.Steam/.local/share/Steam/steamapps/shadercache/$EX_STEAM_GAME_ID
    STEAM_EXTRA_COMPAT_TOOLS_PATHS=/app/share/steam/compatibilitytools.d:/app/utils/share/steam/compatibilitytools.d
    STEAM_FOSSILIZE_DUMP_PATH=$HOME/.var/app/com.valvesoftware.Steam/.local/share/Steam/steamapps/shadercache/$EX_STEAM_GAME_ID/fozpipelinesv6/steamapprun_pipeline_cache
    STEAM_RUNTIME_LIBRARY_PATH=$HOME/.var/app/com.valvesoftware.Steam/.local/share/Steam/ubuntu12_32/steam-runtime/pinned_libs_32:$HOME/.var/app/com.valvesoftware.Steam/.local/share/Steam/ubuntu12_32/steam-runtime/pinned_libs_64:/app/lib/i386-linux-gnu/GL/default/lib:/app/lib32:/app/lib/i386-linux-gnu:/lib64:/app/lib:/usr/lib/x86_64-linux-gnu/GL/default/lib:/usr/lib/x86_64-linux-gnu/openh264/extra:/usr/lib/x86_64-linux-gnu:$HOME/.var/app/com.valvesoftware.Steam/.local/share/Steam/ubuntu12_32/steam-runtime/lib/i386-linux-gnu:$HOME/.var/app/com.valvesoftware.Steam/.local/share/Steam/ubuntu12_32/steam-runtime/usr/lib/i386-linux-gnu:$HOME/.var/app/com.valvesoftware.Steam/.local/share/Steam/ubuntu12_32/steam-runtime/lib/x86_64-linux-gnu:$HOME/.var/app/com.valvesoftware.Steam/.local/share/Steam/ubuntu12_32/steam-runtime/usr/lib/x86_64-linux-gnu:$HOME/.var/app/com.valvesoftware.Steam/.local/share/Steam/ubuntu12_32/steam-runtime/lib:$HOME/.var/app/com.valvesoftware.Steam/.local/share/Steam/ubuntu12_32/steam-runtime/usr/lib
    STEAM_ZENITY=/usr/bin/zenity
    DISPLAY=${DISPLAY_BEFORE_GAMESCOPE:-$DISPLAY}
    PROTON_ENABLE_WAYLAND=1
    set +a
}

set_controller_mapping() {
    set -a
    SDL_GAMECONTROLLERCONFIG="030081b85e0400008e020000080100001118654,Atari Xbox 360 Game Controller,platform:Linux,a:b1,b:b0,x:b3,y:b2,back:b6,start:b7,guide:b8,leftshoulder:b4,rightshoulder:b5,leftstick:b9,rightstick:b10,leftx:a0,lefty:a1,rightx:a3,righty:a4,lefttrigger:a2,righttrigger:a5,dpup:h0.1,dpleft:h0.8,dpdown:h0.4,dpright:h0.2,"
    set +a
}

start_bottle_net() {
    # Create a temp file for storing the instance id
    tmpfile_instance_id=$(mktemp /tmp/run-steam-battlenet-instance-id.XXXXXX)
    echo "Steam instance id will be stored at $tmpfile_instance_id"

    exec 3>"$tmpfile_instance_id"

    /usr/bin/flatpak run --instance-id-fd=3 --branch=stable --arch=x86_64 --command="$HOME/.var/app/com.valvesoftware.Steam/.local/share/Steam/steamapps/common/Proton - Experimental/proton" com.valvesoftware.Steam waitforexitandrun "$HOME/.var/app/com.valvesoftware.Steam/.local/share/Steam/steamapps/compatdata/$EX_STEAM_GAME_ID/pfx/drive_c/Program Files (x86)/Battle.net/Battle.net Launcher.exe"

    exec 3>&-
    set +e

    read instance_id < "$tmpfile_instance_id"
    echo "Running as Battle.net as Steam Instance[$instance_id]"

    while flatpak ps --columns=instance | grep -qx "$instance_id"; do
        sleep 1
    done
}

delete_file() {
    if [ -n "$1" ]
    then
        if [ -f "$1" ]
        then
            rm "$1"
        fi
    fi
}

clean_up() {
    delete_file "$tmpfile_instance_id"
}

EX_STEAM_GAME_ID=3494142050

set -e
tmpfile_instance_id=""
trap 'clean_up' EXIT

set_envs
set_controller_mapping
start_bottle_net
  • run-steam-battlenet-with-gamescope. Since kwin 6.5.2, we can run D2R under Wayland. The cursor works almost out of box in the inventory ui. It disappears if we move the cursor too fast. In kwin 6.5.1, the cursor won't disappear after we close the inventory ui.
#! /usr/bin/env bash

export DISPLAY_BEFORE_GAMESCOPE="$DISPLAY"

/usr/bin/gamescope --hide-cursor-delay=-1 -- "$(dirname $0)/run-steam-battlenet"

Maybe Perfect Solution (20260129)

During the Christmas sale, I bought a second copy of D2R. However, when two instances of D2R are running at the same time, the cursor stops working properly.

Unlike the cursor controlled by a mouse, which is drawn directly by the Wayland compositor, the cursor controlled by a game controller is rendered by the game itself via the Wayland protocol. My guess is that when two applications call the relevant API simultaneously, they interfere with each other, causing the cursor to flicker or malfunction.

Based on this assumption, a possible solution would be to run each game instance in its own standalone compositor. Yes, gamescope is back again.

Run A Game with gamescope XWayland

In the last testing, we replace the gamescope XWayland socket with the one created by KWin. This also causes the "Error Popups". But this time, I should run the game inside the one created by gamescope. The cursor's behavior isn't perfect.

What if I run gamescope inside flatpak. In the previous testing, the gamescope running inside flatpak doesn't work. Proton exits without waiting the game to start. It makes gamescope think that there is no application. This also happens in the gamescope outside flatpak, and I created a script to wait the game to exit. Why is proton run by steam different? After reading the source code of proton, I need to set SteamGameId to output logs. Right after that, the game suddenly shows, and the cursor behaves more better.

Controller Events Effect Even in a Unfocused Window

I opened two instances of D2R. To my surprise, both characters move when I move the stick. I found this issue. The game would be unfocused inside gamescope XWayland, and it will handle controller events.

I remember that steam uses SDL_ environment variables to deal with controller. After some research, I found SDL_GAMECONTROLLER_IGNORE_DEVICES_EXCEPT. I can use this to make the game to handle controller events from specified controllers only. What if I use uinput to create different controllers for different games? Finally, I created Non-Steam Launcher.

Here are my script:

#! /usr/bin/env bash

set_envs() {
    set -a
    # SteamGameId is needed otherwise proton will open nothing
    SteamGameId=$EX_STEAM_GAME_ID
    STEAM_BASE_FOLDER=$HOME/.var/app/com.valvesoftware.Steam/.local/share/Steam
    STEAM_CLIENT_CONFIG_FILE=$HOME/.var/app/com.valvesoftware.Steam/.local/share/steam.cfg
    STEAM_COMPAT_APP_ID=0
    STEAM_COMPAT_CLIENT_INSTALL_PATH=$HOME/.var/app/com.valvesoftware.Steam/.local/share/Steam
    STEAM_COMPAT_DATA_PATH=$HOME/.var/app/com.valvesoftware.Steam/.local/share/Steam/steamapps/compatdata/$EX_STEAM_GAME_ID
    STEAM_COMPAT_LIBRARY_PATHS=$HOME/.var/app/com.valvesoftware.Steam/.local/share/Steam/steamapps
    STEAM_COMPAT_MEDIA_PATH=$HOME/.var/app/com.valvesoftware.Steam/.local/share/Steam/steamapps/shadercache/$EX_STEAM_GAME_ID/fozmediav1
    STEAM_COMPAT_MOUNTS="$HOME/.var/app/com.valvesoftware.Steam/.local/share/Steam/steamapps/common/Proton - Experimental":$HOME/.var/app/com.valvesoftware.Steam/.local/share/Steam/steamapps/common/SteamLinuxRuntime_sniper
    STEAM_COMPAT_PROTON=1
    STEAM_COMPAT_TOOL_PATHS="$HOME/.var/app/com.valvesoftware.Steam/.local/share/Steam/steamapps/common/Proton - Experimental":$HOME/.var/app/com.valvesoftware.Steam/.local/share/Steam/steamapps/common/SteamLinuxRuntime_sniper
    STEAM_COMPAT_TRANSCODED_MEDIA_PATH=$HOME/.var/app/com.valvesoftware.Steam/.local/share/Steam/steamapps/shadercache/$EX_STEAM_GAME_ID
    STEAM_EXTRA_COMPAT_TOOLS_PATHS=/app/share/steam/compatibilitytools.d:/app/utils/share/steam/compatibilitytools.d
    STEAM_FOSSILIZE_DUMP_PATH=$HOME/.var/app/com.valvesoftware.Steam/.local/share/Steam/steamapps/shadercache/$EX_STEAM_GAME_ID/fozpipelinesv6/steamapprun_pipeline_cache
    STEAM_RUNTIME_LIBRARY_PATH=$HOME/.var/app/com.valvesoftware.Steam/.local/share/Steam/ubuntu12_32/steam-runtime/pinned_libs_32:$HOME/.var/app/com.valvesoftware.Steam/.local/share/Steam/ubuntu12_32/steam-runtime/pinned_libs_64:/app/lib/i386-linux-gnu/GL/default/lib:/app/lib32:/app/lib/i386-linux-gnu:/lib64:/app/lib:/usr/lib/x86_64-linux-gnu/GL/default/lib:/usr/lib/x86_64-linux-gnu/openh264/extra:/usr/lib/x86_64-linux-gnu:$HOME/.var/app/com.valvesoftware.Steam/.local/share/Steam/ubuntu12_32/steam-runtime/lib/i386-linux-gnu:$HOME/.var/app/com.valvesoftware.Steam/.local/share/Steam/ubuntu12_32/steam-runtime/usr/lib/i386-linux-gnu:$HOME/.var/app/com.valvesoftware.Steam/.local/share/Steam/ubuntu12_32/steam-runtime/lib/x86_64-linux-gnu:$HOME/.var/app/com.valvesoftware.Steam/.local/share/Steam/ubuntu12_32/steam-runtime/usr/lib/x86_64-linux-gnu:$HOME/.var/app/com.valvesoftware.Steam/.local/share/Steam/ubuntu12_32/steam-runtime/lib:$HOME/.var/app/com.valvesoftware.Steam/.local/share/Steam/ubuntu12_32/steam-runtime/usr/lib
    STEAM_ZENITY=/usr/bin/zenity
    #PROTON_LOG=1
    #PROTON_LOG_DIR=/home/fortime/ws/game
    #PROTON_ENABLE_WAYLAND=1
    set +a
}

set_controller_mapping() {
    local guid vendor_product
    guid="$1"
    vendor_product="$2"

    set -a
    # it isn't guid, it accepts vid/pid pair
    SDL_GAMECONTROLLER_IGNORE_DEVICES_EXCEPT="${vendor_product}"
    SDL_GAMECONTROLLERCONFIG="
${guid},Microsoft Xbox 360,platform:Linux,a:b1,b:b0,x:b3,y:b2,back:b6,start:b7,guide:b8,leftshoulder:b4,rightshoulder:b5,leftstick:b9,rightstick:b10,leftx:a0,lefty:a1,rightx:a3,righty:a4,lefttrigger:a2,righttrigger:a5,dpup:h0.1,dpleft:h0.8,dpdown:h0.4,dpright:h0.2,
"
    set +a
}

start_bottle_net() {
    /usr/bin/flatpak run --branch=stable --arch=x86_64 --command="/usr/lib/extensions/vulkan/gamescope/bin/gamescope" com.valvesoftware.Steam --backend wayland --force-grab-cursor -b -W 2560 -H 1520 -s 1.5 -- "/app/share/steam/compatibilitytools.d/Proton-GE/proton" waitforexitandrun "$HOME/.var/app/com.valvesoftware.Steam/.local/share/Steam/steamapps/compatdata/$EX_STEAM_GAME_ID/pfx/drive_c/Program Files (x86)/Battle.net/Battle.net Launcher.exe"
}

if [ "$EX_STEAM_INSIDE_INHIBIT" != "yes" ]
then
    export EX_STEAM_INSIDE_INHIBIT=yes
    systemd-inhibit --what=idle:sleep --who="Battle.net" --why="Playing games" $0 "$@"
else
    EX_STEAM_GAME_ID=${1}

    set -e
    set_envs

    new_controller_resp=$(busctl --user --json=pretty call fyi.fortime.NonSteamLauncher /fyi/fortime/NonSteamLauncher/Controller fyi.fortime.NonSteamLauncher.Controller1 NewController)
    controller_slot=$(echo $new_controller_resp | jq -r '.data[0]')
    controller_guid=$(echo $new_controller_resp | jq -r '.data[1]')
    controller_vendor_product=$(echo $new_controller_resp | jq -r '.data[2]')
    set_controller_mapping "$controller_guid" "$controller_vendor_product"

    # Use a default config in case of the log level in the user config is debug
    old_manual_mode=$(FCITX5_OSK_CONFIG=/tmp/null fcitx5-osk get-property manual_mode)
    fcitx5-osk set-property manual_mode true
    set +e

    # Not exit even it is failed, so the controller is always removed
    start_bottle_net

    # remove controller
    busctl --user call fyi.fortime.NonSteamLauncher /fyi/fortime/NonSteamLauncher/Controller fyi.fortime.NonSteamLauncher.Controller1 RemoveController y $controller_slot

    fcitx5-osk set-property manual_mode $old_manual_mode

fi

It will create a virtual controller via dbus api and set SDL_GAMECONTROLLER_IGNORE_DEVICES_EXCEPT.

TODO Gamescope doesn't Support Wayland Text Input Protocol