#!/usr/bin/env python2
"""
Universal Unity3D Configurator
Copyright (C) 2013 Kozec
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Lesser General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public License
along with this program. If not, see .
"""
########### #########
HELP = """
Usage:
%(exe)s [-h|--help]
%(exe)s [-f] config_location
Searchs for configuration files of unity-based applications and lets
user to configure their graphics settings
config_location specify this parameter to run in single app mode. In
this mode, application list is not shown and only
settings for specified application are presented
-h, --help displays this help screen
-f creates default pref file, if it doesn't already
exists in config_location
""".strip("\n")
########## ########
APP_NAME = "Universal Unity3D Configurator"
APP_NAME_SHORT = "UUC"
import os, sys, thread, traceback
from abc import ABCMeta, abstractmethod
from threading import Thread
from subprocess import Popen, PIPE
from xml.etree import ElementTree
# Imports GTK, little down after display_error definition
XRANDR = "/usr/bin/xrandr"
DEFAULT_CONFIG = """
800
600
0
"""
def display_error(message):
"""
Displays error message using most common tools for doing so in
user-friendly way and falls back to old-fashioned console if there
is no tool available.
"""
is_exec = lambda x : os.path.isfile(x) and os.access(x, os.X_OK)
if is_exec("/usr/bin/yad") :
Popen(['/usr/bin/yad', '--title', 'Error', '--image=error', '--button=Ok', '--text', message]).communicate()
elif is_exec("/usr/bin/zenity") :
Popen(['/usr/bin/zenity', '--error', '--text', message])
elif is_exec("/usr/bin/kdialog") :
Popen(['/usr/bin/kdialog', '--error', message])
elif is_exec("/usr/bin/Xdialog") :
Popen(['/usr/bin/Xdialog', '--msgbox', message, '10', '100'])
elif is_exec("/usr/bin/gdialog") :
Popen(['/usr/bin/gdialog', '--msgbox', message])
print >>sys.stderr, message
try:
import gtk, pango
except ImportError:
display_error("GTK2 bindings for python not found. Please, use your package manager to install pygtk package")
sys.exit(1)
_ = lambda x : x
class App(gtk.Window):
""" Main window / application interface """
def __init__(self):
gtk.Window.__init__(self)
self.set_title(_(APP_NAME))
self.set_position(gtk.WIN_POS_CENTER)
self.xranrd_resolutions = self.get_xrandr_resolutions()
self.config = None
# Containers
vb = gtk.VBox()
sw = gtk.ScrolledWindow()
hb = gtk.HBox()
tab = gtk.Table()
bb = gtk.HBox()
# Labels
self.lb_pick_game = BoldLabel(_("Pick a game from list:"))
self.lb_resolution = BoldLabel(_("Screen resolution"))
self.lb_custom_res = BoldLabel(_("Custom resolution"))
self.lb_custom_w = BoldLabel("%s" % _("Warning: Using custom fullscreen resolution may make \n your screen unusable or even crash your desktop."))
self.lb_game_info = BoldLabel(_("Game info"))
self.lb_x = gtk.Label("x")
# Usable stuff
self.lv = gtk.TreeView()
self.setup_listview(self.lv)
self.but_close = gtk.Button(stock=gtk.STOCK_CLOSE)
self.but_save = gtk.Button(stock=gtk.STOCK_SAVE)
self.but_save.set_sensitive(False)
self.resolution = gtk.combo_box_new_text()
self.resolution_w = gtk.Entry()
self.resolution_h = gtk.Entry()
self.fullscreen = gtk.CheckButton(_("Fullscreen"))
self.game_info = gtk.Label(_("(select something)"))
self.game_info.set_alignment(0, 0)
self.link = gtk.LinkButton("file:///")
self.link.set_alignment(0, 0)
# Connect signals
self.connect("destroy", gtk.main_quit)
self.but_close.connect("clicked", gtk.main_quit)
self.but_save.connect("clicked", self.on_save)
self.lv.connect("cursor-changed", self.on_game_selected)
self.resolution.connect("changed", self.on_resolution_changed)
self.resolution_w.connect("changed", self.on_custom_res_changed)
self.resolution_h.connect("changed", self.on_custom_res_changed)
self.fullscreen.connect("toggled", self.on_fullscreen_changed)
# Pack it together
self.but_close.set_size_request(100, 30)
self.but_save.set_size_request(100, 30)
bb.pack_end(self.but_save, False, True)
bb.pack_end(self.but_close, False, True)
sw.add(self.lv)
sw.set_size_request(300, 200)
sw.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC)
tab.attach(sw, 0, 1, 1, 12)
tab.attach(self.lb_pick_game, 0, 1, 0, 1, yoptions=gtk.FILL, xpadding=5, ypadding=3)
tab.attach(self.lb_resolution, 1, 3, 1, 2, xoptions=gtk.EXPAND|gtk.FILL, yoptions=0, xpadding=10)
tab.attach(self.resolution, 1, 5, 2, 3, yoptions=0, xpadding=30)
tab.attach(self.fullscreen, 1, 5, 4, 5, yoptions=0, xpadding=30)
tab.attach(self.lb_custom_res, 1, 5, 5, 6, yoptions=0, xpadding=10)
tab.attach(self.resolution_w, 1, 2, 6, 7, yoptions=0, xpadding=30)
tab.attach(self.lb_x, 2, 3, 6, 7, xoptions=0, yoptions=0, xpadding=2)
tab.attach(self.resolution_h, 4, 5, 6, 7, yoptions=0, xpadding=30)
tab.attach(self.lb_custom_w, 1, 5, 7, 8, yoptions=0, xpadding=30)
tab.attach(self.lb_game_info, 1, 5, 8, 9, yoptions=0, xpadding=10)
tab.attach(self.game_info, 1, 5, 9, 10, xoptions=gtk.EXPAND|gtk.FILL, yoptions=0, xpadding=30)
tab.attach(self.link, 1, 5, 10, 11, xoptions=gtk.EXPAND|gtk.FILL, yoptions=0, xpadding=30)
tab.attach(gtk.Label(""), 1, 5, 11, 12, xoptions=gtk.EXPAND, yoptions=gtk.EXPAND)
vb.pack_start(tab, True, True, 3)
vb.pack_end(bb, False, True, 3)
vb.set_border_width(5)
self.add(vb)
self.show_all()
self.lb_custom_w.set_visible(False)
self.set_settings_enabled(False)
self.set_custom_res_enabled(False)
def set_game_list_enabled(self, e):
""" Enables/Disables list of games on left side of window """
for x in ( self.lb_pick_game, self.lv.get_parent()):
x.set_visible(e)
def set_settings_enabled(self, e):
""" Enables/Disables list of options on right side of window """
for x in ( self.lb_resolution, self.resolution, self.fullscreen, self.game_info, self.lb_game_info, self.lb_x, self.link ):
x.set_sensitive(e)
def set_custom_res_enabled(self, v):
""" Enables/Disables custom resolution fields """
for x in ( self.lb_custom_res, self.resolution_w, self.resolution_h, self.lb_x):
x.set_sensitive(v)
def setup_listview(self, lv):
""" Setups... well, listview. That's that gamelist on left side """
lv.set_model(gtk.ListStore(str, str, str)) # config, name, company
r = gtk.CellRendererText()
col = gtk.TreeViewColumn("Game", r, text=1)
lv.append_column(col)
lv.set_headers_visible(False)
lv.set_search_column(1)
def is_custom_res(self):
""" Returns True if resolution is set to custom """
return self.resolution.get_active_text() == _("custom")
def add_game(self, config, name, company):
""" Called from another thread for every game found """
gtk.threads_enter()
self.lv.get_model().append((config, name, company))
gtk.threads_leave()
def get_xrandr_resolutions(self):
""" Uses xrandr utility to loads list of resolutions available for primary display """
if not os.path.exists(XRANDR):
print >>sys.stderr, "Warning: xrandr utility not found. Only current desktop resolution will be available"
return ["%sx%s" % (gtk.gdk.screen_width(), gtk.gdk.screen_height()), _("custom")]
xr_data = Popen([XRANDR], stdout=PIPE).communicate()[0].split("\n")
try:
out = [ x for x in xr_data if "primary" in x ]
if len(out) == 0: out = [ x for x in xr_data if "connected" in x ]
primary = out[0].split(" ")[0]
print "Found primary display:", primary
except Exception, e:
print >>sys.stderr, "Warning: Failed to determine primary display. Only current desktop resolution will be available"
return ["%sx%s" % (gtk.gdk.screen_width(), gtk.gdk.screen_height()), _("custom")]
appends = False
r_list = []
for x in xr_data:
if appends:
if not x.startswith(" "):
break
try:
res = x.strip().split(" ")[0]
except Exception:
continue
print "Found resolution:", res
r_list.append(res)
if x.startswith(primary):
appends = True
r_list.append(_("custom"))
return r_list
def on_search_finished(self):
"""
Called from another thread after search for games is finished.
Shows warning message if there is no game found.
"""
gtk.threads_enter()
if len(self.lv.get_model()) == 0:
md = gtk.MessageDialog(self,
gtk.DIALOG_DESTROY_WITH_PARENT, gtk.MESSAGE_WARNING,
gtk.BUTTONS_OK, _("No Unity3D based games found.\nIf you already have one installed, please, run it before launching this tool to generate default configuration file."))
md.set_title(_("Warning"))
md.run()
md.destroy()
gtk.threads_leave()
def load_config(self, filename, game):
""" Loads game configuration from specified file """
self.config = None
try:
if game in SPECIAL_CASES:
self.config = SPECIAL_CASES[game][0](filename, *SPECIAL_CASES[game][1:])
else:
self.config = DefaultSettings(filename)
except Exception, e:
# ... on error ...
print >>sys.stderr, traceback.format_exc()
self.set_settings_enabled(False)
self.set_custom_res_enabled(False)
md = gtk.MessageDialog(self,
gtk.DIALOG_DESTROY_WITH_PARENT, gtk.MESSAGE_ERROR,
gtk.BUTTONS_CLOSE, _("Failed to parse game configuration") + "\n" + str(e))
md.run()
md.destroy()
return
# Populate resolution combobox
self.resolution.get_model().clear()
for x in self.config.get_supported_resolutions(self):
self.resolution.append_text(x)
# Transfer configuration from wrapper to GUI
self.fullscreen.set_active(self.config.is_fullscreen())
res_w, res_h = self.config.get_resolution()
if res_w == 0 or res_h == 0:
# Resolution is not set, fallback to screen resolution
self.resolution.set_active(0)
self.set_custom_res_enabled(False)
else:
# Select apropriate value in combobox or enable 'custom' checkbox
model = self.resolution.get_model()
res = "%sx%s" % (res_w, res_h)
index = 0
found = False
for i in model:
if i[0] == res:
self.resolution.set_active(index)
self.set_custom_res_enabled(False)
found = True
break
index += 1
if not found:
self.resolution.set_active(len(model) - 1)
self.set_custom_res_enabled(True)
self.resolution_w.set_text(str(res_w))
self.resolution_h.set_text(str(res_h))
self.but_save.set_sensitive(False)
self.set_settings_enabled(True)
def on_resolution_changed(self, cb):
""" Called when value in resolution combobox gets changed """
if self.is_custom_res():
self.set_custom_res_enabled(True)
if self.resolution_h.get_text().strip() == "" or self.resolution_w.get_text().strip() == "":
self.resolution_h.set_text(cb.get_model()[0][0].split("x")[0])
self.resolution_w.set_text(cb.get_model()[0][0].split("x")[1])
else:
self.set_custom_res_enabled(False)
self.but_save.set_sensitive(True)
self.lb_custom_w.set_visible( self.is_custom_res() and self.fullscreen.get_active() )
def on_fullscreen_changed(self, *a):
""" Called fullscreen combobox is toggled """
self.but_save.set_sensitive(True)
self.lb_custom_w.set_visible( self.is_custom_res() and self.fullscreen.get_active() )
def on_custom_res_changed(self, ibox):
""" Called when value in either custom resolution inputbox gets changed """
ibox.set_text( "".join([ c for c in list(ibox.get_text()) if c in "1234567890" ]) )
self.but_save.set_sensitive(True)
def on_save(self, *a):
""" Called when user clicks on Save button """
if self.config:
# Transfer data from UI to configuration wrapper
self.config.set_fullscreen(self.fullscreen.get_active())
if self.is_custom_res():
self.config.set_resolution(int(self.resolution_w.get_text()), int(self.resolution_h.get_text()))
else:
res = self.resolution.get_active_text()
self.config.set_resolution(int(res.split("x")[0]), int(res.split("x")[1]))
# Store configuration on disk
try:
self.config.save(self.is_custom_res())
except Exception, e:
# ... on error ...
print >>sys.stderr, traceback.format_exc()
self.set_settings_enabled(False)
self.set_custom_res_enabled(False)
md = gtk.MessageDialog(self,
gtk.DIALOG_DESTROY_WITH_PARENT, gtk.MESSAGE_ERROR,
gtk.BUTTONS_CLOSE, _("Failed to write game configuration") + "\n" + str(e))
md.run()
md.destroy()
self.but_save.set_sensitive(False)
def on_game_selected(self, lv):
""" Called when user clicks in game list """
model, it = lv.get_selection().get_selected()
try:
config, game, company = model[it]
except Exception:
return
self.load_config(config, game)
self.set_game_info(config, game, company)
def set_game_info(self, config, game, company):
""" Sets values in game info screen """
self.game_info.set_text("%s by %s\n%s" % (game, company, _("Configuration is stored in:")))
config_dir = os.path.split(config)[0]
self.link.set_uri("file://%s" % config_dir)
if config_dir.startswith(os.path.expanduser("~")):
config_dir = "~" + config_dir[len(os.path.expanduser("~")):]
self.link.set_label(config_dir)
class Settings:
""" Abstract class for all settings wrappers """
__metaclass__ = ABCMeta
@abstractmethod
def is_fullscreen(self):
""" Returns True, if game is currently set to fullscreen """
pass
@abstractmethod
def get_supported_resolutions(self, app):
pass
@abstractmethod
def set_fullscreen(self, value):
""" Enables / disables fullscreen mode """
pass
@abstractmethod
def get_resolution(self):
"""
Returns current window size resolution setting.
Returns tuple in (w, h) format.
"""
pass
@abstractmethod
def set_resolution(self, w, h):
""" Sets resolution """
pass
@abstractmethod
def save(self, custom_res):
""" Stores changed settings """
pass
class DefaultSettings(Settings):
""" Wrapper for default unity configuration file format """
def __init__(self, filename):
self.filename = filename
if self.filename != None:
self.from_string(file(filename, "r").read())
else:
# Empty unity prefs
self.tree = ElementTree.fromstring('')
self.fullscreen = False
self.w, self.h = (800, 600)
def from_string(self, string):
""" Reads configuration from string """
self.tree = ElementTree.fromstring(string)
self.fullscreen = False
self.w, self.h = (800, 600)
for child in [ x for x in self.tree.iter("pref") if "name" in x.attrib ] :
try:
if child.attrib["name"] == "Screenmanager Is Fullscreen mode":
self.fullscreen = (child.text.strip(" \t\r\n") != "0")
elif child.attrib["name"] == "Screenmanager Resolution Height":
self.h = int(child.text.strip(" \t\r\n"))
elif child.attrib["name"] == "Screenmanager Resolution Width":
self.w = int(child.text.strip(" \t\r\n"))
except Exception:
continue
return self.tree
def get_supported_resolutions(self, app): return app.xranrd_resolutions
def is_fullscreen(self): return self.fullscreen
def set_fullscreen(self, value): self.fullscreen = value
def get_resolution(self): return (self.w, self.h)
def set_resolution(self, w, h): (self.w, self.h) = (w, h)
def set_setting(self, name, etype, value):
# If specified node is already in tree, just overwrite value
for e in [ x for x in self.tree.iter("pref") if "name" in x.attrib and x.attrib["name"] == name ] :
e.text = value
e.attrib["type"] = etype
return e
# Add new node otherwise
e = ElementTree.SubElement(self.tree, "pref", name=name)
e.text = value
e.attrib["type"] = etype
e.tail = "\n\t"
return e
def to_string(self):
# Replace values
self.set_setting("Screenmanager Is Fullscreen mode", "int", "1" if self.fullscreen else "0")
self.set_setting("Screenmanager Resolution Width", "int", str(self.w))
self.set_setting("Screenmanager Resolution Height", "int", str(self.h))
# Generate string
return ElementTree.tostring(self.tree)
def save(self, custom_res):
if self.filename != None:
file(self.filename, "w").write(self.to_string())
Settings.register(DefaultSettings)
class scsCopyInGameState(DefaultSettings):
""" Special case settings format: Settings are copied in embeded XML """
def __init__(self, filename, embededXmlNode):
# Load setting as usual
DefaultSettings.__init__(self, filename)
# Grab XML file embeded as CDATA node
self.embededXmlNode = embededXmlNode
embeds = [ x for x in self.tree.iter('pref') if "name" in x.attrib and x.attrib["name"] == embededXmlNode ]
self.embeded = ElementTree.Element(embededXmlNode)
if len(embeds) > 0:
# Load data from embeded XML
string = embeds[0].text
if 'encoding="utf-16"' in string:
# Fix for ParseError: encoding specified in XML declaration is incorrect: line 1, column 30
string = string.replace('encoding="utf-16"', 'encoding="utf-8"')
try:
self.embeded = ElementTree.fromstring(string)
self.fullscreen = self.embeded.iter("FullScreen").next().text.lower().strip() != "false"
self.w = int(self.embeded.iter("ScreenWidth").next().text)
self.h = int(self.embeded.iter("ScreenHeight").next().text)
except Exception: pass
def set_in_embeded(self, name, value):
# If specified node is already in tree, just overwrite value
for e in self.embeded.iter(name):
e.text = value
return e
# Add new node otherwise
e = ElementTree.SubElement(self.embeded, name)
e.text = value
e.tail = "\n"
return e
def save(self, custom_res):
# Update settings in embeded file
self.set_in_embeded("FullScreen", "true" if self.fullscreen else "false")
self.set_in_embeded("ScreenWidth", str(self.w))
self.set_in_embeded("ScreenHeight", str(self.h))
self.set_setting(self.embededXmlNode, "string", ElementTree.tostring(self.embeded))
# Call save on superclass
DefaultSettings.save(self, custom_res)
class scsResolutionAsNumber(DefaultSettings):
""" Special case settings format: Resolution saved as number, choosen from pre-defined list """
DEFAULT_RESOLUTIONS = ["640x480", "800x480", "854x480", "960x540", "1024x576",
"800x600", "1024x600", "960x640", "1024x640", "1152x720", "1280x720", "1024x768", "1152x768",
"1280x768", "1366x768", "1280x800", "1152x864", "1280x864", "1440x900", "1600x900", "1280x960",
"1440x960", "1280x1024", "1400x1050", "1680x1050", "1920x1080"]
def __init__(self, filename, resAsNumNode, resolutions):
DefaultSettings.__init__(self, filename)
self.resAsNumNode = resAsNumNode
self.resolutions = resolutions
if resAsNumNode != None:
for child in [ x for x in self.tree.iter("pref") if "name" in x.attrib and x.attrib["name"] == resAsNumNode ] :
try:
num = int(child.text)
self.w = int(resolutions[num].split("x")[0])
self.h = int(resolutions[num].split("x")[1])
except Exception:
continue
def get_supported_resolutions(self, app):
return self.resolutions
def save(self, custom_res):
if self.resAsNumNode != None:
num = self.resolutions.index("%sx%s" % (self.w, self.h))
self.set_setting(self.resAsNumNode, "int", str(num))
DefaultSettings.save(self, custom_res)
class scsFullScreenKeyDoubled(DefaultSettings):
"""
Special case settings format: Fullscreen settings is stored in
additional key
"""
def __init__(self, filename, additionalFSKey):
self.additionalFSKey = additionalFSKey
DefaultSettings.__init__(self, filename)
def from_string(self, string):
""" Reads configuration from string """
self.tree = DefaultSettings.from_string(self, string)
for child in [ x for x in self.tree.iter("pref") if "name" in x.attrib ] :
try:
if child.attrib["name"] == self.additionalFSKey:
self.fullscreen = (child.text.strip(" \t\r\n") != "0")
except Exception:
continue
return self.tree
def to_string(self):
self.set_setting(self.additionalFSKey, "int", "1" if self.fullscreen else "0")
return DefaultSettings.to_string(self)
IGNORED = [
"BattleWorldsKronos", # Has ingame configuration and ignores settings in prefs
"DoE", # Fullscreen can be toggled ingame and ignores settings -_-
"SirYouAreBeingHunted", # Has ingame configuration and ignores settings in prefs
# more to come...
]
SPECIAL_CASES = {
# Format:
# 'Game' : (Class, additonal constructor parameters...)
'Micron' : (scsCopyInGameState, "GameState"),
'Fancy Skulls': (scsResolutionAsNumber, "resolutionNumber", scsResolutionAsNumber.DEFAULT_RESOLUTIONS),
'Breach & Clear': (scsFullScreenKeyDoubled, "Fullscreen"),
}
class BoldLabel(gtk.Label):
""" Left-aligned label with bold text """
def __init__(self, text):
gtk.Label.__init__(self)
self.set_markup("%s" % text)
self.set_alignment(0, 0)
def search_for_configs(app):
""" Searchs for config files in known locations """
configs = Popen(["find", os.path.expanduser("~/.config/unity3d"), "-iname", "prefs"], stdout=PIPE).communicate()[0].split("\n")
configs.sort(key=lambda c : c.split(os.path.sep)[-2].lower() if len(c.split(os.path.sep)) > 2 else "" )
for x in configs:
try:
company = x.split(os.path.sep)[-3]
game = x.split(os.path.sep)[-2]
if game in IGNORED:
continue
except Exception:
continue
app.add_game(x, game, company)
app.on_search_finished()
if __name__ == "__main__":
# Parse arguments
# -f, -h and --help are recognized; Anything else is considered to be config location
if len(sys.argv) <= 1:
gtk.threads_init()
a = App()
a.show()
thread.start_new_thread(search_for_configs, (a,))
gtk.main()
elif sys.argv[1] in ("-h", "--help"):
# Display help and exit
print HELP % {'exe' : sys.argv[0]}
else:
# Check for -f parameter
create = False
if sys.argv[1] == "-f":
sys.argv.remove("-f")
create = True
# Determine if config_location points to configuration directory or configuration file
config_dir = sys.argv[1]
if config_dir.endswith(os.path.sep + "prefs"):
config = config_dir
config_dir = os.path.split(config_dir)[0]
else:
config = os.path.join(config_dir, "prefs")
# Check if configuration file already exists
if not os.path.exists(config):
if create:
# Create new configuration if requested...
print "Creating default configuration in", config_dir
try:
os.makedirs(config_dir)
except Exception:
pass
try:
file(config, "w").write(DEFAULT_CONFIG)
except Exception, e:
print >>sys.stderr, e
sys.exit(1)
else:
# Fail miserably otherwise
print >>sys.stderr, "Specified path is not unity configuration file nor configuration directory"
sys.exit(-1)
# Little preparation
company = config.split(os.path.sep)[-3]
game = config.split(os.path.sep)[-2]
if game in IGNORED:
print >>sys.stderr, _("Sorry, this game is using it's own configuration format and this is known as not working with this tool.")
sys.exit(1)
print "Loading configuration file", config
# Prepare UI and GTK
gtk.threads_init()
a = App()
# Load configuration
a.load_config(config, game)
a.set_game_info(config, game, company)
# Setup and show window
a.set_game_list_enabled(False)
a.set_title("%s: %s" % (_(APP_NAME_SHORT), game))
a.show()
# GTK mainloop
gtk.main()