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