September on Linux, part 3: moving my Final Fantasy XIV setup

With most of the essentials out of the way, it was time for the big one – can I slowly start rebuilding my overly convoluted Final Fantasy XIV environment in CachyOS?

🚀 Installing XIVLauncher-RB

I chose XIVLauncher-RB, an unofficial fork of an already unofficial launcher, for its additional Wine, Proton and DXVK management options. I have a standalone Squeenix account, and wanted a native application I could launch directly and call from a script later on – so I used its AUR package, maintained by the fork creator himself:

sudo pacman -S paru
paru -S xivlauncher-rb

The build initially appeared stuck restoring .NET dependencies. Investigating that led to discovering fully broken IPv6 connectivity, which I thought said something cursed about my Linux installation until I ran an IPv6 Connectivity Test on my MacBook Pro and scored a wonderful 0/10 there as well.

Not wanting to investigate and solve this issue at that moment[1], I temporarily disabled IPv6 on the Linux machine to get the installation moving, and XIVLauncher installed afterwards.

For reference, my CachyOS/Limine workaround was to append ipv6.disable=1 to the existing kernel command line in /etc/default/limine, regenerate the entries and reboot:

sudoedit /etc/default/limine
sudo limine-mkinitcpio

The option belongs on the KERNEL_CMDLINE[...] entry for the kernel being booted, alongside its existing options. After rebooting, cat /proc/cmdline can be used to confirm whether it was applied.

Once the launcher opened, I used it to download a fresh Linux-side copy of the game, followed by a quick initial boot of the game that went without a hitch.

👛 KWallet without a Plasma session

Before the launcher could save my game credentials properly, I had to finish the desktop’s Secret Service setup. I already had KWallet installed, but that hadn’t provided a working org.freedesktop.secrets service in my Hyprland session.

I enabled the necessary API in ~/.config/kwalletrc:

[org.freedesktop.secrets]
apiEnabled=true

The wallet also needed to be named kdewallet for the PAM unlock setup I used. My earlier wallet had a different name. In KWalletManager, I created the kdewallet wallet with the login password, then made it the default:

[Wallet]
Default Wallet=kdewallet

The packages needed for the login integration were:

sudo pacman -S kwalletmanager kwallet-pam

My display manager is greetd. In /etc/pam.d/greetd, the KWallet additions were an optional authentication line after the real authentication step, and an optional session line alongside the existing session setup:

# Add at the end of the auth section:
auth       optional     pam_kwallet5.so

# Add at the end of the session section:
session    optional     pam_kwallet5.so auto_start force_run kwalletd=/usr/bin/ksecretd

There was one more required piece in Hyprland’s autostart:

hl.exec_cmd("/usr/lib/pam_kwallet_init")

I replaced my direct ksecretd startup command from earlier attempts with that helper. KDE’s PAM integration passes information from login to the wallet daemon, then finishes initialising it in the graphical session. Starting the daemon by itself had given me a running service but without the automatic unlock I wanted.

This worked well for me until about a week later, when an unrelated package install brought gnome-keyring as a dependency, which rudely yeeted KWallet out and tried to use GNOME Wallet as a Secret Service provider – the absolute audacity 🤮

The problem manifested itself by both Vivaldi and XIVLauncher prompting me upon application start to create a new wallet, something I definitely had no interest in doing. One of the following three commands solved the issue – I’m not sure which one exactly, as I promptly ran all three in Bash – ’cause one can never be too cautious with removing GNOME cancer from the system 🧌

# Fix 1: Block the normal systemd-user startup path
systemctl --user mask --now \
  gnome-keyring-daemon.service \
  gnome-keyring-daemon.socket

# Fix 2: Point user’s D-Bus activation for org.freedesktop.secrets explicitly at KDE’s provider
mkdir -p ~/.local/share/dbus-1/services

cat > ~/.local/share/dbus-1/services/org.freedesktop.secrets.service <<'EOF'
[D-BUS Service]
Name=org.freedesktop.secrets
Exec=/usr/bin/ksecretd
EOF

