Merge pull request #8 from lutris/master

Update to latest commits
This commit is contained in:
Domenico 2023-05-21 22:51:42 +02:00 committed by GitHub
commit 9b058eaaa2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
49 changed files with 1944 additions and 1555 deletions

View file

@ -74,7 +74,7 @@ if [[ -z $PPA_GPG_KEY_ID ]]; then
PPA_GPG_KEY_ID=$(echo "${PPA_GPG_PRIVATE_KEY}" | gpg --import-options show-only --import | sed -n '2s/^\s*//p')
export PPA_GPG_KEY_ID
echo "${PPA_GPG_KEY_ID}"
echo "${PPA_GPG_PRIVATE_KEY}" | gpg --batch --passphrase "${PPA_GPG_PASSPHRASE}" --import
echo "${PPA_GPG_PRIVATE_KEY}" | gpg --batch --passphrase ${PPA_GPG_PASSPHRASE} --import
echo "::endgroup::"
# May as well since we don't need after at this point.

View file

@ -18,6 +18,7 @@ addons:
- libdbus-1-dev
- python3-yaml
- python3-gi
- python3-gi-cairo
- python3-pil
- python3-setproctitle
- python3-distro

View file

@ -1,8 +1,8 @@
Copyright (C) 2010-2022 Mathieu Comandon <strider@strycore.com>
Copyright (C) 2009 Mathieu Comandon <mathieucomandon@gmail.com>
Contributors:
Mathieu Comandon <strider@strycore.com>
Mathieu Comandon <mathieucomandon@gmail.com>
Pascal Reinhard (Xodetaetl) <dev@xod.me>
Daniel J (@djazz)
Tom Todd

View file

@ -1,6 +1,10 @@
Contributing to Lutris
======================
IMPORTANT!
If you contribute to Lutris on a somewhat regular basis, be sure to add yourself to the AUTHORS file!
Finding features to work on
---------------------------
@ -19,9 +23,7 @@ issues](https://github.com/lutris/lutris/issues?q=is%3Aissue+is%3Aopen+label%3A%
that can't be reproduced on the developers setup. Other issues, tagged [need
help](https://github.com/lutris/lutris/issues?q=is%3Aissue+is%3Aopen+label%3A%22need+help%22)
might be a bit more technical to resolve but you can always have a look and see
if they fit your area of expertise. Also, while not fully ready, we do
appreciate receiving translations for other languages, support for i18n will
come in a future update.
if they fit your area of expertise.
Note that Lutris is not a playground or a toy project. One cannot submit new
features that aren't on the roadmap and submit a pull request for them without

View file

@ -12,7 +12,7 @@ Lutris manually, it requires the following components:
* Python >= 3.7
* PyGObject
* PyGObject bindings for: Gtk, Gdk, GnomeDesktop, Webkit2, Notify
* PyGObject bindings for: Gtk, Gdk, Cairo, GnomeDesktop, Webkit2, Notify
* python3-requests
* python3-pillow
* python3-yaml
@ -40,7 +40,7 @@ games themselves we recommend you install the following packages:
To install all those dependencies (except for Wine and graphics drivers)
on Ubuntu based systems, you can run::
sudo apt install python3-yaml python3-requests python3-pil python3-gi \
sudo apt install python3-yaml python3-requests python3-pil python3-gi python3-gi-cairo \
gir1.2-gtk-3.0 gir1.2-gnomedesktop-3.0 gir1.2-webkit2-4.0 \
gir1.2-notify-0.7 psmisc cabextract unzip p7zip curl fluid-soundfont-gs \
x11-xserver-utils python3-evdev libc6-i386 lib32gcc1 libgirepository1.0-dev \

View file

@ -36,9 +36,12 @@ github-ppa:
# so that _must_ be the last parameter.
echo "y" | debuild -S \
-k"${PPA_GPG_KEY_ID}" \
-p"gpg --batch --passphrase "${PPA_GPG_PASSPHRASE}" --pinentry-mode loopback" \
-p"gpg --batch --passphrase ${PPA_GPG_PASSPHRASE} --pinentry-mode loopback" \
--lintian-opts --suppress-tags malformed-debian-changelog-version
build-deps-ubuntu:
sudo apt install devscripts debhelper dh-python meson
build:
gbp buildpackage --git-debian-branch=${GITBRANCH}

4
debian/changelog vendored
View file

@ -21,11 +21,11 @@ lutris (0.5.13) jammy; urgency=medium
* Improve detection of DOSBox games on GOG
* Added "Unspecified" Vulkan ICD option
* Removed ResidualVM (now merged into ScummVM)
* Detect obsolete Vulkan drivers, warn and default to DXVK 1.x for them
* Detect obsolete Vulkan drivers and default to DXVK 1.x for them
* Improved High-DPI support for custom media
* Performance improvements
-- Mathieu Comandon <strider@strycore.com> Fri, 10 Feb 2023 13:15:40 -0800
-- Mathieu Comandon <mathieucomandon@gmail.com> Fri, 16 May 2023 13:15:40 -0800
lutris (0.5.12) jammy; urgency=medium

3
debian/control vendored
View file

@ -1,7 +1,7 @@
Source: lutris
Section: games
Priority: optional
Maintainer: Mathieu Comandon <strider@strycore.com>
Maintainer: Mathieu Comandon <mathieucomandon@gmail.com>
Build-Depends: debhelper-compat (= 12),
appstream,
dh-sequence-python3,
@ -21,6 +21,7 @@ Depends: ${misc:Depends},
python3-requests,
python3-pil,
python3-gi,
python3-gi-cairo,
python3-setproctitle,
python3-magic,
python3-distro,

6
debian/copyright vendored
View file

@ -1,16 +1,16 @@
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Upstream-Name: lutris
Upstream-Contact: Mathieu Comandon <strider@strycore.com>
Upstream-Contact: Mathieu Comandon <mathieucomandon@gmail.com>
Upstream-Source: https://github.com/lutris
Files: *
Copyright: 2009 Mathieu Comandon <strider@strycore.com>
Copyright: 2009 Mathieu Comandon <mathieucomandon@gmail.com>
License: GPL-3.0-or-later
On Debian systems, the complete text of the General Public License version 3
can be found in "/usr/share/common-licenses/GPL-3".
Files: share/metainfo/net.lutris.Lutris.metainfo.xml
Copyright: Lutris Team <strider@lutris.net>
Copyright: Lutris Team <mathieucomandon@gmail.com>
License: CC0-1.0
On Debian systems, the complete text of the CC0-1.0 license
can be found in "/usr/share/common-licenses/CC0-1.0".

View file

@ -246,3 +246,9 @@ def get_unusued_game_name(game_name):
assigned_name = f"{game_name} {assigned_index}"
return assigned_name
def get_game_count(param, value):
res = sql.db_select(settings.PGA_DB, "games", fields=("COUNT(id)",), condition=(param, value))
if res:
return res[0]["COUNT(id)"]

View file

@ -160,7 +160,7 @@ class AddGamesWindow(ModelessDialog): # pylint: disable=too-many-public-methods
self.destroy()
def on_watched_error(self, error):
ErrorDialog(str(error), parent=self)
ErrorDialog(error, parent=self)
# Initial Page
@ -370,7 +370,7 @@ class AddGamesWindow(ModelessDialog): # pylint: disable=too-many-public-methods
self.display_cancel_button(label=_("_Close"))
if error:
ErrorDialog(str(error), parent=self)
ErrorDialog(error, parent=self)
self.stack.navigation_reset()
return

View file

@ -1,6 +1,6 @@
# pylint: disable=wrong-import-position
#
# Copyright (C) 2009 Mathieu Comandon <strider@strycore.com>
# Copyright (C) 2009 Mathieu Comandon <mathieucomandon@gmail.com>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
@ -767,7 +767,7 @@ class Application(Gtk.Application):
def on_watched_error(self, error):
if self.window:
ErrorDialog(str(error), parent=self.window)
ErrorDialog(error, parent=self.window)
@staticmethod
def get_lutris_action(url):

View file

@ -9,12 +9,11 @@ from gi.repository import Gdk, Gtk
# Lutris Modules
from lutris import settings, sysoptions
from lutris.gui.dialogs import ErrorDialog
from lutris.gui.widgets.common import EditableGrid, FileChooserEntry, Label, VBox
from lutris.gui.widgets.searchable_combobox import SearchableCombobox
from lutris.runners import InvalidRunner, import_runner
from lutris.util.jobs import AsyncCall
from lutris.util.log import logger
from lutris.util.strings import gtk_safe
class ConfigBox(VBox):
@ -84,103 +83,106 @@ class ConfigBox(VBox):
# Go thru all options.
for option in self.options:
if "scope" in option:
if self.config_section not in option["scope"]:
continue
option_key = option["option"]
value = self.config.get(option_key)
try:
if "scope" in option:
if self.config_section not in option["scope"]:
continue
option_key = option["option"]
value = self.config.get(option_key)
if callable(option.get("choices")) and option["type"] != "choice_with_search":
option["choices"] = option["choices"]()
if callable(option.get("condition")):
option["condition"] = option["condition"]()
if callable(option.get("choices")) and option["type"] != "choice_with_search":
option["choices"] = option["choices"]()
if callable(option.get("condition")):
option["condition"] = option["condition"]()
if option.get("section") != current_section:
current_section = option.get("section")
if current_section:
frame = ConfigBox.SectionFrame(current_section)
current_vbox = frame.vbox
self.pack_start(frame, False, False, 0)
else:
current_vbox = self
if option.get("section") != current_section:
current_section = option.get("section")
if current_section:
frame = ConfigBox.SectionFrame(current_section)
current_vbox = frame.vbox
self.pack_start(frame, False, False, 0)
else:
current_vbox = self
self.wrapper = Gtk.Box()
self.wrapper.set_spacing(12)
self.wrapper.set_margin_bottom(6)
self.wrappers[option_key] = self.wrapper
self.wrapper = Gtk.Box()
self.wrapper.set_spacing(12)
self.wrapper.set_margin_bottom(6)
self.wrappers[option_key] = self.wrapper
# Set tooltip's "Default" part
default = option.get("default")
self.tooltip_default = default if isinstance(default, str) else None
# Set tooltip's "Default" part
default = option.get("default")
self.tooltip_default = default if isinstance(default, str) else None
# Generate option widget
self.option_widget = None
self.call_widget_generator(option, option_key, value, default)
# Generate option widget
self.option_widget = None
self.call_widget_generator(option, option_key, value, default)
# Reset button
reset_btn = Gtk.Button.new_from_icon_name("edit-undo-symbolic", Gtk.IconSize.MENU)
reset_btn.set_valign(Gtk.Align.CENTER)
reset_btn.set_margin_bottom(6)
reset_btn.set_relief(Gtk.ReliefStyle.NONE)
reset_btn.set_tooltip_text(_("Reset option to global or default config"))
reset_btn.connect(
"clicked",
self.on_reset_button_clicked,
option,
self.option_widget,
self.wrapper,
)
self.reset_buttons[option_key] = reset_btn
placeholder = Gtk.Box()
placeholder.set_size_request(32, 32)
if option_key not in self.raw_config:
reset_btn.set_visible(False)
reset_btn.set_no_show_all(True)
placeholder.pack_start(reset_btn, False, False, 0)
# Tooltip
helptext = option.get("help")
if isinstance(self.tooltip_default, str):
helptext = helptext + "\n\n" if helptext else ""
helptext += _("<b>Default</b>: ") + _(self.tooltip_default)
if value != default and option_key not in self.raw_config:
helptext = helptext + "\n\n" if helptext else ""
helptext += _(
"<i>(Italic indicates that this option is "
"modified in a lower configuration level.)</i>"
# Reset button
reset_btn = Gtk.Button.new_from_icon_name("edit-undo-symbolic", Gtk.IconSize.MENU)
reset_btn.set_valign(Gtk.Align.CENTER)
reset_btn.set_margin_bottom(6)
reset_btn.set_relief(Gtk.ReliefStyle.NONE)
reset_btn.set_tooltip_text(_("Reset option to global or default config"))
reset_btn.connect(
"clicked",
self.on_reset_button_clicked,
option,
self.option_widget,
self.wrapper,
)
if helptext:
self.wrapper.props.has_tooltip = True
self.wrapper.connect("query-tooltip", self.on_query_tooltip, helptext)
self.reset_buttons[option_key] = reset_btn
hbox = Gtk.Box(visible=True)
option_container = hbox
hbox.set_margin_left(18)
hbox.pack_end(placeholder, False, False, 5)
# Grey out option if condition unmet
if "condition" in option and not option["condition"]:
hbox.set_sensitive(False)
placeholder = Gtk.Box()
placeholder.set_size_request(32, 32)
hbox.pack_start(self.wrapper, True, True, 0)
if option_key not in self.raw_config:
reset_btn.set_visible(False)
reset_btn.set_no_show_all(True)
placeholder.pack_start(reset_btn, False, False, 0)
if "warning" in option:
option_container = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, visible=True)
option_container.pack_start(hbox, False, False, 0)
warning = ConfigBox.WarningBox(option["warning"])
warning.set_margin_left(18)
warning.set_margin_right(18)
warning.set_margin_bottom(6)
warning.update_warning(self.config)
self.warning_boxes[option_key] = warning
option_container.pack_start(warning, False, False, 0)
# Tooltip
helptext = option.get("help")
if isinstance(self.tooltip_default, str):
helptext = helptext + "\n\n" if helptext else ""
helptext += _("<b>Default</b>: ") + _(self.tooltip_default)
if value != default and option_key not in self.raw_config:
helptext = helptext + "\n\n" if helptext else ""
helptext += _(
"<i>(Italic indicates that this option is "
"modified in a lower configuration level.)</i>"
)
if helptext:
self.wrapper.props.has_tooltip = True
self.wrapper.connect("query-tooltip", self.on_query_tooltip, helptext)
# Hide if advanced
if option.get("advanced"):
option_container.get_style_context().add_class("advanced")
hbox = Gtk.Box(visible=True)
option_container = hbox
hbox.set_margin_left(18)
hbox.pack_end(placeholder, False, False, 5)
# Grey out option if condition unmet
if "condition" in option and not option["condition"]:
hbox.set_sensitive(False)
current_vbox.pack_start(option_container, False, False, 0)
hbox.pack_start(self.wrapper, True, True, 0)
if "warning" in option:
option_container = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, visible=True)
option_container.pack_start(hbox, False, False, 0)
warning = ConfigBox.WarningBox(option["warning"])
warning.set_margin_left(18)
warning.set_margin_right(18)
warning.set_margin_bottom(6)
warning.update_warning(self.config)
self.warning_boxes[option_key] = warning
option_container.pack_start(warning, False, False, 0)
# Hide if advanced
if option.get("advanced"):
option_container.get_style_context().add_class("advanced")
current_vbox.pack_start(option_container, False, False, 0)
except Exception as ex:
logger.exception("Failed to generate option widget for '%s': %s", option.get("option"), ex)
self.show_all()
show_advanced = settings.read_setting("show_advanced_options") == "True"
@ -202,10 +204,14 @@ class ConfigBox(VBox):
self.pack_start(self.warning_label, False, False, 0)
def update_warning(self, config):
if callable(self.warning):
text = self.warning(config)
else:
text = self.warning
try:
if callable(self.warning):
text = self.warning(config)
else:
text = self.warning
except Exception as err:
logger.exception("Unable to generate configuration warning: %s", err)
text = gtk_safe(err)
if text:
self.warning_label.set_markup(str(text))
@ -271,9 +277,6 @@ class ConfigBox(VBox):
elif option_type == "bool":
self.generate_checkbox(option, value)
self.tooltip_default = "Enabled" if default else "Disabled"
elif option_type == "extended_bool":
self.generate_checkbox_with_callback(option, value)
self.tooltip_default = "Enabled" if default else "Disabled"
elif option_type == "range":
self.generate_range(option_key, option["min"], option["max"], option["label"], value)
elif option_type == "string":
@ -317,49 +320,10 @@ class ConfigBox(VBox):
self.wrapper.pack_start(switch, False, False, 0)
self.option_widget = switch
# Checkbox with callback
def generate_checkbox_with_callback(self, option, value=None):
"""Generate a checkbox. With callback"""
label = Label(option["label"])
self.wrapper.pack_start(label, False, False, 0)
checkbox = Gtk.Switch()
checkbox.set_sensitive(option["active"] is True)
if value is True:
checkbox.set_active(value)
checkbox.connect("notify::active", self._on_toggle_with_callback, option)
checkbox.set_valign(Gtk.Align.CENTER)
self.wrapper.pack_start(checkbox, False, False, 0)
self.option_widget = checkbox
def checkbox_toggle(self, widget, _gparam, option_name):
"""Action for the checkbox's toggled signal."""
self.option_changed(widget, option_name, widget.get_active())
def _on_toggle_with_callback(self, widget, _gparam, option):
"""Action for the checkbox's toggled signal. With callback method"""
option_name = option["option"]
callback = option["callback"]
callback_on = option.get("callback_on")
if widget.get_active() == callback_on or callback_on is None:
AsyncCall(callback, self._on_callback_finished, widget, option, self.config)
else:
self.option_changed(widget, option_name, widget.get_active())
def _on_callback_finished(self, result, error):
if error:
ErrorDialog(str(error), parent=self.get_toplevel())
return
widget, option, response = result
if response:
self.option_changed(widget, option["option"], widget.get_active())
else:
widget.set_active(False)
# Entry
def generate_entry(self, option_name, label, value=None, option_size=None):
"""Generate an entry box."""

