diff --git a/README.md b/README.md index 9d42d9b..b120c29 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,8 @@ A sync also carries **how the apps are set up**: the Wine/Proton build and prefi - **System Wine** — uses the `wine` binary from PATH. - **Imported builds** — drop any wine or Proton build into `files/wines/`. The folder may contain the build directly, or one level of nesting (zip-extracted layouts are detected automatically). After the copy, PLWL rewires the build's absolute symlinks to relative ones and restores the executable bit on the binaries it needs to run (`proton`, `wine`, `wineserver`, `winetricks`), so a build that arrived from a zip or another machine works without further fiddling. A build can also be given a **displayed name** of its own (Wine versions): it is used everywhere the build is offered — library config, settings and the *Open with PLWL* popup — while the folder in `files/wines/` keeps its name and the symlinks inside prefixes stay valid. -- **Proton without Steam** — fully supported. Proton is invoked like Steam does it: `STEAM_COMPAT_CLIENT_INSTALL_PATH` points to a fake steam client folder, `STEAM_COMPAT_DATA_PATH` is a per-build compat folder, and the prefix lives at `/pfx` which PLWL links to its own prefix folder. No Steam installation required (GE builds work out of the box; for a few Valve builds that refuse to run without a client, PLWL fakes a minimal steam folder in `files/data/fake-steam`). +- **Proton without Steam** — fully supported. Proton is invoked like Steam does it: `STEAM_COMPAT_DATA_PATH` is a per-build compat folder linked to the launcher's own prefix, and `STEAM_COMPAT_CLIENT_INSTALL_PATH` points at an installed Steam client when there is one (Proton reads its legacy DLLs and `steamclient.so` from there, read-only) and at the empty `files/data/fake-steam` otherwise. GE builds work out of the box: PLWL launches through Proton's built-in UMU path (`UMU_ID`), which needs no Steam client at all. An app that breaks under umu.exe has a per-app **Disable UMU** switch in its config — PLWL then runs the build's own wine directly (Proton's `runinprefix` verb): no umu.exe/steam.exe launcher and no Proton prefix setup take part, so the run stays as close to plain wine as possible. (Proton's session init still reads `STEAM_COMPAT_CLIENT_INSTALL_PATH` on every verb; a disableUmu run gets the empty `fake-steam` folder and nothing is copied out of it.) +- **One C: drive per prefix** — Proton normally keeps its prefix one level deeper (`/pfx/drive_c`) than wine (`/drive_c`), which left a prefix used with both kinds with two separate C: drives (two registries, two sets of saves). Whenever a prefix has no `pfx` yet (a fresh prefix, or a plain-wine prefix first used through Proton), PLWL makes `pfx` a relative symlink back to the prefix itself, so Proton and wine share one `drive_c`, one registry and one `dosdevices`. Existing layouts are left exactly as they are — prefixes from older versions keep their real `pfx/` folder, and a prefix with both `drive_c` trees keeps both, because two live Windows installs cannot be merged safely (both stay visible to the prefix import picker). ## Translations diff --git a/development.md b/development.md index ebafbfd..3331162 100644 --- a/development.md +++ b/development.md @@ -122,6 +122,34 @@ handlers in src/ipc/. Keep new APIs in that pattern. relative target) and after a unit lands (healUnitRoots). dosdevices drive mappings and targets that leave files/ (a mount, a system path, the user's home) stay absolute - they are machine-specific by design. +- A prefix that has NO `pfx` yet gets ONE C: drive: `pfx` is a relative + symlink to the prefix itself, so Proton's `/pfx` + (runner.linkPrefix) resolves to `` and shares `drive_c`, the + registry and `dosdevices` with plain wine. `prefixes.ensureUnifiedPrefix` + only ever CREATES that link; an existing layout (a real `pfx/` folder from + an older PLWL, a both-drives prefix) is left exactly as it is. Hoisting a + prefix's files one level up was tried and removed: it breaks every relative + link written against the old depth (the `umu.exe` link into the build is the + one that kills a Proton launch), and moving a live Windows install is not + the launcher's call. `healPrefix`/`rewireSymlinks` repairs such a prefix by + re-anchoring broken relative links through files/. Never assume a single + drive_c path: use `prefixDriveRoots()` (deduplicated) and compare realpaths + when deciding whether a folder is inside a prefix's C: drive. +- App launch env is built in runner.buildEnv only. WINEDLLOVERRIDES is an + ordered semicolon list: inherited value first, the app's overrides last, + because Wine lets a later entry replace an earlier one for the same module. + An empty setting (`dll=`) is Wine's "disabled" and must survive the filter. + Proton appends its own dxvk/nvapi/steam.exe defaults AFTER everything, so an + app override only wins where Proton has no default. `disableUmu` (per app) + drops UMU_ID and switches Proton to its `runinprefix` verb, so no + umu.exe/steam.exe launcher and no Proton prefix setup take part - the + build's own wine runs as close to plain wine as it gets. + STEAM_COMPAT_CLIENT_INSTALL_PATH must be set in BOTH modes: Proton's session + init reads it on every verb (sync_appcache runs even for runinprefix, and + with SteamGameId set a missing variable aborts the launch with a KeyError + before wine starts). On the default path it prefers a real Steam client + found on the machine; a disableUmu run always gets the empty + files/data/fake-steam, whose contents are never read. - Wine and Proton create prefix links with ABSOLUTE local targets whenever they update a prefix (font/dll stubs, Steam appcache), so on-disk links are not always relative after a run: the sync payload relativizes them anyway, and diff --git a/files/translations/en-us.json b/files/translations/en-us.json index f3648ff..e017c1a 100644 --- a/files/translations/en-us.json +++ b/files/translations/en-us.json @@ -81,11 +81,14 @@ "config.prefixDefault": "Default prefix ({0})", "config.prefixLocked": "This app was imported from a prefix, its prefix cannot be changed", "config.prefixLockedHint": "Pinned to the prefix it was imported from (its paths are stored relative to it)", + "config.disableUmu": "Disable UMU (launch with wine directly)", + "config.disableUmuHint": "Runs the game with the build's own wine binary instead of its umu.exe/steam.exe launcher - no UMU and no Steam client is started, and Proton's prefix setup is skipped too. This is the closest to a plain wine run.", "config.dllOverrides": "DLL overrides", "config.overrides.add": "Add override", "config.overrides.dll": "DLL name", "config.overrides.setting": "Setting", "config.overrides.remove": "Remove", + "config.overrides.hint": "Wine module names, e.g. dinput8. native,builtin (the default) loads a DLL shipped with the game and falls back to Wine's builtin; disabled blocks the module. 'name=setting' pasted into the name box is split automatically.", "config.save": "Save changes", "config.shortcut": "Create shortcut", "config.shortcutCreated": "Shortcut for {0} created (menu + desktop)", diff --git a/files/translations/pl.json b/files/translations/pl.json index e00eae0..26cc0cd 100644 --- a/files/translations/pl.json +++ b/files/translations/pl.json @@ -81,11 +81,14 @@ "config.prefixDefault": "Prefiks domyślny ({0})", "config.prefixLocked": "Ta aplikacja została zaimportowana z prefiksu, nie można zmienić jej prefiksu", "config.prefixLockedHint": "Przypięta do prefiksu, z którego została zaimportowana (ścieżki trzymane są względem niego)", + "config.disableUmu": "Wyłącz UMU (uruchamiaj bezpośrednio wine)", + "config.disableUmuHint": "Uruchamia grę bezpośrednio binarką wine z builda, z pominięciem launchera umu.exe/steam.exe — bez UMU i bez uruchamiania klienta Steam, z pominięciem przygotowania prefiksu przez Protona. To najbliżej zwykłego uruchomienia wine.", "config.dllOverrides": "Nadpisania DLL", "config.overrides.add": "Dodaj nadpisanie", "config.overrides.dll": "Nazwa DLL", "config.overrides.setting": "Ustawienie", "config.overrides.remove": "Usuń", + "config.overrides.hint": "Nazwy modułów Wine, np. dinput8. native,builtin (domyślne) wczytuje DLL dołączony do gry, a gdy go nie ma — wbudowany Wine; disabled blokuje moduł. Wklejone 'nazwa=ustawienie' zostanie rozdzielone automatycznie.", "config.save": "Zapisz zmiany", "config.shortcut": "Utwórz skrót", "config.shortcutCreated": "Skrót dla {0} utworzony (menu + pulpit)", diff --git a/renderer/app.js b/renderer/app.js index 5e4888b..771180f 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -1235,6 +1235,7 @@ async function openGameConfig(id) { const lockHint = qs('#gc-prefix-lock-hint') if (lockHint) lockHint.style.display = prefixLocked ? '' : 'none' renderOverrides(game.dllOverrides || []) + qs('#gc-disable-umu').checked = !!game.disableUmu renderTools() } @@ -1257,10 +1258,12 @@ function renderOverrides(overrides) { } } +// native,builtin first: that is the default a new override row starts on +// (load a native DLL when the game has one, fall back to Wine's builtin) const OVERRIDE_OPTIONS = [ + { value: 'native,builtin', label: 'native,builtin' }, { value: 'native', label: 'native' }, { value: 'builtin', label: 'builtin' }, - { value: 'native,builtin', label: 'native,builtin' }, { value: 'builtin,native', label: 'builtin,native' }, { value: '', label: 'disabled' } ] @@ -1274,23 +1277,48 @@ function buildOverrideRow(o) { ` row.querySelector('.dll').value = o.dll || '' const selectHost = document.createElement('span') - new CustomSelect(selectHost, OVERRIDE_OPTIONS, o.setting || 'native') + // a stored empty setting IS the "disabled" choice - a `||` fallback would + // turn every disabled override back into the default on reopen + new CustomSelect(selectHost, OVERRIDE_OPTIONS, o.setting == null ? 'native,builtin' : o.setting) row.insertBefore(selectHost, row.querySelector('[data-act=del]')) row.querySelector('[data-act=del]').onclick = () => row.remove() return row } function addOverrideRow() { - qs('#gc-overrides').appendChild(buildOverrideRow({ dll: '', setting: 'native' })) + qs('#gc-overrides').appendChild(buildOverrideRow({ dll: '', setting: 'native,builtin' })) const rows = qsa('#gc-overrides .override-row') rows[rows.length - 1].querySelector('.dll').focus() } +// The setting a guide or a launch option uses ("n", "native,builtin", "d"...) +// mapped onto the values this select offers. Unknown text keeps the select's +// choice instead of inventing one. +function normalizeOverrideSetting(raw) { + const v = String(raw || '').trim().toLowerCase().replace(/\s+/g, '') + if (!v || v === 'd' || v === 'disabled') return '' + if (v === 'n' || v === 'native') return 'native' + if (v === 'b' || v === 'builtin') return 'builtin' + if (v === 'n,b' || v === 'native,builtin') return 'native,builtin' + if (v === 'b,n' || v === 'builtin,native') return 'builtin,native' + return null +} + function readOverrides() { - return qsa('#gc-overrides .override-row').map(row => ({ - dll: row.querySelector('.dll').value.trim(), - setting: row.querySelector('.custom-select').dataset.value ?? 'native' - })).filter(o => o.dll) + return qsa('#gc-overrides .override-row').map(row => { + let dll = row.querySelector('.dll').value.trim() + let setting = row.querySelector('.custom-select').dataset.value ?? 'native,builtin' + // "dinput8=native" pasted into the name box is the form every guide uses - + // split it here instead of storing a name with '=' that Wine cannot parse + const eq = dll.indexOf('=') + if (eq > 0) { + const pasted = normalizeOverrideSetting(dll.slice(eq + 1)) + if (pasted !== null) setting = pasted + dll = dll.slice(0, eq).trim() + } + dll = dll.replace(/\.dll$/i, '') + return { dll, setting } + }).filter(o => o.dll) } async function saveGameConfig() { @@ -1322,10 +1350,14 @@ async function saveGameConfig() { wine: selects['gc-wine'].value, prefix, env, - dllOverrides: readOverrides() + dllOverrides: readOverrides(), + disableUmu: qs('#gc-disable-umu').checked } await window.plwl.gamesUpdate(game.id, patch) state.games = await window.plwl.gamesList() + // show what was actually stored: a pasted "name=setting" was split and a + // .dll suffix stripped, so the rows would otherwise disagree with the file + renderOverrides(patch.dllOverrides) toast(t('config.saved'), 'success') if (prefixLockedHint) toast(t('config.prefixLocked'), 'error') if (state.currentView === 'gameconfig') { diff --git a/renderer/index.html b/renderer/index.html index 40f3631..714e1c4 100644 --- a/renderer/index.html +++ b/renderer/index.html @@ -180,12 +180,19 @@ + +