# Fix 3: Disable GNOME Keyring’s XDG secrets autostart locally
mkdir -p ~/.config/autostart

cat > ~/.config/autostart/gnome-keyring-secrets.desktop <<'EOF'
[Desktop Entry]
Type=Application
Hidden=true
EOF

🐁 Programming the G600

My Logitech G600 has a twelve-button thumb grid and a G-Shift button. On Windows, I had ordinary keypad actions on the grid and Ctrl+keypad actions on the shifted layer. My actual keyboard has no numpad, so recording those combinations through a GUI wasn’t especially convenient.

Instead, to configure the mouse, I used libratbag’s ratbagctl tool. Piper was useful for looking at the mouse and verifying the setup afterwards, but the command line exposed the shifted buttons more directly:

sudo pacman -S libratbag piper
ratbagctl list
ratbagctl thundering-gerbil info

Why was my mouse called thundering-gerbil? Absolutely no idea on that one 😬

In profile 0, logical buttons 8–19 were the ordinary thumb grid and 29–40 were the shifted grid. Buttons 5 and 26 handled the G-Shift action.

This Fish recipe shows all the mappings I ended up needing:

#!/usr/bin/fish

set DEV thundering-gerbil

set NORMAL_DPI 4800
set PRECISION_DPI 800

# Alternate between high DPI setting and low DPI for pixel-perfect window positioning
ratbagctl $DEV profile 0 resolution 0 dpi set $NORMAL_DPI
ratbagctl $DEV profile 0 resolution 1 dpi set $PRECISION_DPI

# High DPI is the default
ratbagctl $DEV profile 0 resolution default set 0

ratbagctl $DEV profile 0 button 5 action set special second-mode
ratbagctl $DEV profile 0 button 26 action set special second-mode

# Toggle normal <-> precision DPI.
ratbagctl $DEV profile 0 button 6 action set special resolution-cycle-up

ratbagctl $DEV profile 0 button 7 action set special unknown
ratbagctl $DEV profile 0 button 27 action set special unknown
ratbagctl $DEV profile 0 button 28 action set special unknown

ratbagctl $DEV profile 0 button 8 action set macro KEY_KP1
ratbagctl $DEV profile 0 button 9 action set macro KEY_KP2
ratbagctl $DEV profile 0 button 10 action set macro KEY_KP3

ratbagctl $DEV profile 0 button 11 action set macro KEY_KP4
ratbagctl $DEV profile 0 button 12 action set macro KEY_KP5
ratbagctl $DEV profile 0 button 13 action set macro KEY_KP6

ratbagctl $DEV profile 0 button 14 action set macro KEY_KP7
ratbagctl $DEV profile 0 button 15 action set macro KEY_KP8
ratbagctl $DEV profile 0 button 16 action set macro KEY_KP9

ratbagctl $DEV profile 0 button 17 action set macro KEY_KPSLASH
ratbagctl $DEV profile 0 button 18 action set macro KEY_KP0
ratbagctl $DEV profile 0 button 19 action set macro KEY_KPASTERISK

ratbagctl $DEV profile 0 button 29 action set macro +KEY_LEFTCTRL KEY_KP1 -KEY_LEFTCTRL
ratbagctl $DEV profile 0 button 30 action set macro +KEY_LEFTCTRL KEY_KP2 -KEY_LEFTCTRL
ratbagctl $DEV profile 0 button 31 action set macro +KEY_LEFTCTRL KEY_KP3 -KEY_LEFTCTRL

ratbagctl $DEV profile 0 button 32 action set macro +KEY_LEFTCTRL KEY_KP4 -KEY_LEFTCTRL
ratbagctl $DEV profile 0 button 33 action set macro +KEY_LEFTCTRL KEY_KP5 -KEY_LEFTCTRL
ratbagctl $DEV profile 0 button 34 action set macro +KEY_LEFTCTRL KEY_KP6 -KEY_LEFTCTRL