View file

@ -645,4 +645,4 @@ class GameDialogCommon(ModelessDialog, DialogInstallUIDelegate):
self._set_image(image_type, self.image_buttons[image_type])
def on_watched_error(self, error):
dialogs.ErrorDialog(str(error), parent=self)
dialogs.ErrorDialog(error, parent=self)

View file

@ -144,4 +144,4 @@ class RunnerBox(Gtk.Box):
self.action_alignment.add(self.get_action_button())
def on_watched_error(self, error):
ErrorDialog(str(error), parent=self.get_toplevel())
ErrorDialog(error, parent=self.get_toplevel())

View file

@ -15,7 +15,7 @@ from lutris.migrations import migrate
from lutris.util import datapath
from lutris.util.jobs import AsyncCall
from lutris.util.log import logger
from lutris.util.strings import gtk_safe
class Dialog(Gtk.Dialog):
@ -168,11 +168,18 @@ class WarningDialog(Gtk.MessageDialog):
class ErrorDialog(Gtk.MessageDialog):
"""Display an error message."""
def __init__(self, message, secondary=None, parent=None):
def __init__(self, error, secondary=None, parent=None):
super().__init__(buttons=Gtk.ButtonsType.OK, parent=parent)
# Some errors contain < and > and lok like markup, but aren't-
# we'll need to protect the message box against this
message = gtk_safe(error) if isinstance(error, BaseException) else str(error)
# Gtk doesn't wrap long labels containing no space correctly
# the length of the message is limited to avoid display issues
self.set_markup(message[:256])
if secondary:
self.format_secondary_text(secondary[:256])
@ -285,7 +292,7 @@ class LutrisInitDialog(Gtk.Dialog):
def init_cb(self, _result, error):
if error:
ErrorDialog(str(error), parent=self)
ErrorDialog(error, parent=self)
self.destroy()
def on_response(self, _widget, response):
@ -551,7 +558,7 @@ class MoveDialog(ModelessDialog):
def on_game_moved(self, _result, error):
if error:
ErrorDialog(str(error), parent=self)
ErrorDialog(error, parent=self)
self.emit("game-moved")
self.destroy()

View file

@ -252,7 +252,7 @@ class InstallerWindow(ModelessDialog,
)
def on_watched_error(self, error):
ErrorDialog(str(error), parent=self)
ErrorDialog(error, parent=self)
self.stack.navigation_reset()
def set_status(self, text):

View file

