#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""Code relating to a specific Dwarf Fortress installation."""
import os
import re
import shutil
import struct
import sys
import zlib
from datetime import datetime
from functools import total_ordering
from glob import glob
from . import hacks, log, paths
from .lnp import VERSION, lnp
from .settings import DFConfiguration
[docs]def find_df_folders():
"""Locates all suitable Dwarf Fortress installations (folders starting
with "Dwarf Fortress" or "df")"""
lnp.folders = tuple(
os.path.basename(o) for o in glob(os.path.join(lnp.BASEDIR, '*')) if
os.path.isdir(o) and (os.path.exists(os.path.join(
o, 'data', 'init', 'init.txt')) or os.path.exists(os.path.join(
o, 'data', 'init', 'init_default.txt'))))
[docs]def find_df_folder():
"""Tries to select a Dwarf Fortress folder. The set of valid folders is
first detected. If a folder name is passed as the first argument to the
script, that folder will be used. Otherwise, if only one valid folder was
detected, that one will be selected."""
find_df_folders()
if len(lnp.folders) == 1:
set_df_folder(lnp.folders[0])
if lnp.args.df_folder and lnp.args.df_folder in lnp.folders:
set_df_folder(lnp.args.df_folder)
[docs]def set_df_folder(path):
"""
Selects the Dwarf Fortress instance to operate on.
Args:
path: The path of the Dwarf Fortress instance to use.
"""
paths.register('df', lnp.BASEDIR, path, allow_create=False)
paths.register('data', paths.get('df'), 'data', allow_create=False)
paths.register('init', paths.get('data'), 'init', allow_create=False)
paths.register('save', paths.get('data'), 'save', allow_create=False)
paths.register('extras', paths.get('lnp'), 'Extras')
paths.register('defaults', paths.get('lnp'), 'Defaults')
if os.path.exists(paths.get('df', 'hack', 'init')):
paths.register(
'dfhack_config', paths.get('df', 'dfhack-config', 'init'))
else:
paths.register('dfhack_config', paths.get('df'), allow_create=False)
lnp.df_info = DFInstall(paths.get('df'))
lnp.settings = lnp.df_info.settings
if lnp.args.release_prep or lnp.args.raw_lint:
perform_checks()
install_extras()
load_params()
hacks.read_hacks()
[docs]def do_rawlint(path):
"""Runs the raw linter on the specified directory.
Returns:
bool: True if all files in the directory passed the linter.
"""
from . import rawlint
p, f = rawlint.check_df(path)
log.i("%d files passed, %d files failed check" % (len(p), len(f)))
return len(f) == 0
[docs]def cycle_option(field):
"""
Cycles an option field between its possible values.
Args:
field: the field to cycle.
"""
lnp.settings.cycle_item(field)
save_params()
[docs]def set_option(field, value):
"""
Sets a field to a specific value.
Args:
field: the field to set.
value: the new value for the field.
"""
lnp.settings.set_value(field, value)
save_params()
[docs]def load_params():
"""Loads settings from the selected Dwarf Fortress instance."""
try:
lnp.settings.read_settings()
except IOError as exc:
msg = 'Failed to read settings, {} not really a DF dir?'.format(
paths.get('df'))
log.e(msg, stack=True)
raise IOError(msg) from exc
[docs]def save_params():
"""Saves settings to the selected Dwarf Fortress instance."""
lnp.settings.write_settings()
[docs]def restore_defaults():
"""Copy default settings into the selected Dwarf Fortress instance."""
log.i('Restoring to default settings')
if lnp.df_info.version >= '50.01':
os.remove(paths.get('df', 'd_init.txt'))
os.remove(paths.get('df', 'init.txt'))
else:
shutil.copy(paths.get('defaults', 'init.txt'),
paths.get('init', 'init.txt'))
if lnp.df_info.version > '0.31.03':
shutil.copy(paths.get('defaults', 'd_init.txt'),
paths.get('init', 'd_init.txt'))
load_params()
[docs]class DFInstall(object):
"""Contains properties and paths for a given Dwarf Fortress installation."""
def __init__(self, path):
self.df_dir = path
self.init_dir = os.path.join(path, 'data', 'init')
self.save_dir = os.path.join(path, 'data', 'save')
self.version, self.source = self.detect_version()
self.variations = self.detect_variations()
self.settings = DFConfiguration(path, self)
def __str__(self):
result = 'Dwarf Fortress version: {0} (detected using {1})'.format(
self.version, self.source)
if self.variations:
result += '\nVariations detected: ' + ', '.join(self.variations)
return result
@staticmethod
def _detect_version_from_index():
"""The most reliable way to detect DF version is '<df>/data/index'.
Adapted from https://github.com/lethosor/dftext
"""
def convert(func, string):
"""Convert between string and bytes on Py3."""
return func(string, encoding='cp437')
def index_scramble(text):
"""Unscrambles data from the index file."""
text = list(text)
for i, ch in enumerate(text):
text[i] = chr(255 - (i % 5) - ch)
return convert(bytes, ''.join(text))
with open(paths.get('df', 'data', 'index'), 'rb') as f:
in_text = f.read()
decompressed = b''
while in_text:
chunk_length = struct.unpack(str('<L'), in_text[:4])[0]
end = chunk_length + 4
decompressed += zlib.decompress(in_text[4:end])
in_text = in_text[end:]
record_count = struct.unpack(str('<L'), decompressed[:4])[0]
decompressed = decompressed[4:]
for _ in range(record_count):
record_length, record_length_2 = \
struct.unpack(str('<LH'), decompressed[:6])
decompressed = decompressed[6:]
if record_length != record_length_2:
raise ValueError('Record lengths do not match')
record = decompressed[:record_length]
decompressed = decompressed[record_length:]
record = convert(str, index_scramble(record)).strip()
# Check if version is in record of form "18~v0.40.24\r\n"
if re.search(r"\d+~v[\d.a-z]+", record) is not None:
return (Version(record.partition('v')[-1]), 'index')
def _detect_version_from_notes(self):
"""Attempt to detect Dwarf Fortress version based on release notes."""
notes = os.path.join(self.df_dir, 'release notes.txt')
with open(notes, encoding='latin1') as notes_text:
m = re.search(r"Release notes for ([\d.a-z]+)", notes_text.read())
return (Version(m.group(1)), 'release notes')
def _detect_version_from_init(self):
"""Attempt to detect Dwarf Fortress version from init file contents."""
init = os.path.join(self.init_dir, 'init.txt')
if not os.path.exists(init):
init = os.path.join(self.init_dir, 'init_default.txt')
d_init = os.path.join(self.init_dir, 'd_init.txt')
if not os.path.exists(d_init):
d_init = os.path.join(self.init_dir, 'd_init_default.txt')
versions = [
(init, 'USE_CLASSIC_ASCII', '0.50.04', {}),
(init, 'MAXIMUM_INTERFACE_PERCENTAGE', '0.50.02', {}),
(init, 'MASTER_VOLUME', '0.50.01', {}),
(d_init, 'VISITOR_CAP', '0.42.01', {}),
(d_init, 'GRAZE_COEFFICIENT', '0.40.13', {}),
(d_init, 'POST_PREPARE_EMBARK_CONFIRMATION', '0.40.09', {}),
(d_init, 'STRICT_POPULATION_CAP', '0.40.05', {}),
(d_init, 'TREE_ROOTS', '0.40.01', {}),
(d_init, 'TRACK_N', '0.34.08', {}),
(d_init, 'SET_LABOR_LISTS', '0.34.03', {}),
(d_init, 'WALKING_SPREADS_SPATTER_DWF', '0.31.16', {}),
(d_init, 'PILLAR_TILE', '0.31.08', {}),
(d_init, 'AUTOSAVE', '0.31.04', {}),
(init, 'COMPRESSED_SAVES', '0.31.01', {}),
(init, 'PARTIAL_PRINT', '0.28.181.40c', {'num_params': 2}),
(init, 'FULLGRID', '0.28.181.40b', {}),
(init, 'STORE_DIST_ITEM_DECREASE', '0.28.181.40a', {}),
(init, 'GRID', '0.28.181.39f', {}),
(init, 'SHOW_EMBARK_RIVER', '0.28.131.39d', {}),
(init, 'IDLERS', '0.28.131.39a', {}),
(init, 'AUTOSAVE_PAUSE', '0.27.176.38b', {}),
(init, 'ZERO_RENT', '0.27.176.38a', {}),
(init, 'PAUSE_ON_LOAD', '0.27.169.33g', {}),
(init, 'PRIORITY', '0.27.169.33c', {}),
(init, 'AUTOSAVE', '0.27.169.32a', {}),
(init, 'POPULATION_CAP', '0.23.130.23a', {}),
(init, 'FPS', '0.22.121.23b', {}),
(init, 'BLACK_SPACE', '0.22.120.23a', {}),
(init, 'ADVENTURER_TRAPS', '0.22.110.23a', {}),
(init, 'MOUSE', '0.21.104.21a', {}),
(init, 'ENGRAVINGS_START_OBSCURED', '0.21.104.19d', {}),
(init, 'WINDOWED', '0.21.102.19a', {}),
(init, 'KEY_HOLD_MS', '0.21.101.19a', {}),
(init, 'SOUND', '0.21.100.19a', {})]
for v in versions:
if DFConfiguration.has_field(v[0], v[1], **v[3]):
log.w('DF version detected based on init analysis; unreliable')
return (Version(v[2]), 'init detection')
return None
[docs] def detect_version(self):
"""
Attempt to detect Dwarf Fortress version from data/index,
release notes or init file contents.
"""
for func in (self._detect_version_from_index,
self._detect_version_from_notes,
self._detect_version_from_init):
try:
ver = func()
if ver is not None:
return ver
except Exception:
pass
log.w('DF version could not be detected, assuming 0.21.93.19a')
return (Version('0.21.93.19a'), 'fallback')
[docs] def detect_variations(self):
"""
Detect known variations to allow the launcher to adjust accordingly.
Currently supports DFHack, TWBT, and legacy builds.
"""
result = []
if (os.path.exists(os.path.join(self.df_dir, 'dfhack'))
or os.path.exists(os.path.join(self.df_dir, 'SDLreal.dll'))
or os.path.exists(os.path.join(self.df_dir, 'SDLhack.dll'))):
result.append('dfhack')
if glob(os.path.join(
self.df_dir, 'hack', 'plugins', 'twbt.plug.*')):
result.append('twbt')
if self.version <= '0.31.12' or not DFConfiguration.has_field(
os.path.join(self.init_dir, 'init.txt'), 'PRINT_MODE'):
result.append('legacy')
return result
[docs] 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."""
# checked and correct for all versions up to 0.40.24
if self.version >= '50.01':
base = 'df_' + str(self.version).replace('.', '_')
else:
base = 'df_' + str(self.version)[2:].replace('.', '_')
if self.version == '50.04':
return base + 'b_win_s.zip'
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'
# pylint:disable=too-few-public-methods
[docs]@total_ordering
class Version(object):
"""Container for a version number for easy comparisons."""
def __init__(self, version):
# Known errors in release notes
if version == "0.23.125.23a":
version = "0.23.130.23a"
self.version_str = version
s = ""
data = []
for c in version:
if c < '0' or c > '9':
data.append(int(s))
if c != '.':
data.append(c)
s = ""
else:
s = s + c
if s != '':
data.append(int(s))
self.data = tuple(data)
def __lt__(self, other):
if not isinstance(self, Version):
return Version(self) < other
if not isinstance(other, Version):
return self < Version(other)
return self.data < other.data
def __eq__(self, other):
if not isinstance(self, Version):
return Version(self) == other
if not isinstance(other, Version):
return self == Version(other)
return self.data == other.data
def __str__(self):
return self.version_str