r/MoonlightStreaming 17h ago

Virtual monitor for streaming/sunshine and moonlight

Dynamic Virtual Monitor for Sunshine/Moonlight on KDE Wayland — KRFB + Any Resolution + Dynamic Refresh Rate

I originally made a guide for using a KDE KRFB virtual monitor with Sunshine/Moonlight at 1920×1080 @ 120 Hz.

Since then, I changed the setup significantly.

The new version is fully dynamic:

  • No dummy HDMI/DisplayPort plug
  • Physical monitor can remain enabled
  • No hard-coded resolution
  • Resolution comes from the Sunshine/Moonlight client request
  • Automatically creates the requested virtual monitor resolution
  • Automatically creates missing refresh-rate modes
  • Supports 60/90/120/240 Hz
  • Works with resolutions such as 1280×800, 1920×1080, 1920×1200, 2560×1440 and 3840×2160
  • Automatically positions the virtual monitor beside the physical displays
  • No need to manually fix the display position in KDE Display Settings every time

The important part is that KRFB creates the virtual display and KScreen controls its resolution, refresh rate and position.

What the setup does

The basic flow is:

Moonlight
    │
    ▼
Sunshine
    │
    │ client requests resolution/FPS
    ▼
sunshine-vm-dynamic.sh
    │
    ├── creates KRFB virtual monitor
    │
    ├── detects the virtual KScreen output
    │
    ├── positions it automatically
    │
    ├── checks for requested refresh rate
    │
    ├── creates custom mode if necessary
    │
    └── activates requested mode
    │
    ▼
Virtual-sunshine-vm
    │
    ▼
Sunshine
    │
    ▼
Moonlight

The physical monitor does not need to be disabled.

Requirements

This guide is intended for:

  • KDE Plasma
  • Wayland
  • KRFB
  • KScreen / kscreen-doctor
  • Sunshine
  • Moonlight

On Arch/Arch-based systems, install KRFB:

sudo pacman -S krfb

Check that the virtual-monitor executable exists:

krfb-virtualmonitor --help

Also check:

kscreen-doctor --help

You should see the addCustomMode functionality.

STEP 1 — Create the script directory

mkdir -p ~/.local/bin

STEP 2 — Create the dynamic virtual-monitor script

Create:

nano ~/.local/bin/sunshine-vm-dynamic.sh

Paste the following:

#!/bin/bash

set -u

WIDTH="${1:-${SUNSHINE_CLIENT_WIDTH:-1920}}"
HEIGHT="${2:-${SUNSHINE_CLIENT_HEIGHT:-1080}}"
FPS="${3:-${SUNSHINE_CLIENT_FPS:-60}}"

OUTPUT="Virtual-sunshine-vm"
NAME="sunshine-vm"
PASSWORD="CHANGE_THIS_PASSWORD"
PORT="5905"

echo "Dynamic Desktop: ${WIDTH}x${HEIGHT}@${FPS}"

KSCREEN="/usr/bin/kscreen-doctor"

kscreen_output() {
    "$KSCREEN" -o 2>/dev/null |
        sed $'s/\033\\[[0-9;]*m//g'
}

# ------------------------------------------------------------
# Detect Wayland
# ------------------------------------------------------------

export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"

if [ -z "${WAYLAND_DISPLAY:-}" ]; then
    for socket in "$XDG_RUNTIME_DIR"/wayland-*; do
        [ -S "$socket" ] || continue
        WAYLAND_DISPLAY="$(basename "$socket")"
        export WAYLAND_DISPLAY
        break
    done
fi

if [ -z "${WAYLAND_DISPLAY:-}" ]; then
    echo "ERROR: Could not find a Wayland display."
    exit 1
fi

# ------------------------------------------------------------
# Remove any previous virtual monitor.
# ------------------------------------------------------------

pkill -f '/usr/bin/krfb-virtualmonitor' 2>/dev/null || true
sleep 2

# ------------------------------------------------------------
# Create the virtual monitor at the requested resolution.
# ------------------------------------------------------------

/usr/bin/krfb-virtualmonitor \
    --resolution "${WIDTH}x${HEIGHT}" \
    --name "$NAME" \
    --password "$PASSWORD" \
    --desktopfile org.kde.krfb.virtualmonitor \
    --scale 1 \
    --port "$PORT" &

