#!/usr/bin/env python3
# coding=utf-8

from __future__ import print_function
import sys, os.path

if sys.version_info.major < 3:
    print('Python 3 is required to run Playscii.', file=sys.stderr)
    sys.exit(1)

import platform
if platform.system() == 'Windows' or platform.system() == 'Darwin':
    import os
    # set env variable so pysdl2 can find sdl2.dll
    os.environ['PYSDL2_DLL_PATH'] = '.'
    sys.path += ['.']

# fix the working directory when running in a mac app
if platform.system() == 'Darwin' and hasattr(sys, 'frozen'):
    os.chdir(os.path.abspath(os.path.dirname(sys.executable)))

# app imports
import ctypes, time, hashlib, importlib, traceback
import webbrowser
import sdl2
import sdl2.ext
import appdirs
import PIL, OpenGL, numpy # just for version checks
# DEBUG: GL context checking, must be set before other imports and calls
#OpenGL.CONTEXT_CHECKING = True
from sdl2 import video, sdlmixer
from OpenGL import GL
from PIL import Image
from packaging import version
# work around a name being deprecated in different versions of PIL
if version.parse(Image.__version__) > version.parse('10.0.0'):
    Image.ANTIALIAS = Image.LANCZOS
# cache whether pdoc is available for help menu item
pdoc_available = False
try:
    import pdoc
    pdoc_available = True
except: pass

# submodules - set here so cfg file can modify them all easily
from audio import AudioLord
from shader import ShaderLord
from camera import Camera
from charset import CharacterSet, CharacterSetLord, CHARSET_DIR
from palette import Palette, PaletteLord, PALETTE_DIR
from art import Art, ArtFromDisk, DEFAULT_CHARSET, DEFAULT_PALETTE, DEFAULT_WIDTH, DEFAULT_HEIGHT, DEFAULT_ART_FILENAME
from art_import import ArtImporter
from art_export import ArtExporter
from renderable import TileRenderable, OnionTileRenderable
from renderable_line import DebugLineRenderable
from renderable_sprite import UIBGTextureRenderable, SpriteRenderable
from framebuffer import Framebuffer
from art import ART_DIR, ART_FILE_EXTENSION, ART_SCRIPT_DIR
from ui import UI, OIS_WIDTH
from cursor import Cursor
from grid import ArtGrid
from input_handler import InputLord
from ui_file_chooser_dialog import THUMBNAIL_CACHE_DIR
# some classes are imported only so the cfg file can modify their defaults
from renderable_line import LineRenderable
from ui_swatch import CharacterSetSwatch
from ui_element import UIRenderable, FPSCounterUI, DebugTextUI
from ui_menu_pulldown import PulldownMenu
from ui_dialog import UIDialog
from ui_chooser_dialog import ScrollArrowButton, ChooserDialog
from image_convert import ImageConverter
from game_world import GameWorld, TOP_GAME_DIR
from game_object import GameObject
from shader import Shader

APP_NAME = 'Playscii'
VERSION_FILENAME = 'version'

CONFIG_FILENAME = 'playscii.cfg'
CONFIG_TEMPLATE_FILENAME = CONFIG_FILENAME + '.default'
LOG_FILENAME = 'console.log'
SESSION_FILENAME = 'playscii.session'
LOGO_FILENAME = 'ui/logo.png'
SCREENSHOT_DIR = 'screenshots/'
FORMATS_DIR = 'formats/'
AUTOPLAY_GAME_FILENAME = 'autoplay_this_game'

WEBSITE_URL = 'https://jplebreton.com/playscii'
WEBSITE_HELP_URL = 'docs/html/howto_main.html'
AUTOGEN_DOCS_PATH = 'docs/html/generated/'
AUTOGEN_DOC_MODULES = ['game_object', 'game_world', 'game_room', 'collision',
                       'game_util_objects', 'art', 'renderable', 'vector',
                       'art_import', 'art_export']
AUTOGEN_DOC_TOC_PAGE = 'pdoc_toc.html'

MAX_ONION_FRAMES = 3

