Batocera Vrr With A Rotated Portrait Monitor

[ ]

I use a monitor mounted physically in portrait orientation, 90 degrees clockwise, for playing vertical shooters using Batocera and various other systems. Batocera’s built-in display rotation option supports this setup, in that it can display EmulationStation and games correctly, but it breaks FreeSync/VRR meaning that it is not possible to run games at their original framerate, where this isn’t the default 60fps.

When attempting DoDonPachi (should run at approx 57 FPS) using this setup, the RetroArch FPS counter reports the desired 57 but the monitor’s built in counter reports a consistent 60.

Batocera uses Xorg rotation and it turns out this does not work with AMD VRR. The AMD Xorg driver documentation describes (at least) two ways of sending frames to the display, Present and TearFree. VRR does not work with TearFree and TearFree is always used when the screen is rotated.

The solution that worked was to leave Batocera’s normal Xorg rotation in place for EmulationStation, then use a Batocera hook to remove that rotation when a libretro game starts. RetroArch then rotates its own output internally. When the game exits, the hook restores the original Xorg rotation so the frontend is upright again.

Why the simpler options did not work

Leaving Xorg rotated during gameplay kept the picture the right way up, but VRR did not engage properly.

EmulationStation also supports --screenrotate 1, but on my Batocera build that only rotated the rendered image without correctly swapping the logical width and height. The result was black bars, cut-off UI, and incorrect window placement. Adding --resolution 1080 1920 did not fix it.

I also confirmed that editing /userdata/system/configs/retroarch/retroarchcustom.cfg directly is not the right answer. Batocera regenerates that file when launching a game. The persistent setting needs to go through Batocera’s own config system instead.

Prerequisites

Enable FreeSync or Adaptive Sync in the monitor’s own settings.

In Batocera, enable Variable Refresh Rate in the system settings.

Confirm that Xorg sees the monitor as VRR-capable:

DISPLAY=:0 xrandr --props | grep -E ' connected|vrr_capable'

On the working machine, the relevant output looked like:

DP-1 connected primary ...
    vrr_capable: 1

Confirm that the AMD Xorg configuration enables variable refresh:

grep -Ri VariableRefresh /etc/X11

Expected result:

/etc/X11/xorg.conf.d/20-amdgpu.conf: Option "VariableRefresh" "true"

The Xorg log should also confirm that the option was accepted:

grep -i VariableRefresh /var/log/Xorg.0.log

Expected output:

VariableRefresh: enabled

Steps to replicate

Step 1: Configure the normal EmulationStation rotation

Set Batocera’s display rotation to 90 degrees clockwise:

batocera-settings-set display.rotate 1

Remove any experimental EmulationStation custom arguments:

batocera-settings-set es.customsargs ""

Reboot:

reboot

After reboot, EmulationStation should appear upright and correctly fill the portrait display.

Step 2: Configure RetroArch’s internal rotation

When Xorg is changed back to normal orientation during gameplay, RetroArch needs to rotate its output internally.

Set RetroArch to rotate by 270 degrees:

batocera-settings-set global.retroarch.video_rotation 3

The values are:

0 = no rotation
1 = 90 degrees
2 = 180 degrees
3 = 270 degrees

On this setup, 1 produced an upside-down portrait image. 3 produced the correct orientation.

Step 3: Install the Batocera game hook

Create the scripts directory:

mkdir -p /userdata/system/scripts

Create /userdata/system/scripts/rotate-vrr.sh with the following contents:

#!/bin/bash

OUTPUT="DP-1"
TOKEN_FILE="/userdata/system/rotate-vrr.token"
LOG_FILE="/userdata/system/logs/rotate-vrr.log"

export DISPLAY=:0

log() {
    echo "$(date '+%F %T') $*" >> "$LOG_FILE"
}

get_display_state() {
    xrandr 2>/dev/null | grep "^${OUTPUT} "
}

