diff --git a/.hgignore b/.hgignore index c9f4347..b1b142c 100644 --- a/.hgignore +++ b/.hgignore @@ -6,3 +6,8 @@ dist PyLNP.user stderr.txt stdout.txt + +Dwarf Fortress * +LNP +__pycache__ +*.bat diff --git a/core/baselines.py b/core/baselines.py new file mode 100644 index 0000000..cf99731 --- /dev/null +++ b/core/baselines.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +"""Advanced raw and data folder management, for mods or graphics packs.""" +from __future__ import print_function, unicode_literals, absolute_import + +import os, shutil, filecmp, sys, glob, tempfile, zipfile +import distutils.dir_util as dir_util + +from . import paths, update +from .lnp import lnp + +def find_vanilla_raws(): + """Finds vanilla raws for the current version. + Starts by unzipping any DF releases in baselines and preprocessing them. + + Returns: + Path to the vanilla 'raw' folder, eg 'LNP/Baselines/df_40_15/raw' + False if baseline not available (and start download) + None if version detection is not accurate + """ + if lnp.df_info.source == "init detection": + # WARNING: probably the wrong version! Restore 'release notes.txt'. + return None + prepare_baselines() + version = 'df_' + str(lnp.df_info.version)[2:].replace('.', '_') + if os.path.isdir(paths.get('baselines', version, 'raw')): + return paths.get('baselines', version, 'raw') + update.download_df_baseline() + return False + +def prepare_baselines(): + """Unzip any DF releases found, and discard non-universial files.""" + zipped = glob.glob(os.path.join(paths.get('baselines'), 'df_??_?*.zip')) + for item in zipped: + version = os.path.basename(item) + for s in ['_win', '_legacy', '_s', '.zip']: + version = version.replace(s, '') + f = paths.get('baselines', version) + if not os.path.isdir(f): + zipfile.ZipFile(item).extractall(f) + simplify_pack(version, 'baselines') + os.remove(item) + +def set_auto_download(value): + """Sets the option for auto-download of baselines.""" + lnp.userconfig['downloadBaselines'] = value + lnp.userconfig.save_data() + +def simplify_pack(pack, folder): + """Removes unnecessary files from LNP//. + Necessary files means: + * 'raw/objects/' and 'raw/graphics/' + * 'data/art/' for graphics, and specific files in 'data/init/' + * readme.txt and manifest.json for mods + + Params: + pack + The pack to simplify. + folder + The parent folder of the pack (either 'mods' or 'graphics') + + Returns: + The number of files removed if successful + False if an exception occurred + None if folder is empty + """ + valid_dirs = ('graphics', 'mods', 'baselines') + if not folder in valid_dirs: + return False + pack = paths.get(folder, pack) + files_before = sum(len(f) for (_, _, f) in os.walk(pack)) + if files_before == 0: + return None + tmp = tempfile.mkdtemp() + try: + dir_util.copy_tree(pack, tmp) + if os.path.isdir(pack): + dir_util.remove_tree(pack) + + os.makedirs(pack) + os.makedirs(os.path.join(pack, 'raw', 'graphics')) + os.makedirs(os.path.join(pack, 'raw', 'objects')) + + if os.path.exists(os.path.join(tmp, 'raw', 'graphics')): + dir_util.copy_tree( + os.path.join(tmp, 'raw', 'graphics'), + os.path.join(pack, 'raw', 'graphics')) + if os.path.exists(os.path.join(tmp, 'raw', 'objects')): + dir_util.copy_tree( + os.path.join(tmp, 'raw', 'objects'), + os.path.join(pack, 'raw', 'objects')) + + if not folder == 'mods': + os.makedirs(os.path.join(pack, 'data', 'init')) + os.makedirs(os.path.join(pack, 'data', 'art')) + if os.path.exists(os.path.join(tmp, 'data', 'art')): + dir_util.copy_tree( + os.path.join(tmp, 'data', 'art'), + os.path.join(pack, 'data', 'art')) + for filename in ('colors.txt', 'init.txt', + 'd_init.txt', 'overrides.txt'): + if os.path.isfile(os.path.join(tmp, 'data', 'init', filename)): + shutil.copyfile( + os.path.join(tmp, 'data', 'init', filename), + os.path.join(pack, 'data', 'init', filename)) + + except IOError: + sys.excepthook(*sys.exc_info()) + retval = False + else: + files_after = sum(len(f) for (_, _, f) in os.walk(pack)) + retval = files_after - files_before + if os.path.isdir(tmp): + dir_util.remove_tree(tmp) + return retval + +def remove_vanilla_raws_from_pack(pack, folder): + """Remove files identical to vanilla raws, return files removed + + Params: + pack + The pack to simplify. + folder + The parent folder of the pack (either 'mods' or 'graphics') + """ + raw_folder = paths.get(folder, pack, 'raw') + vanilla_raw_folder = find_vanilla_raws() + for root, _, files in os.walk(raw_folder): + for f in files: + f = os.path.join(root, f) + # silently clean up so empty dirs can be removed + silently_kill = ('Thumbs.db', 'installed_mods.txt') + if any(f.endswith(k) for k in silently_kill): + os.remove(f) + continue + f = os.path.relpath(f, raw_folder) + # if there's an identical vanilla file, remove the mod file + if os.path.isfile(os.path.join(vanilla_raw_folder, f)): + if filecmp.cmp(os.path.join(vanilla_raw_folder, f), + os.path.join(raw_folder, f)): + os.remove(os.path.join(raw_folder, f)) + +def remove_empty_dirs(pack, folder): + """Removes empty subdirs in a mods or graphics pack. + + Params: + pack + The pack to simplify. + folder + The parent folder of the pack (either 'mods' or 'graphics') + """ + for _ in range(3): + # only catches the lowest level each iteration + for root, dirs, files in os.walk(paths.get(folder, pack)): + if not dirs and not files: + os.rmdir(root) diff --git a/core/df.py b/core/df.py index 84b8081..550a0ad 100644 --- a/core/df.py +++ b/core/df.py @@ -207,6 +207,23 @@ def detect_variations(self): result.append('legacy') return result + def get_archive_name(self): + """Return the filename of the download for this version. + Always windows, for comparison of raws in baselines. + Prefer small and SDL releases when available.""" + base = 'df_' + str(self.version)[2:].replace('.', '_') + if self.version >= '0.31.13': + return base + '_win_s.zip' + if self.version >= '0.31.05': + return base + '_legacy_s.zip' + if self.version == '0.31.04': + return base + '_legacy.zip' + if self.version == '0.31.01': + return base + '.zip' + if self.version >= '0.21.104.19b': + return base + '_s.zip' + return base + '.zip' + @total_ordering class Version(object): """Container for a version number for easy comparisons.""" diff --git a/core/graphics.py b/core/graphics.py index 1b1f7e9..b9ac8dd 100644 --- a/core/graphics.py +++ b/core/graphics.py @@ -7,7 +7,7 @@ import distutils.dir_util as dir_util from .launcher import open_folder from .lnp import lnp -from . import colors, df, paths +from . import colors, df, paths, baselines from .dfraw import DFRaw def open_graphics(): @@ -15,8 +15,7 @@ def open_graphics(): open_folder(paths.get('graphics')) def current_pack(): - """ - Returns the currently installed graphics pack. + """Returns the currently installed graphics pack. If the pack cannot be identified, returns "FONT/GRAPHICS_FONT". """ packs = read_graphics() @@ -45,8 +44,7 @@ def read_graphics(): return tuple(result) def install_graphics(pack): - """ - Installs the graphics pack located in LNP/Graphics/. + """Installs the graphics pack located in LNP/Graphics/. Params: pack @@ -55,67 +53,69 @@ def install_graphics(pack): Returns: True if successful, False if an exception occured - None if required files are missing (raw/graphics, data/init) + None if baseline vanilla raws are missing """ - gfx_dir = paths.get('graphics', pack) - if (os.path.isdir(gfx_dir) and - os.path.isdir(os.path.join(gfx_dir, 'raw', 'graphics')) and - os.path.isdir(os.path.join(gfx_dir, 'data', 'init'))): - try: - # Delete old graphics - if os.path.isdir(paths.get('df', 'raw', 'graphics')): - dir_util.remove_tree( - paths.get('df', 'raw', 'graphics')) + retval = None + if not baselines.find_vanilla_raws(): + # TODO: add user warning re: missing baseline, download + return None + gfx_dir = tempfile.mkdtemp() + dir_util.copy_tree(baselines.find_vanilla_raws(), gfx_dir) + dir_util.copy_tree(os.path.join(paths.get('graphics'), pack), gfx_dir) - # Copy new raws - dir_util.copy_tree( - os.path.join(gfx_dir, 'raw'), - paths.get('df', 'raw')) + try: + # Delete old graphics + if os.path.isdir(paths.get('df', 'raw', 'graphics')): + dir_util.remove_tree(paths.get('df', 'raw', 'graphics')) - #Copy art - if os.path.isdir(paths.get('data', 'art')): - dir_util.remove_tree(paths.get('data', 'art')) - dir_util.copy_tree( - os.path.join(gfx_dir, 'data', 'art'), - paths.get('data', 'art')) + # Copy new raws + dir_util.copy_tree(os.path.join(gfx_dir, 'raw'), + paths.get('df', 'raw')) - patch_inits(gfx_dir) + #Copy art + if os.path.isdir(os.path.join(paths.get('data'), 'art')): + dir_util.remove_tree(paths.get('data', 'art')) + dir_util.copy_tree(os.path.join(gfx_dir, 'data', 'art'), + paths.get('data', 'art')) + for tiles in glob.glob(paths.get('tilesets', '*')): + shutil.copy(tiles, paths.get('data', 'art')) - # Install colorscheme - if lnp.df_info.version >= '0.31.04': - colors.load_colors(os.path.join( - gfx_dir, 'data', 'init', 'colors.txt')) - else: - colors.load_colors(os.path.join( - gfx_dir, 'data', 'init', 'init.txt')) + patch_inits(gfx_dir) - # TwbT overrides - try: - os.remove(paths.get('init', 'overrides.txt')) - except: - pass - try: - shutil.copyfile( - os.path.join(gfx_dir, 'data', 'init', 'overrides.txt'), - paths.get('init', 'overrides.txt')) - except: - pass - except Exception: - sys.excepthook(*sys.exc_info()) - result = False + # Install colorscheme + if lnp.df_info.version >= '0.31.04': + colors.load_colors(os.path.join( + gfx_dir, 'data', 'init', 'colors.txt')) else: - result = True - df.load_params() - return result + colors.load_colors(os.path.join( + gfx_dir, 'data', 'init', 'init.txt')) + + # TwbT overrides + try: + os.remove(paths.get('init', 'overrides.txt')) + except: + pass + try: + shutil.copyfile( + os.path.join(gfx_dir, 'data', 'init', 'overrides.txt'), + paths.get('init', 'overrides.txt')) + except: + pass + except Exception: + sys.excepthook(*sys.exc_info()) + retval = False else: - return None + retval = True + if os.path.isdir(gfx_dir): + dir_util.remove_tree(gfx_dir) + df.load_params() + return retval def validate_pack(pack): """Checks for presence of all required files for a pack install.""" result = True gfx_dir = paths.get('graphics', pack) result &= os.path.isdir(gfx_dir) - result &= os.path.isdir(os.path.join(gfx_dir, 'raw', 'graphics')) result &= os.path.isdir(os.path.join(gfx_dir, 'data', 'init')) result &= os.path.isdir(os.path.join(gfx_dir, 'data', 'art')) result &= os.path.isfile(os.path.join(gfx_dir, 'data', 'init', 'init.txt')) @@ -127,10 +127,8 @@ def validate_pack(pack): return result def patch_inits(gfx_dir): - """ - Installs init files from a graphics pack by selectively changing - specific fields. All settings outside of the mentioned fields are - preserved. + """Installs init files from a graphics pack by selectively changing + specific fields. All settings but the mentioned fields are preserved. """ d_init_fields = [ 'WOUND_COLOR_NONE', 'WOUND_COLOR_MINOR', @@ -210,70 +208,17 @@ def simplify_graphics(): simplify_pack(pack) def simplify_pack(pack): - """ - Removes unnecessary files from LNP/Graphics/. - - Params: - pack - The pack to simplify. - - Returns: - The number of files removed if successful - False if an exception occurred - None if folder is empty - """ - pack = paths.get('graphics', pack) - files_before = sum(len(f) for (_, _, f) in os.walk(pack)) - if files_before == 0: - return None - tmp = tempfile.mkdtemp() - try: - dir_util.copy_tree(pack, tmp) - if os.path.isdir(pack): - dir_util.remove_tree(pack) - - os.makedirs(pack) - os.makedirs(os.path.join(pack, 'data', 'art')) - os.makedirs(os.path.join(pack, 'raw', 'graphics')) - os.makedirs(os.path.join(pack, 'raw', 'objects')) - os.makedirs(os.path.join(pack, 'data', 'init')) - - dir_util.copy_tree( - os.path.join(tmp, 'data', 'art'), - os.path.join(pack, 'data', 'art')) - dir_util.copy_tree( - os.path.join(tmp, 'raw', 'graphics'), - os.path.join(pack, 'raw', 'graphics')) - dir_util.copy_tree( - os.path.join(tmp, 'raw', 'objects'), - os.path.join(pack, 'raw', 'objects')) - shutil.copyfile( - os.path.join(tmp, 'data', 'init', 'colors.txt'), - os.path.join(pack, 'data', 'init', 'colors.txt')) - shutil.copyfile( - os.path.join(tmp, 'data', 'init', 'init.txt'), - os.path.join(pack, 'data', 'init', 'init.txt')) - shutil.copyfile( - os.path.join(tmp, 'data', 'init', 'd_init.txt'), - os.path.join(pack, 'data', 'init', 'd_init.txt')) - shutil.copyfile( - os.path.join(tmp, 'data', 'init', 'overrides.txt'), - os.path.join(pack, 'data', 'init', 'overrides.txt')) - except IOError: - sys.excepthook(*sys.exc_info()) - retval = False - else: - files_after = sum(len(f) for (_, _, f) in os.walk(pack)) - retval = files_after - files_before - if os.path.isdir(tmp): - dir_util.remove_tree(tmp) - return retval + """Removes unnecessary files from one graphics pack.""" + baselines.simplify_pack(pack, 'graphics') + baselines.remove_vanilla_raws_from_pack(pack, 'graphics') + baselines.remove_empty_dirs(pack, 'graphics') def savegames_to_update(): """Returns a list of savegames that will be updated.""" - return [ - o for o in glob.glob(paths.get('save', '*')) - if os.path.isdir(o) and not o.endswith('current')] + saves = [o for o in glob.glob(paths.get('save', '*')) + if os.path.isdir(o) and not o.endswith('current')] + return [s for s in saves if not + os.path.isfile(os.path.join(s, 'raw', 'installed_raws.txt'))] def update_savegames(): """Update save games with current raws.""" @@ -299,10 +244,12 @@ def open_tilesets(): def read_tilesets(): """Returns a list of tileset files.""" - files = glob.glob(paths.get('tilesets', '*.bmp')) + files = glob.glob(paths.get('data', 'art', '*.bmp')) if 'legacy' not in lnp.df_info.variations: - files += glob.glob(paths.get('tilesets', '*.png')) - return tuple([os.path.basename(o) for o in files]) + files += glob.glob(paths.get('data', 'art', '*.png')) + return tuple([os.path.basename(o) for o in files if not ( + o.endswith('mouse.png') or o.endswith('mouse.bmp') + or o.endswith('shadows.png'))]) def current_tilesets(): """Returns the current tilesets as a tuple (FONT, GRAPHICS_FONT).""" @@ -311,22 +258,14 @@ def current_tilesets(): return (lnp.settings.FONT, None) def install_tilesets(font, graphicsfont): - """ - Installs the provided tilesets as [FULL]FONT and GRAPHICS_[FULL]FONT. + """Installs the provided tilesets as [FULL]FONT and GRAPHICS_[FULL]FONT. To skip either option, use None as the parameter. """ - if font is not None and os.path.isfile( - paths.get('tilesets', font)): - shutil.copyfile( - paths.get('tilesets', font), - paths.get('data', 'art', font)) + if font is not None and os.path.isfile(paths.get('data', 'art', font)): df.set_option('FONT', font) df.set_option('FULLFONT', font) if (lnp.settings.version_has_option('GRAPHICS_FONT') and graphicsfont is not None and os.path.isfile( - paths.get('tilesets', graphicsfont))): - shutil.copyfile( - paths.get('tilesets', graphicsfont), - paths.get('data', 'art', graphicsfont)) + paths.get('data', 'art', graphicsfont))): df.set_option('GRAPHICS_FONT', graphicsfont) df.set_option('GRAPHICS_FULLFONT', graphicsfont) diff --git a/core/lnp.py b/core/lnp.py index bf1792f..76a8c07 100644 --- a/core/lnp.py +++ b/core/lnp.py @@ -54,6 +54,8 @@ def __init__(self): paths.register('colors', paths.get('lnp'), 'Colors') paths.register('embarks', paths.get('lnp'), 'Embarks') paths.register('tilesets', paths.get('lnp'), 'Tilesets') + paths.register('baselines', paths.get('lnp'), 'Baselines') + paths.register('mods', paths.get('lnp'), 'Mods') self.df_info = None self.folders = [] diff --git a/core/mods.py b/core/mods.py new file mode 100644 index 0000000..1a42080 --- /dev/null +++ b/core/mods.py @@ -0,0 +1,251 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +"""Mod Pack management and merging tools.""" +from __future__ import print_function, unicode_literals, absolute_import + +import os, shutil, glob +from difflib import SequenceMatcher +from io import open + +from . import paths, baselines + +def read_mods(): + """Returns a list of mod packs""" + return [os.path.basename(o) for o in + glob.glob(paths.get('mods', '*')) + if os.path.isdir(o)] + +def simplify_mods(): + """Removes unnecessary files from all mods.""" + for pack in read_mods(): + simplify_pack(pack) + +def simplify_pack(pack): + """Removes unnecessary files from one mod.""" + baselines.simplify_pack(pack, 'mods') + baselines.remove_vanilla_raws_from_pack(pack, 'mods') + baselines.remove_empty_dirs(pack, 'mods') + +def install_mods(): + """Deletes installed raw folder, and copies over installed raws.""" + shutil.rmtree(os.path.join(paths.get('df'), 'raw')) + shutil.copytree(os.path.join(paths.get('baselines'), 'temp', 'raw'), + os.path.join(paths.get('df'), 'raw')) + +def do_merge_seq(mod_text, vanilla_text, gen_text): + """Merges sequences of lines. + + Params: + mod_text + The lines of the mod file being added to the merge. + vanilla_text + The lines of the corresponding vanilla file. + gen_text + The lines of the previously merged file or files. + + Returns: + tuple(status, lines); status is 0/'ok' or 2/'overlap merged' + """ + status = 0 + # special cases - where merging is not required because two are equal + if vanilla_text == gen_text: + return 0, mod_text + if vanilla_text == mod_text: + return 0, gen_text + + # Get a list of 5-tuples describing how to turn vanilla into mod or gen + # lines. Each specifies an operation, and start+end lines for each change. + van_mod_ops = SequenceMatcher(None, vanilla_text, mod_text).get_opcodes() + van_gen_ops = SequenceMatcher(None, vanilla_text, gen_text).get_opcodes() + + # cur_v holds the line we're up to, effectively truncates blocks which were + # partially covered in the previous iteration. + output_file_temp, cur_v = [], 0 + + while van_mod_ops and van_gen_ops: + # get names from the next set of opcodes + mod_tag, _, mod_i2, mod_j1, mod_j2 = van_mod_ops[0] + gen_tag, _, gen_i2, gen_j1, gen_j2 = van_gen_ops[0] + # if the mod is vanilla for these lines + if mod_tag == 'equal': + # if the gen lines are also vanilla + if gen_tag == 'equal': + # append the shorter block to new genned lines + if mod_i2 < gen_i2: + output_file_temp += vanilla_text[cur_v:mod_i2] + cur_v = mod_i2 + van_mod_ops.pop(0) + else: + output_file_temp += vanilla_text[cur_v:gen_i2] + cur_v = gen_i2 + van_gen_ops.pop(0) + if mod_i2 == gen_i2: + van_mod_ops.pop(0) + # otherwise append current genned lines + else: + output_file_temp += gen_text[gen_j1:gen_j2] + cur_v = gen_i2 + van_gen_ops.pop(0) + if mod_i2 == gen_i2: + van_mod_ops.pop(0) + # if mod has changes from vanilla + else: + # if no earlier mod changed this section, adopt these changes + if gen_tag == 'equal': + output_file_temp += mod_text[mod_j1:mod_j2] + cur_v = mod_i2 + van_mod_ops.pop(0) + if mod_i2 == gen_i2: + van_gen_ops.pop(0) + else: + # An over-write merge. Changes status to warn the user. + status = 2 + # append the shorter block to new genned lines + if mod_i2 < gen_i2: + output_file_temp += mod_text[cur_v:mod_i2] + cur_v = mod_i2 + van_mod_ops.pop(0) + else: + output_file_temp += mod_text[cur_v:gen_i2] + cur_v = gen_i2 + van_gen_ops.pop(0) + if mod_i2 == gen_i2: + van_mod_ops.pop(0) + # clean up trailing opcodes, to avoid dropping the end of the file + while van_mod_ops: + mod_tag, _, mod_i2, mod_j1, mod_j2 = van_mod_ops.pop(0) + output_file_temp += mod_text[mod_j1:mod_j2] + while van_gen_ops: + gen_tag, _, gen_i2, gen_j1, gen_j2 = van_gen_ops.pop(0) + output_file_temp += gen_text[gen_j1:gen_j2] + return status, output_file_temp + +def do_merge_files(mod_file_name, van_file_name, gen_file_name): + """Calls merge sequence on the files, and returns true if they could be + (and were) merged or false if the merge was conflicting (and thus skipped). + """ + van_lines = open(van_file_name, mode='r', encoding='cp437', + errors='replace').readlines() + mod_lines = open(mod_file_name, mode='r', encoding='cp437', + errors='replace').readlines() + gen_lines = open(gen_file_name, mode='r', encoding='cp437', + errors='replace').readlines() + status, gen_lines = do_merge_seq(mod_lines, van_lines, gen_lines) + gen_file = open(gen_file_name, "w") + for line in gen_lines: + try: + gen_file.write(line) + except UnicodeEncodeError: + return 3 # invalid character for DF encoding + return status + +def merge_a_mod(mod): + """Merges the specified mod, and returns an exit code 0-3. + + 0: Merge was successful, all well + 1: Potential compatibility issues, no merge problems + 2: Non-fatal error, overlapping lines or non-existent mod etc + 3: Fatal error, respond by rebuilding to previous mod + """ + if not baselines.find_vanilla_raws(): + return 3 # no baseline; caught properly earlier + mod_raw_folder = paths.get('mods', mod, 'raw') + if not os.path.isdir(mod_raw_folder): + return 2 + status = merge_raw_folders(mod_raw_folder, baselines.find_vanilla_raws()) + if status < 3: + with open(paths.get('baselines', 'temp', 'raw', 'installed_raws.txt'), + 'a') as log: + log.write(mod + '\n') + return status + +def merge_raw_folders(mod_raw_folder, vanilla_raw_folder): + """Merge the specified folders, output going in LNP/Baselines/temp/raw""" + mixed_raw_folder = paths.get('baselines', 'temp', 'raw') + status = 0 + for file_tuple in os.walk(mod_raw_folder): + for item in file_tuple[2]: + f = os.path.join(file_tuple[0], item) + f = os.path.relpath(f, mod_raw_folder) + if not f.endswith('.txt'): + continue + if (os.path.isfile(os.path.join(vanilla_raw_folder, f)) and + os.path.isfile(os.path.join(mixed_raw_folder, f))): + status = max(do_merge_files(os.path.join(mod_raw_folder, f), + os.path.join(vanilla_raw_folder, f), + os.path.join(mixed_raw_folder, f)), + status) + else: + shutil.copy(os.path.join(mod_raw_folder, f), + os.path.join(mixed_raw_folder, f)) + return status + +def clear_temp(): + """Resets the folder in which raws are mixed.""" + if not baselines.find_vanilla_raws(): + # TODO: add user warning re: missing baseline, download + return None + if os.path.exists(paths.get('baselines', 'temp')): + shutil.rmtree(paths.get('baselines', 'temp')) + shutil.copytree(baselines.find_vanilla_raws(), + paths.get('baselines', 'temp', 'raw')) + with open(paths.get('baselines', 'temp', 'raw', 'installed_raws.txt'), + 'w') as log: + log.write('# List of raws merged by PyLNP:\n' + + os.path.basename( + os.path.dirname(baselines.find_vanilla_raws())) + '\n') + +def make_mod_from_installed_raws(name): + """Capture whatever unavailable mods a user currently has installed + as a mod called $name. + + * If `installed_raws.txt` is not present, compare to vanilla + * Otherwise, rebuild as much as possible then compare to installed + """ + if os.path.isdir(paths.get('mods', name)): + return False + if get_installed_mods_from_log(): + clear_temp() + for mod in get_installed_mods_from_log(): + merge_a_mod(mod) + reconstruction = paths.get('baselines', 'temp2', 'raw') + shutil.copytree(paths.get('baselines', 'temp', 'raw'), reconstruction) + else: + reconstruction = baselines.find_vanilla_raws() + if not reconstruction: + # TODO: add user warning re: missing baseline, download + return None + + clear_temp() + merge_raw_folders(reconstruction, paths.get('df', 'raw')) + + baselines.simplify_pack('temp', 'baselines') + baselines.remove_vanilla_raws_from_pack('temp', 'baselines') + baselines.remove_empty_dirs('temp', 'baselines') + + if os.path.isdir(paths.get('baselines', 'temp2')): + shutil.rmtree(paths.get('baselines', 'temp2')) + + if os.path.isdir(paths.get('baselines', 'temp')): + shutil.copytree(paths.get('baselines', 'temp'), paths.get('mods', name)) + return True + return False + +def get_installed_mods_from_log(): + """Return best mod load order to recreate installed with available.""" + logged = read_installation_log(paths.get('df', 'raw', 'installed_raws.txt')) + return [mod for mod in logged if mod in read_mods()] + +def read_installation_log(log): + """Read an 'installed_raws.txt' and return it's full contents.""" + try: + with open(log) as f: + file_contents = list(f.readlines()) + except IOError: + return [] + mods_list = [] + for line in file_contents: + if not line.strip() or line.startswith('#'): + continue + mods_list.append(line.strip()) + return mods_list diff --git a/core/update.py b/core/update.py index d20a43a..b5e2e74 100644 --- a/core/update.py +++ b/core/update.py @@ -3,8 +3,7 @@ """Update handling.""" from __future__ import print_function, unicode_literals, absolute_import -import sys, re, time -from threading import Thread +import sys, re, time, os, threading try: # Python 2 # pylint:disable=import-error @@ -15,7 +14,8 @@ from urllib.error import URLError from .lnp import lnp -from . import launcher +from .df import DFInstall +from . import launcher, paths, download def updates_configured(): """Returns True if update checking have been configured.""" @@ -28,7 +28,7 @@ def check_update(): if lnp.userconfig.get_number('updateDays') == -1: return if lnp.userconfig.get_number('nextUpdate') < time.time(): - t = Thread(target=perform_update_check) + t = threading.Thread(target=perform_update_check) t.daemon = True t.start() @@ -63,4 +63,10 @@ def start_update(): """Launches a webbrowser to the specified update URL.""" launcher.open_url(lnp.config.get_string('updates/downloadURL')) - +def download_df_baseline(): + """Download the current version of DF from Bay12 Games to serve as a + baseline, in LNP/Baselines/""" + filename = lnp.df_info.get_archive_name() + url = 'http://www.bay12games.com/dwarves/' + filename + target = os.path.join(paths.get('baselines'), filename) + download.download('baselines', url, target) diff --git a/readme.rst b/readme.rst index bba8530..e78e525 100644 --- a/readme.rst +++ b/readme.rst @@ -278,6 +278,7 @@ PyLNP expects to see the following directory structure:: Extras Graphics Keybinds + Mods Tilesets Utilities @@ -297,6 +298,12 @@ PyLNP.user ---------- This file, found in the base folder, contains user settings such as window width and height. It should not be distributed if you make a pack. +Baselines +--------- +This folder contains full unmodified raws for various versions of DF, and the settings and images relevant to graphics packs. These are used to rebuild the reduced raws used by graphics packs and mods, and should not be modified or removed - any new graphics or mod install would break. Extra tilesets added to a /data/art/ folder will be available to all graphics packs (useful for TwbT text options). + +Add versions by downloading the windows SDL edition of that version and placing it in the folder (eg "df_40_15_win.zip"). + Colors ------ This folder contains color schemes. As of DF 0.31.04, these are stored as data/init/colors.txt in the Dwarf Fortress folder; in 0.31.03 and below, they are contained in data/init/init.txt. @@ -323,11 +330,15 @@ If this version of PyLNP has not yet been run on the selected DF installation, a Graphics -------- -This folder contains graphics packs, consisting of data and raw folders. +This folder contains graphics packs, consisting of data and raw folders. Any raws identical to vanilla files will be discarded; when installing a graphics pack the remaining files will be copied over a set of vanilla raws and the combination installed. Tilesets -------- -This folder contains tilesets; individual image files that the user can use for the FONT and GRAPHICS_FONT settings (and their fullscreen counterparts). +This folder contains tilesets; individual image files that the user can use for the FONT and GRAPHICS_FONT settings (and their fullscreen counterparts). Tilesets can be installed through the graphics customisation tab, as they are added to each graphics pack as the pack is installed. + +Mods +---- +This folder contains mods for Dwarf Fortress, in the form of changes to the defining raws (which define the content DF uses). Mods use the same reduced format for raws as graphics packs. Keybinds -------- @@ -384,3 +395,13 @@ If DFHack is detected in the Dwarf Fortress folder, a DFHack tab is added to the This tab includes a list where preconfigured hacks can be turned on or off. See the respective section in the description of PyLNP.json for information on how to configure these hacks. All active hacks are written to a file named ``PyLNP_dfhack_onload.init`` in the Dwarf Fortress folder. This file must be loaded by your standard ``onload.init`` file to take effect. + +Mods +==== +If mods are present in LNP/Mods/, a mods tab is added to the launcher. + +Multiple mods can be merged, in the order shown in the 'installed' pane. Those shown in green merged OK; in yellow with minor issues. Orange signifies an overlapping merge or other serious issue, and red could not be merged. Once you are happy with the combination, you can install them to the DF folder and generate a new world to start playing. + +Note that even an all-green combination might be broken in subtle (or non-subtle) ways. Mods are not currently compatible with graphics! Never update graphics on savegames with installed mods - they will break. + +For mod authors: note that the reduced raw format is equivalent to copying over a vanilla install - missing files are taken to be vanilla. Modifying existing files instead of adding new files decreases the chance of producing conflicting raws without a merge conflict. diff --git a/tkgui/mods.py b/tkgui/mods.py new file mode 100644 index 0000000..b8b425b --- /dev/null +++ b/tkgui/mods.py @@ -0,0 +1,205 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# pylint:disable=unused-wildcard-import,wildcard-import,invalid-name,attribute-defined-outside-init +"""Mods tab for the TKinter GUI.""" +from __future__ import print_function, unicode_literals, absolute_import + +import sys + +from . import controls +from .tab import Tab +from core import mods + +if sys.version_info[0] == 3: # Alternate import names + # pylint:disable=import-error + from tkinter import * + from tkinter.ttk import * + import tkinter.messagebox as messagebox + import tkinter.simpledialog as simpledialog +else: + # pylint:disable=import-error + from Tkinter import * + from ttk import * + import tkMessageBox as messagebox + import tkSimpleDialog as simpledialog + +class ModsTab(Tab): + """Mods tab for the TKinter GUI.""" + def create_variables(self): + self.installed = Variable() + self.available = Variable() + + def read_data(self): + mods.clear_temp() + available = mods.read_mods() + installed = mods.get_installed_mods_from_log() + available = [m for m in available if m not in installed] + self.available.set(tuple(available)) + self.installed.set(tuple(installed)) + + def create_controls(self): + Grid.columnconfigure(self, 0, weight=1) + Grid.rowconfigure(self, 0, weight=1) + Grid.rowconfigure(self, 2, weight=1) + + f = controls.create_control_group(self, 'Installed') + install_frame, self.installed_list = controls.create_file_list( + f, None, self.installed, selectmode='multiple') + self.installed_list.bind( + "", lambda e: self.remove_from_installed()) + reorder_frame = controls.create_control_group(install_frame, None) + controls.create_trigger_button( + reorder_frame, '↑', 'Move up', self.move_up).pack() + controls.create_trigger_button( + reorder_frame, '↓', 'Move down', self.move_down).pack() + reorder_frame.grid(row=0, column=2, sticky="nse") + + f.grid(row=0, column=0, sticky="nsew") + + f = controls.create_control_group(self, None, True) + controls.create_trigger_button( + f, '⇑', 'Add', self.add_to_installed).grid( + row=0, column=0, sticky="nsew") + controls.create_trigger_button( + f, '⇓', 'Remove', self.remove_from_installed).grid( + row=0, column=1, sticky="nsew") + f.grid(row=1, column=0, sticky="ew") + + f = controls.create_control_group(self, 'Available') + _, self.available_list = controls.create_file_list( + f, None, self.available, selectmode='multiple') + self.available_list.bind( + "", lambda e: self.add_to_installed()) + f.grid(row=2, column=0, sticky="nsew") + + f = controls.create_control_group(self, None, True) + controls.create_trigger_button( + f, 'Simplify Mods', 'Removes unnecessary and vanilla files.', + self.simplify_mods).grid( + row=0, column=0, sticky="nsew") + controls.create_trigger_button( + f, 'Install Mods', 'Copy merged mods to DF folder. ' + 'WARNING: do not combine with graphics. May cause problems.', + self.install_mods).grid(row=0, column=1, sticky="nsew") + controls.create_trigger_button( + f, 'Create Mod from Installed', 'Creates a mod from unique changes ' + 'to your installed raws. Use to preserve custom tweaks.', + self.create_from_installed).grid( + row=1, column=0, sticky="nsew", columnspan=2) + f.grid(row=3, column=0, sticky="ew") + + def move_up(self): + """Moves the selected item/s up in the merge order and re-merges.""" + if len(self.installed_list.curselection()) == 0: + return + selection = [int(i) for i in self.installed_list.curselection()] + newlist = list(self.installed_list.get(0, END)) + for i in range(1, len(newlist)): + j = i + while j in selection and i-1 not in selection and j < len(newlist): + newlist[j-1], newlist[j] = newlist[j], newlist[j-1] + j += 1 + self.installed_list.delete(0, END) + for i in newlist: + self.installed_list.insert(END, i) + first_missed = False + for i in range(0, len(newlist)): + if i not in selection: + first_missed = True + else: + self.installed_list.select_set(i - int(first_missed)) + self.perform_merge() + + def move_down(self): + """Moves the selected item/s down in the merge order and re-merges.""" + if len(self.installed_list.curselection()) == 0: + return + selection = [int(i) for i in self.installed_list.curselection()] + newlist = list(self.installed_list.get(0, END)) + for i in range(len(newlist) - 1, 0, -1): + j = i + while i not in selection and j-1 in selection and j > 0: + newlist[j-1], newlist[j] = newlist[j], newlist[j-1] + j -= 1 + self.installed_list.delete(0, END) + for i in newlist: + self.installed_list.insert(END, i) + first_missed = False + for i in range(len(newlist), 0, -1): + if i - 1 not in selection: + first_missed = True + else: + self.installed_list.select_set(i - 1 + int(first_missed)) + self.perform_merge() + + def create_from_installed(self): + """Extracts a mod from the currently installed raws.""" + m = simpledialog.askstring("Create Mod", "New mod name:") + if m is not None and m != '': + if mods.make_mod_from_installed_raws(m): + messagebox.showinfo('Mod extracted', + 'Your custom mod was extracted as ' + m) + else: + messagebox.showinfo( + 'Error', ('There is already a mod with that name, ' + 'or only pre-existing mods were found.')) + self.read_data() + + def add_to_installed(self): + """Move selected mod/s from available to merged list and re-merge.""" + if len(self.available_list.curselection()) == 0: + return + for i in self.available_list.curselection(): + self.installed_list.insert(END, self.available_list.get(i)) + for i in self.available_list.curselection()[::-1]: + self.available_list.delete(i) + self.perform_merge() + + def remove_from_installed(self): + """Move selected mod/s from merged to available list and re-merge.""" + if len(self.installed_list.curselection()) == 0: + return + for i in self.installed_list.curselection()[::-1]: + self.available_list.insert(END, self.installed_list.get(i)) + self.installed_list.delete(i) + + #Re-sort items + temp_list = sorted(list(self.available_list.get(0, END))) + self.available_list.delete(0, END) + for item in temp_list: + self.available_list.insert(END, item) + + self.perform_merge() + + def perform_merge(self): + """Merge the selected mods, with background color for user feedback.""" + mods.clear_temp() + # Set status to unknown before merging + for i, _ in enumerate(self.installed_list.get(0, END)): + self.installed_list.itemconfig(i, bg='white') + status = 3 + colors = ['limegreen', 'yellow', 'orange', 'red'] + for i, mod in enumerate(self.installed_list.get(0, END)): + status = mods.merge_a_mod(mod) + self.installed_list.itemconfig(i, bg=colors[status]) + if status == 3: + return + + @staticmethod + def install_mods(): + """Replaces /raw with the contents LNP/Baselines/temp/raw""" + if messagebox.askokcancel( + message=('Your graphics will be removed and raws changed.\n\n' + 'The mod merging function is still in beta. This ' + 'could break new worlds, or even cause crashes.\n\n'), + title='Are you sure?'): + mods.install_mods() + messagebox.showinfo( + 'Mods installed', + 'The selected mods were installed.\nGenerate a new world to ' + 'start playing with them!') + + @staticmethod + def simplify_mods(): + """Simplify mods; runs on startup if called directly by button.""" + mods.simplify_mods() diff --git a/tkgui/tkgui.py b/tkgui/tkgui.py index 0d58e68..a61871b 100644 --- a/tkgui/tkgui.py +++ b/tkgui/tkgui.py @@ -17,9 +17,10 @@ from .utilities import UtilitiesTab from .advanced import AdvancedTab from .dfhack import DFHackTab +from .mods import ModsTab from core.lnp import lnp -from core import df, launcher, paths, update +from core import df, launcher, paths, update, mods if sys.version_info[0] == 3: # Alternate import names # pylint:disable=import-error @@ -144,6 +145,8 @@ def __init__(self): self.create_tab(AdvancedTab, 'Advanced') if 'dfhack' in lnp.df_info.variations: self.create_tab(DFHackTab, 'DFHack') + if mods.read_mods(): + self.create_tab(ModsTab, 'Mods') n.enable_traversal() n.pack(fill=BOTH, expand=Y, padx=2, pady=3)