class Application:
    # default window dimensions, may be updated during screen res detection
    window_width, window_height = 1280, 720
    # if window gets extremely small it causes problems, enforce a minumum size
    # (UI becomes hard to use below 640 x 480, but allow it in case user is
    # doing something weird or reshuffling their desktop)
    min_window_width, min_window_height = 320, 240
    # if True, use the older method of creating a test window to determine
    # desktop resolution - used to be needed on Linux, but maybe no longer.
    create_test_window = False
    fullscreen = False
    # framerate: uncapped if -1
    framerate = 30
    # fixed timestep for game physics
    update_rate = 30
    # force to run even if we can't get an OpenGL 2.1 context
    run_if_opengl_incompatible = False
    # arbitrary size cap, but something bigger = probably a bad idea
    max_art_width, max_art_height = 9999, 9999
    # use capslock as another ctrl key - SDL2 doesn't seem to respect OS setting
    capslock_is_ctrl = False
    bg_color = [0.2, 0.2, 0.2, 2]
    default_overlay_image_opacity = 0.25
    show_bg_texture = True
    # if True, ignore camera loc saved in .psci files
    override_saved_camera = False
    # launch into art mode even if a game dir is specified via CLI
    always_launch_art_mode = False
    # show dev-only log messages
    show_dev_log = False
    # in art mode, show layers marked invisible to game mode
    show_hidden_layers = False
    welcome_message = 'Welcome to Playscii! Press SPACE to select characters and colors to paint.'
    compat_fail_message = "your hardware doesn't appear to meet Playscii's requirements!  Sorry ;________;"
    game_mode_message = 'Game Mode active, press %s to return to Art Mode.'
    img_convert_message = 'converting bitmap image: %s'
    # can_edit: if False, user can't use art or edit functionality
    can_edit = True
    # these values should be written to cfg files on exit
    # key = module path, value = [member object (blank if self), var name]
    persistent_setting_names = {
        'UI.popup_hold_to_show': ['ui', 'popup_hold_to_show'],
        'Framebuffer.start_crt_enabled': ['fb', 'crt'],
        'Application.show_bg_texture': ['', 'show_bg_texture'],
        'ArtGrid.visible': ['art_grid', 'visible']
    }
    # characters that can't appear in filenames (any OS; Windows is least permissive)
    forbidden_filename_chars = ['/', '\\', '*', ':']
    
    def __init__(self, config_dir, documents_dir, cache_dir, logger,
                 art_filename, game_dir_to_load, state_to_load, autoplay_game):
        self.init_success = False
        self.config_dir = config_dir
        # keep playscii.cfg lines in case we want to add some
        self.config_lines = open(self.config_dir + CONFIG_FILENAME).readlines()
        self.documents_dir = documents_dir
        self.cache_dir = cache_dir
        # last dir art was opened from
        self.last_art_dir = None
        # last dir file was imported from
        self.last_import_dir = None
        # class to use for temp thumbnail renderable
        self.thumbnail_renderable_class = TileRenderable
        # logger fed in from __main__
        self.logger = logger
        self.last_time = 0
        self.this_frame_start, self.last_frame_end = 0, 0
        # number of updates (world, etc) and rendered frames this session
        self.updates, self.frames = 0, 0
        self.timestep = (1 / self.update_rate) * 1000
        # for FPS counter
        self.frame_time, self.fps = 0, 0
        self.should_quit = False
        self.mouse_x, self.mouse_y = 0, 0
        self.mouse_dx, self.mouse_dy = 0, 0
        self.has_input_focus = self.has_mouse_focus = False
        self.inactive_layer_visibility = 1
        self.version = get_version()
        # last edit came from keyboard or mouse, used by cursor control logic
        self.keyboard_editing = False
        # set ui None so other objects can check it None, eg load_art check
        # for its active art on later runs (audiolord too)
        self.ui, self.al = None, None
        # on linux, prefer wayland, via SDL 2.0.22's SDL_HINT_VIDEODRIVER
        # http://wiki.libsdl.org/SDL_SetHint
        if platform.system() == 'Linux':
            # but if we're running x11, wayland won't be available
            # and we'll get an SDLError - so handle the exception.
            # seems like we should be able to ask for, eg
            # sdl2.SDL_SetHint(sdl2.SDL_HINT_VIDEODRIVER, b'wayland, x11'),
            # though. TODO research this further, see how other progs do it.
            try:
                sdl2.SDL_SetHint(sdl2.SDL_HINT_VIDEODRIVER, b'wayland')
                sdl2.ext.init()
            except:
                try:
                    sdl2.SDL_SetHint(sdl2.SDL_HINT_VIDEODRIVER, b'x11')
                    sdl2.ext.init()
                except:
                    sdl2.ext.init()
        winpos = sdl2.SDL_WINDOWPOS_UNDEFINED
        screen_width, screen_height = self.get_desktop_resolution()
        # make sure main window won't be too big for screen
        max_width = int(screen_width * 0.8)
        max_height = int(screen_height * 0.8)
        self.window_width = min(self.window_width, max_width)
        self.window_height = min(self.window_height, max_height)
        # TODO: SDL_WINDOW_ALLOW_HIGHDPI doesn't seem to work right,
        # determine whether we're using it wrong or it's broken
        flags = sdl2.SDL_WINDOW_OPENGL | sdl2.SDL_WINDOW_RESIZABLE# | sdl2.SDL_WINDOW_ALLOW_HIGHDPI
        if self.fullscreen:
            flags = flags | sdl2.SDL_WINDOW_FULLSCREEN_DESKTOP
        self.window = sdl2.SDL_CreateWindow(bytes(APP_NAME, 'utf-8'),
                                            winpos, winpos,
                                            self.window_width, self.window_height,
                                            flags)
        sdl2.SDL_SetWindowMinimumSize(self.window, self.min_window_width,
                                      self.min_window_height)
        # force GL2.1 'core' before creating context
        video.SDL_GL_SetAttribute(video.SDL_GL_CONTEXT_MAJOR_VERSION, 2)
        video.SDL_GL_SetAttribute(video.SDL_GL_CONTEXT_MINOR_VERSION, 1)
        video.SDL_GL_SetAttribute(video.SDL_GL_CONTEXT_PROFILE_MASK,
                                  video.SDL_GL_CONTEXT_PROFILE_CORE)
        self.context = sdl2.SDL_GL_CreateContext(self.window)
        # if creating a core profile context fails, try GL ES 2.0
        if not self.context:
            video.SDL_GL_SetAttribute(video.SDL_GL_CONTEXT_MAJOR_VERSION, 2)
            video.SDL_GL_SetAttribute(video.SDL_GL_CONTEXT_MINOR_VERSION, 0)
            video.SDL_GL_SetAttribute(video.SDL_GL_CONTEXT_PROFILE_MASK,
                                      video.SDL_GL_CONTEXT_PROFILE_ES)
            self.context = sdl2.SDL_GL_CreateContext(self.window)
            # save ES status for later use by eg Shaders
            self.context_es = True
        else:
            self.context_es = False
        self.log('Detecting hardware...')
        cpu = platform.processor() or platform.machine()
        self.log('  CPU: %s' % (cpu if cpu != '' else "[couldn't detect CPU]"))
        # report GL vendor, version, GLSL version etc
        try: gpu_vendor = GL.glGetString(GL.GL_VENDOR).decode('utf-8')
        except: gpu_vendor = "[couldn't detect vendor]"
        try: gpu_renderer = GL.glGetString(GL.GL_RENDERER).decode('utf-8')
        except: gpu_renderer = "[couldn't detect renderer]"
        self.log('  GPU: %s - %s' % (gpu_vendor, gpu_renderer))
        try:
            # try single-argument GL2.0 version first
            gl_ver = GL.glGetString(GL.GL_VERSION)
            if not gl_ver:
                gl_ver = GL.glGetString(GL.GL_VERSION, ctypes.c_int(0))
            gl_ver = gl_ver.decode('utf-8')
        except:
            gl_ver = "[couldn't detect GL version]"
        self.log('  OpenGL detected: %s' % gl_ver)
        # GL 1.1 doesn't even habla shaders, quit if we fail GLSL version check
        try:
            glsl_ver = GL.glGetString(GL.GL_SHADING_LANGUAGE_VERSION)
            if not glsl_ver:
                glsl_ver = GL.glGetString(GL.GL_SHADING_LANGUAGE_VERSION, ctypes.c_int(0))
        except:
            self.log('GLSL support not detected, ' + self.compat_fail_message)
            self.should_quit = True
            return
        glsl_ver = glsl_ver.decode('utf-8') if glsl_ver != None else None
        self.log('  GLSL detected: %s' % glsl_ver or '[unknown]')
        # verify that we got at least a 2.1 context
        majorv, minorv = ctypes.c_int(0), ctypes.c_int(0)
        video.SDL_GL_GetAttribute(video.SDL_GL_CONTEXT_MAJOR_VERSION, majorv)
        video.SDL_GL_GetAttribute(video.SDL_GL_CONTEXT_MINOR_VERSION, minorv)
        context_version = majorv.value + (minorv.value * 0.1)
        self.use_vao = bool(GL.glGenVertexArrays)
        self.log('  Vertex Array Object support %sfound.' % ['NOT ', ''][self.use_vao])
        if not self.context:
            self.log("No OpenGL context found!")
        # enforce GL version requirement
        if not self.context or context_version < 2.1 or gl_ver.startswith('2.0'):
            self.log("Couldn't create a compatible OpenGL context, " + self.compat_fail_message)
            if not self.run_if_opengl_incompatible:
                self.should_quit = True
                return
        # enforce GLSL version requirement
        try:
            gv = float(glsl_ver.split()[0])
            if bool(glsl_ver) and gv <= 1.2:
                self.log("GLSL 1.30 or higher is required, " + self.compat_fail_message)
                if not self.run_if_opengl_incompatible:
                    self.should_quit = True
                    return
        except:
            # can't get a firm number out of reported GLSL version string :/
            pass
        # detect max texture size
        mts = ctypes.c_int(0)
        GL.glGetIntegerv(GL.GL_MAX_TEXTURE_SIZE, mts)
        self.max_texture_size = mts.value
        self.log('  Maximum supported texture size: %s x %s' % (self.max_texture_size, self.max_texture_size))
        self.log('  Detected screen resolution: %.0f x %.0f, window: %s x %s' % (screen_width, screen_height, self.window_width, self.window_height))
        self.log('Detecting software environment...')
        self.log('  OS: %s' % platform.platform())
        py_version = ' '.join(sys.version.split('\n'))
        # report 32 vs 64 bit as it's not clear from sys.version or OS
        bitness = platform.architecture()[0]
        # on linux, report whether we're running x11 or wayland
        if platform.system() == 'Linux':
            driver = sdl2.SDL_GetCurrentVideoDriver().decode('utf-8')
            self.log('  Linux SDL2 "video driver": %s' % driver)
        self.log('  Python: %s (%s)' % (py_version, bitness))
        module_versions = 'PySDL2 %s, ' % sdl2.__version__
        module_versions += 'numpy %s, ' % numpy.__version__
        module_versions += 'PyOpenGL %s, ' % OpenGL.__version__
        module_versions += 'appdirs %s, ' % appdirs.__version__
        module_versions += 'PIL %s' % PIL.__version__
        self.log('  Modules: %s' % module_versions)
        sdl_version = '%s.%s.%s ' % (sdl2.version.SDL_MAJOR_VERSION,
                                     sdl2.version.SDL_MINOR_VERSION,
                                     sdl2.version.SDL_PATCHLEVEL)
        sdl_version += sdl2.version.SDL_GetRevision().decode('utf-8')
        sdl_version += ', SDLmixer: %s.%s.%s' % (sdlmixer.SDL_MIXER_MAJOR_VERSION,
                                                 sdlmixer.SDL_MIXER_MINOR_VERSION,
                                                 sdlmixer.SDL_MIXER_PATCHLEVEL)
        self.log('  SDL: %s' % sdl_version)
        # draw black screen while doing other init
        GL.glClearColor(0.0, 0.0, 0.0, 1.0)
        GL.glClear(GL.GL_COLOR_BUFFER_BIT)
        # initialize audio
        self.al = AudioLord(self)
        self.set_icon()
        # SHADERLORD rules shader init/destroy, hot reload
        self.sl = ShaderLord(self)
        # separate cameras for edit vs game mode
        self.art_camera = Camera(self)
        self.camera = self.art_camera
        self.art_loaded_for_edit, self.edit_renderables = [], []
        # raster image overlay
        self.overlay_renderable = None
        self.draw_overlay = False
        self.overlay_scale_type = OIS_WIDTH
        self.converter = None
        # set when an import is in progress
        self.importer = None
        # set when an exporter is chosen, remains so last_export can run
        self.exporter = None
        self.last_export_options = {}
        # dict of available importer/exporter modules
        self.converter_modules = {}
        # last art script run (remember for "run last")
        self.last_art_script = None
        self.game_mode = False
        self.gw = GameWorld(self)
        # if game dir specified, set it before we try to load any art
        if game_dir_to_load or autoplay_game:
            self.gw.set_game_dir(game_dir_to_load or autoplay_game, False)
        # autoplay = distribution mode, no editing
        if autoplay_game and not game_dir_to_load and self.gw.game_dir:
            self.can_edit = False
        # debug line renderable
        self.debug_line_renderable = DebugLineRenderable(self, None)
        # onion skin renderables
        self.onion_frames_visible = False
        self.onion_show_frames = MAX_ONION_FRAMES
        # store constant so input_handler etc can read it
        self.max_onion_frames = MAX_ONION_FRAMES
        self.onion_show_frames_behind = self.onion_show_frames_ahead = True
        self.onion_renderables_prev, self.onion_renderables_next = [], []
        # lists of currently loaded character sets and palettes
        self.charsets, self.palettes = [], []
        self.csl = CharacterSetLord(self)
        self.pl = PaletteLord(self)
        # set/create an active art
        self.load_art_for_edit(art_filename)
        self.fb = Framebuffer(self)
        # setting cursor None now makes for easier check in status bar drawing
        self.cursor, self.grid = None, None
        # separate grids for art vs game mode
        self.art_grid = None
        # forward-declare inputlord in case UI looks for it
        self.il = None
        # initialize UI with first art loaded active
        self.ui = UI(self, self.art_loaded_for_edit[0])
        # textured background renderable
        self.bg_texture = UIBGTextureRenderable(self)
        # init onion skin
        for i in range(self.onion_show_frames):
            renderable = OnionTileRenderable(self, self.ui.active_art)
            self.onion_renderables_prev.append(renderable)
        for i in range(self.onion_show_frames):
            renderable = OnionTileRenderable(self, self.ui.active_art)
            self.onion_renderables_next.append(renderable)
        # set camera bounds based on art size
        self.camera.set_for_art(self.ui.active_art)
        self.update_window_title()
        self.cursor = Cursor(self)
        self.art_grid = ArtGrid(self, self.ui.active_art)
        self.grid = self.art_grid
        self.ui.set_active_layer(self.ui.active_art.active_layer)
        # INPUTLORD rules input handling and keybinds
        self.il = InputLord(self)
        # update UI (hovered elements) and cursor now that we have input
        self.ui.update()
        self.cursor.pre_first_update()
        self.pdoc_available = pdoc_available
        self.init_success = True
        self.log('Init done.')
        if self.can_edit:
            self.restore_session()
        # if art file was given in arguments, set it active
        if art_filename:
            self.ui.set_active_art_by_filename(art_filename)
        if (game_dir_to_load or autoplay_game) and self.gw.game_dir:
            # set initial game state
            if state_to_load:
                self.gw.load_game_state(state_to_load)
            else:
                self.gw.load_game_state()
        else:
            #self.ui.message_line.post_line(self.welcome_message, 10)
            pass
        # if "autoplay_this_game" used and game is valid, lock out edit mode
        if not self.can_edit:
            self.enter_game_mode()
            self.ui.set_game_edit_ui_visibility(False, False)
            self.gw.draw_debug_objects = False
        elif self.gw.game_dir and self.always_launch_art_mode:
            self.exit_game_mode()
    
    def get_desktop_resolution(self):
        winpos = sdl2.SDL_WINDOWPOS_UNDEFINED
        # use the more direct way of getting desktop resolution
        if not self.create_test_window:
            desktop = sdl2.video.SDL_DisplayMode()
            sdl2.SDL_GetDesktopDisplayMode(0, desktop)
            return desktop.w, desktop.h
        # this method seems to have broken recently (2022-06) on our Linux +
        # Nvidia + X11 + SDL2.20 setup; default it off but keep it around...
        test_window = sdl2.SDL_CreateWindow(bytes(APP_NAME, 'utf-8'),
                                            winpos, winpos,
                                            128, 128,
                                            sdl2.SDL_WINDOW_FULLSCREEN_DESKTOP)
        sdl2.SDL_HideWindow(test_window)
        screen_width, screen_height = ctypes.c_int(0), ctypes.c_int(0)
        sdl2.SDL_GetWindowSize(test_window, ctypes.pointer(screen_width),
                               ctypes.pointer(screen_height))
        screen_width = screen_width.value
        screen_height = screen_height.value
        sdl2.SDL_DestroyWindow(test_window)
        return screen_width, screen_height
    
    def is_mouse_inside_window(self):
        "returns True if mouse is inside application window"
        wx, wy = ctypes.c_int(0), ctypes.c_int(0)
        sdl2.SDL_GetWindowPosition(self.window, wx, wy)
        wx, wy = int(wx.value), int(wy.value)
        mx, my = ctypes.c_int(0), ctypes.c_int(0)
        # "global" mouse state = whole-desktop mouse coordinates
        sdl2.mouse.SDL_GetGlobalMouseState(mx, my)
        mx, my = int(mx.value), int(my.value)
        return wx <= mx <= wx + self.window_width and \
           wy <= my <= wy + self.window_height
    
    def set_icon(self):
        # TODO: this doesn't seem to work in Ubuntu, what am i missing?
        img = Image.open(LOGO_FILENAME).convert('RGBA')
        # does icon need to be a specific size?
        img = img.resize((32, 32), Image.Resampling.LANCZOS)
        w, h = img.size
        depth, pitch = 32, w * 4
        #SDL_CreateRGBSurfaceFrom((pixels, width, height, depth, pitch, Rmask, Gmask, Bmask, Amask)
        #mask = (0x0f00, 0x00f0, 0x000f, 0xf000)
        mask = (0x000000ff, 0x0000ff00, 0x00ff0000, 0xff000000)
        icon_surf = sdl2.SDL_CreateRGBSurfaceFrom(img.tobytes(), w, h, depth, pitch, *mask)
        # SDL_SetWindowIcon(self.window, SDL_Surface* icon)
        sdl2.SDL_SetWindowIcon(self.window, icon_surf)
        sdl2.SDL_FreeSurface(icon_surf)
    
    def log(self, new_line, error=False):
        "write to log file, stdout, and in-app console log"
        self.logger.log(new_line)
        if self.ui and self.can_edit:
            self.ui.message_line.post_line(new_line, hold_time=None, error=error)
    
    def dev_log(self, new_line):
        if self.show_dev_log:
            self.log(new_line)
    
    def log_import_exception(self, e, module_name):
        """
        Logs a readable version of stack trace of given exception encountered
        importing given module name.
        """
        for line in traceback.format_exc().split('\n'):
            # ignore the importlib parts of the call stack,
            # not useful and always the same
            if line and not 'importlib' in line and \
               not 'in _import_all' in line and \
               not '_bootstrap._gcd_import' in line:
                self.log(line.rstrip())
            s = 'Error importing module %s! See console.' % module_name
            if self.ui:
                self.ui.message_line.post_line(s, 10, True)
    
    def new_art(self, filename, width=None, height=None,
                charset=None, palette=None):
        width, height = width or DEFAULT_WIDTH, height or DEFAULT_HEIGHT
        filename = filename if filename and filename != '' else DEFAULT_ART_FILENAME
        charset = self.load_charset(charset or DEFAULT_CHARSET)
        palette = self.load_palette(palette or DEFAULT_PALETTE)
        art = Art(filename, self, charset, palette, width, height)
        art.set_filename(filename)
        art.time_loaded = time.time()
        return art
    
    def load_art(self, filename, autocreate=True):
        """
        load given file from disk; by default autocreate new file if it
        couldn't be found
        """
        valid_filename = self.find_filename_path(filename, ART_DIR,
                                                 ART_FILE_EXTENSION)
        art = None
        if not valid_filename:
            if autocreate:
                self.log('Creating new art %s' % filename)
                return self.new_art(filename)
            else:
                #self.log("Couldn't find art %s" % filename)
                return None
        # if already loaded, return that
        for a in self.art_loaded_for_edit + self.gw.art_loaded:
            if a.filename == valid_filename:
                return a
        art = ArtFromDisk(valid_filename, self)
        # if loading failed, create new file
        if not art or not art.valid:
            return self.new_art(valid_filename)
        # remember time loaded for UI list sorting
        art.time_loaded = time.time()
        return art
    
    def new_art_for_edit(self, filename, width=None, height=None):
        "Create a new Art and set it editable in Art Mode."
        art = self.new_art(filename, width, height)
        self.set_new_art_for_edit(art)
    
    def set_new_art_for_edit(self, art):
        "Makes given Art editable in Art Mode UI."
        self.art_loaded_for_edit.insert(0, art)
        renderable = TileRenderable(self, art)
        self.edit_renderables.insert(0, renderable)
        self.ui.set_active_art(art)
        self.camera.toggle_zoom_extents()
        art.set_unsaved_changes(True)
    
    def load_art_for_edit(self, filename):
        art = self.load_art(filename)
        if art in self.art_loaded_for_edit:
            self.ui.set_active_art(art)
            #self.ui.message_line.post_line('Art file %s already loaded' % filename)
            return
        self.art_loaded_for_edit.insert(0, art)
        renderable = TileRenderable(self, art)
        self.edit_renderables.insert(0, renderable)
        if self.ui:
            self.ui.set_active_art(art)
    
    def close_art(self, art):
        if not art in self.art_loaded_for_edit:
            return
        self.art_loaded_for_edit.remove(art)
        for r in art.renderables:
            if r in self.edit_renderables:
                self.edit_renderables.remove(r)
        if art is self.ui.active_art:
            self.ui.active_art = None
        self.log('Unloaded %s' % art.filename)
        if len(self.art_loaded_for_edit) > 0:
            self.ui.set_active_art(self.art_loaded_for_edit[0])
        self.update_window_title()
    
    def revert_active_art(self):
        filename = self.ui.active_art.filename
        self.close_art(self.ui.active_art)
        self.load_art_for_edit(filename)
    
    def get_file_hash(self, filename):
        f_data = open(filename, 'rb').read()
        return hashlib.md5(f_data).hexdigest()
    
    def get_dirnames(self, subdir=None, include_base=True):
        "returns list of suitable directory names across app and user dirs"
        dirnames = []
        # build list of dirs to check, by priority:
        # gamedir/subdir if it exists, then ./subdir, then ./
        if self.gw.game_dir is not None:
            game_dir = self.gw.game_dir
            if subdir:
                game_dir += subdir
            if os.path.exists(game_dir):
                dirnames.append(game_dir)
        if subdir is not None:
            dirnames.append(subdir)
        if include_base:
            dirnames.append('')
        # add duplicate set of dirs in user documents path
        doc_dirs = []
        for dirname in dirnames:
            # dir might already have documents path in it, add as-is if so
            if dirname.startswith(self.documents_dir) and os.path.exists(dirname):
                doc_dirs.append(dirname)
                continue
            doc_dir = self.documents_dir + dirname
            if os.path.exists(doc_dir):
                doc_dirs.append(doc_dir)
        # check in user document dirs first
        return doc_dirs + dirnames
    
    def find_filename_path(self, filename, subdir=None, extensions=None):
        "returns a valid path for given file, extension, subdir (art/ etc)"
        if not filename or filename == '':
            return None
        dirnames = self.get_dirnames(subdir)
        # build list of filenames from each dir, first w/ extension then w/o
        filenames = []
        # extensions: accept list or single item,
        # list with one empty string if None passed
        if extensions is None or len(extensions) == 0:
            extensions = ['']
        elif not type(extensions) is list:
            extensions = [extensions]
        for dirname in dirnames:
            for ext in extensions:
                f = '%s%s' % (dirname, filename)
                # filename passed in might already have intended extension,
                # eg from a directory listing
                if ext and ext != '' and not filename.endswith(ext):
                    f += '.' + ext
                filenames.append(f)
        # return first one we find
        for f in filenames:
            if f is not None and os.path.exists(f) and os.path.isfile(f):
                return f
        return None
    
    def get_converter_classes(self, base_class):
        "return a list of converter classes for importer/exporter selection"
        classes = []
        # on first load, documents dir may not be in import path
        if not self.documents_dir in sys.path:
            sys.path += [self.documents_dir]
        # read from application (builtins) and user documents dirs
        files = os.listdir(FORMATS_DIR)
        files += os.listdir(self.documents_dir + FORMATS_DIR)
        for filename in files:
            basename, ext = os.path.splitext(filename)
            if not ext.lower() == '.py':
                continue
            try:
                if basename in self.converter_modules:
                    m = importlib.reload(self.converter_modules[basename])
                else:
                    m = importlib.import_module('formats.%s' % basename)
                    self.converter_modules[basename] = m
            except Exception as e:
                self.log_import_exception(e, basename)
            for k,v in m.__dict__.items():
                if not type(v) is type:
                    continue
                # don't add duplicates
                # (can happen if eg one importer extends another)
                if v in classes:
                    continue
                if issubclass(v, base_class) and v is not base_class:
                    classes.append(v)
        return classes
    
    def get_importers(self):
        "Returns list of all ArtImporter subclasses found in formats/ dir."
        return self.get_converter_classes(ArtImporter)
    
    def get_exporters(self):
        "Returns list of all ArtExporter subclasses found in formats/ dir."
        return self.get_converter_classes(ArtExporter)
    
    def load_charset(self, charset_to_load, log=False):
        "creates and returns a character set with the given name"
        # already loaded?
        base_charset_to_load = os.path.basename(charset_to_load)
        base_charset_to_load = os.path.splitext(base_charset_to_load)[0]
        for charset in self.charsets:
            if charset.base_filename == base_charset_to_load:
                return charset
        new_charset = CharacterSet(self, charset_to_load, log)
        if new_charset.init_success:
            self.charsets.append(new_charset)
            return new_charset
        elif self.ui and self.ui.active_art:
            # if init failed (eg bad filename) return something safe
            return self.ui.active_art.charset
    
    def load_palette(self, palette_to_load, log=False):
        base_palette_to_load = os.path.basename(palette_to_load)
        base_palette_to_load = os.path.splitext(base_palette_to_load)[0]
        for palette in self.palettes:
            if palette.base_filename == base_palette_to_load:
                return palette
        new_palette = Palette(self, palette_to_load, log)
        if new_palette.init_success:
            self.palettes.append(new_palette)
            return new_palette
        elif self.ui and self.ui.active_art:
            # if init failed (eg bad filename) return something safe
            return self.ui.active_art.palette
    
    def set_window_title(self, text=None):
        # if editing is locked, don't even show Playscii name
        new_title = '%s - %s' % (APP_NAME, text) if self.can_edit else str(text)
        new_title = bytes(new_title, 'utf-8')
        sdl2.SDL_SetWindowTitle(self.window, new_title)
    
    def update_window_title(self):
        if self.game_mode:
            if self.gw and self.gw.game_dir:
                # if edit UI is up, show last loaded state
                if self.ui.game_menu_bar.visible:
                    title = self.gw.last_state_loaded
                # if not, show user-friendly game name
                else:
                    title = self.gw.game_title
            else:
                title = 'Game Mode'
            self.set_window_title(title)
            return
        if not self.ui or not self.ui.active_art:
            self.set_window_title()
            return
        # show message if converting
        if self.converter:
            title = self.img_convert_message % self.converter.image_filename
            self.set_window_title(title)
            return
        # display current active document's name and info
        filename = self.ui.active_art.filename
        if filename and os.path.exists(filename):
            full_filename = os.path.abspath(filename)
        else:
            full_filename = filename
        if self.ui.active_art.unsaved_changes:
            full_filename += '*'
        self.set_window_title(full_filename)
    
    def resize_window(self, new_width, new_height):
        GL.glViewport(0, 0, new_width, new_height)
        self.window_width, self.window_height = new_width, new_height
        # tell FB, camera, and UI that view aspect has changed
        self.fb.resize(new_width, new_height)
        self.camera.window_resized()
        self.ui.window_resized()
    
    def toggle_fullscreen(self):
        self.fullscreen = not self.fullscreen
        flags = 0
        if self.fullscreen:
            flags = sdl2.SDL_WINDOW_FULLSCREEN_DESKTOP
        sdl2.SDL_SetWindowFullscreen(self.window, flags)
        # for all intents and purposes, this is like resizing the window
        self.resize_window(self.window_width, self.window_height)
    
    def screenshot(self):
        "saves a date + time-stamped screenshot"
        timestamp = time.strftime('%Y-%m-%d_%H-%M-%S')
        output_filename = 'playscii_%s.png' % timestamp
        w, h = self.window_width, self.window_height
        pixels = GL.glReadPixels(0, 0, w, h, GL.GL_RGBA, GL.GL_UNSIGNED_BYTE,
                                 outputType=None)
        pixel_bytes = pixels.flatten().tobytes()
        img = Image.frombytes(mode='RGBA', size=(w, h), data=pixel_bytes)
        img = img.transpose(Image.FLIP_TOP_BOTTOM)
        img.save('%s%s' % (self.documents_dir + SCREENSHOT_DIR, output_filename))
        self.log('Saved screenshot %s' % output_filename)
    
    def enter_game_mode(self):
        self.game_mode = True
        self.camera = self.gw.camera
        self.grid = self.gw.grid
        # cursor might be hovering an object's art, undo preview viz
        self.cursor.undo_preview_edits()
        # display message on how to toggle game mode
        mode_bind = self.il.get_command_shortcut('toggle_game_mode')
        mode_bind = mode_bind.title()
        if self.can_edit:
            self.ui.message_line.post_line(self.game_mode_message % mode_bind, 10)
        self.al.resume_music()
        self.ui.menu_bar.close_active_menu()
        self.ui.menu_bar = self.ui.game_menu_bar
    
    def exit_game_mode(self):
        self.game_mode = False
        self.camera = self.art_camera
        self.grid = self.art_grid
        if self.ui.active_art:
            self.camera.set_for_art(self.ui.active_art)
        self.ui.message_line.post_line('', 1)
        self.update_window_title()
        self.al.pause_music()
        self.ui.menu_bar.close_active_menu()
        self.ui.menu_bar = self.ui.art_menu_bar
    
    def get_elapsed_time(self):
        return sdl2.timer.SDL_GetTicks()
    
    def main_loop(self):
        self.last_time = self.get_elapsed_time()
        while not self.should_quit:
            self.this_frame_start = self.get_elapsed_time()
            self.update()
            self.render()
            self.last_frame_end = self.get_elapsed_time()
            self.frames += 1
            self.sl.check_hot_reload()
            self.csl.check_hot_reload()
            self.pl.check_hot_reload()
            # determine FPS
            # alpha: lower = smoother
            alpha = 0.05
            dt = self.get_elapsed_time() - self.this_frame_start
            self.frame_time = alpha * dt + (1 - alpha) * self.frame_time
            self.fps = 1000 / self.frame_time
            # delay to maintain framerate, if uncapped
            if self.framerate != -1:
                delay = 1000 / self.framerate
                # subtract work time from delay to maintain framerate
                delay -= min(delay, dt)
                #print('frame time %s, delaying %sms to hit %s' % (self.frame_time, delay, self.framerate))
                sdl2.timer.SDL_Delay(int(delay))
        return 1
    
    def update(self):
        # update whether app has mouse + input (keybaord) focus
        flags = ctypes.c_uint(0)
        flags = sdl2.SDL_GetWindowFlags(self.window)
        self.has_input_focus = flags & sdl2.SDL_WINDOW_INPUT_FOCUS
        self.has_mouse_focus = flags & sdl2.SDL_WINDOW_MOUSE_FOCUS
        # start-of-frame stuff
        if self.game_mode:
            self.gw.frame_begin()
        else:
            # set all arts to "not updated"
            for art in self.art_loaded_for_edit:
                art.updated_this_tick = False
        # handle input - once per frame
        self.il.handle_input()
        # update game world & anything else that should happen on fixed timestep
        # avoid too many updates if eg machine straight up hangs
        if self.get_elapsed_time() - self.last_time > 1000:
            self.last_time = self.get_elapsed_time()
        updates = (self.get_elapsed_time() - self.last_time) / self.timestep
        for i in range(int(updates)):
            if self.game_mode:
                self.gw.pre_update()
                self.gw.update()
                self.gw.post_update()
            self.last_time += self.timestep
            self.updates += 1
        self.frame_update()
    
    def frame_update(self):
        "updates that should happen once per frame"
        if self.converter:
            self.converter.update()
        # game world has its own once-a-frame updates, eg art/renderables
        if self.game_mode:
            self.gw.frame_update()
        else:
            for art in self.art_loaded_for_edit:
                art.update()
        if self.ui.active_art and \
           not self.ui.console.visible and not self.game_mode and \
           not self.ui.menu_bar in self.ui.hovered_elements and \
           not self.ui.status_bar in self.ui.hovered_elements and \
           not self.ui.menu_bar.active_menu_name and not self.ui.active_dialog:
            self.cursor.update()
        self.camera.update()
        if not self.game_mode:
            self.grid.update()
            self.cursor.end_update()
        if self.ui.visible:
            self.ui.update()
        self.al.update()
    
    def debug_onion_frames(self):
        "debug function to log onion renderable state"
        # TODO: remove this once it's served its purpose
        debug = ['current frame: %s' % self.ui.active_art.active_frame, '']
        debug.append('onion_renderables_prev:')
        def get_onion_info(i, r):
            visible = 'VISIBLE' if r.visible else ''
            return '%s: %s frame %s %s' % (i, r.art.filename.ljust(20), r.frame, visible)
        for i,r in enumerate(self.onion_renderables_prev):
            debug.append(get_onion_info(i, r))
        debug.append('')
        debug.append('onion_renderables_next:')
        for i,r in enumerate(self.onion_renderables_next):
            debug.append(get_onion_info(i, r))
        self.ui.debug_text.post_lines(debug)
    
    def set_overlay_image(self, image_filename):
        "sets given image to draw over the active art"
        try:
            img = Image.open(image_filename).convert('RGBA')
            r = SpriteRenderable(self, image_filename, img)
            r.alpha = self.default_overlay_image_opacity
        except Exception as e:
            for line in traceback.format_exc().split('\n')[3:]:
                if line.strip():
                    self.log(line.rstrip())
            return
        self.log('Using %s as overlay image.' % image_filename)
        self.overlay_renderable = r
        self.ui.size_and_position_overlay_image()
        self.draw_overlay = True
    
    def render(self):
        # draw main scene to framebuffer
        GL.glBindFramebuffer(GL.GL_FRAMEBUFFER, self.fb.framebuffer)
        bg_color = self.gw.bg_color if self.game_mode else self.bg_color
        GL.glClearColor(*bg_color)
        GL.glClear(GL.GL_COLOR_BUFFER_BIT | GL.GL_DEPTH_BUFFER_BIT)
        if self.game_mode:
            self.gw.render()
        else:
            for renderable in self.edit_renderables:
                renderable.update()
            if self.show_bg_texture:
                self.bg_texture.render()
            if self.converter:
                self.converter.preview_sprite.render()
            for r in self.edit_renderables:
                r.render()
            #self.debug_onion_frames()
            if self.onion_frames_visible:
                # draw "nearest" frames first
                i = 0
                while i < self.onion_show_frames:
                    if self.onion_show_frames_behind:
                        self.onion_renderables_prev[i].render()
                    if self.onion_show_frames_ahead:
                        self.onion_renderables_next[i].render()
                    i += 1
            # draw selection grid, then selection, then cursor
            if self.ui.active_art:
                self.grid.render()
            self.ui.select_tool.render_selections()
            if self.ui.active_art and not self.ui.console.visible and \
               not self.ui.menu_bar in self.ui.hovered_elements and \
               not self.ui.status_bar in self.ui.hovered_elements and \
               not self.ui.popup in self.ui.hovered_elements and \
               not self.ui.menu_bar.active_menu_name and \
               not self.ui.active_dialog:
                self.cursor.render()
        self.debug_line_renderable.render()
        if self.draw_overlay and not self.game_mode:
            self.overlay_renderable.render()
        # draw framebuffer to screen
        GL.glBindFramebuffer(GL.GL_FRAMEBUFFER, 0)
        self.fb.render()
        if self.ui.visible:
            self.ui.render()
        GL.glUseProgram(0)
        sdl2.SDL_GL_SwapWindow(self.window)
    
    def save_persistent_setting(self, setting_name, setting_value):
        # iterate over list backwards so we may safely remove from it
        for line in reversed(self.config_lines):
            if line.strip().startswith(setting_name):
                # ignore lines that contain setting name but don't set it
                if line.find('=') == -1:
                    continue
                # setting already found, remove this redundant line
                self.config_lines.remove(line)
        # get current value from top-level scope and write it to end of cfg
        self.config_lines += '%s = %s\n' % (setting_name, setting_value)
    
    def save_persistent_config(self):
        "write options we want to persist across sessions to config file"
        for name in self.persistent_setting_names:
            # get current setting value from top-level scope
            obj, member = self.persistent_setting_names[name]
            obj = self if obj == '' else getattr(self, obj)
            value = getattr(obj, member)
            self.save_persistent_setting(name, value)
    
    def restore_session(self):
        session_filename = self.config_dir + SESSION_FILENAME
        if not os.path.exists(session_filename):
            return
        # more recent arts should open later
        filenames = open(session_filename).readlines()
        filenames.reverse()
        for filename in filenames:
            self.load_art_for_edit(filename.strip())
    
    def save_session(self):
        if not self.can_edit:
            return
        # write all currently open art to a file
        session_file = open(self.config_dir + SESSION_FILENAME, 'w')
        for art in self.art_loaded_for_edit:
            # if an art has never been saved, don't bother storing it
            if not os.path.exists(art.filename):
                continue
            session_file.write(art.filename + '\n')
        session_file.close()
    
    def quit(self):
        if self.init_success:
            self.save_persistent_config()
            self.save_session()
            for r in self.edit_renderables:
                r.destroy()
            self.gw.destroy()
            self.fb.destroy()
            self.ui.destroy()
            for charset in self.charsets:
                charset.texture.destroy()
            for palette in self.palettes:
                palette.texture.destroy()
            self.sl.destroy()
        if self.al:
            self.al.destroy()
        sdl2.SDL_GL_DeleteContext(self.context)
        sdl2.SDL_DestroyWindow(self.window)
        sdl2.SDL_Quit()
        # write to config file
        cfg_file = open(self.config_dir + CONFIG_FILENAME, 'w')
        cfg_file.writelines(self.config_lines)
        cfg_file.close()
        self.log('Thank you for using Playscii!  <3')
    
    def edit_cfg(self):
        cfg_path = self.config_dir + CONFIG_FILENAME
        if platform.system() == 'Windows':
            editor_bin = 'notepad'
        elif platform.system() == 'Darwin':
            editor_bin = 'open -a TextEdit'
        else:
            editor_bin = os.environ.get('EDITOR', None)
            if not editor_bin:
                return
        cmd = '%s "%s"' % (editor_bin, cfg_path)
        os.system(cmd)
        # update resident cfg file lines, which will be saved out on exit
        self.config_lines = open(cfg_path).readlines()
        # execute newly edited cfg! (but only if changes were made?)
        for line in self.config_lines:
            exec(line)
    
    def open_local_url(self, url):
        "opens given local (this file system) URL in a cross-platform way"
        webbrowser.open('file://%s/%s' % (os.getcwd(), url))
    
    def open_help_docs(self):
        self.open_local_url(WEBSITE_HELP_URL)
    
    def open_website(self):
        webbrowser.open(WEBSITE_URL)
    
    def generate_docs(self):
        # fail gracefully if pdoc not found
        try:
            import pdoc
        except:
            self.log("pdoc module needed for documentation generation not found.")
            return
        for module_name in AUTOGEN_DOC_MODULES:
            # pdoc.pdoc takes module name as string, returns HTML doc string
            html = pdoc.pdoc(module_name)
            docfile = open(AUTOGEN_DOCS_PATH + module_name + '.html', 'w')
            docfile.write(html)
            docfile.close()
        self.log('Documentation generated successfully.')
        # open ToC page
        self.open_local_url(AUTOGEN_DOCS_PATH + AUTOGEN_DOC_TOC_PAGE)