ratbagctl $DEV profile 0 button 35 action set macro +KEY_LEFTCTRL KEY_KP7 -KEY_LEFTCTRL
ratbagctl $DEV profile 0 button 36 action set macro +KEY_LEFTCTRL KEY_KP8 -KEY_LEFTCTRL
ratbagctl $DEV profile 0 button 37 action set macro +KEY_LEFTCTRL KEY_KP9 -KEY_LEFTCTRL

ratbagctl $DEV profile 0 button 38 action set macro +KEY_LEFTCTRL KEY_KPSLASH -KEY_LEFTCTRL
ratbagctl $DEV profile 0 button 39 action set macro +KEY_LEFTCTRL KEY_KP0 -KEY_LEFTCTRL
ratbagctl $DEV profile 0 button 40 action set macro +KEY_LEFTCTRL KEY_KPASTERISK -KEY_LEFTCTRL

ratbagctl $DEV profile 1 disable
ratbagctl $DEV profile 2 disable

ratbagctl $DEV profile active set 0
ratbagctl $DEV profile 0 resolution active set 0

I saved this into a script in case it’s ever needed to execute this again, but it seems that the programming is pretty permanent – nice!

While experimenting, I accidentally used KEY_1 instead of KEY_KP1 a few times in the G-Shifted portion, which resulted in the mouse sending a number row keycode instead of a numpad’s keycode (which XIV, of course, did not match on with my existing keybinding).

I also needed Num Lock on. In Hyprland’s existing input table:

numlock_by_default = true,

That took effect immediately, and I confirmed the ordinary mappings survived a reboot. After correcting the shifted keycodes, I confirmed G-Shift in the game, too.

📑 Copying existing configuration

I kept the Windows installation available and used it as the source for the game and plugin settings. With the NAS degraded, copying directly from the Windows SSD also avoided dragging my obscenely large mod folder through it.

I did a bit of research, and it seems that mounting NTFS in Linux can still be a bit scary, but mounting it in read-only mode after Windows is restarted (rather than shut down or hibernated) should be okay. I first identified the Windows partition by disk model and filesystem – for me, that was /dev/nvme0n1p3:

lsblk -o NAME,SIZE,FSTYPE,LABEL,UUID,MOUNTPOINTS,MODEL

With Windows clearly restarted into Linux (for a clean dismount on the Windows side), the additional block-device protection and mount recipe is:

sudo mkdir -p /mnt/windows
sudo blockdev --setro /dev/nvme0n1p3
sudo mount -t ntfs3 -o ro /dev/nvme0n1p3 /mnt/windows
sudo blockdev --getro /dev/nvme0n1p3
findmnt /mnt/windows

--getro should print 1, and the mount options should contain ro, which confirmed that the device and mount were read-only.

These were the paths in my launcher setup:

Windows source Linux destination
Documents\My Games\FINAL FANTASY XIV - A Realm Reborn\ ~/.xlcore/ffxivConfig/
%APPDATA%\XIVLauncher\pluginConfigs\ ~/.xlcore/pluginConfigs/
%APPDATA%\XIVLauncher\installedPlugins\ ~/.xlcore/installedPlugins/
%APPDATA%\XIVLauncher\dalamudConfig.json ~/.xlcore/dalamudConfig.json
%APPDATA%\XIVLauncher\dalamudUI.ini ~/.xlcore/dalamudUI.ini
%APPDATA%\XIVLauncher\dalamudVfs.db ~/.xlcore/dalamudVfs.db
C:\FFXIVMods ~/Documents/Final Fantasy XIV/Mods

Close XIV and the launcher before replacing their configuration, and keep a copy of the Linux destination first. For example, this copies the contents of the Windows character configuration directory, including its FFXIV_CHR* directories:

rsync -a --info=progress2 \
    '/mnt/windows/Users/Milan/Documents/My Games/FINAL FANTASY XIV - A Realm Reborn/' \
    ~/.xlcore/ffxivConfig/

Then, of course, similar commands to copy over the rest of paths

The trailing slashes, I think, mean to copy the directory’s contents – because rsync’s syntax can just not stop being scary and obscure.

For the plugins, I copied the configuration and the installed DLLs; XIVLauncher migration guidance doesn’t recommend doing the latter, but for me, it went without a hitch.

Penumbra was the largest separate copy, roughly 30 GB. It had lived in C:\FFXIVMods and ended up under ~/Documents/Final Fantasy XIV/Mods rather than within ~/.xlcore for easy access from Dolphin whenever I install new mods. Wine’s Z: drive maps the Linux filesystem root, so the path I needed in Penumbra was[2]:

Z:\home\milan\Documents\Final Fantasy XIV\Mods

For some plugins like Penumbra, I edited the paths in their configuration JSON (~/.xlcore/pluginConfigs/Penumbra/config/penumbra.json) directly (of course escaping the backslashes as necessary):

"Z:\\home\\milan\\Documents\\Final Fantasy XIV\\Mods"

For others like JustBackup, I simply changed the paths within the plugin’s configuration UI, with Z:\home\milan\NAS being the magic incantation that leads from Wine → Linux → Samba → NAS. How simple, how elegant.

📐 Keeping the game at the right size

My main monitor is 3440×1440. The first window geometry I inspected was 3440×1405, offset below Noctalia’s 35-pixel panel. I wanted the game to cover the whole monitor and live on workspace X without my ordinary fullscreen shortcut accidentally changing it.

This is the configuration snippet (~/.config/hypr/config/windowrules.lua) that I used to accomplish this:

hl.window_rule({
	match = { workspace = "name:X" },
	float = true,
})

hl.window_rule({
	match = {
		class = "^ffxiv_dx11\\.exe$",
		title = "^FINAL FANTASY XIV$",
	},

	-- Always put XIV on the dedicated gaming workspace / main monitor.
	workspace = "name:X silent",
	monitor = MONITOR1 .. " silent",
	tile = true,

	-- Cover the entire monitor, including reserved panel space.
	fullscreen = true,
	fullscreen_state = "2 0",
	sync_fullscreen = true,

	content = "game",
	decorate = false,
	confine_pointer = false,

	-- Don't let XIV itself request fullscreen/maximize state changes.
	suppress_event = "fullscreen maximize",
})

hl.window_rule({
	match = { class = "XIVLauncher.Core" },
	workspace = "name:X silent",
	monitor = MONITOR1 .. " silent",
})

Yes, the full Hyprland configuration folder is still coming… sometime soon 😬

The first rule is a catch-all for any window that tries to open on workspace X; for example, clicking a button in the Browsingway plugin caused Chrome Developer Tools to open, tiling side-by-side with XIV’s main game window and making my UI go haywire. I definitely don’t want to restore dalamudUI.ini from backup every time this happens, so the default behaviour on workspace X is to float[3].

The second rule gives XIV permission (command, really 🤣) to occupy the entire screen and stay on the workspace X, on the primary screen. The final rule puts the launcher on the same workspace.

🔓 Automating login with Bitwarden

On Windows, I used a PowerShell script to generate a TOTP and send it to XIVLauncher. For Linux, I wanted Bash to do something similar but more robust – rather than calculating the code in the script (meaning the script would have to embed a secret 🙂‍↔️), it should fetch the code from Bitwarden, whose master password could come from the already-unlocked kdewallet wallet (it’s all coming together, isn’t it?). The full sequence is thus:

  1. Read Bitwarden’s status;
  2. Unlock it if necessary;
  3. Verify vault access;
  4. Start XIVLauncher with a selected saved account;
  5. Wait for the XIVLauncher to listen for OTP; and
  6. Only then obtain and submit a fresh TOTP.