@ -535,13 +535,15 @@ class LutrisWindow(Gtk.ApplicationWindow,
def show_empty_label(self):
"""Display a label when the view is empty"""
filter_text = self.filters.get("text")
has_uninstalled_games = games_db.get_game_count("installed", "0")
has_hidden_games = games_db.get_game_count("hidden", "1")
if filter_text:
if self.filters.get("category") == "favorite":
self.show_label(_("Add a game matching '%s' to your favorites to see it here.") % filter_text)
elif self.filters.get("installed"):
elif self.filters.get("installed") and has_uninstalled_games:
self.show_label(
_("No installed games matching '%s' found. Press Ctrl+I to show uninstalled games.") % filter_text)
elif self.filters.get("hidden") is False: # but not if missing!
elif self.filters.get("hidden") is False and has_hidden_games: # but not if missing!
self.show_label(_("No visible games matching '%s' found. Press Ctrl+H to show hidden games.") %
filter_text)
else:
@ -549,14 +551,15 @@ class LutrisWindow(Gtk.ApplicationWindow,
else:
if self.filters.get("category") == "favorite":
self.show_label(_("Add games to your favorites to see them here."))
elif self.filters.get("installed"):
elif self.filters.get("installed") and has_uninstalled_games:
self.show_label(_("No installed games found. Press Ctrl+I to show uninstalled games."))
elif self.filters.get("hidden") is False: # but not if missing!
elif self.filters.get("hidden") is False and has_hidden_games: # but not if missing!
self.show_label(_("No visible games found. Press Ctrl+H to show hidden games."))
elif (
not self.filters.get("runner")
and not self.filters.get("service")
and not self.filters.get("platform")
and not self.filters.get("dynamic_category")
):
self.show_splash()
else:
@ -791,7 +794,7 @@ class LutrisWindow(Gtk.ApplicationWindow,
def _service_reloaded_cb(self, error):
if error:
dialogs.ErrorDialog(str(error), parent=self)
dialogs.ErrorDialog(error, parent=self)
def on_service_logout(self, service):
if self.service and service.id == self.service.id:
@ -874,7 +877,7 @@ class LutrisWindow(Gtk.ApplicationWindow,
def on_game_unhandled_error(self, game, error):
"""Called when a game has sent the 'game-error' signal"""
dialogs.ErrorDialog(str(error), parent=self)
dialogs.ErrorDialog(error, parent=self)
return True
@GtkTemplate.Callback
@ -1054,4 +1057,4 @@ class LutrisWindow(Gtk.ApplicationWindow,
game.emit("game-install")
def on_watched_error(self, error):
dialogs.ErrorDialog(str(error), parent=self)
dialogs.ErrorDialog(error, parent=self)

View file

@ -29,17 +29,18 @@ class GameGridView(Gtk.IconView, GameView):
self.image_renderer = None
self.set_item_padding(1)
if hide_text:
self.cell_renderer = None
self.text_renderer = None
else:
self.cell_renderer = GridViewCellRendererText()
self.pack_end(self.cell_renderer, False)
self.add_attribute(self.cell_renderer, "markup", COL_NAME)
self.text_renderer = GridViewCellRendererText()
self.pack_end(self.text_renderer, False)
self.add_attribute(self.text_renderer, "markup", COL_NAME)
self.set_game_store(store)
self.connect_signals()
self.connect("item-activated", self.on_item_activated)
self.connect("selection-changed", self.on_selection_changed)
self.connect("style-updated", self.on_style_updated)
def set_game_store(self, game_store):
self.game_store = game_store
@ -53,9 +54,9 @@ class GameGridView(Gtk.IconView, GameView):
self.image_renderer.media_width = size[0]
self.image_renderer.media_height = size[1]
if self.cell_renderer:
if self.text_renderer:
cell_width = max(size[0], self.min_width)
self.cell_renderer.set_width(cell_width)
self.text_renderer.set_width(cell_width)
@property
def show_badges(self):
@ -104,3 +105,7 @@ class GameGridView(Gtk.IconView, GameView):
selected_items = self.get_selected_item()
if selected_items:
self.emit("game-selected", selected_items)
def on_style_updated(self, widget):
if self.text_renderer:
self.text_renderer.clear_caches()

View file

@ -12,7 +12,8 @@ from lutris.gui.widgets.utils import (
class GridViewCellRendererText(Gtk.CellRendererText):
"""CellRendererText adjusted for grid view display, removes extra padding"""
"""CellRendererText adjusted for grid view display, removes extra padding
and caches cell metrics for improved resize performance."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@ -20,9 +21,66 @@ class GridViewCellRendererText(Gtk.CellRendererText):
self.props.wrap_mode = Pango.WrapMode.WORD
self.props.xalign = 0.5
self.props.yalign = 0
self.fixed_width = None
self.cached_height = {}
self.cached_width = {}
def set_width(self, width):
self.fixed_width = width
self.props.wrap_width = width
self.clear_caches()
def clear_caches(self):
self.cached_height.clear()
self.cached_width.clear()
def do_get_preferred_width(self, widget):
text = self.props.text # pylint:disable=no-member
if self.fixed_width and text in self.cached_width:
return self.cached_width[text]
width = Gtk.CellRendererText.do_get_preferred_width(self, widget)
if self.fixed_width:
self.cached_width[text] = width
return width
def do_get_preferred_width_for_height(self, widget, width):
text = self.props.text # pylint:disable=no-member
if self.fixed_width and text in self.cached_width:
return self.cached_width[text]
width = Gtk.CellRendererText.do_get_preferred_width_for_height(self, widget, width)
if self.fixed_width:
self.cached_width[text] = width
return width
def do_get_preferred_height(self, widget):
text = self.props.text # pylint:disable=no-member
if self.fixed_width and text in self.cached_height:
return self.cached_height[text]
height = Gtk.CellRendererText.do_get_preferred_height(self, widget)
if self.fixed_width:
self.cached_height[text] = height
return height
def do_get_preferred_height_for_width(self, widget, width):
text = self.props.text # pylint:disable=no-member
if self.fixed_width and text in self.cached_height:
return self.cached_height[text]
height = Gtk.CellRendererText.do_get_preferred_height_for_width(self, widget, width)
if self.fixed_width:
self.cached_height[text] = height
return height
class GridViewCellRendererImage(Gtk.CellRenderer):
@ -39,6 +97,7 @@ class GridViewCellRendererImage(Gtk.CellRenderer):
self._is_installed = True
self.cached_surfaces_new = {}
self.cached_surfaces_old = {}
self.cached_surfaces_loaded = 0
self.cycle_cache_idle_id = None
self.cached_surface_generation = 0
@ -115,15 +174,14 @@ class GridViewCellRendererImage(Gtk.CellRenderer):
if surface:
x, y = self.get_media_position(surface, cell_area)
surface_width = get_surface_size(surface)[0]
if alpha >= 1:
self.render_media(cr, widget, surface, x, y)
self.render_platforms(cr, widget, x + surface_width, cell_area)
self.render_platforms(cr, widget, surface, x, cell_area)
else:
cr.push_group()
self.render_media(cr, widget, surface, x, y)
self.render_platforms(cr, widget, x + surface_width, cell_area)
self.render_platforms(cr, widget, surface, x, cell_area)
cr.pop_group_to_source()
cr.paint_with_alpha(alpha)
@ -132,6 +190,50 @@ class GridViewCellRendererImage(Gtk.CellRenderer):
if not self.cycle_cache_idle_id:
self.cycle_cache_idle_id = GLib.idle_add(self.cycle_cache)
@staticmethod
def is_bright_corner(surface, corner_size):
"""Tests several pixels near the corner of the surface where the badges
are drawn. If all are 'bright', we'll render the badges differently. This
means all 4 components must be at least 128/255."""
surface_format = surface.get_format()
# We only use the ARGB32 format, so we just give up
# for anything else.
if surface_format != cairo.FORMAT_ARGB32: # pylint:disable=no-member
return False
# Scale the corner according to the surface's scale factor -
# normally the same as our UI scale factor.
device_scale_x, device_scale_y = surface.get_device_scale()
corner_pixel_width = int(corner_size[0] * device_scale_x)
corner_pixel_height = int(corner_size[1] * device_scale_y)
pixel_width = surface.get_width()
pixel_height = surface.get_height()
def is_bright_pixel(x, y):
# Checks if a pixel is 'bright'; this does not care
# if the pixel is big or little endian- it just checks
# all four channels.
if 0 <= x < pixel_width and 0 <= y < pixel_height:
stride = surface.get_stride()
data = surface.get_data()
offset = (y * stride) + x * 4
pixel = data[offset: offset + 4]
for channel in pixel:
if channel < 128:
return False
return True
return False
return (
is_bright_pixel(pixel_width - 1, pixel_height - 1)
and is_bright_pixel(pixel_width - corner_pixel_width, pixel_height - 1)
and is_bright_pixel(pixel_width - 1, pixel_height - corner_pixel_height)
and is_bright_pixel(pixel_width - corner_pixel_width, pixel_height - corner_pixel_height)
)
def get_media_position(self, surface, cell_area):
"""Computes the position of the upper left corner where we will render
a surface within the cell area."""
@ -162,7 +264,7 @@ class GridViewCellRendererImage(Gtk.CellRenderer):
cr.rectangle(x, y, width, height)
cr.fill()
def render_platforms(self, cr, widget, media_right, cell_area):
def render_platforms(self, cr, widget, surface, surface_x, cell_area):
"""Renders the stack of platform icons. They appear lined up vertically to the
right of 'media_right', if that will fit in 'cell_area'."""
platform = self.platform
@ -176,23 +278,33 @@ class GridViewCellRendererImage(Gtk.CellRenderer):
icon_paths = [get_runtime_icon_path(p + "-symbolic") for p in platforms]
icon_paths = [path for path in icon_paths if path]
if icon_paths:
self.render_badge_stack(cr, widget, icon_paths, icon_size, media_right, cell_area)
self.render_badge_stack(cr, widget, surface, surface_x, icon_paths, icon_size, cell_area)
def render_badge_stack(self, cr, widget, icon_paths, icon_size, media_right, cell_area):
def render_badge_stack(self, cr, widget, surface, surface_x, icon_paths, icon_size, cell_area):
"""Renders a vertical stack of badges, placed at the edge of the media, off to the right
of 'media_right' if this will fit in the 'cell_area'. The icons in icon_paths are drawn from
top to bottom, and spaced to fit in 'cell_area', even if they overlap because of this."""
def render_badge(badge_x, badge_y, path):
cr.rectangle(badge_x, badge_y, icon_size[0], icon_size[0])
cr.set_source_rgba(0.2, 0.2, 0.2, 0.6)
cr.fill()
icon = self.get_cached_surface_by_path(widget, path, size=icon_size)
cr.set_source_rgba(0.8, 0.8, 0.8, 0.6)
cr.mask_surface(icon, badge_x, badge_y)
badge_width = icon_size[0]
badge_height = icon_size[1]
on_bright_surface = GridViewCellRendererImage.is_bright_corner(surface, (badge_width, badge_height))
alpha = 0.6
bright_color = 0.8, 0.8, 0.8
dark_color = 0.2, 0.2, 0.2
back_color = bright_color if on_bright_surface else dark_color
fore_color = dark_color if on_bright_surface else bright_color
def render_badge(badge_x, badge_y, path):
cr.rectangle(badge_x, badge_y, icon_size[0], icon_size[0])
cr.set_source_rgba(back_color[0], back_color[1], back_color[2], alpha)
cr.fill()
icon = self.get_cached_surface_by_path(widget, path, size=icon_size)
cr.set_source_rgba(fore_color[0], fore_color[1], fore_color[2], alpha)
cr.mask_surface(icon, badge_x, badge_y)
media_right = surface_x + get_surface_size(surface)[0]
x = media_right - badge_width
spacing = (cell_area.height - badge_height * len(icon_paths)) / max(1, len(icon_paths) - 1)
@ -215,9 +327,15 @@ class GridViewCellRendererImage(Gtk.CellRenderer):
We call this at idle time after rendering a cell; this should keep all the surfaces
rendered at that time, so during scrolling the visible media are kept and scrolling is smooth.
At other times we may discard almost all surfaces, saving memory."""
self.cached_surfaces_old = self.cached_surfaces_new
self.cached_surfaces_new = {}
At other times we may discard almost all surfaces, saving memory.
We skip clearing anything if no surfaces have been loaded; this happens if drawing was
serviced entirely from cache. GTK may have redrawn just one image or something, so
let's not disturb the cache for that."""
if self.cached_surfaces_loaded > 0:
self.cached_surfaces_old = self.cached_surfaces_new
self.cached_surfaces_new = {}
self.cached_surfaces_loaded = 0
self.cycle_cache_idle_id = None
def get_cached_surface_by_path(self, widget, path, size=None, preserve_aspect_ratio=True):
@ -231,14 +349,17 @@ class GridViewCellRendererImage(Gtk.CellRenderer):
key = widget, path, size, preserve_aspect_ratio
surface = self.cached_surfaces_new.get(key)
if surface:
return surface
if key in self.cached_surfaces_new:
return self.cached_surfaces_new[key]
surface = self.cached_surfaces_old.get(key)
if not surface:
if key in self.cached_surfaces_old:
surface = self.cached_surfaces_old[key]
else:
surface = self.get_surface_by_path(widget, path, size, preserve_aspect_ratio)
# We cache missing surfaces too, but only a successful load trigger
# cache cycling
if surface:
self.cached_surfaces_loaded += 1
self.cached_surfaces_new[key] = surface
return surface

View file

@ -76,7 +76,7 @@ class SearchableCombobox(Gtk.Bin):
def _populate_combobox_choices_cb(self, _result, error):
if error:
ErrorDialog(str(error), parent=self.get_toplevel())
ErrorDialog(error, parent=self.get_toplevel())
@staticmethod
def _on_combobox_scroll(combobox, _event):

View file

@ -15,6 +15,7 @@ from lutris.gui.dialogs import ErrorDialog
from lutris.gui.dialogs.runner_install import RunnerInstallDialog
from lutris.gui.widgets.utils import has_stock_icon
from lutris.installer.interpreter import ScriptInterpreter
from lutris.runners import InvalidRunner
from lutris.services import SERVICES
from lutris.services.base import AuthTokenExpired, BaseService
@ -166,7 +167,7 @@ class ServiceSidebarRow(SidebarRow):
self.service.logout()
self.service.login(parent=self.get_toplevel())
else:
ErrorDialog(str(error), parent=self.get_toplevel())
ErrorDialog(error, parent=self.get_toplevel())
GLib.timeout_add(2000, self.enable_refresh_button)
def enable_refresh_button(self):
@ -212,7 +213,11 @@ class RunnerSidebarRow(SidebarRow):
# Creation is delayed because only installed runners can be imported
# and all visible boxes should be installed.
self.runner = runners.import_runner(self.id)()
try:
self.runner = runners.import_runner(self.id)()
except InvalidRunner:
return entries
if self.runner.multiple_versions:
entries.append((
"system-software-install-symbolic",
@ -243,7 +248,7 @@ class RunnerSidebarRow(SidebarRow):
runner=self.runner, parent=self.get_toplevel())
def on_watched_error(self, error):
dialogs.ErrorDialog(str(error), parent=self.get_toplevel())
dialogs.ErrorDialog(error, parent=self.get_toplevel())
class SidebarHeader(Gtk.Box):

View file

@ -40,7 +40,7 @@ class ScriptInterpreter(GObject.Object, CommandsMixin):
def report_error(self, error):
"""Called to report an error during installation. The installation will then stop."""
logger.exception("Error during installation: %s", str(error))
logger.exception("Error during installation: %s", error)
def report_status(self, status):
"""Called to report the current activity of the installer."""
@ -223,6 +223,11 @@ class ScriptInterpreter(GObject.Object, CommandsMixin):
_("Lutris does not have the necessary permissions to install to path:"),
self.target_path,
) from err
except FileNotFoundError as err:
raise ScriptingError(
_("Path %s not found, unable to create game folder. Is the disk mounted?"),
self.target_path,
) from err
def get_runners_to_install(self):
"""Check if the runner is installed before starting the installation

View file

@ -120,7 +120,10 @@ def get_runner_human_name(runner_name):
names."""
if runner_name:
if runner_name not in _cached_runner_human_names:
_cached_runner_human_names[runner_name] = import_runner(runner_name)().human_name
try:
_cached_runner_human_names[runner_name] = import_runner(runner_name)().human_name
except InvalidRunner:
_cached_runner_human_names[runner_name] = runner_name # an obsolete runner
return _cached_runner_human_names[runner_name]
return ""

View file

@ -247,7 +247,7 @@ def wineexec( # noqa: C901
exclude_processes = shlex.split(exclude_processes)
if not runner:
runner = import_runner("wine")()
runner = import_runner("wine")(prefix=prefix, working_dir=working_dir, wine_arch=arch)
if not wine_path:
wine_path = runner.get_executable()

View file

@ -3,6 +3,7 @@ import os
import shlex
from gettext import gettext as _
from lutris import settings
# Lutris Modules
from lutris.runners.commands.dosbox import dosexec, makeconfig # NOQA pylint: disable=unused-import
from lutris.runners.runner import Runner
@ -14,8 +15,8 @@ class dosbox(Runner):
description = _("MS-DOS emulator")
platforms = [_("MS-DOS")]
runnable_alone = True
runner_executable = "dosbox/bin/dosbox"
require_libs = ["libopusfile.so.0", ]
runner_executable = "dosbox/dosbox"
require_libs = []
game_options = [
{
"option": "main_file",
@ -120,6 +121,23 @@ class dosbox(Runner):
def main_file(self):
return self.make_absolute(self.game_config.get("main_file"))
@property
def libs_dir(self):
path = os.path.join(settings.RUNNER_DIR, "dosbox/lib")
return path if system.path_exists(path) else ""
def get_command(self):
return [
self.get_executable(),
]
def get_run_data(self):
env = self.get_env()
env["LD_LIBRARY_PATH"] = os.pathsep.join(filter(None, [
self.libs_dir,
env.get("LD_LIBRARY_PATH")]))
return {"env": env, "command": self.get_command()}
@property
def working_dir(self):
"""Return the working directory to use when running the game."""
@ -162,4 +180,4 @@ class dosbox(Runner):
if args:
command.extend(args)
return {"command": command}
return {"command": command, "ld_library_path": self.libs_dir}

View file

@ -31,6 +31,7 @@ class JsonRunner(Runner):
self.system_options_override = self._json_data.get("system_options_override", [])
self.entry_point_option = self._json_data.get("entry_point_option", "main_file")
self.download_url = self._json_data.get("download_url")
self.runnable_alone = self._json_data.get("runnable_alone")
def play(self):
"""Return a launchable command constructed from the options"""

View file

@ -37,19 +37,7 @@ class pcsx2(Runner):
"type": "bool",
"label": _("No GUI"),
"default": False
},
{
"option": "config_file",
"type": "file",
"label": _("Custom config file"),
"advanced": True,
},
{
"option": "config_path",
"type": "directory_chooser",
"label": _("Custom config path"),
"advanced": True,
},
}
]
# PCSX2 currently uses an AppImage, no need for the runtime.
@ -59,15 +47,11 @@ class pcsx2(Runner):
arguments = [self.get_executable()]
if self.runner_config.get("fullscreen"):
arguments.append("--fullscreen")
arguments.append("-fullscreen")
if self.runner_config.get("full_boot"):
arguments.append("--fullboot")
arguments.append("-slowboot")
if self.runner_config.get("nogui"):
arguments.append("--nogui")
if self.runner_config.get("config_file"):
arguments.append("--cfg={}".format(self.runner_config["config_file"]))
if self.runner_config.get("config_path"):
arguments.append("--cfgpath={}".format(self.runner_config["config_path"]))
arguments.append("-nogui")
iso = self.game_config.get("main_file") or ""
if not system.path_exists(iso):

View file

@ -13,14 +13,13 @@ from lutris.runners.commands.wine import ( # noqa: F401 pylint: disable=unused-
from lutris.runners.runner import Runner
from lutris.util import system
from lutris.util.display import DISPLAY_MANAGER, get_default_dpi
from lutris.util.graphics.vkquery import is_vulkan_supported
from lutris.util.jobs import thread_safe_call
from lutris.util.graphics import vkquery
from lutris.util.log import logger
from lutris.util.steam.config import get_steam_dir
from lutris.util.strings import parse_version, split_arguments
from lutris.util.wine.d3d_extras import D3DExtrasManager
from lutris.util.wine.dgvoodoo2 import dgvoodoo2Manager
from lutris.util.wine.dxvk import DXVKManager
from lutris.util.wine.dxvk import REQUIRED_VULKAN_API_VERSION, DXVKManager
from lutris.util.wine.dxvk_nvapi import DXVKNVAPIManager
from lutris.util.wine.extract_icon import PEFILE_AVAILABLE, ExtractIcon
from lutris.util.wine.prefix import DEFAULT_DLL_OVERRIDES, WinePrefixManager, find_prefix
@ -36,6 +35,115 @@ DEFAULT_WINE_PREFIX = "~/.wine"
MIN_SAFE_VERSION = "7.0" # Wine installers must run with at least this version
def _get_prefix_warning(config):
if config.get("prefix"):
return None
exe = config.get("exe")
if exe and find_prefix(exe):
return None
return _("Some Wine configuration options cannot be applied, if no prefix can be found.")
def _get_dxvk_warning(config):
if config.get("dxvk") and not vkquery.is_vulkan_supported():
return _("Vulkan is not installed or is not supported by your system")
return None
def _get_dxvk_version_warning(config):
if config.get("dxvk") and vkquery.is_vulkan_supported():
version = config.get("dxvk_version")
if version and not version.startswith("v1."):
required_api_version = REQUIRED_VULKAN_API_VERSION
library_api_version = vkquery.get_vulkan_api_version()
if library_api_version and library_api_version < required_api_version:
return _("<b>Warning</b> Lutris has detected that Vulkan API version %s is installed, "
"but to use the latest DXVK version, %s is required."
) % (
vkquery.format_version(library_api_version),
vkquery.format_version(required_api_version)
)
devices = vkquery.get_device_info()
if devices and devices[0].api_version < required_api_version:
return _(
"<b>Warning</b> Lutris has detected that the best device available ('%s') supports Vulkan API %s, "
"but to use the latest DXVK version, %s is required."
) % (
devices[0].name,
vkquery.format_version(devices[0].api_version),
vkquery.format_version(required_api_version)
)
return None
def _get_vkd3d_warning(config):
if config.get("vkd3d"):
if not vkquery.is_vulkan_supported():
return _("<b>Warning</b> Vulkan is not installed or is not supported by your system")
return None
def _get_path_for_version(config, version=None):
"""Return the absolute path of a wine executable for a given version,
or the configured version if you don't ask for a version."""
if not version:
version = config["version"]
if version in WINE_PATHS:
return system.find_executable(WINE_PATHS[version])
if "Proton" in version:
for proton_path in get_proton_paths():
if os.path.isfile(os.path.join(proton_path, version, "dist/bin/wine")):
return os.path.join(proton_path, version, "dist/bin/wine")
if os.path.isfile(os.path.join(proton_path, version, "files/bin/wine")):
return os.path.join(proton_path, version, "files/bin/wine")
if version.startswith("PlayOnLinux"):
version, arch = version.split()[1].rsplit("-", 1)
return os.path.join(POL_PATH, "wine", "linux-" + arch, version, "bin/wine")
if version == "custom":
return config.get("custom_wine_path", "")
return os.path.join(WINE_DIR, version, "bin/wine")
def _get_esync_warning(config):
if config.get("esync"):
limits_set = is_esync_limit_set()
wine_path = _get_path_for_version(config)
wine_ver = is_version_esync(wine_path)
if not wine_ver:
return _("<b>Warning</b> The Wine build you have selected does not support Esync")
if not limits_set:
return _("<b>Warning</b> Your limits are not set correctly. Please increase them as described here:\n"
"<a href='https://github.com/lutris/docs/blob/master/HowToEsync.md'>"
"How-to-Esync (https://github.com/lutris/docs/blob/master/HowToEsync.md)</a>")
return None
def _get_fsync_warning(config):
if config.get("fsync"):
fsync_supported = is_fsync_supported()
wine_path = _get_path_for_version(config)
wine_ver = is_version_fsync(wine_path)
if not wine_ver:
return _("<b>Warning</b> The Wine build you have selected does not support Fsync.")
if not fsync_supported:
return _("<b>Warning</b> Your kernel is not patched for fsync.")
return None
class wine(Runner):
description = _("Runs Windows games")
human_name = _("Wine")
@ -71,6 +179,7 @@ class wine(Runner):
"option": "prefix",
"type": "directory_chooser",
"label": _("Wine prefix"),
"warning": _get_prefix_warning,
"help": _(
'The prefix used by Wine.\n'
"It's a directory containing a set of files and "
@ -106,8 +215,11 @@ class wine(Runner):
"wineboot.exe",
)
def __init__(self, config=None): # noqa: C901
def __init__(self, config=None, prefix=None, working_dir=None, wine_arch=None): # noqa: C901
super().__init__(config)
self._prefix = prefix
self._working_dir = working_dir
self._wine_arch = wine_arch
self.dll_overrides = DEFAULT_DLL_OVERRIDES.copy() # we'll modify this, so we better copy it
def get_wine_version_choices():
@ -128,43 +240,6 @@ class wine(Runner):
version_choices.append((label, version))
return version_choices
def esync_limit_callback(widget, option, config):
limits_set = is_esync_limit_set()
wine_path = self.get_path_for_version(config["version"])
wine_ver = is_version_esync(wine_path)
response = True
if not wine_ver:
response = thread_safe_call(esync_display_version_warning)
if not limits_set:
thread_safe_call(esync_display_limit_warning)
response = False
return widget, option, response
def fsync_support_callback(widget, option, config):
fsync_supported = is_fsync_supported()
wine_path = self.get_path_for_version(config["version"])
wine_ver = is_version_fsync(wine_path)
response = True
if not wine_ver:
response = thread_safe_call(fsync_display_version_warning)
if not fsync_supported:
thread_safe_call(fsync_display_support_warning)
response = False
return widget, option, response
def dxvk_vulkan_callback(widget, option, config):
response = True
if not is_vulkan_supported():
if not thread_safe_call(display_vulkan_error):
response = False
return widget, option, response
self.runner_options = [
{
"option": "version",
@ -198,10 +273,9 @@ class wine(Runner):
"option": "dxvk",
"section": _("Graphics"),
"label": _("Enable DXVK"),
"type": "extended_bool",
"callback": dxvk_vulkan_callback,
"callback_on": True,
"type": "bool",
"default": True,
"warning": _get_dxvk_warning,
"active": True,
"help": _(
"Use DXVK to "
@ -216,15 +290,15 @@ class wine(Runner):
"type": "choice_with_entry",
"choices": DXVKManager().version_choices,
"default": DXVKManager().version,
"warning": _get_dxvk_version_warning
},
{
"option": "vkd3d",
"section": _("Graphics"),
"label": _("Enable VKD3D"),
"type": "extended_bool",
"callback": dxvk_vulkan_callback,
"callback_on": True,
"type": "bool",
"warning": _get_vkd3d_warning,
"default": True,
"active": True,
"help": _(
@ -306,9 +380,8 @@ class wine(Runner):
{
"option": "esync",
"label": _("Enable Esync"),
"type": "extended_bool",
"callback": esync_limit_callback,
"callback_on": True,
"type": "bool",
"warning": _get_esync_warning,
"active": True,
"default": True,
"help": _(
@ -320,10 +393,9 @@ class wine(Runner):
{
"option": "fsync",
"label": _("Enable Fsync"),
"type": "extended_bool",
"type": "bool",
"default": is_fsync_supported(),
"callback": fsync_support_callback,
"callback_on": True,
"warning": _get_fsync_warning,
"active": True,
"help": _(
"Enable futex-based synchronization (fsync). "
@ -517,14 +589,26 @@ class wine(Runner):
@property
def prefix_path(self):
"""Return the absolute path of the Wine prefix"""
_prefix_path = self.game_config.get("prefix") or os.environ.get("WINEPREFIX")
"""Return the absolute path of the Wine prefix. Falls back to default WINE prefix."""
_prefix_path = self._get_raw_prefix_path()
if not _prefix_path:
logger.warning("No WINE prefix provided, falling back to system default WINE prefix.")
_prefix_path = DEFAULT_WINE_PREFIX
return os.path.expanduser(_prefix_path)
@property
def prefix_path_if_provided(self):
"""Return the absolute path of the Wine prefix, if known. None if not."""
_prefix_path = self._get_raw_prefix_path()
if _prefix_path:
return os.path.expanduser(_prefix_path)
def _get_raw_prefix_path(self):
_prefix_path = self._prefix or self.game_config.get("prefix") or os.environ.get("WINEPREFIX")
if not _prefix_path and self.game_config.get("exe"):
# Find prefix from game if we have one
_prefix_path = find_prefix(self.game_exe)
if not _prefix_path:
_prefix_path = DEFAULT_WINE_PREFIX
return os.path.expanduser(_prefix_path)
return _prefix_path
@property
def game_exe(self):
@ -544,9 +628,9 @@ class wine(Runner):
@property
def working_dir(self):
"""Return the working directory to use when running the game."""
option = self.game_config.get("working_dir")
if option:
return option
_working_dir = self._working_dir or self.game_config.get("working_dir")
if _working_dir:
return _working_dir
if self.game_exe:
game_dir = os.path.dirname(self.game_exe)
if os.path.isdir(game_dir):
@ -563,7 +647,7 @@ class wine(Runner):
"""Return the wine architecture.
Get it from the config or detect it from the prefix"""
arch = self.game_config.get("arch") or "auto"
arch = self._wine_arch or self.game_config.get("arch") or "auto"
if arch not in ("win32", "win64"):
arch = detect_arch(self.prefix_path, self.get_executable())
return arch
@ -588,20 +672,7 @@ class wine(Runner):
def get_path_for_version(self, version):
"""Return the absolute path of a wine executable for a given version"""
if version in WINE_PATHS:
return system.find_executable(WINE_PATHS[version])
if "Proton" in version:
for proton_path in get_proton_paths():
if os.path.isfile(os.path.join(proton_path, version, "dist/bin/wine")):
return os.path.join(proton_path, version, "dist/bin/wine")
if os.path.isfile(os.path.join(proton_path, version, "files/bin/wine")):
return os.path.join(proton_path, version, "files/bin/wine")
if version.startswith("PlayOnLinux"):
version, arch = version.split()[1].rsplit("-", 1)
return os.path.join(POL_PATH, "wine", "linux-" + arch, version, "bin/wine")
if version == "custom":
return self.runner_config.get("custom_wine_path", "")
return os.path.join(WINE_DIR, version, "bin/wine")
return _get_path_for_version(self.runner_config, version)
def resolve_config_path(self, path, relative_to=None):
# Resolve paths with tolerance for Windows-isms;
@ -780,35 +851,37 @@ class wine(Runner):
def set_regedit_keys(self):
"""Reset regedit keys according to config."""
prefix_manager = WinePrefixManager(self.prefix_path)
# Those options are directly changed with the prefix manager and skip
# any calls to regedit.
managed_keys = {
"ShowCrashDialog": prefix_manager.set_crash_dialogs,
"Desktop": prefix_manager.set_virtual_desktop,
"WineDesktop": prefix_manager.set_desktop_size,
}
prefix = self.prefix_path_if_provided
if prefix:
prefix_manager = WinePrefixManager(prefix)
# Those options are directly changed with the prefix manager and skip
# any calls to regedit.
managed_keys = {
"ShowCrashDialog": prefix_manager.set_crash_dialogs,
"Desktop": prefix_manager.set_virtual_desktop,
"WineDesktop": prefix_manager.set_desktop_size,
}
for key, path in self.reg_keys.items():
value = self.runner_config.get(key) or "auto"
if not value or value == "auto" and key not in managed_keys:
prefix_manager.clear_registry_subkeys(path, key)
elif key in self.runner_config:
if key in managed_keys:
# Do not pass fallback 'auto' value to managed keys
if value == "auto":
value = None
managed_keys[key](value)
continue
# Convert numeric strings to integers so they are saved as dword
if value.isdigit():
value = int(value)
for key, path in self.reg_keys.items():
value = self.runner_config.get(key) or "auto"
if not value or value == "auto" and key not in managed_keys:
prefix_manager.clear_registry_subkeys(path, key)
elif key in self.runner_config:
if key in managed_keys:
# Do not pass fallback 'auto' value to managed keys
if value == "auto":
value = None
managed_keys[key](value)
continue
# Convert numeric strings to integers so they are saved as dword
if value.isdigit():
value = int(value)
prefix_manager.set_registry_key(path, key, value)
prefix_manager.set_registry_key(path, key, value)
# We always configure the DPI, because if the user turns off DPI scaling, but it
# had been on the only way to implement that is to save 96 DPI into the registry.
prefix_manager.set_dpi(self.get_dpi())
# We always configure the DPI, because if the user turns off DPI scaling, but it
# had been on the only way to implement that is to save 96 DPI into the registry.
prefix_manager.set_dpi(self.get_dpi())
def get_dpi(self):
"""Return the DPI to be used by Wine; returns None to allow Wine's own
@ -817,10 +890,11 @@ class wine(Runner):
explicit_dpi = self.runner_config.get("ExplicitDpi")
if explicit_dpi == "auto":
explicit_dpi = None
try:
explicit_dpi = int(explicit_dpi)
except:
explicit_dpi = None
else:
try:
explicit_dpi = int(explicit_dpi)
except:
explicit_dpi = None
return explicit_dpi or get_default_dpi()
return None
@ -830,15 +904,17 @@ class wine(Runner):
logger.warning("No valid prefix detected in %s, creating one...", self.prefix_path)
create_prefix(self.prefix_path, wine_path=self.get_executable(), arch=self.wine_arch, runner=self)
prefix_manager = WinePrefixManager(self.prefix_path)
if self.runner_config.get("autoconf_joypad", False):
prefix_manager.configure_joypads()
prefix_manager.create_user_symlinks()
self.sandbox(prefix_manager)
self.set_regedit_keys()
prefix = self.prefix_path_if_provided
if prefix:
prefix_manager = WinePrefixManager(prefix)
if self.runner_config.get("autoconf_joypad", False):
prefix_manager.configure_joypads()
prefix_manager.create_user_symlinks()
self.sandbox(prefix_manager)
self.set_regedit_keys()
for manager, enabled in self.get_dll_managers().items():
manager.setup(enabled)
for manager, enabled in self.get_dll_managers().items():
manager.setup(enabled)
def get_dll_managers(self, enabled_only=False):
"""Returns the DLL managers in a dict; the keys are the managers themselves,
@ -853,17 +929,19 @@ class wine(Runner):
]
managers = {}
prefix = self.prefix_path_if_provided
for manager_class, enabled_option, version_option in manager_classes:
enabled = bool(self.runner_config.get(enabled_option))
version = self.runner_config.get(version_option)
if enabled or not enabled_only:
manager = manager_class(
self.prefix_path,
arch=self.wine_arch,
version=version
)
managers[manager] = enabled
if prefix:
for manager_class, enabled_option, version_option in manager_classes:
enabled = bool(self.runner_config.get(enabled_option))
version = self.runner_config.get(version_option)
if enabled or not enabled_only:
manager = manager_class(
prefix,
arch=self.wine_arch,
version=version
)
managers[manager] = enabled
return managers
@ -989,7 +1067,7 @@ class wine(Runner):
if using_dxvk:
# Set this to 1 to enable access to more RAM for 32bit applications
launch_info["env"]["WINE_LARGE_ADDRESS_AWARE"] = "1"
if not is_vulkan_supported():
if not vkquery.is_vulkan_supported():
if not display_vulkan_error(on_launch=True):
return {"error": "VULKAN_NOT_FOUND"}

View file

@ -108,12 +108,16 @@ def get_path_from_config(game):
if not game.config:
logger.warning("Game %s has no configuration", game)
return ""
game_config = game.config.game_config
# Skip MAME roms referenced by their ID
if game.runner_name == "mame" and "." not in game.config.game_config["main_file"]:
return
if game.runner_name == "mame":
if "main_file" in game_config and "." not in game_config["main_file"]:
return ""
for key in ["exe", "main_file", "iso", "rom", "disk-a", "path", "files"]:
if key in game.config.game_config:
path = game.config.game_config[key]
if key in game_config:
path = game_config[key]
if key == "files":
path = path[0]
if not path.startswith("/"):
@ -176,11 +180,15 @@ def remove_from_path_cache(game):
def get_path_cache():
"""Return the contents of the path cache file"""
with open(GAME_PATH_CACHE_PATH, encoding="utf-8") as cache_file:
return json.load(cache_file)
try:
return json.load(cache_file)
except json.JSONDecodeError:
return {}
def get_missing_game_ids():
"""Return a list of IDs for games that can't be found"""
logger.debug("Checking for missing games")
missing_ids = []
for game_id, path in get_path_cache().items():
if not os.path.exists(path):

View file

@ -437,7 +437,7 @@ class AmazonService(OnlineService):
if not response:
logger.error("There was an error getting game manifest: %s", game_id)
raise UnavailableGameError(_(
"Unable to get game manifest info, please check your Amazon credentials and internet connectivity"))
"Unable to get game manifest info"))
return response
@ -455,7 +455,7 @@ class AmazonService(OnlineService):
except HTTPError as ex:
logger.error("Failed http request %s", url)
raise UnavailableGameError(_(
"Unable to get game manifest, please check your Amazon credentials and internet connectivity")) from ex
"Unable to get game manifest")) from ex
content = request.content
@ -471,8 +471,7 @@ class AmazonService(OnlineService):
else:
logger.error("Unknown compression algorithm found in manifest")
raise UnavailableGameError(_(
"Unknown compression algorithm found in manifest, "
"please check your Amazon credentials and internet connectivity"))
"Unknown compression algorithm found in manifest"))
manifest = Manifest()
manifest.decode(raw_manifest)
@ -532,11 +531,13 @@ class AmazonService(OnlineService):
hashes.append(file_hash)
files.append({"path": file.path.decode().replace("\\", "/"), "size": file.size, "url": None})
hashpairs.append(dict(
sourceHash=None,
targetHash=dict(value=file_hash,
algorithm=HashAlgorithm.get_name(file.hash.algorithm)),
))
hashpairs.append({
'sourceHash': None,
'targetHash': {
'value': file_hash,
'algorithm': HashAlgorithm.get_name(file.hash.algorithm)
}
})
for __, directory in enumerate(package.dirs):
if directory.path is not None:
directories.append(directory.path.decode().replace("\\", "/"))
@ -572,7 +573,7 @@ class AmazonService(OnlineService):
except HTTPError as ex:
logger.error("Failed http request %s", fuel_url)
raise UnavailableGameError(_(
"Unable to get fuel.json file, please check your Amazon credentials and internet connectivity")) from ex
"Unable to get fuel.json file, please check your Amazon credentials")) from ex
try:
res_yaml_text = request.text

View file

@ -221,18 +221,23 @@ class EpicGamesStoreService(OnlineService):
def start_session(self, exchange_code=None, authorization_code=None):
if exchange_code:
params = dict(grant_type='exchange_code',
exchange_code=exchange_code,
token_type='eg1')
params = {
'grant_type': 'exchange_code',
'exchange_code': exchange_code,
'token_type': 'eg1'
}
elif authorization_code:
params = dict(grant_type='authorization_code',
code=authorization_code,
token_type='eg1')
params = {
'grant_type': 'authorization_code',
'code': authorization_code,
'token_type': 'eg1'
}
else:
params = dict(grant_type='refresh_token',
refresh_token=self.session_data["refresh_token"],
token_type='eg1')
params = {
'grant_type': 'refresh_token',
'refresh_token': self.session_data["refresh_token"],
'token_type': 'eg1'
}
response = self.session.post(
'https://account-public-service-prod03.ol.epicgames.com/account/api/oauth/token',

View file

@ -226,7 +226,7 @@ class GOGService(OnlineService):
request.get()
except HTTPError:
logger.error(
"Failed to request %s, check your GOG credentials and internet connectivity",
"Failed to request %s, check your GOG credentials",
url,
)
return

View file

@ -133,7 +133,7 @@ class HumbleBundleService(OnlineService):
request.get()
except HTTPError:
logger.error(
"Failed to request %s, check your Humble Bundle credentials and internet connectivity",
"Failed to request %s, check your Humble Bundle credentials",
url,
)
return

View file

@ -20,11 +20,12 @@ from lutris.runtime import RuntimeUpdater
from lutris.scanners.lutris import build_path_cache
from lutris.services import DEFAULT_SERVICES
from lutris.services.lutris import sync_media
from lutris.util.display import USE_DRI_PRIME
from lutris.util.graphics import drivers, vkquery
from lutris.util.linux import LINUX_SYSTEM
from lutris.util.log import logger
from lutris.util.steam.shortcut import update_all_artwork
from lutris.util.system import create_folder
from lutris.util.system import create_folder, preload_vulkan_gpu_names
from lutris.util.wine.d3d_extras import D3DExtrasManager
from lutris.util.wine.dgvoodoo2 import dgvoodoo2Manager
from lutris.util.wine.dxvk import REQUIRED_VULKAN_API_VERSION, DXVKManager
@ -143,51 +144,21 @@ def check_vulkan():
logger.warning("Vulkan is not available or your system isn't Vulkan capable")
else:
required_api_version = REQUIRED_VULKAN_API_VERSION
library_api_version = vkquery.get_vulkan_api_version_tuple()
library_api_version = vkquery.get_vulkan_api_version()
if library_api_version and library_api_version < required_api_version:
logger.warning("Vulkan reports an API version of %s. "
"%s is required for the latest DXVK.",
vkquery.format_version_tuple(library_api_version),
vkquery.format_version_tuple(library_api_version))
setting = "dismiss-obsolete-vulkan-api-warning"
if settings.read_setting(setting) != "True":
DontShowAgainDialog(
setting,
_("Obsolete Vulkan libraries"),
secondary_message=_(
"Lutris has detected that Vulkan API version %s is installed, "
"but to use the latest DXVK version, %s is required.\n\n"
"DXVK 1.x will be used instead."
) % (
vkquery.format_version_tuple(library_api_version),
vkquery.format_version_tuple(required_api_version)
)
)
return
vkquery.format_version(library_api_version),
vkquery.format_version(required_api_version))
max_dev_name, max_dev_api_version = vkquery.get_best_device_info()
devices = vkquery.get_device_info()
if max_dev_api_version and max_dev_api_version < required_api_version:
if devices and devices[0].api_version < required_api_version:
logger.warning("Vulkan reports that the '%s' device has API version of %s. "
"%s is required for the latest DXVK.",
max_dev_name,
vkquery.format_version_tuple(max_dev_api_version),
vkquery.format_version_tuple(required_api_version))
setting = "dismiss-obsolete-vulkan-api-warning"
if settings.read_setting(setting) != "True":
DontShowAgainDialog(
setting,
_("Obsolete Vulkan driver support"),
secondary_message=_(
"Lutris has detected that the best device available ('%s') supports Vulkan API %s, "
"but to use the latest DXVK version, %s is required.\n\n"
"DXVK 1.x will be used instead."
) % (
max_dev_name,
vkquery.format_version_tuple(max_dev_api_version),
vkquery.format_version_tuple(required_api_version)
)
)
devices[0].name,
vkquery.format_version(devices[0].api_version),
vkquery.format_version(required_api_version))
def check_gnome():
@ -219,6 +190,7 @@ def run_all_checks():
check_libs()
check_vulkan()
check_gnome()
preload_vulkan_gpu_names(USE_DRI_PRIME)
fill_missing_platforms()
build_path_cache()

View file

@ -1,30 +1,12 @@
"""Options list for system config."""
import functools
import glob
import os
import re
import shutil
import subprocess
from collections import OrderedDict
from gettext import gettext as _
from lutris import runners
from lutris.util import linux, system
from lutris.util.display import DISPLAY_MANAGER, SCREEN_SAVER_INHIBITOR, USE_DRI_PRIME
from lutris.util.log import logger
# vulkan dirs used by distros or containers that aren't from:
# https://github.com/KhronosGroup/Vulkan-Loader/blob/v1.3.235/docs/LoaderDriverInterface.md#driver-discovery-on-linux
# don't include the /vulkan suffix
FALLBACK_VULKAN_DATA_DIRS = [
"/usr/local/etc", # standard site-local location
"/usr/local/share", # standard site-local location
"/etc", # standard location
"/usr/share", # standard location
"/usr/lib/x86_64-linux-gnu/GL", # Flatpak GL extension
"/usr/lib/i386-linux-gnu/GL", # Flatpak GL32 extension
"/opt/amdgpu-pro/etc" # AMD GPU Pro - TkG
]
from lutris.util.system import get_vk_icd_file_sets, get_vulkan_gpu_name
def get_resolution_choices():
@ -89,128 +71,17 @@ def get_optirun_choices():
return choices
# cache this to avoid calling vulkaninfo repeatedly, shouldn't change at runtime
@functools.lru_cache
def get_vulkan_gpus(icd_files):
"""Runs vulkaninfo to determine the default and DRI_PRIME gpu if available"""
if not shutil.which("vulkaninfo"):
logger.warning("vulkaninfo not available, unable to list GPUs")
return "Unknown GPU"
gpu = get_vulkan_gpu(icd_files, False)
if USE_DRI_PRIME:
prime_gpu = get_vulkan_gpu(icd_files, True)
if prime_gpu != gpu:
gpu += f" (Discrete GPU: {prime_gpu})"
return gpu
def get_vulkan_gpu(icd_files, prime):
"""Runs vulkaninfo to find the primary GPU"""
subprocess_env = dict(os.environ)
if icd_files:
subprocess_env["VK_DRIVER_FILES"] = icd_files
subprocess_env["VK_ICD_FILENAMES"] = icd_files
if prime:
subprocess_env["DRI_PRIME"] = "1"
infocmd = "vulkaninfo --summary | grep deviceName | head -n 1 | tr -s '[:blank:]' | cut -d ' ' -f 3-"
with subprocess.Popen(infocmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
env=subprocess_env) as infoget:
result = infoget.communicate()[0].decode("utf-8").strip()
if "Failed to detect any valid GPUs" in result or "ERROR: [Loader Message]" in result:
return "No GPU"
# Shorten result to just the friendly name of the GPU
# vulkaninfo returns Vendor Friendly Name (Chip Developer Name)
# AMD Radeon Pro W6800 (RADV NAVI21) -> AMD Radeon Pro W6800
return re.sub(r"\s*\(.*?\)", "", result)
def get_vk_icd_files():
"""Returns available vulkan ICD files in the same search order as vulkan-loader"""
all_icd_search_paths = []
def add_icd_search_path(paths):
if paths:
# unixy env vars with multiple paths are : delimited
for path in paths.split(":"):
path = os.path.join(path, "vulkan")
if os.path.exists(path) and path not in all_icd_search_paths:
all_icd_search_paths.append(path)
# Must match behavior of
# https://github.com/KhronosGroup/Vulkan-Loader/blob/v1.3.235/docs/LoaderDriverInterface.md#driver-discovery-on-linux
# (or a newer version of the same standard)
# 1.a XDG_CONFIG_HOME or ~/.config if unset
add_icd_search_path(os.getenv("XDG_CONFIG_HOME") or (f"{os.getenv('HOME')}/.config"))
# 1.b XDG_CONFIG_DIRS
add_icd_search_path(os.getenv("XDG_CONFIG_DIRS") or "/etc/xdg")
# 2, 3 SYSCONFDIR and EXTRASYSCONFDIR
# Compiled in default has both the same
add_icd_search_path("/etc")
# 4 XDG_DATA_HOME
add_icd_search_path(os.getenv("XDG_DATA_HOME") or (f"{os.getenv('HOME')}/.local/share"))
# 5 XDG_DATA_DIRS or fall back to /usr/local/share and /usr/share
add_icd_search_path(os.getenv("XDG_DATA_DIRS") or "/usr/local/share:/usr/share")
# FALLBACK
# dirs that aren't from the loader spec are searched last
for fallback_dir in FALLBACK_VULKAN_DATA_DIRS:
add_icd_search_path(fallback_dir)
all_icd_files = []
for data_dir in all_icd_search_paths:
path = os.path.join(data_dir, "icd.d", "*.json")
# sort here as directory enumeration order is not guaranteed in linux
# so it's consistent every time
icd_files = sorted(glob.glob(path))
if icd_files:
all_icd_files += icd_files
return all_icd_files
def get_vk_icd_choices():
"""Return available Vulkan ICD loaders"""
intel = []
amdradv = []
nvidia = []
amdvlk = []
amdvlkpro = []
# fallback in case any ICDs don't match a known type
unknown = []
icd_file_sets = get_vk_icd_file_sets()
all_icd_files = get_vk_icd_files()
# Add loaders for each vendor
for loader in all_icd_files:
if "intel" in loader:
intel.append(loader)
elif "radeon" in loader:
amdradv.append(loader)
elif "nvidia" in loader:
nvidia.append(loader)
elif "amd" in loader:
if "pro" in loader:
amdvlkpro.append(loader)
else:
amdvlk.append(loader)
else:
unknown.append(loader)
intel_files = ":".join(intel)
amdradv_files = ":".join(amdradv)
nvidia_files = ":".join(nvidia)
amdvlk_files = ":".join(amdvlk)
amdvlkpro_files = ":".join(amdvlkpro)
unknown_files = ":".join(unknown)
intel_files = ":".join(icd_file_sets["intel"])
amdradv_files = ":".join(icd_file_sets["amdradv"])
nvidia_files = ":".join(icd_file_sets["nvidia"])
amdvlk_files = ":".join(icd_file_sets["amdvlk"])
amdvlkpro_files = ":".join(icd_file_sets["amdvlkpro"])
unknown_files = ":".join(icd_file_sets["unknown"])
# default choice should always be blank so the env var gets left as is
# This ensures Lutris doesn't change the vulkan loader behavior unless you select
@ -230,10 +101,10 @@ def get_vk_icd_choices():
choices.append(("AMDVLK Open source", amdvlk_files))
if amdvlkpro_files:
choices.append(("AMDGPU-PRO Proprietary", amdvlkpro_files))
if unknown:
if unknown_files:
choices.append(("Unknown Vendor", unknown_files))
choices = [(prefix + ": " + get_vulkan_gpus(files), files) for prefix, files in choices]
choices = [(prefix + ": " + get_vulkan_gpu_name(files, USE_DRI_PRIME), files) for prefix, files in choices]
return choices

View file

@ -38,10 +38,13 @@ def get_default_dpi():
"""Computes the DPI to use for the primary monitor
which we pass to WINE."""
display = Gdk.Display.get_default()
monitor = display.get_primary_monitor()
scale = monitor.get_scale_factor()
dpi = 96 * scale
return int(dpi)
if display:
monitor = display.get_primary_monitor()
if monitor:
scale = monitor.get_scale_factor()
dpi = 96 * scale
return int(dpi)
return 96
def restore_gamma():
@ -389,7 +392,7 @@ def _get_screen_saver_inhibitor():
inhibitor.set_dbus_iface(name, path, interface)
except GLib.Error as err:
logger.warning("Failed to set up a DBus proxy for name %s, path %s, "
"interface %s: %s", name, path, interface, str(err))
"interface %s: %s", name, path, interface, err)
return inhibitor

View file

@ -2,6 +2,7 @@
# Vulkan detection by Patryk Obara (@dreamer)
"""Query Vulkan capabilities"""
from collections import namedtuple
# Standard Library
from ctypes import (
CDLL, POINTER, Structure, byref, c_char, c_char_p, c_float, c_int32, c_size_t, c_uint8, c_uint32, c_uint64,
@ -31,6 +32,8 @@ VkInstance = c_void_p # handle (struct ptr)
VkPhysicalDevice = c_void_p # handle (struct ptr)
VkDeviceSize = c_uint64
DeviceInfo = namedtuple('DeviceInfo', 'name api_version')
def vk_make_version(major, minor, patch):
"""
@ -38,7 +41,7 @@ def vk_make_version(major, minor, patch):
https://www.khronos.org/registry/vulkan/specs/1.1-extensions/html/vkspec.html#fundamentals-versionnum
"""
return c_uint32((major << 22) | (minor << 12) | patch)
return (major << 22) | (minor << 12) | patch
def vk_api_version_major(version):
@ -75,8 +78,8 @@ class VkApplicationInfo(Structure):
super().__init__()
self.sType = VK_STRUCTURE_TYPE_APPLICATION_INFO
self.pApplicationName = name.encode()
self.applicationVersion = vk_make_version(*version)
self.apiVersion = vk_make_version(1, 0, 0)
self.applicationVersion = c_uint32(vk_make_version(*version))
self.apiVersion = c_uint32(vk_make_version(1, 0, 0))
class VkInstanceCreateInfo(Structure):
@ -262,10 +265,11 @@ def is_vulkan_supported():
@lru_cache(maxsize=None)
def get_vulkan_api_version_tuple():
def get_vulkan_api_version():
"""
Queries libvulkan to get the API version; if this library is missing
it returns None. Returns a tuple of (major, minor, patch) version numbers.
it returns None. Returns an encoded Vulkan version integer; use
vk_api_version_major() and like methods to parse it.
"""
try:
vulkan = CDLL("libvulkan.so.1")
@ -276,95 +280,79 @@ def get_vulkan_api_version_tuple():
enumerate_instance_version = vulkan.vkEnumerateInstanceVersion
except AttributeError:
# Vulkan 1.0 did not have vkEnumerateInstanceVersion at all!
return 1, 0
return vk_make_version(1, 0, 0)
version = c_uint32(0)
result = enumerate_instance_version(byref(version))
if result == VK_SUCCESS:
return make_version_tuple(version.value)
return None
return version.value if result == VK_SUCCESS else None
def get_device_info():
"""
Returns a dictionary of the physical devices known to Vulkan, omitting software
rendered devices. The keys are the device names, and the values are their API
version tuples.
Returns a list of the physical devices known to Vulkan, represented as
(name, api_version) named-tuples and the api_version numbers are encoded, so
use vk_api_version_major() and friends to parse them. They are sorted so the
highest version device is first, and software rendering devices are omitted.
"""
try:
vulkan = CDLL("libvulkan.so.1")
except OSError:
return {}
return []
app_info = VkApplicationInfo("vkinfo", version=(0, 1, 0))
create_info = VkInstanceCreateInfo(app_info)
instance = VkInstance()
result = vulkan.vkCreateInstance(byref(create_info), 0, byref(instance))
if result != VK_SUCCESS:
return {}
return []
dev_count = c_uint32(0)
result = vulkan.vkEnumeratePhysicalDevices(instance, byref(dev_count), 0)
if result != VK_SUCCESS or dev_count.value <= 0:
return {}
return []
devices = (VkPhysicalDevice * dev_count.value)()
result = vulkan.vkEnumeratePhysicalDevices(instance, byref(dev_count), byref(devices))
if result != VK_SUCCESS:
return {}
return []
getPhysicalDeviceProperties = vulkan.vkGetPhysicalDeviceProperties
getPhysicalDeviceProperties.restype = None
getPhysicalDeviceProperties.argtypes = [VkPhysicalDevice, c_void_p] # pointer(VkPhysicalDeviceProperties)]
getPhysicalDeviceProperties.argtypes = [VkPhysicalDevice, c_void_p]
devices_dict = {}
device_info = []
for physical_device in devices:
dev_props = VkPhysicalDeviceProperties()
getPhysicalDeviceProperties(physical_device, byref(dev_props))
if dev_props.deviceType != VK_PHYSICAL_DEVICE_TYPE_CPU:
name = dev_props.deviceName.decode("utf-8")
api_version = make_version_tuple(dev_props.apiVersion)
devices_dict[name] = api_version
device_info.append(DeviceInfo(name, dev_props.apiVersion))
vulkan.vkDestroyInstance(instance, 0)
return devices_dict
def get_best_device_info():
"""Returns the name and version tuple of the device with the highest
version; this is the best tuple from the get_device_info() method, so
the key element is the name, and the value element is a version tuple.
Go nested tuples! If there are no devices at all, this returns
(None, None), but still a tuple."""
devices_dict = get_device_info()
if not devices_dict:
return None, None
by_version = sorted(
devices_dict.items(),
key=lambda t: t[1],
reverse=True
)
return by_version[0] if by_version else (None, None)
return sorted(device_info, key=lambda t: t.api_version, reverse=True)
@lru_cache(maxsize=None)
def get_expected_api_version_tuple():
def get_expected_api_version():
"""Returns the version tuple of the API version we expect
to have; it is the least of the Vulkan library API version, and
the best device's API version."""
api_version = get_vulkan_api_version_tuple()
_best_dev_name, best_dev_version = get_best_device_info()
if best_dev_version:
return min(api_version, best_dev_version)
api_version = get_vulkan_api_version()
if not api_version:
return None
devices = get_device_info()
if devices:
return min(api_version, devices[0].api_version)
return api_version
def make_version_tuple(source_int):
major = vk_api_version_major(source_int)
minor = vk_api_version_minor(source_int)
patch = vk_api_version_patch(source_int)
return major, minor, patch
def format_version(version):
if version:
major = vk_api_version_major(version)
minor = vk_api_version_minor(version)
patch = vk_api_version_patch(version)
return "%s.%s.%s" % (major, minor, patch)
def format_version_tuple(version_tuple):
return "%s.%s.%s" % version_tuple
return "(none)"

View file

@ -496,12 +496,12 @@ def gather_system_info_str():
graphics_dict["Vendor"] = "Unable to obtain glxinfo"
# check Vulkan support
if vkquery.is_vulkan_supported():
graphics_dict["Vulkan Version"] = vkquery.format_version_tuple(vkquery.get_vulkan_api_version_tuple())
graphics_dict["Vulkan Version"] = vkquery.format_version(vkquery.get_vulkan_api_version())
graphics_dict["Vulkan Drivers"] = ", ".join({
"%s (%s)" % (name, vkquery.format_version_tuple(version))
"%s (%s)" % (name, vkquery.format_version(version))
for name, version
in vkquery.get_device_info().items()
in vkquery.get_device_info()
})
else:
graphics_dict["Vulkan"] = "Not Supported"

View file

@ -113,10 +113,7 @@ class Process:
"""Return the process' environment variables"""
environ_path = "/proc/{}/environ".format(self.pid)
_environ_text = self._read_content(environ_path)
if not _environ_text:
return {}
if "=" not in _environ_text:
logger.debug("Invalid environment value '%s' (%s) in %s", _environ_text, len(_environ_text), environ_path)
if not _environ_text or "=" not in _environ_text:
return {}
return dict([line.split("=", 1) for line in _environ_text.split("\x00") if line])

View file

@ -1,4 +1,5 @@
"""System utilities"""
import glob
import hashlib
import os
import re
@ -8,12 +9,15 @@ import stat
import string
import subprocess
import zipfile
from collections import defaultdict
from functools import lru_cache
from gettext import gettext as _
from pathlib import Path
from gi.repository import Gio, GLib
from lutris import settings
from lutris.util.jobs import AsyncCall
from lutris.util.log import logger
# Home folders that should never get deleted.
@ -28,6 +32,19 @@ PROTECTED_HOME_FOLDERS = (
_("Games")
)
# vulkan dirs used by distros or containers that aren't from:
# https://github.com/KhronosGroup/Vulkan-Loader/blob/v1.3.235/docs/LoaderDriverInterface.md#driver-discovery-on-linux
# don't include the /vulkan suffix
FALLBACK_VULKAN_DATA_DIRS = [
"/usr/local/etc", # standard site-local location
"/usr/local/share", # standard site-local location
"/etc", # standard location
"/usr/share", # standard location
"/usr/lib/x86_64-linux-gnu/GL", # Flatpak GL extension
"/usr/lib/i386-linux-gnu/GL", # Flatpak GL32 extension
"/opt/amdgpu-pro/etc" # AMD GPU Pro - TkG
]
def execute(command, env=None, cwd=None, log_errors=False, quiet=False, shell=False, timeout=None):
"""
@ -544,3 +561,136 @@ def set_keyboard_layout(layout):
with subprocess.Popen(setxkbmap_command, env=os.environ, stdout=xkbcomp.stdin) as setxkbmap:
setxkbmap.communicate()
xkbcomp.communicate()
def preload_vulkan_gpu_names(use_dri_prime):
"""Runs threads to load the GPU data from vulkan info for each ICD file set,
and one for the default 'unspecified' info. The results are cached by @lru_cache,
so we can just ignore them here."""
try:
all_files = [":".join(fs) for fs in get_vk_icd_file_sets().values()]
all_files.append("")
for files in all_files:
# ignore any errors from get_vulkan_gpu_name
AsyncCall(get_vulkan_gpu_name, None, files, use_dri_prime, daemon=True)
except Exception as ex:
logger.exception("Failed to preload Vulkan GPU Names: %s", ex)
# cache this to avoid calling vulkaninfo repeatedly, shouldn't change at runtime
@lru_cache
def get_vulkan_gpu_name(icd_files, use_dri_prime):
"""Runs vulkaninfo to determine the default and DRI_PRIME gpu if available,
returns 'Not Found' if the GPU is not found or 'Unknown GPU' if vulkaninfo
is not available."""
def fetch_vulkan_gpu_name(prime):
"""Runs vulkaninfo to find the primary GPU"""
subprocess_env = dict(os.environ)
if icd_files:
subprocess_env["VK_DRIVER_FILES"] = icd_files
subprocess_env["VK_ICD_FILENAMES"] = icd_files
if prime:
subprocess_env["DRI_PRIME"] = "1"
infocmd = "vulkaninfo --summary | grep deviceName | head -n 1 | tr -s '[:blank:]' | cut -d ' ' -f 3-"
with subprocess.Popen(infocmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
env=subprocess_env) as infoget:
result = infoget.communicate()[0].decode("utf-8").strip()
if "Failed to detect any valid GPUs" in result or "ERROR: [Loader Message]" in result:
return "No GPU"
# Shorten result to just the friendly name of the GPU
# vulkaninfo returns Vendor Friendly Name (Chip Developer Name)
# AMD Radeon Pro W6800 (RADV NAVI21) -> AMD Radeon Pro W6800
return re.sub(r"\s*\(.*?\)", "", result)
if not shutil.which("vulkaninfo"):
logger.warning("vulkaninfo not available, unable to list GPUs")
return "Unknown GPU"
gpu = fetch_vulkan_gpu_name(False)
if use_dri_prime:
prime_gpu = fetch_vulkan_gpu_name(True)
if prime_gpu != gpu:
gpu += f" (Discrete GPU: {prime_gpu})"
return gpu or "Not Found"
def get_vk_icd_file_sets():
"""Returns the vulkan ICD files in a default-dict of lists; the keys are the separate
drivers, 'intel', 'amdradv', 'amdvlkpro', 'amdvlk', 'nvidia', and 'unknown'."""
def get_vk_icd_files():
"""Returns available vulkan ICD files in the same search order as vulkan-loader,
but in a single list"""
all_icd_search_paths = []
def add_icd_search_path(paths):
if paths:
# unixy env vars with multiple paths are : delimited
for path in paths.split(":"):
path = os.path.join(path, "vulkan")
if os.path.exists(path) and path not in all_icd_search_paths:
all_icd_search_paths.append(path)
# Must match behavior of
# https://github.com/KhronosGroup/Vulkan-Loader/blob/v1.3.235/docs/LoaderDriverInterface.md#driver-discovery-on-linux
# (or a newer version of the same standard)
# 1.a XDG_CONFIG_HOME or ~/.config if unset
add_icd_search_path(os.getenv("XDG_CONFIG_HOME") or (f"{os.getenv('HOME')}/.config"))
# 1.b XDG_CONFIG_DIRS
add_icd_search_path(os.getenv("XDG_CONFIG_DIRS") or "/etc/xdg")
# 2, 3 SYSCONFDIR and EXTRASYSCONFDIR
# Compiled in default has both the same
add_icd_search_path("/etc")
# 4 XDG_DATA_HOME
add_icd_search_path(os.getenv("XDG_DATA_HOME") or (f"{os.getenv('HOME')}/.local/share"))
# 5 XDG_DATA_DIRS or fall back to /usr/local/share and /usr/share
add_icd_search_path(os.getenv("XDG_DATA_DIRS") or "/usr/local/share:/usr/share")
# FALLBACK
# dirs that aren't from the loader spec are searched last
for fallback_dir in FALLBACK_VULKAN_DATA_DIRS:
add_icd_search_path(fallback_dir)
all_icd_files = []
for data_dir in all_icd_search_paths:
path = os.path.join(data_dir, "icd.d", "*.json")
# sort here as directory enumeration order is not guaranteed in linux
# so it's consistent every time
icd_files = sorted(glob.glob(path))
if icd_files:
all_icd_files += icd_files
return all_icd_files
sets = defaultdict(list)
all_icd_files = get_vk_icd_files()
# Add loaders for each vendor
for loader in all_icd_files:
if "intel" in loader:
sets["intel"].append(loader)
elif "radeon" in loader:
sets["amdradv"].append(loader)
elif "nvidia" in loader:
sets["nvidia"].append(loader)
elif "amd" in loader:
if "pro" in loader:
sets["amdvlkpro"].append(loader)
else:
sets["amdvlk"].append(loader)
else:
sets["unknown"].append(loader)
return sets

View file

@ -11,7 +11,7 @@ from lutris.util.log import logger
from lutris.util.system import create_folder, execute, remove_folder
from lutris.util.wine.dll_manager import DLLManager
REQUIRED_VULKAN_API_VERSION = 1, 3, 0
REQUIRED_VULKAN_API_VERSION = vkquery.vk_make_version(1, 3, 0)
class DXVKManager(DLLManager):
@ -20,7 +20,7 @@ class DXVKManager(DLLManager):
versions_path = os.path.join(base_dir, "dxvk_versions.json")
managed_dlls = ("dxgi", "d3d11", "d3d10core", "d3d9",)
releases_url = "https://api.github.com/repos/lutris/dxvk/releases"
vulkan_api_version = vkquery.get_expected_api_version_tuple()
vulkan_api_version = vkquery.get_expected_api_version()
def is_recommended_version(self, version):
# DXVK 2.x and later require Vulkan 1.3, so if that iss lacking

1960
po/nl.po

File diff suppressed because it is too large Load diff

View file

@ -23,7 +23,7 @@ msgstr "Lutris"
#: share/applications/net.lutris.Lutris.desktop:4
msgid "Video Game Preservation Platform"
msgstr "Платформа для сохранения видеоигр"
msgstr "Площадка для сохранения видеоигр"
#: share/applications/net.lutris.Lutris.desktop:6
msgid "gaming;wine;emulator;"
@ -32,7 +32,7 @@ msgstr "gaming;wine;emulator;"
#: share/metainfo/net.lutris.Lutris.metainfo.xml:11
#: share/lutris/ui/about-dialog.ui:18
msgid "Video game preservation platform"
msgstr "Платформа для сохранения видеоигр"
msgstr "Площадка для сохранения видеоигр"
#: share/metainfo/net.lutris.Lutris.metainfo.xml:14
msgid "Main window"
@ -75,8 +75,8 @@ msgstr ""
"или ПРИГОДНОСТИ ДЛЯ ОПРЕДЕЛЕННЫХ ЦЕЛЕЙ. Подробнее см. в Стандартной\n"
"общественной лицензии GNU.\n"
"\n"
"Вы должны были получить копию Стандартной общественной лицензии GNU\n"
"вместе с этой программой. Если это не так, см. <https://www.gnu.org/licenses/"
"Вы должны были получить копию Основной общественной лицензии GNU\n"
"вместе с этим приложением. Если это не так, см. <https://www.gnu.org/licenses/"
">.\n"
#: share/lutris/ui/dialog-lutris-login.ui:8
@ -126,7 +126,7 @@ msgstr "Масштаб:"
#: share/lutris/ui/lutris-window.ui:345
msgid "Sort Ascending"
msgstr "Сортировать по возрастанию"
msgstr "Упорядочивать по возрастанию"
#: share/lutris/ui/lutris-window.ui:360 lutris/gui/config/common.py:127
#: lutris/gui/views/list.py:48 lutris/gui/views/list.py:172
@ -207,7 +207,7 @@ msgstr "Не удалось найти файл {}"
#: lutris/game.py:256
msgid "This game has no executable set. The install process didn't finish properly."
msgstr "В этой игре нет исполняемого набора. Процесс установки не завершился должным образом."
msgstr "В этой игре нет исполняемого набора. Установка не завершилась должным образом."
#: lutris/game.py:259
#, python-format
@ -217,7 +217,7 @@ msgstr "Файл %s не является исполняемым"
#: lutris/game.py:261
#, python-format
msgid "The path '%s' is not set. please set it in the options."
msgstr "Путь '%s' не установлен, пожалуйста установите его в опциях."
msgstr "Путь «%s» не установлен, пожалуйста установите его в опциях."
#: lutris/game.py:263
#, python-format
@ -395,7 +395,7 @@ msgid ""
"The folder doesn't exist."
msgstr ""
"Не удается открыть %s \n"
"Каталог не существует."
"Папка не существует."
#. use primary configuration
#: lutris/game_actions.py:335
@ -405,15 +405,15 @@ msgstr "Удалить ярлык с рабочего стола"
#: lutris/gui/addgameswindow.py:28
msgid "Search the Lutris website for installers"
msgstr "Найдите установщик на веб-сайте Lutris"
msgstr "Найдите установщик на сайте Lutris"
#: lutris/gui/addgameswindow.py:29
msgid "Query our website for community installers"
msgstr "Запросите установщик на веб-сайте нашего сообщества"
msgstr "Запросите установщик на сайте нашего сообщества"
#: lutris/gui/addgameswindow.py:35
msgid "Scan a folder for games"
msgstr "Сканировать папку на наличие игр"
msgstr "Проверить папку на наличие игр"
#: lutris/gui/addgameswindow.py:36
msgid "Mass-import a folder of games"
@ -480,7 +480,7 @@ msgstr "Отмена"
#: lutris/gui/addgameswindow.py:114 lutris/gui/config/boxes.py:439
msgid "Select folder"
msgstr "Выбрать каталог"
msgstr "Выбрать папку"
#: lutris/gui/addgameswindow.py:123
#, fuzzy
@ -522,7 +522,7 @@ msgstr ""
#: lutris/gui/addgameswindow.py:307
#, fuzzy
msgid "Folder to scan"
msgstr "Выбрать каталог"
msgstr "Выбрать папку"
#: lutris/gui/addgameswindow.py:313
msgid ""
@ -538,7 +538,7 @@ msgstr ""
#: lutris/gui/addgameswindow.py:331
#, fuzzy
msgid "You must select a folder to scan for games."
msgstr "Выбрать каталог"
msgstr "Выбрать папку"
#: lutris/gui/addgameswindow.py:333
#, python-format
@ -562,7 +562,7 @@ msgstr "З_акрыть"
#: lutris/gui/addgameswindow.py:409
#, fuzzy
msgid "Game name"
msgstr "Информация об игре"
msgstr "Сведения об игре"
#: lutris/gui/addgameswindow.py:425
msgid ""
@ -639,7 +639,7 @@ msgstr ""
#: lutris/runners/snes9x.py:27 lutris/runners/vice.py:38
#: lutris/runners/yuzu.py:23
msgid "ROM file"
msgstr "Файл образа ПЗУ (ROM)"
msgstr "Файл образа ПЗУ"
#: lutris/gui/addgameswindow.py:557
msgid ""
@ -654,7 +654,7 @@ msgstr ""
#: lutris/gui/addgameswindow.py:576
msgid "You must select a ROM file to install."
msgstr "Вы должны выбрать файл ROM для установки."
msgstr "Вы должны выбрать файл ПЗУ для установки."
#: lutris/gui/application.py:92
msgid ""
@ -1150,12 +1150,12 @@ msgstr "Скрывать текст за иконками"
#: lutris/gui/config/sysinfo_box.py:36
msgid "Copy to clipboard"
msgstr "Копировать в буфер обмена"
msgstr "Скопировать в буфер обмена"
#: lutris/gui/config/sysinfo_box.py:39
#, fuzzy
msgid "<b>System information</b>"
msgstr "Информация о системе"
msgstr "Сведения о системе"
#: lutris/gui/dialogs/cache.py:13
msgid "Cache configuration"
@ -1167,7 +1167,7 @@ msgstr "Путь к кэшу"
#: lutris/gui/dialogs/cache.py:43
msgid "Set the folder for the cache path"
msgstr "Укажите каталог для кэша"
msgstr "Укажите папку для кэша"
#: lutris/gui/dialogs/cache.py:55
msgid ""
@ -1183,7 +1183,7 @@ msgstr ""
#: lutris/gui/dialogs/delegates.py:70
msgid "Runtime currently updating"
msgstr "В данный момент среда выполнения обновляется"
msgstr "На данное время среда исполнения обновляется"
#: lutris/gui/dialogs/delegates.py:71
msgid "Game might not work as expected"
@ -1414,7 +1414,7 @@ msgstr "Пожалуйста, выберите файл"
#: lutris/gui/dialogs/__init__.py:271
msgid "Checking for runtime updates, please wait…"
msgstr ""
msgstr "Проверка обновлений сред исполения, пожалуйста, подождите"
#: lutris/gui/dialogs/__init__.py:309
#, python-format

View file

@ -22,7 +22,7 @@ setup(
version=VERSION,
license='GPL-3',
author='Mathieu Comandon',
author_email='strider@strycore.com',
author_email='mathieucomandon@gmail.com',
packages=[
'lutris',
'lutris.database',

View file

@ -2,7 +2,7 @@
"human_name": "Citra",
"description": "Nintendo 3DS emulator",
"platforms": ["Nintendo 3DS"],
"require_libs": ["libQt5OpenGL.so.5", "libQt5Widgets.so.5", "libQt5Multimedia.so.5"],
"require_libs": [],
"runner_executable": "citra/citra-qt",
"runnable_alone": true,
"game_options": [

View file

@ -3,6 +3,7 @@
"description": "Sony PSP emulator",
"platforms": ["Sony PlayStation Portable"],
"runner_executable": "ppsspp/PPSSPPSDL",
"runnable_alone": true,
"game_options": [
{
"option": "main_file",

View file

@ -6,7 +6,7 @@
<metadata_license>CC0-1.0</metadata_license>
<translation type="gettext">lutris</translation>
<developer_name translatable="no">Lutris Team</developer_name>
<update_contact>strider@strycore.com</update_contact>
<update_contact>mathieucomandon@gmail.com</update_contact>
<name translatable="no">Lutris</name>
<summary>Video game preservation platform</summary>
<screenshots>
@ -24,7 +24,7 @@
<url type="bugtracker">https://github.com/lutris/lutris/issues</url>
<launchable type="desktop-id">net.lutris.Lutris.desktop</launchable>
<releases>
<release date="2023-02-11" version="0.5.13" />
<release date="2023-05-16" version="0.5.13" />
</releases>
<content_rating type="oars-1.1" />
</component>

View file

@ -28,7 +28,7 @@ class TestPCSX2Runner(unittest.TestCase):
mock_config.game_config = {'main_file': main_file}
mock_config.runner_config = {'fullscreen': True}
self.runner.config = mock_config
expected = {'command': [self.runner.get_executable(), '--fullscreen', main_file]}
expected = {'command': [self.runner.get_executable(), '-fullscreen', main_file]}
self.assertEqual(self.runner.play(), expected)
@patch('lutris.util.system.path_exists')
@ -39,7 +39,7 @@ class TestPCSX2Runner(unittest.TestCase):
mock_config.game_config = {'main_file': main_file}
mock_config.runner_config = {'full_boot': True}
self.runner.config = mock_config
expected = {'command': [self.runner.get_executable(), '--fullboot', main_file]}
expected = {'command': [self.runner.get_executable(), '-slowboot', main_file]}
self.assertEqual(self.runner.play(), expected)
@patch('lutris.util.system.path_exists')
@ -50,47 +50,19 @@ class TestPCSX2Runner(unittest.TestCase):
mock_config.game_config = {'main_file': main_file}
mock_config.runner_config = {'nogui': True}
self.runner.config = mock_config
expected = {'command': [self.runner.get_executable(), '--nogui', main_file]}
self.assertEqual(self.runner.play(), expected)
@patch('lutris.util.system.path_exists')
def test_play_cfg_set(self, mock_path_exists):
main_file = '/valid/path/to/iso'
config_file = '/valid/path/to/cfg'
cfg_arg = '--cfg=' + config_file
mock_path_exists.return_value = True
mock_config = MagicMock()
mock_config.game_config = {'main_file': main_file}
mock_config.runner_config = {'config_file': config_file}
self.runner.config = mock_config
expected = {'command': [self.runner.get_executable(), cfg_arg, main_file]}
self.assertEqual(self.runner.play(), expected)
@patch('lutris.util.system.path_exists')
def test_play_cfgpath_set(self, mock_path_exists):
main_file = '/valid/path/to/iso'
config_path = '/valid/path/to/cfgpath'
cfgpath_arg = '--cfgpath=' + config_path
mock_path_exists.return_value = True
mock_config = MagicMock()
mock_config.game_config = {'main_file': main_file}
mock_config.runner_config = {'config_path': config_path}
self.runner.config = mock_config
expected = {'command': [self.runner.get_executable(), cfgpath_arg, main_file]}
expected = {'command': [self.runner.get_executable(), '-nogui', main_file]}
self.assertEqual(self.runner.play(), expected)
@patch('lutris.util.system.path_exists')
def test_play(self, mock_path_exists):
main_file = '/valid/path/to/iso'
config_path = '/valid/path/to/cfgpath'
cfgpath_arg = '--cfgpath=' + config_path
mock_path_exists.return_value = True
mock_config = MagicMock()
mock_config.game_config = {'main_file': main_file}
mock_config.runner_config = {
'config_path': config_path, 'fullscreen': False, 'nogui': True,
'full_boot': True, 'config_file': '',
'fullscreen': False, 'nogui': True,
'full_boot': True,
}
self.runner.config = mock_config
expected = {'command': [self.runner.get_executable(), '--fullboot', '--nogui', cfgpath_arg, main_file]}
expected = {'command': [self.runner.get_executable(), '-slowboot', '-nogui', main_file]}
self.assertEqual(self.runner.play(), expected)