def get_win_documents_path():
    # from http://stackoverflow.com/a/30924555/1191587
    # (winshell module too much of a pain to get working with py2exe)
    import ctypes.wintypes
    CSIDL_PERSONAL = 5       # My Documents
    SHGFP_TYPE_CURRENT = 1   # Get current, not default value
    buf = ctypes.create_unicode_buffer(ctypes.wintypes.MAX_PATH)
    ctypes.windll.shell32.SHGetFolderPathW(None, CSIDL_PERSONAL, None, SHGFP_TYPE_CURRENT, buf)
    return buf.value

def get_paths():
    # pass False as second arg to disable "app author" windows dir convention
    config_dir = appdirs.user_config_dir(APP_NAME, False) + '/'
    cache_dir = appdirs.user_cache_dir(APP_NAME, False) + '/'
    if not os.path.exists(config_dir):
        os.mkdir(config_dir)
    if not os.path.exists(cache_dir):
        os.mkdir(cache_dir)
    if not os.path.exists(cache_dir + THUMBNAIL_CACHE_DIR):
        os.mkdir(cache_dir + THUMBNAIL_CACHE_DIR)
    DOCUMENTS_SUBDIR = '/Documents'
    if platform.system() == 'Windows':
        documents_dir = get_win_documents_path()
        # issue #18: win documents path may not exist?!
        if not os.path.exists(documents_dir):
            os.mkdir(documents_dir)
    elif platform.system() == 'Darwin':
        documents_dir = os.path.expanduser('~') + DOCUMENTS_SUBDIR
    # assume anything that isn't Win/Mac is a UNIX
    else:
        # XDG spec doesn't cover any concept of a documents folder :[
        # if ~/Documents exists use that, else just use ~/Playscii
        documents_dir = os.path.expanduser('~')
        if os.path.exists(documents_dir + DOCUMENTS_SUBDIR):
            documents_dir += DOCUMENTS_SUBDIR
    # add Playscii/ to documents path
    documents_dir += '/%s/' % APP_NAME
    # create Playscii dir AND subdirs for user art, charsets etc if not present
    for subdir in ['', ART_DIR, CHARSET_DIR, PALETTE_DIR, FORMATS_DIR,
                   ART_SCRIPT_DIR, SCREENSHOT_DIR, TOP_GAME_DIR]:
        new_dir = os.path.abspath(documents_dir + subdir)
        # os.path.exists can fail in Windows b/c case insensitivity,
        # so just try and fail :[
        try: os.mkdir(new_dir)
        except: pass
    return config_dir, documents_dir, cache_dir