is_right() {
    local state

    state="$(get_display_state)"

    case "$state" in
        *"1080x1920+0+0 right "*)
            return 0
            ;;
    esac

    return 1
}

is_normal() {
    local state

    state="$(get_display_state)"

    case "$state" in
        *"1920x1080+0+0 (normal "*)
            return 0
            ;;
    esac

    return 1
}

retroarch_running() {
    pgrep -x retroarch >/dev/null
}

token_is_current() {
    [ "$(cat "$TOKEN_FILE" 2>/dev/null)" = "$1" ]
}

make_token() {
    echo "$1-$$-$(date +%s)"
}

rotate_normal() {
    xrandr --output "$OUTPUT" --rotate normal
}

rotate_right() {
    xrandr --output "$OUTPUT" --rotate right
}

watch_game_start() {
    local token="$1"
    local stable=0
    local attempts=0

    log "Launch watcher started: $token"

    while [ "$attempts" -lt 300 ]; do
        token_is_current "$token" || {
            log "Launch watcher superseded: $token"
            return
        }

        if retroarch_running && is_right; then
            stable=$((stable + 1))
        elif is_normal; then
            log "Launch display already normal"
            stable=0
            break
        else
            stable=0
        fi

        if [ "$stable" -ge 3 ]; then
            log "Launch state ready; rotating normal"
            rotate_normal
            stable=0
            break
        fi

        attempts=$((attempts + 1))
        sleep 0.1
    done

    if [ "$attempts" -ge 300 ]; then
        log "Launch watcher timed out"
        return
    fi

    attempts=0

    while [ "$attempts" -lt 100 ]; do
        token_is_current "$token" || {
            log "Launch verification superseded: $token"
            return
        }

        retroarch_running || {
            log "RetroArch stopped during launch verification"
            return
        }

        if is_normal; then
            stable=$((stable + 1))
        else
            stable=0
            log "Launch rotation was overwritten; reapplying normal"
            rotate_normal
        fi

        if [ "$stable" -ge 10 ]; then
            log "Launch rotation verified"
            return
        fi

        attempts=$((attempts + 1))
        sleep 0.1
    done

    log "Launch verification timed out"
}

watch_game_stop() {
    local token="$1"
    local stable=0
    local attempts=0

    log "Exit watcher started: $token"

    while [ "$attempts" -lt 300 ]; do
        token_is_current "$token" || {
            log "Exit watcher superseded: $token"
            return
        }

        if ! retroarch_running && is_right; then
            stable=$((stable + 1))

            if [ "$stable" -ge 3 ]; then
                log "Exit display already restored"
                return
            fi
        elif ! retroarch_running && is_normal; then
            stable=$((stable + 1))

            if [ "$stable" -ge 3 ]; then
                log "Exit state ready; rotating right"
                rotate_right
                stable=0
                break
            fi
        else
            stable=0
        fi

        attempts=$((attempts + 1))
        sleep 0.1
    done

    if [ "$attempts" -ge 300 ]; then
        log "Exit watcher timed out"
        return
    fi

    attempts=0

    while [ "$attempts" -lt 100 ]; do
        token_is_current "$token" || {
            log "Exit verification superseded: $token"
            return
        }

        if is_right; then
            stable=$((stable + 1))
        else
            stable=0
            log "Exit rotation was overwritten; reapplying right"
            rotate_right
        fi

        if [ "$stable" -ge 10 ]; then
            log "Exit rotation verified"
            return
        fi

        attempts=$((attempts + 1))
        sleep 0.1
    done

    log "Exit verification timed out"
}

[ "$3" = "libretro" ] || exit 0

token="$(make_token "$1")"
echo "$token" > "$TOKEN_FILE"

case "$1" in
    gameStart)
        watch_game_start "$token" >/dev/null 2>&1 &
        ;;

    gameStop)
        watch_game_stop "$token" >/dev/null 2>&1 &
        ;;
esac

exit 0

Make the script executable:

chmod +x /userdata/system/scripts/rotate-vrr.sh

How the script works