You can find my entire, definitely not vibe-coded script here. If you want to reuse it, don’t forget to change any of the configuration in the first 42 or so lines of code; also, it requires Bash 4 or newer, bw, jq, kwallet-query, curl, setxkbmap (to force my Czech Apple QWERTY keyboard layout to XWayland) and xivlauncher-rb. The CLI must already be logged into the correct Bitwarden or Vaultwarden server, and XIVLauncher must already know the account. The script automates vault unlocking and game login, not the initial account setup.

To expose the script in Noctalia, I placed the script in ~/.local/bin and gave it executable permissions with chmod u+x ~/.local/bin/xiv-autologin, then created a ~/.local/share/applications/xiv-main.desktop entry:

[Desktop Entry]
Type=Application
Name=FFXIV – MySqueenixAccountID
Comment=Launch FFXIV and automatically supply MySqueenixAccountID’s OTP
Exec=/home/milan/.local/bin/xiv-autologin MySqueenixAccountID
Icon=xivlauncher-rb
Terminal=false
Categories=Game;
Keywords=FFXIV;Final Fantasy XIV;XIV;MySqueenixAccountID;
StartupNotify=false

Exec requires an absolute path; it doesn’t expand ~ like a shell. The script writes its log under ~/.local/state/xiv-autologin/, which can be inspected with tail -f ~/.local/state/xiv-autologin/MySqueenixAccountID.log in case something goes wrong.

After that, I stored the Bitwarden master password in the KWallet entry the script reads by running this in Bash:

read -rsp 'Bitwarden master password: ' BW_PASSWORD
printf '\n'
printf '%s' "$BW_PASSWORD" |
    kwallet-query -f Passwords -w xiv-autologin-bitwarden-master kdewallet
unset BW_PASSWORD

The script selects a saved launcher account using --CurrentAccountId, enables autologin and enables the OTP server. Its default account-ID construction is for my non-Steam accounts with OTP enabled; XIV_ACCOUNT_ID can override that for a different saved ID, in case I decide to log in to one of my seven other XIV accounts. It submits the code through the documented OTP interface:

http://127.0.0.1:4646/ffxivlauncher/SIX_DIGIT_CODE

My usual pattern followed, in which everything was working fine until a fresh boot, which exposed two more problems. The first was my private CA: a script launched by Noctalia doesn’t source ~/.config/fish/conf.d/, so it didn’t inherit the environment that made bw work in a terminal. Near the top of the Bash script, I needed to add:

export NODE_EXTRA_CA_CERTS=/etc/ssl/certs/ca-certificates.crt

That points Node at the system CA bundle containing the root installed in Part 2. Bitwarden documents this variable for custom certificates.

The remaining failure was unreliable vault unlocking: bw unlock returned a session, but the following TOTP lookup still behaved as if the vault was locked. The change I remember settling it was disabling the SDK unlock path in ~/.config/Bitwarden CLI/data.json:

"featureStates": {
    "unlock-via-sdk": false
}

A hacky workaround, but it seems to work – auto-login now became stable. I also stopped using /bw in Noctalia’s launcher because I suspected it might be locking the vault. I didn’t isolate those changes well enough to say how much the second one mattered because I replaced Noctalia Launcher with something else a few days later.

At this point, my configuration and controls had moved across, and login was convenient again. The game still looked noticeably different from my Windows setup without its shaders, but I left ReShade and GPosingway for the following session – and thus, the next article.


  1. The next day, I tried restarting the IPv6 WAN interface on my OpenWrt router, which restored a 10/10 IPv6 test after removing the temporary Linux kernel option, regenerating the entries and rebooting once again.

  2. You can also use Wine’s C: drive for accessing some folders that Wine maps for you; for example, the documents folder ~/Documents is also available under C:\Users\milan\Documents. Either works, and it’s probably just a matter of preference.

  3. Thinking about this more while writing this footnote, I could perhaps tighten the match rule to avoid matching games – but it does seem to work regardless.