+
+

diff --git a/src/importer.js b/src/importer.js index 78cba15..5a19765 100644 --- a/src/importer.js +++ b/src/importer.js @@ -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' } diff --git a/src/library.js b/src/library.js index 309d3c9..7aa98af 100644 --- a/src/library.js +++ b/src/library.js @@ -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, diff --git a/src/openwith.js b/src/openwith.js index fb256a1..520c4de 100644 --- a/src/openwith.js +++ b/src/openwith.js @@ -73,6 +73,7 @@ function makeTarget(openPath, wineId, prefixName) { prefix: prefixName || '', args: '', dllOverrides: [], + disableUmu: false, env: {}, iconSource: '', playSeconds: 0, diff --git a/src/prefixes.js b/src/prefixes.js index 1a7b237..854b5e5 100644 --- a/src/prefixes.js +++ b/src/prefixes.js @@ -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=), Proton-managed prefixes nest one under pfx/ -// (WINEPREFIX=/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=), legacy Proton-managed prefixes nest one under pfx/ +// (WINEPREFIX=/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 /drive_c, Proton always runs its prefix +// from /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 +// /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 } diff --git a/src/runner.js b/src/runner.js index 635febc..441fe2b 100644 --- a/src/runner.js +++ b/src/runner.js @@ -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 /drive_c/Program Files (x86)/Steam/ // appcache -> /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 +// /legacycompat into the prefix, its built-in steam.exe loads the +// client's own steamclient.so, and media libs come from /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 diff --git a/src/symlinks.js b/src/symlinks.js index cf724d9..e1f774c 100644 --- a/src/symlinks.js +++ b/src/symlinks.js @@ -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)