Batocera calls scripts in /userdata/system/scripts for events including gameStart and gameStop.

For DoDonPachi under FBNeo, the observed arguments were:

gameStart fbneo libretro fbneo /userdata/roms/fbneo/ddonpach.zip
gameStop fbneo libretro fbneo /userdata/roms/fbneo/ddonpach.zip

The third argument is therefore libretro, so the script ignores non-libretro emulators.

On launch, the script does not rotate immediately just because the event fired. Instead, a background watcher waits until RetroArch is actually running and the display is still in the stable rotated frontend state, then changes Xorg back to normal.

On exit, it waits until RetroArch has stopped and the display is in the stable normal gameplay state, then restores the rotated frontend state.

The generation token prevents an older watcher from acting after a newer launch or exit event has happened, and the verification loops reapply the expected orientation if Batocera briefly overwrites it.

Step 4: Test the setup

Clear the old log:

> /userdata/system/logs/rotate-vrr.log

Launch a RetroArch game such as DoDonPachi.

While the game is running, confirm the Xorg orientation:

DISPLAY=:0 xrandr | grep '^DP-1'

Expected result:

DP-1 connected primary 1920x1080+0+0 (normal left inverted right x axis y axis) ...

Confirm the generated RetroArch rotation:

grep '^video_rotation' /userdata/system/configs/retroarch/retroarchcustom.cfg

Expected result:

video_rotation = "3"

The game should now be upright on the portrait monitor, and the monitor’s refresh-rate display should no longer stay fixed at 60. For DoDonPachi, it should fluctuate through values around 56, 57, 58 and occasionally 60.

Exit the game and confirm the frontend orientation:

DISPLAY=:0 xrandr | grep '^DP-1'

Expected result:

DP-1 connected primary 1080x1920+0+0 right ...

EmulationStation should be upright and fill the display correctly.

Inspect the hook log:

cat /userdata/system/logs/rotate-vrr.log

A successful launch and exit should include messages similar to:

Launch watcher started
Launch state ready; rotating normal
Launch rotation verified
Exit watcher started
Exit state ready; rotating right
Exit rotation verified

Useful diagnostic commands

Show the active display mode:

DISPLAY=:0 batocera-resolution currentMode

Show the active XRandR orientation:

DISPLAY=:0 xrandr | grep '^DP-1'

Show VRR capability:

DISPLAY=:0 xrandr --props | grep -E ' connected|vrr_capable'

Show AMD VRR configuration:

grep -Ri VariableRefresh /etc/X11

Show whether Xorg accepted VRR:

grep -i VariableRefresh /var/log/Xorg.0.log

Show the RetroArch VRR setting:

grep vrr_runloop_enable /userdata/system/configs/retroarch/retroarchcustom.cfg

Expected result:

vrr_runloop_enable = true

Show the RetroArch internal rotation:

grep '^video_rotation' /userdata/system/configs/retroarch/retroarchcustom.cfg

Expected result:

video_rotation = "3"

Show the running RetroArch command:

ps -ef | grep '[r]etroarch'

Show the hook log:

cat /userdata/system/logs/rotate-vrr.log

Hardware-specific values

The script assumes OUTPUT="DP-1". If the monitor uses another connector, determine it with:

DISPLAY=:0 xrandr | grep ' connected'

Then change OUTPUT near the top of the script.

The state checks also assume a normal mode of 1920x1080, a rotated mode of 1080x1920, and a frontend rotation of right. Those values need adjusting for a display with a different native resolution or physical orientation.

Final configuration summary

Batocera configuration:

display.rotate=1
global.retroarch.video_rotation=3

Persistent hook:

/userdata/system/scripts/rotate-vrr.sh

Frontend:

Xorg rotation = right
desktop = 1080x1920
EmulationStation upright

RetroArch game:

Xorg rotation = normal
desktop = 1920x1080
RetroArch internal rotation = 3
VRR active

After game exit:

Xorg rotation restored to right
EmulationStation upright