# ------------------------------------------------------------
# Wait for KScreen to register the virtual monitor.
# ------------------------------------------------------------

FOUND=0

for i in $(seq 1 30); do
    if kscreen_output | grep -q "$OUTPUT"; then
        FOUND=1
        break
    fi

    sleep 0.5
done

if [ "$FOUND" -ne 1 ]; then
    echo "ERROR: Virtual monitor was not detected."
    exit 1
fi

# ------------------------------------------------------------
# Get the KScreen output number.
# ------------------------------------------------------------

OUTPUT_ID=$(
    kscreen_output |
    awk -v name="$OUTPUT" '
        $0 ~ name {
            print $2
            exit
        }
    '
)

if [ -z "$OUTPUT_ID" ]; then
    echo "ERROR: Could not determine output ID."
    exit 1
fi

echo "Virtual monitor output ID: ${OUTPUT_ID}"

# ------------------------------------------------------------
# Automatically position the virtual monitor.
#
# Find the rightmost physical display and place the virtual
# monitor immediately to its right.
#
# No physical resolution is hard-coded.
# ------------------------------------------------------------

PHYSICAL_RIGHT=0
PHYSICAL_Y=0

while read -r ID NAME; do

    if [ "$ID" = "$OUTPUT_ID" ]; then
        continue
    fi

    GEOMETRY=$(
        kscreen_output |
        awk -v id="$ID" '
            $0 ~ "^Output: " id " " {
                inside=1
                next
            }

            inside && /^Output:/ {
                exit
            }

            inside && /Geometry:/ {
                print $2, $3
                exit
            }
        '
    )

    if [ -z "$GEOMETRY" ]; then
        continue
    fi

    POSITION=${GEOMETRY%% *}
    SIZE=${GEOMETRY#* }

    X=${POSITION%,*}
    Y=${POSITION#*,}

    DISPLAY_WIDTH=${SIZE%x*}

    RIGHT=$((X + DISPLAY_WIDTH))

    if [ "$RIGHT" -gt "$PHYSICAL_RIGHT" ]; then
        PHYSICAL_RIGHT="$RIGHT"
        PHYSICAL_Y="$Y"
    fi

done < <(
    kscreen_output |
    awk '/^Output:/ {print $2, $3}'
)

echo "Positioning virtual monitor at ${PHYSICAL_RIGHT},${PHYSICAL_Y}"

if ! "$KSCREEN" \
    "output.${OUTPUT_ID}.position.${PHYSICAL_RIGHT},${PHYSICAL_Y}"; then

    echo "WARNING: Could not automatically position virtual monitor."
else
    echo "Virtual monitor positioned automatically."
fi

# ------------------------------------------------------------
# Find or create the requested refresh rate.
#
# KScreen/KRFB may report 120 Hz as something like 119.xx Hz.
# Therefore a small tolerance is used.
# ------------------------------------------------------------

MODE_ID=""

echo "Checking for ${WIDTH}x${HEIGHT}@${FPS} Hz..."

MODE_ID=$(
    kscreen_output |
    awk \
        -v name="$OUTPUT" \
        -v res="${WIDTH}x${HEIGHT}" \
        -v target="$FPS" '
        $0 ~ name {
            inside=1
            next
        }

        inside && /^Output:/ {
            exit
        }

        inside && /Modes:/ {
            best_id=""
            best_diff=999999

            for (i=1; i<=NF; i++) {

                token=$i

                if (token ~ /^[0-9]+:/ && token ~ res "@") {

                    id=token
                    sub(/:.*/, "", id)

                    mode=token
                    sub(/^[0-9]+:/, "", mode)

                    split(mode, p, "@")
                    rate=p[2] + 0

                    diff=rate-target

                    if (diff < 0)
                        diff=-diff

                    if (diff <= 2 && diff < best_diff) {
                        best_diff=diff
                        best_id=id
                    }
                }
            }

            if (best_id != "") {
                print best_id
                exit
            }
        }
    '
)

# ------------------------------------------------------------
# Requested mode does not exist.
# Create it as a custom mode.
# ------------------------------------------------------------

if [ -z "$MODE_ID" ]; then

    echo "No ${WIDTH}x${HEIGHT}@${FPS} mode found."
    echo "Adding custom ${WIDTH}x${HEIGHT}@${FPS} Hz mode..."

    if ! "$KSCREEN" \
        "output.${OUTPUT_ID}.addCustomMode.${WIDTH}.${HEIGHT}.${FPS}000.full"; then

        echo "ERROR: Failed to add ${WIDTH}x${HEIGHT}@${FPS} custom mode."
        exit 1
    fi

    sleep 1

    # Find the newly-created mode.
    MODE_ID=$(
        kscreen_output |
        awk \
            -v name="$OUTPUT" \
            -v res="${WIDTH}x${HEIGHT}" \
            -v target="$FPS" '
            $0 ~ name {
                inside=1
                next
            }

            inside && /^Output:/ {
                exit
            }

            inside && /Modes:/ {

                best_id=""
                best_diff=999999

                for (i=1; i<=NF; i++) {

                    token=$i

                    if (token ~ /^[0-9]+:/ && token ~ res "@") {

                        id=token
                        sub(/:.*/, "", id)

                        mode=token
                        sub(/^[0-9]+:/, "", mode)

                        split(mode, p, "@")
                        rate=p[2] + 0

                        diff=rate-target

                        if (diff < 0)
                            diff=-diff

                        if (diff < best_diff) {
                            best_diff=diff
                            best_id=id
                        }
                    }
                }

                if (best_id != "") {
                    print best_id
                    exit
                }
            }
        '
    )
fi

# ------------------------------------------------------------
# Verify that a mode was found.
# ------------------------------------------------------------

if [ -z "$MODE_ID" ]; then

    echo "ERROR: Could not find ${WIDTH}x${HEIGHT}@${FPS} mode."
    echo
    echo "Available virtual monitor modes:"
    kscreen_output | sed -n "/${OUTPUT}/,/^Output:/p"

    exit 1
fi

echo "Using KScreen mode ID: ${MODE_ID}"

# ------------------------------------------------------------
# Apply the mode.
# ------------------------------------------------------------

if ! "$KSCREEN" \
    "output.${OUTPUT_ID}.mode.${MODE_ID}"; then

    echo "ERROR: Failed to configure ${WIDTH}x${HEIGHT}@${FPS}."
    exit 1
fi

echo "Configured ${WIDTH}x${HEIGHT}@${FPS}"

exit 0

IMPORTANT

Change:

PASSWORD="CHANGE_THIS_PASSWORD"

to your own KRFB password.

Do not use the password from this Reddit post.

STEP 3 — Make the script executable

chmod +x ~/.local/bin/sunshine-vm-dynamic.sh

Check the script before running it:

bash -n ~/.local/bin/sunshine-vm-dynamic.sh

There should be no output.

STEP 4 — Test the virtual monitor

The script accepts:

WIDTH HEIGHT FPS

For example:

~/.local/bin/sunshine-vm-dynamic.sh 1920 1080 60

Then:

~/.local/bin/sunshine-vm-dynamic.sh 1920 1080 120

You can also test:

~/.local/bin/sunshine-vm-dynamic.sh 1280 800 90


~/.local/bin/sunshine-vm-dynamic.sh 1280 800 120


~/.local/bin/sunshine-vm-dynamic.sh 1920 1200 60


~/.local/bin/sunshine-vm-dynamic.sh 1920 1200 120


~/.local/bin/sunshine-vm-dynamic.sh 2560 1440 120


~/.local/bin/sunshine-vm-dynamic.sh 3840 2160 60


~/.local/bin/sunshine-vm-dynamic.sh 3840 2160 120

And even:

~/.local/bin/sunshine-vm-dynamic.sh 1920 1080 240

If the requested refresh rate doesn't already exist, the script uses:

kscreen-doctor output.<ID>.addCustomMode.<width>.<height>.<refresh>

For example, 240 Hz becomes:

240000 mHz

The important part is that the script doesn't assume that 120 Hz is the maximum.

STEP 5 — Verify the virtual monitor

Run:

kscreen-doctor -o

You should see something similar to:

Output: 1 Virtual-sunshine-vm
        enabled
        connected
        Modes:
            1:1920x1080@60.00
            2:1920x1080@119.93
            3:1920x1080@239.XX

The exact mode numbers and refresh-rate values will vary.

For example, KDE may report:

119.93

instead of:

120

That is normal.

Likewise, a requested 90 Hz mode may appear as:

89.89

The script intentionally allows a small refresh-rate difference when selecting a mode.

STEP 6 — Sunshine configuration

The important difference from my original guide:

There is no "Force Capture Method" step in this setup.

Do not look for a "Force Capture" option and don't add one just because an older version of this guide mentioned it.

The dynamic script is responsible for creating and configuring the virtual monitor.

Configure the script as the Sunshine preparation command used when a client connects.

The script already understands Sunshine's client environment variables:

SUNSHINE_CLIENT_WIDTH
SUNSHINE_CLIENT_HEIGHT
SUNSHINE_CLIENT_FPS

Therefore, when Moonlight requests a particular resolution/FPS, Sunshine can pass that information to the script.

For example, a client request can result in:

SUNSHINE_CLIENT_WIDTH=2560
SUNSHINE_CLIENT_HEIGHT=1440
SUNSHINE_CLIENT_FPS=120

and the script effectively performs:

2560x1440@120

without you hard-coding 2560×1440 into the script.

STEP 7 — Why this is better than the old version

The old setup was essentially:

1920x1080
      +
120 Hz

Everything was hard-coded.

The new setup is:

Moonlight request
       │
       ▼
Requested width
Requested height
Requested FPS
       │
       ▼
Dynamic script
       │
       ├── KRFB resolution
       ├── KScreen mode detection
       ├── custom mode creation
       └── automatic positioning

So the same script can handle:

1280x800 @ 60
1280x800 @ 90
1280x800 @ 120

1920x1080 @ 60
1920x1080 @ 90
1920x1080 @ 120
1920x1080 @ 240

1920x1200 @ 60
1920x1200 @ 120

2560x1440 @ 60
2560x1440 @ 120

3840x2160 @ 60
3840x2160 @ 120

You don't need a separate script for each resolution.

STEP 8 — Automatic monitor positioning

One problem with the earlier version was that after creating the virtual monitor, part of the display could overlap the physical monitor.

The new script fixes this automatically.

It examines the current KScreen geometry:

Geometry: X,Y WIDTHxHEIGHT

It finds the rightmost physical display and calculates its right edge.

Then it places the virtual monitor there:

physical monitor
        │
        │
        ▼
┌───────────────────┐ ┌───────────────────┐
│                   │ │                   │
│ Physical monitor  │ │ Virtual monitor   │
│                   │ │                   │
└───────────────────┘ └───────────────────┘

There is no hard-coded physical resolution in this calculation.

This is important for systems with different monitor layouts.

STEP 9 — Start it automatically

Once manual testing works, the script can be connected to your Sunshine startup/client preparation workflow.

The important part is that the script should be executed as the user running the KDE Wayland session.

It needs access to:

XDG_RUNTIME_DIR
WAYLAND_DISPLAY
KScreen
KWin
KRFB

Do not run the virtual-monitor configuration as a normal system service without access to the user's Wayland session.

STEP 10 — Check the logs

If something doesn't work, first run:

kscreen-doctor -o

Then run the script manually:

~/.local/bin/sunshine-vm-dynamic.sh 1920 1080 120

The output is very useful.

For example:

Dynamic Desktop: 1920x1080@120
Virtual monitor output ID: 1
Positioning virtual monitor at 5405,0
Virtual monitor positioned automatically.
Checking for 1920x1080@120 Hz...
Using KScreen mode ID: 2
Configured 1920x1080@120

If a mode doesn't exist:

Checking for 1920x1080@240 Hz...
No 1920x1080@240 mode found.
Adding custom 1920x1080@240 Hz mode...
Using KScreen mode ID: 10
Configured 1920x1080@240

That means the script successfully created the missing mode.

STEP 11 — Test with Moonlight

Open Moonlight on your client.

Connect to your Sunshine host and start the desktop.

Try different resolutions and refresh rates.

For example:

1920×1080 @ 60
1920×1080 @ 120
2560×1440 @ 120
3840×2160 @ 60

If your client exposes 90 Hz or 240 Hz:

1280×800 @ 90
1920×1080 @ 240

can also be tested.

The virtual monitor should automatically change to the requested configuration.

Troubleshooting

Virtual monitor isn't created

Check:

krfb-virtualmonitor --help

and:

kscreen-doctor -o

Make sure you are running KDE Wayland.

The requested mode doesn't exist

Run:

kscreen-doctor -o

The script should automatically create a custom mode when necessary.

For example:

No 1920x1080@240 mode found.
Adding custom 1920x1080@240 Hz mode...

is expected.

KDE reports 119.xx instead of 120

This is normal.

For example:

119.93 Hz

is the mode corresponding to the requested 120 Hz refresh rate on this setup.

The script accounts for this small difference.

KDE reports 89.xx instead of 90

Also normal.

For example:

89.89 Hz

can be the actual reported mode for a requested 90 Hz mode.

Displays overlap

The current script automatically calculates the position of the virtual display.

Run:

kscreen-doctor -o

and look for:

Geometry:

The script uses the physical display geometry rather than assuming a particular resolution.

Sunshine cannot see the virtual display

First check:

kscreen-doctor -o

You should see:

Virtual-sunshine-vm

Then make sure Sunshine is running inside the same KDE Wayland user session.

Final result

The finished setup looks like this:

                 KDE Plasma / Wayland
                         │
                         ▼
                 KRFB Virtual Monitor
                         │
                         ▼
                 Virtual-sunshine-vm
                         │
              ┌──────────┴──────────┐
              │                     │
        Dynamic resolution     Dynamic refresh
              │                     │
        1280×800              60 / 90 / 120
        1920×1080                  240
        1920×1200
        2560×1440
        3840×2160
              │                     │
              └──────────┬──────────┘
                         ▼
                      Sunshine
                         │
                         ▼
                      Moonlight

Notes

This setup is specifically for KDE Plasma Wayland using KRFB's virtual-monitor functionality. KRFB creates the compositor-level virtual output, while KScreen controls its modes and geometry

If you are using a different desktop environment, X11 instead of Wayland, or a different virtual-display implementation, the commands in this guide may not apply.

These steps written with the help of chatgpt because I can't find anything related to virtual monitor on Linux and I tried alot of steps didn't work out well and then I found this post

https://discuss.kde.org/t/how-to-create-a-virtual-monitor-display/2725/13

And there is alot good suggestion provided by other users and after alot of trial and error I manage to start sunshine using virtual monitor instead of physical monitor and without using any physical display port I.e., when using edid method hope it will help someone

5 Upvotes

7 comments sorted by

3

u/hollywood__kills 17h ago

2

u/Appropriate-Ad8630 15h ago

Performance on AMD cards on 4k is not as great as Sunshine + HDMI Dummy. Iḿ actively helping the dev testing on my 9070 XT, but performance is not there yet.

1

u/MickeyBronson Developer | Polaris/Nova 13h ago

first off thanks to you the app is getting closer and closer to AMD optimization so really appreciate the patience as well as the contribution. AMD is my blind spot as you know since I don't have the hardware. But good news is that the 1.4.0 release addresses a lot of those pain points, although i'm still trying to figure out the wlroots virtual output capture. hopefully will get this pushed out today!

1

u/Zealousideal-Rice663 16h ago

I didn't use that I always used edid option for virtual monitor and and found out recently someone posted there that you can create virtual monitor like that so made this tutorial because I used moonlight just not for gaming and I didn't even know these options exist and I share this if anyone want this type of a setup

1

u/MickeyBronson Developer | Polaris/Nova 12h ago

all good! half the reason polaris exists is that none of this stuff is discoverable. so solid guide btw, the addCustomMode + refresh tolerance part is where everyone gets stuck, so i'm sure a lot of KDE users will appreciate your post. if you ever want the lazy version, polaris does the resolution and fps matching automatically (and handles desktop use too), but your setup already works so no pressure! let me know if you have any questions about it though!

1

u/mevouc 9h ago

Why not using the forks like Artemis/Apollo for virtual display?

1

u/C43d34 46m ago

Great guide. Good information here.
From my own testing with krfb and kscreen-doctor virtual display shenanigans, kscreen-doctor works just fine parsing custom modes with the ~0.03 random offset FPS values.
Instead of applying the mode with
kscreen-doctor output.${OUTPUT_ID}.mode.${MODE_ID}
I apply it with
kscreen-doctor output.${OUTPUT_ID}.mode.${WIDTH}x${HEIGHT}@${FPS}

Assuming you just added the given mode you can be certain those categories satisfy. Hopefully you can simplify your script much more, but please let me know if I'm seeing it wrong.
Cheers.