def get_version():
    return open(VERSION_FILENAME).readlines()[0].strip()


class Logger:
    """
    Minimal object for logging, starts very early so we can write to it even
    before Application has initialized.
    """
    def __init__(self, config_dir):
        self.lines = []
        config_dir, docs_dir, cache_dir = get_paths()
        # use line buffering (last lines should appear even in case of crash)
        bufsize = 1
        self.log_file = open(config_dir + LOG_FILENAME, 'w', bufsize)
    
    def log(self, new_line):
        self.log_file.write('%s\n' % new_line)
        self.lines.append(str(new_line))
        print(new_line)
    
    def close(self):
        self.log_file.close()


def get_app():
    "creates and returns the Application instance"
    # get paths for config file, later to be passed into Application
    config_dir, documents_dir, cache_dir = get_paths()
    # start logger even before Application has initialized so we can write to it
    # startup message: application and version #
    logger = Logger(config_dir)
    logger.log('%s v%s' % (APP_NAME, get_version()))
    # see if "autoplay this game" file exists and has anything in it
    autoplay_game = None
    if os.path.exists(AUTOPLAY_GAME_FILENAME):
        ap = open(AUTOPLAY_GAME_FILENAME).readlines()
        if len(ap) > 0:
            autoplay_game = ap[0].strip()
    # load in config - may change above values and submodule class defaults
    cfg_filename = config_dir + CONFIG_FILENAME
    if os.path.exists(cfg_filename):
        logger.log('Loading config from %s...' % cfg_filename)
        # execute cfg line by line so we can continue past lines with errors.
        # this does mean that commenting out blocks with triple-quotes fails,
        # but that's not a good practice anyway.
        cfg_lines = open(cfg_filename).readlines()
        # compile a new cfg with any error lines stripped out
        new_cfg_lines = []
        for i,cfg_line in enumerate(cfg_lines):
            cfg_line = cfg_line.strip()
            try:
                exec(cfg_line)
                new_cfg_lines.append(cfg_line + '\n')
            except:
                # find line with "Error", ie the exception name, log that
                error_lines = traceback.format_exc().split('\n')
                error = '[an unknown error]'
                for el in error_lines:
                    if 'Error' in el:
                        error = el
                        break
                logger.log('  Removing line %s with %s' % (i, error))
        new_cfg = open(cfg_filename, 'w')
        new_cfg.writelines(new_cfg_lines)
        new_cfg.close()
        logger.log('Config loaded.')
    # if cfg file doesn't exist, copy a new one from playscii.cfg.default
    else:
        # snip first "this is a template" line
        default_data = open(CONFIG_TEMPLATE_FILENAME).readlines()[1:]
        new_cfg = open(cfg_filename, 'w')
        new_cfg.writelines(default_data)
        new_cfg.close()
        exec(''.join(default_data))
        logger.log('Created new config file %s' % cfg_filename)
    art_to_load, game_dir_to_load, state_to_load = None, None, None
    # usage:
    # playscii.py [artfile] | [-game gamedir [-state statefile | artfile]]
    if len(sys.argv) > 1:
        # "-game test1" args will set test1/ as game dir
        if len(sys.argv) > 2 and sys.argv[1] == '-game':
            game_dir_to_load = sys.argv[2]
            # "-state testX" args will load testX game state from given game dir
            if len(sys.argv) > 4 and sys.argv[3] == '-state':
                state_to_load = sys.argv[4]
            elif len(sys.argv) > 3:
                art_to_load = sys.argv[3]
        else:
            # else assume first arg is an art file to load in art mode
            art_to_load = sys.argv[1]
    app = Application(config_dir, documents_dir, cache_dir, logger,
                      art_to_load or DEFAULT_ART_FILENAME, game_dir_to_load,
                      state_to_load, autoplay_game)
    return app

if __name__ == "__main__":
    app = get_app()
    error = app.main_loop()
    app.quit()
    app.logger.close()
    sys.exit(error)
