fix dll overrides & add disable UMU

This commit is contained in:
olcxja 2026-10-04 15:40:09 +02:00
commit 8c6db38fab
12 changed files with 295 additions and 45 deletions

View file

@ -1,6 +1,7 @@
'use strict'
const fsp = require('fs').promises
const fs = require('fs')
const fsp = fs.promises
const path = require('path')
const { DIRS, resolvePortablePath } = require('./paths')
const { newGame, newGameId, addGame } = require('./library')
@ -82,8 +83,22 @@ async function importGameFromPrefix(prefix, srcDir, onProgress) {
const driveRoots = prefixDriveRoots(prefix)
if (!driveRoots.length) return { ok: false, error: 'no-drive-c' }
const src = path.resolve(String(srcDir))
// realpath on both sides: a unified prefix reaches drive_c through the pfx
// symlink too, and that path is the same folder (see ensureUnifiedPrefix)
let realSrc
try {
realSrc = fs.realpathSync(src)
} catch {
return { ok: false, error: 'outside-drive-c' }
}
const insideDrive = driveRoots.some(root => {
const rel = path.relative(root, src)
let realRoot
try {
realRoot = fs.realpathSync(root)
} catch {
return false
}
const rel = path.relative(realRoot, realSrc)
return rel && !rel.startsWith('..') && !path.isAbsolute(rel)
})
if (!insideDrive) return { ok: false, error: 'outside-drive-c' }

View file

@ -217,6 +217,9 @@ function newGame({ id, name, exePath, workdir, mode }) {
prefix: '',
args: '',
dllOverrides: [],
// Proton-only switch: keep UMU (the default path) or fall back to the
// build's steam.exe launcher for this app (see runner.buildEnv)
disableUmu: false,
env: {},
iconSource: '',
playSeconds: 0,

View file

@ -73,6 +73,7 @@ function makeTarget(openPath, wineId, prefixName) {
prefix: prefixName || '',
args: '',
dllOverrides: [],
disableUmu: false,
env: {},
iconSource: '',
playSeconds: 0,

View file

@ -15,6 +15,7 @@ function seed() {
if (!defs || !Array.isArray(defs.prefixes) || !defs.prefixes.length) {
const seeded = { default: MAIN, prefixes: [{ name: MAIN, created: Date.now(), locked: true }] }
if (!fs.existsSync(prefixPath(MAIN))) fs.mkdirSync(prefixPath(MAIN), { recursive: true })
ensureUnifiedPrefix(MAIN)
writeJson(REGISTRY, seeded)
}
}
@ -42,22 +43,65 @@ function prefixExists(name) {
}
// Every Windows C: drive a prefix has: plain wine keeps one at the top
// (WINEPREFIX=<prefix>), Proton-managed prefixes nest one under pfx/
// (WINEPREFIX=<prefix>/pfx). A prefix used with BOTH wine kinds has both,
// and an import may come from either one - so callers that can accept
// several get them all, top level first.
// (WINEPREFIX=<prefix>), legacy Proton-managed prefixes nest one under pfx/
// (WINEPREFIX=<prefix>/pfx). A prefix used with BOTH wine kinds has both, and
// an import may come from either one - so callers that can accept several get
// them all, top level first. A unified prefix (pfx -> .) reaches the same
// drive twice; it is reported once.
function prefixDriveRoots(name) {
const root = prefixPath(name)
const found = []
const seen = new Set()
for (const base of [root, path.join(root, 'pfx')]) {
try {
const driveC = path.join(base, 'drive_c')
if (fs.statSync(driveC).isDirectory()) found.push(driveC)
if (!fs.statSync(driveC).isDirectory()) continue
const real = fs.realpathSync(driveC)
if (seen.has(real)) continue
seen.add(real)
found.push(driveC)
} catch {}
}
return found
}
function lstatOrNull(p) {
try {
return fs.lstatSync(p)
} catch {
return null
}
}
// Wine keeps its C: drive at <prefix>/drive_c, Proton always runs its prefix
// from <compatdata>/pfx - and PLWL points compatdata at the launcher's prefix
// folder, so without a link a prefix used with both wine kinds grows TWO C:
// drives with separate registries, saves and installed DLLs. Making `pfx` a
// relative symlink back to the prefix itself collapses them: Proton's
// <compatdata>/pfx resolves to the prefix root, the exact folder wine uses.
// Every walker PLWL owns (fsutil, symlinks, LAN sync) treats symlinks as
// links and never follows them, so the self-reference is safe.
//
// Only a prefix that has no pfx AT ALL gets the link - that covers a fresh
// prefix and a plain-wine prefix first used through Proton. An existing
// layout is left exactly as it is: a prefix from an older PLWL keeps its real
// pfx/ folder (and here the two wine kinds keep two C: drives), and a prefix
// with both real drive_c trees keeps them - moving Windows installs around
// behind the user's back loses data and breaks the absolute/relative links
// inside them.
function ensureUnifiedPrefix(name) {
if (!name) return false
const pfx = path.join(prefixPath(name), 'pfx')
if (lstatOrNull(pfx)) return false
try {
fs.symlinkSync('.', pfx, 'dir')
return true
} catch {
// the prefix folder may not exist yet - the link is added on creation
return false
}
}
// True when a path IS one of a prefix's own roots: the prefix folder, its
// pfx/ or either drive_c. A copy/move import of such a folder would move the
// prefix's C: drive (or the whole prefix) into files/apps, gutting it - the
@ -82,6 +126,7 @@ function createPrefix(name) {
if (clean.toLowerCase() === MAIN.toLowerCase()) return { ok: false, error: 'alreadyExists' }
if (fs.existsSync(prefixPath(clean))) return { ok: false, error: 'alreadyExists' }
fs.mkdirSync(prefixPath(clean), { recursive: true })
ensureUnifiedPrefix(clean)
const defs = listPrefixes()
defs.prefixes.push({ name: clean, created: Date.now() })
if (!defs.prefixes.some(p => p.name === defs.default)) defs.default = clean
@ -153,4 +198,4 @@ function setDefaultPrefix(name) {
return { ok: true }
}
module.exports = { listPrefixes, savePrefixes, createPrefix, deletePrefix, renamePrefix, setDefaultPrefix, prefixPath, prefixExists, prefixDriveRoots, isPrefixRoot, healPrefix, MAIN }
module.exports = { listPrefixes, savePrefixes, createPrefix, deletePrefix, renamePrefix, setDefaultPrefix, prefixPath, prefixExists, prefixDriveRoots, isPrefixRoot, healPrefix, ensureUnifiedPrefix, MAIN }

View file

@ -2,13 +2,14 @@
const path = require('path')
const fs = require('fs')
const os = require('os')
const { spawn } = require('child_process')
const { DIRS } = require('./paths')
const { prefixPath } = require('./prefixes')
const { prefixPath, ensureUnifiedPrefix } = require('./prefixes')
const { prefixNameForGame } = require('./library')
// The fake Steam install Proton is pointed at through
// STEAM_COMPAT_CLIENT_INSTALL_PATH. Both folders Proton links into its prefix
// The fake Steam install PLWL falls back to when no real Steam client is
// found (see findSteamClientDir). Both folders Proton links into its prefix
// have to exist: it symlinks <prefix>/drive_c/Program Files (x86)/Steam/
// appcache -> <fake>/appcache while setting the prefix up, and a link into a
// missing folder is a dangling link in a tree that gets synced.
@ -19,6 +20,73 @@ function ensureFakeSteam() {
return fake
}
// Proton expects the Steam client it is normally started by at
// STEAM_COMPAT_CLIENT_INSTALL_PATH: it copies the client's legacy DLLs from
// <client>/legacycompat into the prefix, its built-in steam.exe loads the
// client's own steamclient.so, and media libs come from <client>/ubuntu12_*.
// A client installed on this machine is worth using (read-only) - without one
// a Valve build's launch dies in a "steamclient_main.c" assert dialog - and
// only when there is none does PLWL fall back to the empty fake folder. GE
// builds don't need any of it: their UMU path never starts steam.exe.
const STEAM_CLIENT_MARKERS = [
['legacycompat', 'steamclient.dll'],
['ubuntu12_32', 'steamclient.so']
]
function findSteamClientDir() {
const home = os.homedir()
const dataHome = process.env.XDG_DATA_HOME || path.join(home, '.local', 'share')
const candidates = [
process.env.STEAM_COMPAT_CLIENT_INSTALL_PATH,
path.join(home, '.steam', 'steam'),
path.join(home, '.steam', 'root'),
path.join(dataHome, 'Steam'),
// flatpak Steam keeps its install inside the sandbox's data dir
path.join(home, '.var', 'app', 'com.valvesoftware.Steam', '.local', 'share', 'Steam')
]
for (const dir of candidates) {
if (!dir) continue
for (const marker of STEAM_CLIENT_MARKERS) {
if (fs.existsSync(path.join(dir, ...marker))) return dir
}
}
return null
}
function steamClientDir() {
return findSteamClientDir() || ensureFakeSteam()
}
// Module names travel in a semicolon-separated WINEDLLOVERRIDES list where
// '=' splits name from setting and ',' separates names - anything else in a
// name would corrupt the entries around it. A trailing .dll is what guides
// write; Wine strips it itself, so PLWL stores the bare name.
function sanitizeDllName(name) {
const clean = String(name || '').trim().replace(/\.dll$/i, '')
return /^[^;=\s]+$/.test(clean) ? clean : ''
}
// Wine replaces an earlier entry for the same module with a later one, so an
// inherited WINEDLLOVERRIDES goes FIRST and the app's own overrides are
// appended after it. An empty setting is Wine's "disabled" and has to survive
// as `name=`, not be dropped. Proton appends its own dxvk/nvapi/steam.exe
// defaults after everything - an app override for one of those modules only
// takes effect where Proton itself has no default.
function dllOverridesFor(game, env) {
const parts = []
const inherited = String(env.WINEDLLOVERRIDES || '').trim().replace(/;+$/, '')
if (inherited) parts.push(inherited)
for (const entry of game.dllOverrides || []) {
if (!entry) continue
const dll = sanitizeDllName(entry.dll)
if (!dll) continue
const setting = String(entry.setting == null ? '' : entry.setting).trim()
if (!/^[a-z,]*$/i.test(setting)) continue
parts.push(`${dll}=${setting}`)
}
return parts.join(';')
}
// Wine only ever repairs links that are BROKEN: a prefix that came from
// another machine (or from an older PLWL) can still hold absolute links into
// its build that happen to resolve here, and those would be synced out again.
@ -74,23 +142,41 @@ function buildEnv({ game, wine }) {
const isProton = wine && wine.type === 'proton'
// same fallback the games service snapshots into its running entries
// (library.prefixNameForGame), so in-use checks always name this folder
const prefixDir = prefixPath(prefixNameForGame(game))
const prefixName = prefixNameForGame(game)
// a prefix with no pfx yet gets the shared-link layout, so every launch -
// wine, Proton, tools - lands on that prefix's ONE C: drive; an existing
// nested layout is left as it is (see ensureUnifiedPrefix)
try {
ensureUnifiedPrefix(prefixName)
} catch {}
const prefixDir = prefixPath(prefixName)
if (isProton) {
const fakeSteam = ensureFakeSteam()
const buildRoot = path.resolve(wine.path, '..')
env.STEAM_COMPAT_CLIENT_INSTALL_PATH = fakeSteam
env.STEAM_COMPAT_DATA_PATH = path.join(DIRS.DATA, 'compat', game.id)
env.STEAM_COMPAT_APP_ID = '0'
env.SteamAppId = '0'
env.SteamGameId = '0'
// Launch like the UMU protocol does (UMU/PortProton/Heroic): with UMU_ID
// set, newer Protons (incl. DW Proton 11, GE-Proton 10+) run the game via
// their built-in umu.exe instead of the steam.exe helper, which would
// otherwise try to load Steam's steamclient.so and crash with an
// "Assertion failed \"!status\"" dialog (steamclient_main.c) when no
// Steam client is installed.
if (!env.UMU_ID) env.UMU_ID = 'umu-0'
// Proton's session init reads STEAM_COMPAT_CLIENT_INSTALL_PATH on EVERY
// verb: its appcache sync (g_compatdata.sync_appcache) runs even for
// runinprefix and does os.environ["STEAM_COMPAT_CLIENT_INSTALL_PATH"] as
// soon as SteamGameId is set, so the variable must never be dropped - a
// missing one aborts the launch with a KeyError before wine even starts.
// Only what it POINTS AT depends on the mode: a UMU run may use a real
// client found on this machine, a disableUmu run always gets the empty
// fake folder (runinprefix copies nothing out of it; no steam.exe and no
// umu.exe is started there).
if (game.disableUmu) {
delete env.UMU_ID
env.STEAM_COMPAT_CLIENT_INSTALL_PATH = ensureFakeSteam()
} else {
env.STEAM_COMPAT_CLIENT_INSTALL_PATH = steamClientDir()
// Launch like the UMU protocol does (UMU/PortProton/Heroic): with UMU_ID
// set, newer Protons (incl. GE-Proton 9+) run the game through their
// built-in umu.exe instead of the steam.exe helper, which needs the
// Steam client's DLLs and dies in a "steamclient_main.c" assert dialog
// without them (see steamClientDir).
if (!env.UMU_ID) env.UMU_ID = 'umu-0'
}
env.WINEPREFIX = path.join(env.STEAM_COMPAT_DATA_PATH, 'pfx')
env.PROTON_LOG_DIR = path.join(DIRS.DATA, 'logs')
fs.mkdirSync(env.PROTON_LOG_DIR, { recursive: true })
@ -108,16 +194,25 @@ function buildEnv({ game, wine }) {
env.WINEPREFIX = prefixDir
}
env.WINEDLLOVERRIDES = (game.dllOverrides || [])
.filter(o => o && o.dll && o.setting)
.map(o => `${o.dll}=${o.setting}`)
.join(';')
if (!env.WINEDLLOVERRIDES) delete env.WINEDLLOVERRIDES
const overrides = dllOverridesFor(game, env)
if (overrides) env.WINEDLLOVERRIDES = overrides
else delete env.WINEDLLOVERRIDES
// the app's own environment comes last: it overrides everything above,
// including a WINEDLLOVERRIDES configured here
for (const [k, v] of Object.entries(game.env || {})) env[k] = v
return { env, prefixDir }
}
// Proton verbs: 'run' goes through the build's umu.exe/steam.exe launcher,
// while 'runinprefix' runs the build's wine directly (same session env, no
// umu/steam launcher ever starts - see game.disableUmu). runinprefix also skips
// Proton's prefix setup, so a prefix that never saw a normal Proton run stays
// a plain wine prefix instead of gaining Proton's default DLLs.
function protonVerb(game) {
return game.disableUmu ? 'runinprefix' : 'run'
}
function parseArgs(args) {
const out = []
const re = /"([^"]*)"|'([^']*)'|(\S+)/g
@ -167,7 +262,7 @@ async function runGame({ game, wine, systemWine, onExit }) {
let base
if (isProton) {
cmd = wine.wineBin || path.join(wine.path, 'proton')
base = ['run', exe, ...argList]
base = [protonVerb(game), exe, ...argList]
} else {
cmd = systemWine ? 'wine' : wine.wineBin
base = [exe, ...argList]
@ -200,6 +295,13 @@ async function runGame({ game, wine, systemWine, onExit }) {
started = true
const logFile = latestLogPath(game.id)
const ws = openLatestLog(game.id)
// a launch line of our own before the raw wine output: when a game
// refuses to start, "which prefix, which overrides, umu or steam.exe"
// is exactly what has to be answerable from the log
ws.write(`[plwl] ${cmd} ${base.join(' ')}\n`)
ws.write(`[plwl] WINEPREFIX=${env.WINEPREFIX}\n`)
if (env.WINEDLLOVERRIDES) ws.write(`[plwl] WINEDLLOVERRIDES=${env.WINEDLLOVERRIDES}\n`)
if (isProton) ws.write(`[plwl] proton launcher: ${game.disableUmu ? 'direct wine (no UMU, no Steam)' : `umu.exe (UMU_ID=${env.UMU_ID})`}\n`)
child.stdout.pipe(ws)
child.stderr.pipe(ws)
child.unref()
@ -237,7 +339,7 @@ async function runTool({ game, wine, tool, args, systemWine }) {
let base
if (isProton) {
cmd = wine.wineBin || path.join(wine.path, 'proton')
base = ['run', tool, ...argList]
base = [protonVerb(game), tool, ...argList]
} else if (systemWine) {
cmd = tool
base = argList

View file

@ -16,7 +16,11 @@
//
// Everything here rewires a link to a RELATIVE path when the file it points at
// can be found locally, so the tree stays relocatable forever, and restores
// the executable bit on the binaries a wine/proton build needs.
// the executable bit on the binaries a wine/proton build needs. An absolute
// link is always rewritten; a relative one is only touched when it no longer
// resolves, because a tree that changed layout (a prefix whose contents moved
// one level up, see prefixes.ensureUnifiedPrefix) keeps relative links written
// against the old depth and every one of them breaks at once.
const fsp = require('fs').promises
const fs = require('fs')
@ -116,10 +120,11 @@ function relativeTarget(link, target, roots) {
return null
}
// Rewire every absolute symlink under `root` that can be expressed relative to
// `roots` (usually [root, DIRS.FILES]). Returns how many links changed. A link
// may only become rewirable once another link it chains through has been
// fixed, so the pass repeats until nothing changes.
// Rewire the symlinks under `root` that can be expressed relative to `roots`
// (usually [root, DIRS.FILES]): absolute ones always, relative ones only when
// they no longer resolve. Returns how many links changed. A link may only
// become rewirable once another link it chains through has been fixed, so the
// pass repeats until nothing changes.
async function rewireSymlinks(root, roots = []) {
if (!root) return 0
const candidates = [root, ...roots.filter(r => r && r !== root)]
@ -135,8 +140,13 @@ async function rewireSymlinks(root, roots = []) {
} catch {
continue
}
if (!path.isAbsolute(target)) continue // already relocatable
const next = relativeTarget(link, path.normalize(target), candidates)
const absolute = path.isAbsolute(target)
// a relative link that still resolves is already relocatable - only a
// broken one is worth touching, and the suffix search in relativeTarget
// finds the file it was pointing at before the layout changed
if (!absolute && exists(path.resolve(path.dirname(link), target))) continue
const from = absolute ? path.normalize(target) : path.resolve(path.dirname(link), target)
const next = relativeTarget(link, from, candidates)
if (!next || next === target) continue
try {
await fsp.unlink(link)