diff --git a/.gitignore b/.gitignore
index d458c5f0..2da080a4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,4 +4,11 @@ build/
dist/
.eggs/
spacehaven-modloader.spec
-logs.txt
\ No newline at end of file
+logs.txt
+previous_spacehaven_path.txt
+*.patch
+*.orig
+*.rej
+quicklaunch_*.jar
+quicklaunch_*/
+extra_mods_path.txt
diff --git a/README.md b/README.md
index 6ab4ae29..098a3a7e 100644
--- a/README.md
+++ b/README.md
@@ -7,7 +7,7 @@ It is **not associated with Bugbyte or Space Haven in any way** other than that
## Getting Started
-Download the latest release from [the releases page](https://github.com/anatarist/spacehaven-modloader/releases) and fire it up.
+Download the [latest release](https://github.com/PhiR42/spacehaven-modloader/releases/download/0.0.7/spacehaven-modloader-win-amd64.zip) and fire it up.

@@ -22,12 +22,14 @@ spacehaven.jar
savegames/
...
mods/
- artificial-plant/
- info
- library/
- exterior-air-vent/
- info
+ BetterToilets/
+ info.xml
library/
+ haven.xml
+ animations.xml
+ textures.xml
+ textures/
+ 2283.png
...
```
@@ -35,20 +37,31 @@ mods/
5. When you're ready click "Launch Space Haven!" to play with mods. The mod loader will load the mods into the game, launch the game, and then unload them again when the game exits.
+6. Once you've played with a given set of mods, the loader will keep a quick launch file for them. The next time they will load a lot faster. If you want to develop your own mod or tweak one, you should always click on "Clear QuickLaunch file" before running the game, so your changes are taken into account.
+
+## Known issues
+
+If you change the language or restart the game from within itself, the modloader will think the game has quit and will throw an error. Nothing permanent.
+
+Running the game from the modloader will not load your cloud credentials correctly.
## Modding Guide
Mods are stored as a series of XML files in roughly the same format as the game's library.
-You can take a look at the library by clicking the "Extract & annotate game assets" button. That will extract the game library from `spacehaven.jar` into `mods/spacehaven/` and open the folder.
+You can take a look at the library by clicking the "Extract game assets" button. That will extract the game library from `spacehaven.jar` into `mods/spacehaven/` and open the folder.
-The main file of interest is `library/haven.annotated`, which is an annotated copy of `library/haven`, which is the main game library. It contains definitions for most of the things in the game (buildings, items, ships, characters, objectives, generation parameters, etc). Also of interest are `library/texts`, `library/animations`, and `library/textures`.
+Once that's done, you can also click "Annotate XML":
+The main file of interest is `library/haven_annotated.xml`, which is an annotated copy of `library/haven`, which is the main game library. It contains definitions for most of the things in the game (buildings, items, ships, characters, objectives, generation parameters, etc). Also of interest are `library/texts`, `library/animations`, and `library/textures`.
+
+`library/animations_annotated.xml` will be created as well, with the textures names for some of the individual blocks I have personnally used. You can add/change textures names by editing the the `textures_annotations.xml` file in the modloader directory. You should copy the corresponding line from `library/textures` and add an `_annotation` tag with the name that makes sense for you, after finding the textures of interest in `library/textures.exploded/`.
Mods follow the same folder structure and file format and should be reasonably obvious from the included sample mods.
Note that because mods are loaded by doing an id-wise merge with the base game library, only the following files and tags are currently supported:
- All definitions in `library/haven`
- `animations` in `library/animations`
+- `t`s and `re`s in `library/textures`
- `t`s in `library/texts`
@@ -109,12 +122,18 @@ and looking up the `` we see the category is named:
```
-To make life easier, the mod loader does these name lookups automatically in a few places and stores the results in `_name=""` attributes. This annotated version of the library is saved to `library/haven.annotated` and is (accordingly) a bit easier to navigate.
+To make life easier, the mod loader does these name lookups automatically in a few places and stores the results in `_annotated_name=""` attributes. This annotated version of the library is saved to `library/haven.annotated.xml` and is (accordingly) a bit easier to navigate.
### Textures and Animations
Extracting and annotating game assets also decodes and explodes the game's textures into `library/textures.exploded`. The game's original packed textures are written to `library/textures.exploded/*.png` and the texture regions are written to `library/textures.exploded/*/*.png`, where the folder name is the texture ID and the filename is the region ID.
-Regions definitions can be found in `library/textures` in `` tags and are used in `library/animations` by the `` tags.
+Regions definitions can be found in `library/textures.xml` in `` tags and are used in `library/animations.xml` by the `` tags.
+
+The loader can overwrite existing textures from the base game and add new ones. To overwrite a texture, simply place a file called `123.png` in a directory called `textures` (where 123 is the id of the texture you want to replace).
+
+To add a new texture, you need to add a `` entry in `library/textures.xml`. Textures are packed together in `.cim` files, which are defined in `` entries. The `re` entry references the `t` entry through it's index `i`. Take care of choosing reasonably unique indexes to avoid conflicts. Once this is done, you can add the `123456.png` file to the `textures` directory.
+
+When loading your mod, the modloader will output the png version of the packed texture file for debugging purposes. This will allow you to check proper placement for new textures, or correct merging for existing ones.
diff --git a/loader/assets/annotate.py b/loader/assets/annotate.py
index 50ded288..d6dea366 100644
--- a/loader/assets/annotate.py
+++ b/loader/assets/annotate.py
@@ -5,18 +5,35 @@
import ui.log
-
def annotate(corePath):
"""Generate an annotated Space Haven library"""
+ texture_names = {}
+ local_texture_names = ElementTree.parse("textures_annotations.xml", parser=XMLParser(recover=True))
+ for region in local_texture_names.findall(".//re[@n]"):
+ if not region.get("_annotation"):
+ continue
+ texture_names[region.get('n')] = region.get("_annotation")
+
+ animations = ElementTree.parse(os.path.join(corePath, "library", "animations"), parser=XMLParser(recover=True))
+ for assetPos in animations.findall('.//assetPos[@a]'):
+ asset_id = assetPos.get('a')
+ if not asset_id in texture_names:
+ continue
+ assetPos.set('_annotation', texture_names[asset_id])
+
+ annotatedPath = os.path.join(corePath, "library", "animations_annotated.xml")
+ animations.write(annotatedPath)
+ ui.log.log(" Wrote annotated annimations to {}".format(annotatedPath))
+
haven = ElementTree.parse(os.path.join(corePath, "library", "haven"), parser=XMLParser(recover=True))
texts = ElementTree.parse(os.path.join(corePath, "library", "texts"), parser=XMLParser(recover=True))
-
- # Load texts
+
tids = {}
+ # Load texts
for text in texts.getroot():
tids[text.get("id")] = text.find("EN").text
-
+
def nameOf(element):
name = element.find("name")
if name is None:
@@ -27,54 +44,107 @@ def nameOf(element):
return ""
return tids[tid]
-
+
+ ElementRoot = haven.find("Element")
# Annotate Elements
- for element in haven.find("Element"):
+ for element in ElementRoot:
mid = element.get("mid")
objectInfo = element.find("objectInfo")
if objectInfo is not None:
- element.set("_name", nameOf(objectInfo))
-
+ element.set("_annotation", nameOf(objectInfo))
+
# Annotate basic products
+ # first pass also builds the names cache
elementNames = {}
- for element in haven.find("Product"):
+ ProductRoot = haven.find("Product")
+ for element in ProductRoot:
name = nameOf(element) or element.get("elementType") or ""
-
- element.set("_name", name)
+
+ if name:
+ element.set("_annotation", name)
elementNames[element.get("eid")] = name
-
- # Annotate process products
- for element in haven.find("Product"):
+
+ for item in haven.find("Item"):
+ name = nameOf(item) or item.get("elementType") or ""
+
+ if name:
+ item.set("_annotation", name)
+ elementNames[item.get("mid")] = name
+
+ # small helped to annotate a node
+ def _annotate_elt(element, attr = None):
+ if attr:
+ name = elementNames[element.get(attr)]
+ else:
+ name = elementNames[element.get("element", element.get("elementId"))]
+ if name:
+ element.set("_annotation", name)
+ return name
+
+ # construction blocks for the build menu
+ for me in ElementRoot:
+ for customPrice in me.findall(".//customPrice"):
+ for sub_l in customPrice:
+ _annotate_elt(sub_l)
+
+ # Annotate facility processes, now that we know the names of all the products involved
+ for element in ProductRoot:
processName = []
-
- needs = element.find("needs")
- if needs is not None:
- for need in needs:
- name = elementNames[need.get("element")]
- need.set("_name", name)
- processName.append(name)
-
+ for need in element.xpath("needs/l"):
+ name = _annotate_elt(need)
+ processName.append(name)
+
processName.append("to")
-
- products = element.find("products")
- if products is not None:
- for product in products:
- name = elementNames[product.get("element")]
- product.set("_name", name)
- processName.append(name)
-
- processName = " ".join(processName)
- if len(processName) > 2 and not element.get("_name"):
+
+ for product in element.xpath("products/l"):
+ name = _annotate_elt(product)
+ processName.append(name)
+
+ if len(processName) > 2 and not element.get("_annotation"):
+ processName = " ".join(processName)
elementNames[element.get("eid")] = processName
- element.set("_name", processName)
-
- for element in haven.find("Product"):
- list = element.find("list")
- if list is not None:
- for process in list.find("processes"):
- process.set("_name", elementNames[process.get("process")])
-
- annotatedHavenPath = os.path.join(corePath, "library", "haven.annotated")
+ element.set("_annotation", processName)
+
+ #generic rule should work for all remaining nodes ?
+ for sub_element in haven.findall(".//*[@consumeEvery]"):
+ try:
+ _annotate_elt(sub_element)
+ except:
+ pass
+ # error on 446, weird stuff
+ #print(sub_element.tag)
+ #print(sub_element.attrib)
+
+ # iterate again once we have built all the process names
+ for process in ProductRoot.xpath('.//list/processes/l[@process]'):
+ process.set("_annotation", elementNames[process.get("process")])
+
+ for trade in haven.find('TradingValues').findall('.//t'):
+ try:
+ _annotate_elt(trade, attr = 'eid')
+ except:
+ pass
+
+ DifficultySettings = haven.find('DifficultySettings')
+ for settings in DifficultySettings:
+ name = nameOf(settings)
+
+ if name:
+ settings.set("_annotation", name)
+
+ for res in DifficultySettings.xpath('.//l'):
+ try:
+ _annotate_elt(res, attr = 'elementId')
+ except:
+ pass
+
+ for res in DifficultySettings.xpath('.//rules/r'):
+ try:
+ _annotate_elt(res, attr = 'cat')
+ except:
+ pass
+
+ annotatedHavenPath = os.path.join(corePath, "library", "haven_annotated.xml")
haven.write(annotatedHavenPath)
ui.log.log(" Wrote annotated spacehaven library to {}".format(annotatedHavenPath))
diff --git a/loader/assets/explode.py b/loader/assets/explode.py
index 2dc48386..cb2c4b9a 100644
--- a/loader/assets/explode.py
+++ b/loader/assets/explode.py
@@ -4,6 +4,7 @@
import io
import struct
import os
+import hashlib
import png
@@ -11,24 +12,90 @@
import ui.log
-
+PIXEL_SIZE = 4
+RGBA_FORMAT = 4
+HEADER_SIZE = 12
+
+"""
+alt code for png
+ if 0:
+ from PIL import Image
+ reader = Image.open(path)
+ print(reader.format, reader.size, reader.mode)
+ width, height = reader.size
+ img_data = reader.getdata()
+ for row_idx in range(height):
+ start = (x + ((row_idx + y) * width)) * PIXEL_SIZE + HEADER_SIZE
+ end = start + (width * PIXEL_SIZE)
+ # FIXME might be tricky to get the image in the correct format ...
+ else:
+"""
class Texture:
- def __init__(self, path):
+ def __init__(self, path, create = False, width = None, height = None):
+ if create:
+ return self._init_cim(width, height)
+ else:
+ return self._import_cim(path)
+
+ def _init_cim(self, width, height):
+ self.width = int(width)
+ self.height = int(height)
+
+ self.header = bytearray(HEADER_SIZE)
+ struct.pack_into('>i', self.header, 0, self.width)
+ struct.pack_into('>i', self.header, 4, self.height)
+ struct.pack_into('>i', self.header, 8, RGBA_FORMAT)
+
+ self.data = bytearray(self.width * self.height * PIXEL_SIZE)
+
+ def _import_cim(self, path):
data = io.BytesIO(zlib.decompress(open(path, "rb").read()))
-
- self.width = struct.unpack('>i', data.read(4))[0]
- self.height = struct.unpack('>i', data.read(4))[0]
- self.format = struct.unpack('>i', data.read(4))[0]
-
- if self.format == 4:
+ md5 = hashlib.md5(data.getbuffer()).hexdigest()
+ ui.log.log(" %s vanilla md5 %s %d bytes" % (os.path.split(path)[1], md5, data.getbuffer().nbytes))
+
+ self.header = data.read(HEADER_SIZE)
+ self.width = struct.unpack_from('>i', self.header)[0]
+ self.height = struct.unpack_from('>i', self.header, offset = 4)[0]
+ self.format = struct.unpack_from('>i', self.header, offset = 8)[0]
+
+ if self.format == RGBA_FORMAT:
self.mode = "RGBA"
else:
- print("ERROR: Unknown CIM format: {}".format(self.format))
+ ui.log.log("ERROR: Unknown CIM format: {}".format(self.format))
return
- self.data = data.read()
-
- def save(self, path, x=0, y=0, width=None, height=None):
+ self.data = bytearray(data.read())
+ expected_size = self.width * self.height * PIXEL_SIZE
+ if len(self.data) != expected_size:
+ ui.log.log("ERROR: Wrong size %s: %d vs %d" % (path, len(self.data), expected_size))
+
+ def pack_png(self, path, x=0, y=0, w=0, h=0):
+ reader = png.Reader(filename = path)
+ (width, height, rows, info) = reader.asRGBA()
+ if w and w != width:
+ ui.log.log("ERROR: Wrong width in %s: %d vs %d" % (path, width, w))
+ return
+ if h and h != height:
+ ui.log.log("ERROR: Wrong width in %s: %d vs %d" % (path, height, h))
+ return
+
+ row_idx = 0
+ for row in rows:
+ start = (x + ((row_idx + y) * self.width)) * PIXEL_SIZE
+ end = start + (width * PIXEL_SIZE)
+
+ self.data[start:end] = row
+ row_idx += 1
+ ui.log.log(" Repacked {}...".format(os.path.split(path)[1]))
+
+ def export_cim(self, path):
+ export = self.header + self.data
+ md5 = hashlib.md5(export).hexdigest()
+ ui.log.log(" %s MODDED md5 %s %d bytes" % (os.path.split(path)[1], md5, len(export)))
+ with open(path, "wb") as cim:
+ cim.write(zlib.compress(export))
+
+ def export_png(self, path, x=0, y=0, width=None, height=None):
if width is None:
width = self.width
if height is None:
@@ -36,23 +103,23 @@ def save(self, path, x=0, y=0, width=None, height=None):
rows = []
for row in range(height):
- start = (x + ((row + y) * self.width)) * 4
- end = start + (width * 4)
+ start = (x + ((row + y) * self.width)) * PIXEL_SIZE
+ end = start + (width * PIXEL_SIZE)
rows.append(self.data[start:end])
with open(path, 'wb') as file:
- writer = png.Writer(width=width, height=height, alpha=True)
+ writer = png.Writer(width=width, height=height, greyscale=False, alpha=True)
writer.write_packed(file, rows)
def explode(corePath):
"""Decode textures and write them out as individual regions"""
- animations = lxml.etree.parse(os.path.join(corePath, "library", "animations"), parser=lxml.etree.XMLParser(recover=True))
textures = lxml.etree.parse(os.path.join(corePath, "library", "textures"), parser=lxml.etree.XMLParser(recover=True))
cims = {}
-
+ export_cims = {}
+
regions = textures.xpath("//re[@n]")
ui.log.log(" Exploding textures at {}...".format(corePath))
@@ -68,19 +135,21 @@ def explode(corePath):
page = region.get("t")
if not page in cims:
- cims[page] = Texture(os.path.join(corePath, 'library', '{}.cim'.format(page)))
-
+ cim_filename = '{}.cim'.format(page)
+ ui.log.updateBackgroundState("Unpacking textures ({})".format(cim_filename))
+ cims[page] = Texture(os.path.join(corePath, 'library', cim_filename))
+
try:
os.makedirs(os.path.join(corePath, 'library', 'textures.exploded', page))
except FileExistsError:
pass
-
- cims[page].save(
- os.path.join(corePath, 'library', 'textures.exploded', page, '{}.png'.format(name)),
- x, y, w, h
- )
+
+ png_filename = os.path.join(corePath, 'library', 'textures.exploded', page, '{}.png'.format(name))
+ cims[page].export_png(png_filename, x, y, w, h)
for page in cims:
- cims[page].save(os.path.join(corePath, 'library', 'textures.exploded', '{}.png'.format(page)))
+ cims[page].export_png(os.path.join(corePath, 'library', 'textures.exploded', '{}.png'.format(page)))
+
ui.log.log(" Wrote {} texture regions".format(len(regions)))
+
diff --git a/loader/assets/library.py b/loader/assets/library.py
index c73d406b..58870092 100644
--- a/loader/assets/library.py
+++ b/loader/assets/library.py
@@ -7,15 +7,18 @@
import ui.log
-PATCHABLE_FILES = [
+PATCHABLE_XML_FILES = [
'library/haven',
'library/texts',
- 'library/animations'
+ 'library/animations',
+ 'library/textures',
]
+PATCHABLE_CIM_FILES = ["library/%d.cim" % i for i in range(24)]
def extract(jarPath, corePath):
"""Extract library files from spacehaven.jar"""
+ ui.log.updateBackgroundState("Extracting game files")
if not os.path.exists(corePath):
os.mkdir(corePath)
@@ -24,21 +27,31 @@ def extract(jarPath, corePath):
with ZipFile(jarPath, "r") as spacehaven:
for file in set(spacehaven.namelist()):
if file.startswith("library/") and not file.endswith("/"):
- ui.log.log(" {}".format(file))
+# ui.log.log(" {}".format(file))
spacehaven.extract(file, corePath)
-def patch(jarPath, corePath, resultPath):
+def patch(jarPath, corePath, resultPath, extra_assets = None):
"""Patch spacehaven.jar with custom library files"""
original = ZipFile(jarPath, "r")
patched = ZipFile(resultPath, "w")
-
+
+ ui.log.updateBackgroundState("Merging vanilla files")
+
+ update_files = PATCHABLE_XML_FILES + PATCHABLE_CIM_FILES
for file in set(original.namelist()):
- if not file.endswith("/") and not file in PATCHABLE_FILES:
+ if not file.endswith("/") and not file in update_files:
patched.writestr(file, original.read(file))
-
- for file in PATCHABLE_FILES:
+
+ original.close()
+
+ ui.log.updateBackgroundState("Merging modded files")
+
+ if extra_assets:
+ update_files += extra_assets
+ for file in update_files:
+ ui.log.log(" Merging modded {}...".format(file))
patched.write(os.path.join(corePath, file.replace('/', os.sep)), file)
patched.close()
diff --git a/loader/assets/merge.py b/loader/assets/merge.py
index 5488d2a9..db4e9923 100644
--- a/loader/assets/merge.py
+++ b/loader/assets/merge.py
@@ -4,28 +4,148 @@
import lxml.etree
import loader.assets.library
+from .library import PATCHABLE_XML_FILES, PATCHABLE_CIM_FILES
+from .explode import Texture
import ui.log
+def _detect_textures(coreLibrary, modLibrary, mod):
+ textures_path = os.path.join(mod, 'textures')
+ if not os.path.isdir(textures_path):
+ return {}
+
+ mapping_n_region = {}
+ modded_textures = {}
+ seen_textures = set()
+
+ def _add_texture(region_id):
+ filename = region_id + '.png'
+ if filename in seen_textures:
+ return
+
+ path = os.path.join(textures_path, filename)
+ if not os.path.isfile(path):
+ ui.log.log(" ERROR MISSING {}...".format(filename))
+ ui.log.log(" ERROR MISSING {}...".format(filename))
+ ui.log.log(" ERROR MISSING {}...".format(filename))
+ return
+
+ ui.log.log(" Found {}...".format(filename))
+ if int(region_id) > coreLibrary['_last_core_region_id']:
+ # adding a new texture, this gets tricky as they have to have consecutive numbers.
+ core_region_id = str(coreLibrary['_next_region_id'])
+ mapping_n_region[region_id] = core_region_id
+ coreLibrary['_next_region_id'] += 1
+ else:
+ core_region_id = region_id
+
+ seen_textures.add(filename)
+ modded_textures[core_region_id] = {
+ 'mapped_from_id' : region_id,
+ 'filename' : filename,
+ 'path' : path,
+ }
+
+ for filename in os.listdir(textures_path):
+ # also scan the directory for overwriting existing core textures
+ if not filename.endswith('.png'):
+ continue
+ try:
+ int(filename.split('.')[0])
+ except:
+ # wrong format
+ continue
+ _add_texture(filename.split('.')[0])
+
+ if 'library/textures' not in modLibrary:
+ # no textures.xml file, we're done
+ return modded_textures
+
+ #FIXME verify that there's only one file
+ textures_mod = modLibrary['library/textures'][0]
+
+ for texture_pack in textures_mod.xpath("//t[@i]"):
+ cim_id = texture_pack.get('i')
+ coreLibrary['_custom_textures_cim'][cim_id] = texture_pack.attrib
+
+ for region in textures_mod.xpath("//re[@n]"):
+ region_id = region.get('n')
+ _add_texture(region_id)
+
+ if not mapping_n_region:
+ # no custom mod textures, no need to remap ids
+ return modded_textures
+
+ for animation_chunk in modLibrary['library/animations']:
+ for asset in animation_chunk.xpath("//assetPos[@a]"):
+ mod_local_id = asset.get('a')
+ if mod_local_id not in mapping_n_region:
+ continue
+ new_id = mapping_n_region[mod_local_id]
+ ui.log.log(" Mapping animation 'assetPos' {} to {}...".format(mod_local_id, new_id))
+ asset.set('a', new_id)
+
+ for asset in textures_mod.xpath("//re[@n]"):
+ mod_local_id = asset.get('n')
+ if mod_local_id not in mapping_n_region:
+ continue
+ new_id = mapping_n_region[mod_local_id]
+ ui.log.log(" Mapping texture 're' {} to {}...".format(mod_local_id, new_id))
+ asset.set('n', new_id)
+
+ return modded_textures
+
def mods(corePath, modPaths):
# Load the core library files
coreLibrary = {}
- for filename in loader.assets.library.PATCHABLE_FILES:
- with open(os.path.join(corePath, filename), 'rb') as f:
+ def _core_path(filename):
+ return os.path.join(corePath, filename.replace('/', os.sep))
+
+ for filename in PATCHABLE_XML_FILES:
+ with open(_core_path(filename), 'rb') as f:
coreLibrary[filename] = lxml.etree.parse(f, parser=lxml.etree.XMLParser(recover=True))
-
+
+ # find the last region in the texture file and remember its index
+ # we will need this to add mod textures with consecutive indexes...
+ coreLibrary['_last_core_region_id'] = int(coreLibrary['library/textures'].find("//re[@n][last()]").get('n'))
+ coreLibrary['_next_region_id'] = coreLibrary['_last_core_region_id'] + 1
+ coreLibrary['_all_modded_textures'] = {}
+ coreLibrary['_custom_textures_cim'] = {}
+
# Merge in modded files
for mod in modPaths:
+ ui.log.updateLaunchState("Installing {}".format(os.path.basename(mod)))
+
ui.log.log(" Loading mod {}...".format(mod))
-
+
# Load the mod's library
modLibrary = {}
- for filename in loader.assets.library.PATCHABLE_FILES:
- modLibraryFilePath = os.path.join(mod, filename.replace('/', os.sep))
- if os.path.exists(modLibraryFilePath):
- with open(modLibraryFilePath) as f:
- modLibrary[filename] = lxml.etree.parse(f, parser=lxml.etree.XMLParser(remove_comments=True))
+ def _mod_path(filename):
+ return os.path.join(mod, filename.replace('/', os.sep))
+
+ mod_files = []
+ for mod_file in os.listdir(_mod_path('library')):
+ mod_files.append('library/' + mod_file)
+
+ # we allow breaking down mod xml files into smaller pieces for readability
+ for target in PATCHABLE_XML_FILES:
+ for mod_file in mod_files:
+ if not mod_file.startswith(target):
+ continue
+ if target not in modLibrary:
+ modLibrary[target] = []
+ ui.log.log("{} => {}".format(mod_file, target))
+ with open(_mod_path(mod_file)) as f:
+ modLibrary[target].append(lxml.etree.parse(f, parser=lxml.etree.XMLParser(remove_comments=True)))
+
+
+ mod_file = _mod_path(target)
+ if not os.path.exists(mod_file):
+ # try again with the extension ?
+ mod_file += '.xml'
+ if not os.path.exists(mod_file):
+ continue
# Do an element-wise merge (replacing conflicts)
mergeDefinitions(coreLibrary, modLibrary, file="library/haven", xpath="/data/Randomizer", idAttribute="id")
@@ -43,6 +163,7 @@ def mods(corePath, modPaths):
mergeDefinitions(coreLibrary, modLibrary, file="library/haven", xpath="/data/Encounter", idAttribute="id")
mergeDefinitions(coreLibrary, modLibrary, file="library/haven", xpath="/data/CostGroup", idAttribute="id")
mergeDefinitions(coreLibrary, modLibrary, file="library/haven", xpath="/data/CharacterSet", idAttribute="cid")
+ mergeDefinitions(coreLibrary, modLibrary, file="library/haven", xpath="/data/DifficultySettings", idAttribute="id")
mergeDefinitions(coreLibrary, modLibrary, file="library/haven", xpath="/data/Room", idAttribute="rid")
mergeDefinitions(coreLibrary, modLibrary, file="library/haven", xpath="/data/ObjectiveCollection", idAttribute="nid")
mergeDefinitions(coreLibrary, modLibrary, file="library/haven", xpath="/data/Notes", idAttribute="id")
@@ -65,12 +186,77 @@ def mods(corePath, modPaths):
mergeDefinitions(coreLibrary, modLibrary, file="library/haven", xpath="/data/MainCat", idAttribute="id")
mergeDefinitions(coreLibrary, modLibrary, file="library/texts", xpath="/t", idAttribute="id")
- mergeDefinitions(coreLibrary, modLibrary, file="library/animations", xpath="/AllAnimations/animations", idAttribute="id")
+
+ # do that before merging animations and textures because references might have to be remapped!
+ coreLibrary['_all_modded_textures'].update(_detect_textures(coreLibrary, modLibrary, mod))
+
+ # this way the last mod loaded will overwrite previous textures
+ #FIXME reimplement this test
+ # if region_id in all_modded_textures:
+ # ui.log.log(" ERROR CONFLICT {}...".format(filename))
+ # ui.log.log(" ERROR CONFLICT {}...".format(filename))
+ # ui.log.log(" ERROR CONFLICT {}...".format(filename))
+ # continue
+
+ mergeDefinitions(coreLibrary, modLibrary, file="library/animations", xpath="/AllAnimations/animations", idAttribute="n")
+ mergeDefinitions(coreLibrary, modLibrary, file="library/textures", xpath="/AllTexturesAndRegions/textures", idAttribute="i")
+ mergeDefinitions(coreLibrary, modLibrary, file="library/textures", xpath="/AllTexturesAndRegions/regions", idAttribute="n")
+
+ ui.log.updateLaunchState("Updating XML")
+
# Write out the new base library
- for filename in loader.assets.library.PATCHABLE_FILES:
- with open(os.path.join(corePath, filename.replace('/', os.sep)), "wb") as f:
+ for filename in PATCHABLE_XML_FILES:
+ with open(_core_path(filename), "wb") as f:
f.write(lxml.etree.tostring(coreLibrary[filename], pretty_print=True, encoding="UTF-8"))
+
+ ui.log.updateLaunchState("Packing textures")
+ # add or overwrite textures from mods. This is done after all the XML has been merged into the core "textures" file
+ cims = {}
+ reexport_cims = {}
+ extra_assets = []
+
+ for region in coreLibrary['library/textures'].xpath("//re[@n]"):
+ name = region.get("n")
+
+ if name not in coreLibrary['_all_modded_textures']:
+ continue
+
+ png_file = coreLibrary['_all_modded_textures'][name]['path']
+
+ page = region.get("t")
+ if not page in cims:
+ cim_name = '{}.cim'.format(page)
+ kwargs = {'create': False}
+ # TODO better cross checking of texture packs
+ if 'library/' + cim_name not in PATCHABLE_CIM_FILES:
+ kwargs['create'] = True
+ kwargs['width'] = coreLibrary['_custom_textures_cim'][page]['w']
+ kwargs['height'] = coreLibrary['_custom_textures_cim'][page]['h']
+ extra_assets.append('library/' + cim_name)
+ cims[page] = Texture(os.path.join(corePath, 'library', cim_name), **kwargs)
+
+ reexport_cims[page] = set()
+
+ # write back the cim file as png for debugging
+ reexport_cims[page].add(os.path.dirname(png_file))
+
+ x = int(region.get("x"))
+ y = int(region.get("y"))
+ w = int(region.get("w"))
+ h = int(region.get("h"))
+
+ ui.log.log(" Patching {}.cim...".format(page))
+ cims[page].pack_png(png_file, x, y, w, h)
+
+ # cims contains only the textures files that have actually been modified
+ for page in cims:
+ ui.log.log(" Writing {}.cim...".format(page))
+ cims[page].export_cim(os.path.join(corePath, 'library', '{}.cim'.format(page)))
+ for path in reexport_cims[page]:
+ cims[page].export_png(os.path.join(path, 'modded_cim_{}.png'.format(page)))
+
+ return extra_assets
def mergeDefinitions(baseLibrary, modLibrary, file, xpath, idAttribute):
@@ -79,18 +265,28 @@ def mergeDefinitions(baseLibrary, modLibrary, file, xpath, idAttribute):
return
try:
- modRoot = modLibrary[file].xpath(xpath)[0]
baseRoot = baseLibrary[file].xpath(xpath)[0]
except IndexError:
- ui.log.log(" {}: Nothing at {}".format(file, xpath))
+ #that's a big error if we can't find it in the core!
+ ui.log.log(" {}: ERROR CORE NOTHING AT {}".format(file, xpath))
return
+
+ for mod_xml in modLibrary[file]:
+ try:
+ modRoot = mod_xml.xpath(xpath)[0]
+ except:
+ continue
+
+ merged = 0
+ for element in list(modRoot):
+ conflicts = baseRoot.xpath("*[@{}='{}']".format(idAttribute, element.get(idAttribute)))
- for element in list(modRoot):
- conflicts = baseRoot.xpath("*[@{}='{}']".format(idAttribute, element.get(idAttribute)))
-
- for conflict in conflicts:
- baseRoot.remove(conflict)
-
- baseRoot.append(copy.deepcopy(element))
+ for conflict in conflicts:
+ baseRoot.remove(conflict)
- ui.log.log(" {}: Merged {} elements into {}".format(file, len(list(modRoot)), xpath))
+ baseRoot.append(copy.deepcopy(element))
+ merged += 1
+
+ if merged:
+ # TODO add source filename
+ ui.log.log(" {}: Merged {} elements into {}".format(file, merged, xpath))
diff --git a/loader/extract.py b/loader/extract.py
index 50740de3..551c93a3 100644
--- a/loader/extract.py
+++ b/loader/extract.py
@@ -12,7 +12,8 @@ def extract(jarPath, corePath):
ui.log.log("Running extract & annotate")
+ ui.log.updateBackgroundState("Extracting game files")
loader.assets.library.extract(jarPath, corePath)
+ ui.log.updateBackgroundState("Unpacking textures")
loader.assets.explode.explode(corePath)
- loader.assets.annotate.annotate(corePath)
diff --git a/loader/load.py b/loader/load.py
index fee92e1e..a015ada8 100644
--- a/loader/load.py
+++ b/loader/load.py
@@ -8,11 +8,13 @@
import ui.log
+def quick_launch_filename(mods_cache_signature):
+ return "quicklaunch_" + mods_cache_signature + ".jar"
-def load(jarPath, modPaths):
+def load(jarPath, modPaths, mods_cache_signature = None):
"""Load mods into spacehaven.jar"""
- unload(jarPath)
+ unload(jarPath, message = False)
coreDirectory = tempfile.TemporaryDirectory()
corePath = coreDirectory.name
@@ -21,26 +23,43 @@ def load(jarPath, modPaths):
ui.log.log(" jarPath: {}".format(jarPath))
ui.log.log(" corePath: {}".format(corePath))
ui.log.log(" modPaths:\n {}".format("\n ".join(modPaths)))
-
+
loader.assets.library.extract(jarPath, corePath)
- loader.assets.merge.mods(corePath, modPaths)
+ ui.log.updateBackgroundState("Installing Mods")
+ extra_assets = loader.assets.merge.mods(corePath, modPaths)
os.rename(jarPath, jarPath + '.vanilla')
- loader.assets.library.patch(jarPath + '.vanilla', corePath, jarPath)
-
+ loader.assets.library.patch(jarPath + '.vanilla', corePath, jarPath, extra_assets = extra_assets)
+
coreDirectory.cleanup()
+
+ if mods_cache_signature:
+ import shutil
+ ui.log.updateBackgroundState("Saving QuickLaunch file")
+ shutil.copyfile(jarPath, quick_launch_filename(mods_cache_signature))
+
+def quickload(jarPath, mods_cache_signature):
+ import shutil
+ unload(jarPath, message = False)
+
+ os.rename(jarPath, jarPath + '.vanilla')
+
+ ui.log.updateBackgroundState("Loading QuickLaunch file")
+ shutil.copyfile(quick_launch_filename(mods_cache_signature), jarPath)
-
-def unload(jarPath):
+def unload(jarPath, message = True):
"""Unload mods from spacehaven.jar"""
-
+
+ if message:
+ ui.log.updateBackgroundState("Unloading mods")
+
vanillaPath = jarPath + '.vanilla'
-
- ui.log.log("Unloading mods...")
if not os.path.exists(vanillaPath):
- ui.log.log(" No active mods")
+ if message:
+ ui.log.log(" No active mods")
return
-
+
ui.log.log(" Unloading {} from {}".format(jarPath, vanillaPath))
+ # FIXME check if the game is running again if that fails ? Restarting from ingame after a language change does that
os.remove(jarPath)
os.rename(vanillaPath, jarPath)
diff --git a/setup.py b/setup.py
index f78105b5..5fbb6f44 100644
--- a/setup.py
+++ b/setup.py
@@ -5,7 +5,11 @@
import version
# Dependencies are automatically detected, but it might need fine tuning.
-build_exe_options = {"packages": ["six", "pkg_resources._vendor", "sysconfig"], "excludes": []}
+build_exe_options = {
+ "packages": ["six", "pkg_resources._vendor", "sysconfig"],
+ "excludes": [],
+ 'include_files' : ['textures_annotations.xml', ],
+ }
# GUI applications require a different base on Windows (the default is for a
# console application).
@@ -14,22 +18,12 @@
base = "Win32GUI"
APP = ['spacehaven-modloader.py']
-DATA_FILES = []
+DATA_FILES = [('spacehaven-modloader', ['textures_annotations.xml', ]), ]
OPTIONS = {}
setup(
name="spacehaven-modloader",
version=version.version,
- app=APP,
- data_files=DATA_FILES,
options={"build_exe": build_exe_options},
executables=[Executable("spacehaven-modloader.py", base=base)],
- setup_requires=[],
- install_requires=[
- 'lxml',
- 'click',
- 'pypng',
- 'six',
- 'appdirs'
- ]
)
diff --git a/spacehaven-modloader.py b/spacehaven-modloader.py
index 0ac8d6ca..bbc193ff 100755
--- a/spacehaven-modloader.py
+++ b/spacehaven-modloader.py
@@ -2,6 +2,7 @@
import os
import subprocess
+import threading
import traceback
from tkinter import filedialog
@@ -25,14 +26,20 @@
"/Applications/Games/Space Haven/spacehaven.app",
"./spacehaven.app",
"../spacehaven.app",
+ # could add default steam library location here for mac, unless mac installs steam games in the previous locations?
# Windows
"../spacehaven/spacehaven.exe",
"../../spacehaven/spacehaven.exe",
"../spacehaven.exe",
"../../spacehaven.exe",
+ "C:/Program Files (x86)/Steam/steamapps/common/SpaceHaven/spacehaven.exe",
- # Linux?
+ # Linux
+ "../SpaceHaven/spacehaven",
+ "../../SpaceHaven/spacehaven",
+ "~/Games/SpaceHaven/spacehaven",
+ ".local/share/Steam/steamapps/common/SpaceHaven/spacehaven",
]
class Window(Frame):
@@ -48,72 +55,118 @@ def __init__(self, master=None):
self.header.pack(fill=X, padx=0, pady=0)
self.pack(fill=BOTH, expand=1, padx=4, pady=4)
+
+ # separator
+ #Frame(self, height=1, bg="grey").pack(fill=X, padx=4, pady=8)
- self.spacehavenGameLabel = Label(self, text="Game Location", anchor=NW)
- self.spacehavenGameLabel.pack(fill=X, padx=4, pady=4)
- self.spacehavenPicker = Frame(self)
- self.spacehavenBrowse = Button(self.spacehavenPicker, text="Browse...", command=self.browseForSpacehaven)
- self.spacehavenBrowse.pack(side=RIGHT, padx=4, pady=4)
-
- self.spacehavenText = Entry(self.spacehavenPicker)
- self.spacehavenText.pack(fill=X, padx=4, pady=4)
-
- self.spacehavenPicker.pack(fill=X, padx=0, pady=0)
-
- Frame(self, height=1, bg="grey").pack(fill=X, padx=4, pady=8)
-
-
- self.modLabel = Label(self, text="Installed mods", anchor=NW)
- self.modLabel.pack(fill=X, padx=4, pady=4)
+# self.modLabel = Label(self, text="Installed mods", anchor=NW)
+# self.modLabel.pack(fill=X, padx=4, pady=4)
self.modBrowser = Frame(self)
-
+
+ # left side mods list
self.modListFrame = Frame(self.modBrowser)
- self.modList = Listbox(self.modListFrame, height=0)
+ self.modList = Listbox(self.modListFrame, height=0, selectmode=SINGLE, activestyle = NONE)
self.modList.bind('<>', self.showCurrentMod)
self.modList.pack(fill=BOTH, expand=1, padx=4, pady=4)
- self.modListOpenFolder = Button(self.modListFrame, text="Open Mods Folder", command=self.openModFolder)
- self.modListOpenFolder.pack(fill=X, padx=4, pady=4)
-
self.modListFrame.pack(side=LEFT, fill=Y, padx=4, pady=4)
-
+
+ # right side mod info
self.modDetailsFrame = Frame(self.modBrowser)
- self.modDetailsName = Label(self.modDetailsFrame, font="TkDefaultFont 14 bold", anchor=W)
- self.modDetailsName.pack(fill=X, padx=4, pady=4)
-
+ frame = Frame(self.modDetailsFrame)
+ self.modEnableDisable = Button(frame, text="Enable", command=self.toggle_current_mod)
+ self.modEnableDisable.pack(side = RIGHT, padx=4, pady=4)
+
+ self.modDetailsName = Label(frame, font="TkDefaultFont 14 bold", anchor=W)
+ self.modDetailsName.pack(fill = X, padx=4, pady=4)
+ frame.pack(fill = X, padx=4, pady=4)
+
self.modDetailsDescription = Text(self.modDetailsFrame, wrap=WORD, font="TkDefaultFont", height=0)
self.modDetailsDescription.pack(fill=BOTH, expand=1, padx=4, pady=4)
self.modDetailsFrame.pack(fill=BOTH, expand=1, padx=4, pady=4)
self.modBrowser.pack(fill=BOTH, expand=1, padx=0, pady=0)
-
+
+ # separator
Frame(self, height=1, bg="grey").pack(fill=X, padx=4, pady=8)
-
- self.launchButton = Button(self, text="Launch Space Haven!", command=self.patchAndLaunch)
+
+ # launcher
+ self.launchButton_default_text = "LAUNCH!"
+ self.launchButton = Button(self, text=self.launchButton_default_text, command=self.launch_wrapper, height = 5)
self.launchButton.pack(fill=X, padx=4, pady=4)
+
- self.extractButton = Button(self, text="Extract & annotate game assets", command=self.extractAndAnnotate)
- self.extractButton.pack(fill=X, padx=4, pady=4)
+ #Frame(self, height=1, bg="grey").pack(fill=X, padx=4, pady=8)
+ self.spacehavenPicker = Frame(self)#.pack(fill=X, padx=4, pady=4)
+ self.spacehavenBrowse = Button(self.spacehavenPicker, text="Find game...", command=self.browseForSpacehaven)
+ self.spacehavenBrowse.pack(side = LEFT, padx=8, pady=4)
- self.quitButton = Button(self, text="Quit", command=self.quit)
- self.quitButton.pack(fill=X, padx=4, pady=4)
+ #self.spacehavenGameLabel = Label(self, text="Game Location :", anchor=NE)
+ #self.spacehavenGameLabel.pack(side = LEFT, padx=4, pady=4)
+ # game path
+ self.spacehavenText = Entry(self.spacehavenPicker)
+ # damn cant align properly with the "find game" button...
+ self.spacehavenText.pack(fill = X, padx=4, pady=4, anchor = S)
+ self.spacehavenPicker.pack(fill=X, padx=0, pady=0)
+ Frame(self, height=1, bg="grey").pack(fill=X, padx=4, pady=8)
+
+
+ # buttons at the bottom
+ #buttonFrame = Frame(self).pack(fill = X, padx = 4, pady = 8)
+ self.quitButton = Button(self, text="Quit", command=self.quit)
+ self.quitButton.pack(side=RIGHT, expand = False, padx=8, pady=4)
+ #self.quitButton.grid(column = 2, padx=4, pady=4)
+
+ self.annotateButton = Button(self, text="Annotate XML", command = lambda: self.start_background_task(self.annotate, "Annotating"))
+ self.annotateButton.pack(side=RIGHT, expand = False, padx=8, pady=4)
+
+ self.extractButton = Button(self, text="Extract game assets", command = lambda: self.start_background_task(self.extract_assets, "Extracting"))
+ self.extractButton.pack(side=RIGHT, expand = False, padx=8, pady=4)
+ #self.extractButton.grid(column = 0, padx=4, pady=4)
+
+ self.modListOpenFolder = Button(self, text="Open Mods Folder", command=self.openModFolder)
+ self.modListOpenFolder.pack(side = RIGHT, expand = False, padx=8, pady=4)
+ #self.modListOpenFolder.grid(column = 1, padx=4, pady=4)
+
+ self.modListRefresh = Button(self, text="Refresh Mods", command=self.refreshModList)
+ self.modListRefresh.pack(side = RIGHT, expand = False, padx=8, pady=4)
+ #self.modListOpenFolder.grid(column = 1, padx=4, pady=4)
+
+ self.quickLaunchClear = Button(self, text="Clear Quicklaunch file", command=self.clear_quick_launch)
+ self.quickLaunchClear.pack(side = RIGHT, expand = False, padx=8, pady=4)
+ #self.modListOpenFolder.grid(column = 1, padx=4, pady=4)
+
self.autolocateSpacehaven()
def autolocateSpacehaven(self):
self.gamePath = None
self.jarPath = None
self.modPath = None
-
+
+ try:
+ with open("previous_spacehaven_path.txt", 'r') as f:
+ location = f.read()
+ if os.path.exists(location):
+ self.locateSpacehaven(location)
+ return
+ except:
+ import traceback
+ traceback.print_exc()
+ pass
+
for location in POSSIBLE_SPACEHAVEN_LOCATIONS:
- location = os.path.abspath(location)
- if os.path.exists(location):
- self.locateSpacehaven(location)
- return
-
+ try:
+ location = os.path.abspath(location)
+ if os.path.exists(location):
+ self.locateSpacehaven(location)
+ return
+ except:
+ pass
+
def locateSpacehaven(self, path):
if path is None:
return
@@ -128,7 +181,7 @@ def locateSpacehaven(self, path):
self.jarPath = path
self.modPath = os.path.join(os.path.dirname(path), "mods")
- elif path.endswith('.exe'):
+ else:
self.gamePath = path
self.jarPath = os.path.join(os.path.dirname(path), "spacehaven.jar")
self.modPath = os.path.join(os.path.dirname(path), "mods")
@@ -141,14 +194,27 @@ def locateSpacehaven(self, path):
ui.log.log(" gamePath: {}".format(self.gamePath))
ui.log.log(" modPath: {}".format(self.modPath))
ui.log.log(" jarPath: {}".format(self.jarPath))
-
+
+
+ with open("previous_spacehaven_path.txt", 'w') as f:
+ f.write(path)
+
self.checkForLoadedMods()
self.gameInfo = ui.gameinfo.GameInfo(self.jarPath)
self.spacehavenText.delete(0, 'end')
self.spacehavenText.insert(0, self.gamePath)
-
+
+ self.modPath = [self.modPath, ]
+ try:
+ with open("extra_mods_path.txt", 'r') as f:
+ for mod_path in f.read().split('\n'):
+ if mod_path.strip():
+ self.modPath.append(mod_path.strip())
+ except:
+ pass
+
self.refreshModList()
def checkForLoadedMods(self):
@@ -158,84 +224,290 @@ def checkForLoadedMods(self):
loader.load.unload(self.jarPath)
def browseForSpacehaven(self):
+ import platform
+
+ filetypes = []
+ if platform.system() == "Windows":
+ filetypes.append(('spacehaven.exe', '*.exe'))
+ elif platform.system() == "Darwin":
+ filetypes.append(('spacehaven.app', '*.app'))
+ elif platform.system() == "Linux":
+ filetypes.append(('all files', '*'))
+
self.locateSpacehaven(
filedialog.askopenfilename(
parent=self.master,
title="Locate spacehaven",
- filetypes=[
- ('spacehaven.exe', '*.exe'),
- ('spacehaven.app', '*.app'),
- ('spacehaven.jar', '*.jar'),
- ]
+ filetypes=filetypes,
)
)
-
+
def focus(self, _arg=None):
- self.refreshModList()
+ # disabled, refreshes too much and resets the selection
+ #self.refreshModList()
+ pass
def refreshModList(self):
+ try:
+ # might fail at init time
+ previously_selected = self.selected_mod()
+ except:
+ previously_selected = None
+ pass
self.modList.delete(0, END)
if self.modPath is None:
- self.showMod("Spacehaven not found", "Please use the Browse button above to locate Spacehaven.")
+ self.showModError("Spacehaven not found", "Please use the 'Find game' button below to locate Spacehaven.")
return
self.modDatabase = ui.database.ModDatabase(self.modPath, self.gameInfo)
-
+
+ mod_idx = 0
for mod in self.modDatabase.mods:
self.modList.insert(END, mod.name)
-
+ mod.display_idx = mod_idx
+
+ self.update_list_style(mod)
+ if previously_selected and mod == previously_selected.name:
+ self.modList.selection_set(mod_idx)
+ mod_idx += 1
+
+ self.check_quick_launch()
self.showCurrentMod()
-
+
+ def update_list_style(self, mod):
+ if mod.enabled:
+ self.modList.itemconfig(mod.display_idx, foreground = 'black', selectforeground = 'white')
+ else:
+ self.modList.itemconfig(mod.display_idx, foreground = 'grey', selectforeground = 'lightgrey')
+
+ def selected_mod(self):
+ if not len(self.modDatabase.mods):
+ return None
+ if len(self.modList.curselection()) == 0:
+ self.modList.selection_set(0)
+ selected = 0
+ else:
+ selected = self.modList.curselection()[0]
+
+ return self.modDatabase.mods[self.modList.curselection()[0]]
+
def showCurrentMod(self, _arg=None):
- if len(self.modDatabase.mods) == 0:
- self.showMod("No mods found", "Please install some mods into your mods folder.")
+ self.showMod(self.selected_mod())
+
+ def toggle_current_mod(self):
+ mod = self.selected_mod()
+ if not mod:
return
-
- if len(self.modList.curselection()) == 0:
- mod = self.modDatabase.mods[0]
-
+
+ if mod.enabled:
+ mod.disable()
else:
- mod = self.modDatabase.mods[self.modList.curselection()[0]]
-
- self.showMod(mod.name, mod.description)
-
- def showMod(self, name, description):
- self.modDetailsName.config(text=name)
-
+ mod.enable()
+
+ self.update_list_style(mod)
+ self.showMod(mod)
+ self.check_quick_launch()
+
+ def update_description(self, description):
self.modDetailsDescription.config(state="normal")
self.modDetailsDescription.delete(1.0, END)
self.modDetailsDescription.insert(END, description)
self.modDetailsDescription.config(state="disabled")
-
-
+
+ def showMod(self, mod):
+ if not mod:
+ return self.showModError("No mods found", "Please install some mods into your mods folder.")
+
+ title = mod.title()
+ if mod.enabled:
+ command_label = "Disable"
+ else:
+ command_label = "Enable"
+ title += " [DISABLED]"
+
+ self.modDetailsName.config(text = title)
+ self.modEnableDisable.config(text = command_label)
+ description = mod.description
+ if mod.known_issues:
+ description += "\n\n" + "KNOWN ISSUES: " + mod.known_issues
+ if mod.author:
+ description += "\n\n" + "AUTHOR: " + mod.author
+ if mod.website:
+ # FIXME make it a separate textfield, can't select from this one
+ description += "\n\n" + "URL: " + mod.website
+
+ self.update_description(description)
+
+ def showModError(self, title, error):
+ self.modDetailsName.config(text = title)
+ self.update_description(error)
+
def openModFolder(self):
- ui.launcher.open(self.modPath)
-
- def extractAndAnnotate(self):
- if not messagebox.askokcancel("Extract & Annotate", "Extracting and annotating game assets will take a minute or two.\n\nWould you like to proceed?"):
- return
-
- corePath = os.path.join(self.modPath, "spacehaven")
-
+ ui.launcher.open(self.modPath[0])
+
+ def set_ui_state(self, state, message):
+ self.launchButton.config(state = state, text = message)
+ self.modEnableDisable.config(state = state)
+ self.spacehavenBrowse.config(state = state)
+ self.quickLaunchClear.config(state = state)
+ self.modListRefresh.config(state = state)
+ self.modListOpenFolder.config(state = state)
+ self.extractButton.config(state = state)
+ self.annotateButton.config(state = state)
+ self.quitButton.config(state = state)
+
+ can_quit = True
+ def disable_UI(self, message):
+ self.set_ui_state(DISABLED, message)
+ self.config(cursor = 'wait')
+ self.can_quit = False
+
+ def enable_UI(self, message):
+ self.set_ui_state(NORMAL, message)
+ self.config(cursor = '')
+ self.can_quit = True
+
+ background_refresh_delay = 1000
+ background_thread = None
+ background_finished = True
+
+ def start_background_task(self, task, message):
+ self.disable_UI(message)
+
+ ui.log.logger.backgroundState = message
+
+ self.background_finished = False
+ # for counting the iterations in update_background_state
+ self.background_counter = 0
+
+ def _wrapper():
+ try:
+ task()
+ finally:
+ self.background_finished = True
+
+ self.background_thread = threading.Thread(target = _wrapper)
+ self.background_thread.start()
+ self.after(self.background_refresh_delay, self.update_background_state)
+
+ def update_background_state(self):
+ extra_label = "." * (self.background_counter % 5)
+ self.background_counter += 1
+
+ self.launchButton.config(text = extra_label + " " + ui.log.logger.backgroundState + " " + extra_label)
+ if self.background_finished:
+ self.background_thread.join()
+ self.background_thread = None
+ self.enable_UI(self.launchButton_default_text)
+ self.check_quick_launch()
+ else:
+ self.after(self.background_refresh_delay, self.update_background_state)
+
+ def _core_extract_path(self):
+ return os.path.join(self.modPath[0], "spacehaven_" + self.gameInfo.version)
+
+ def extract_assets(self):
+ corePath = self._core_extract_path()
+
loader.extract.extract(self.jarPath, corePath)
- ui.launcher.open(corePath)
-
+ ui.launcher.open(os.path.join(corePath, 'library'))
+
+ def annotate(self):
+ corePath = self._core_extract_path()
+
+ loader.assets.annotate.annotate(corePath)
+
+ ui.launcher.open(os.path.join(corePath, 'library'))
+
+ def mods_enabled(self):
+ return [mod for mod in self.modDatabase.mods if mod.enabled]
+
+ def current_mods_signature(self):
+ import hashlib
+
+ mods_signature = ["spacehaven", self.gameInfo.version]
+ # mods are supposedly ordered alphabetically
+ for mod in self.mods_enabled():
+ mods_signature.append(mod.name)
+ mods_signature.append(mod.version or "VERSION_UNKNOWN")
+
+ text_sig = "__".join(mods_signature).lower()
+ md5 = hashlib.md5(text_sig.encode('utf-8')).hexdigest()
+ return md5
+
+ def quick_launch_available(self):
+ mods_sig = self.current_mods_signature()
+ return os.path.isfile(loader.load.quick_launch_filename(mods_sig))
+
+ def check_quick_launch(self):
+ if not self.mods_enabled():
+ self.launchButton_default_text = "LAUNCH ORIGINAL GAME"
+ self.quickLaunchClear.config(state = DISABLED)
+ elif self.quick_launch_available():
+ self.launchButton_default_text = "QUICKLAUNCH!"
+ self.quickLaunchClear.config(state = NORMAL)
+ else:
+ self.launchButton_default_text = "LAUNCH!"
+ self.quickLaunchClear.config(state = DISABLED)
+ self.launchButton.config(text = self.launchButton_default_text)
+
+ def clear_quick_launch(self):
+ try:
+ os.unlink(loader.load.quick_launch_filename(self.current_mods_signature()))
+ except:
+ pass
+ self.check_quick_launch()
+
+ def launch_wrapper(self):
+ if not self.mods_enabled():
+ task = self.launch_vanilla
+ message = "Launching original game"
+ elif self.quick_launch_available():
+ task = self.quick_launch
+ message = "Quicklaunching"
+ else:
+ task = self.patchAndLaunch
+ message = "Launching"
+
+ self.start_background_task(task, message)
+
+ def launch_vanilla(self):
+ ui.launcher.launchAndWait(self.gamePath)
+
+ def quick_launch(self):
+ try:
+ loader.load.quickload(self.jarPath, self.current_mods_signature())
+ ui.launcher.launchAndWait(self.gamePath)
+ # FIXME this will crash if the game restarts by itself (changing language)
+ loader.load.unload(self.jarPath)
+ except Exception as ex:
+ import traceback
+ traceback.print_exc()
+ messagebox.showerror("Error during quick launch", traceback.format_exc(3))
+
def patchAndLaunch(self):
activeModPaths = []
for mod in self.modDatabase.mods:
+ if not mod.enabled:
+ continue
activeModPaths.append(mod.path)
-
+
try:
- loader.load.load(self.jarPath, activeModPaths)
+ loader.load.load(self.jarPath, activeModPaths, self.current_mods_signature())
ui.launcher.launchAndWait(self.gamePath)
loader.load.unload(self.jarPath)
except Exception as ex:
- messagebox.showerror("Error loading mods", str(ex))
-
-
+ import traceback
+ traceback.print_exc()
+ messagebox.showerror("Error loading mods", traceback.format_exc(3))
+
def quit(self):
- self.master.destroy()
+ if self.can_quit:
+ self.master.destroy()
+ return
+
+ messagebox.showerror("Error", "Cannot quit while a task is running!")
def handleException(type, value, trace):
@@ -261,4 +533,5 @@ def fixNoButtonLabelsBug():
root.update()
root.update_idletasks()
root.after(0, fixNoButtonLabelsBug)
+ root.protocol("WM_DELETE_WINDOW", app.quit)
root.mainloop()
diff --git a/textures_annotations.xml b/textures_annotations.xml
new file mode 100644
index 00000000..338b6aca
--- /dev/null
+++ b/textures_annotations.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ui/database.py b/ui/database.py
index bc5ea785..5dd12260 100644
--- a/ui/database.py
+++ b/ui/database.py
@@ -12,8 +12,8 @@
class ModDatabase:
"""Information about a collection of mods"""
- def __init__(self, path, gameInfo):
- self.path = path
+ def __init__(self, path_list, gameInfo):
+ self.path_list = path_list
self.gameInfo = gameInfo
self.locateMods()
@@ -21,47 +21,76 @@ def locateMods(self):
self.mods = []
ui.log.log("Locating mods...")
- for modFolder in os.listdir(self.path):
- if modFolder == 'spacehaven':
- continue # don't need to load core game definitions
-
- if os.path.isfile(modFolder):
- continue # don't load logs, prefs, etc
-
- self.mods.append(Mod(os.path.join(self.path, modFolder), self.gameInfo))
-
+ def _get_mods_from_dir(path):
+ for modFolder in os.listdir(path):
+ if modFolder == 'spacehaven':
+ continue # don't need to load core game definitions
+ modPath = os.path.join(path, modFolder)
+ if os.path.isfile(modPath):
+ # TODO add support for zip files ? unzip them on the fly ?
+ continue # don't load logs, prefs, etc
+
+ info_file = os.path.join(modPath, "info")
+ if os.path.isfile(info_file):
+ self.mods.append(Mod(info_file, self.gameInfo))
+ else:
+ info_file += '.xml'
+ if os.path.isfile(info_file):
+ self.mods.append(Mod(info_file, self.gameInfo))
+
+ for path in self.path_list:
+ _get_mods_from_dir(path)
+
self.mods.sort(key=lambda mod: mod.name)
-
+DISABLED_MARKER = "disabled.txt"
class Mod:
"""Details about a specific mod (name, description)"""
- def __init__(self, path, gameInfo):
- ui.log.log(" Loading mod at {}...".format(path))
-
- self.path = path
+ def __init__(self, info_file, gameInfo):
+ self.path = os.path.dirname(info_file)
+ ui.log.log(" Loading mod at {}...".format(self.path))
+
+ # TODO add a flag to warn users about savegame compatibility ?
+
self.name = os.path.basename(self.path)
self.gameInfo = gameInfo
+
+ self.enabled = not os.path.isfile(os.path.join(self.path, DISABLED_MARKER))
+
+ self.loadInfo(info_file)
- self.loadInfo()
-
- def loadInfo(self):
- infoFile = os.path.join(self.path, "info")
-
+ def loadInfo(self, infoFile):
+
if not os.path.exists(infoFile):
ui.log.log(" No info file present")
self.name += " [!]"
self.description = "Error loading mod: no info file present. Please create one."
return
-
+
+ def _sanitize(elt):
+ return elt.text.strip("\r\n\t ")
+
+ def _optional(tag):
+ try:
+ return _sanitize(mod.find(tag))
+ except:
+ return ""
+
try:
info = ElementTree.parse(infoFile)
mod = info.getroot()
- self.name = mod.find("name").text.strip()
- self.description = mod.find("description").text.strip() + "\n\n"
-
+ self.name = _sanitize(mod.find("name"))
+ self.description = _sanitize(mod.find("description"))
+
+ self.known_issues = _optional("knownIssues")
+ self.version = _optional("version")
+ self.author = _optional("author")
+ self.website = _optional("website")
+ self.updates = _optional("updates")
+
self.verifyLoaderVersion(mod)
self.verifyGameVersion(mod, self.gameInfo)
@@ -72,7 +101,25 @@ def loadInfo(self):
ui.log.log(" Failed to parse info file")
ui.log.log(" Finished loading {}".format(self.name))
-
+
+ def enable(self):
+ try:
+ os.unlink(os.path.join(self.path, DISABLED_MARKER))
+ self.enabled = True
+ except:
+ pass
+
+ def disable(self):
+ with open(os.path.join(self.path, DISABLED_MARKER), "w") as marker:
+ marker.write("this mod is disabled, remove this file to enable it again (or toggle it via the modloader UI)")
+ self.enabled = False
+
+ def title(self):
+ title = self.name
+ if self.version:
+ title += " (%s)" % self.version
+ return title
+
def verifyLoaderVersion(self, mod):
self.minimumLoaderVersion = mod.find("minimumLoaderVersion").text
if distutils.version.StrictVersion(self.minimumLoaderVersion) > distutils.version.StrictVersion(version.version):
@@ -81,6 +128,8 @@ def verifyLoaderVersion(self, mod):
ui.log.log(" Minimum Loader Version: {}".format(self.minimumLoaderVersion))
def verifyGameVersion(self, mod, gameInfo):
+ # FIXME disabled ATM as this check doesn't work
+ return
self.gameVersions = []
gameVersionsTag = mod.find("gameVersions")
@@ -108,4 +157,4 @@ def verifyGameVersion(self, mod, gameInfo):
def warn(self, message):
ui.log.log(" Warning: {}".format(message))
self.name += " [!]"
- self.description += "\nWarning: {}".format(message)
+ self.description += "\nWARNING: {}!".format(message)
diff --git a/ui/gameinfo.py b/ui/gameinfo.py
index cee07bdc..ce799c4a 100644
--- a/ui/gameinfo.py
+++ b/ui/gameinfo.py
@@ -1,13 +1,7 @@
-import hashlib
-
import ui.log
-KNOWN_VERSIONS = {
- '11a3cc26d5afe56906cd5831627c303878074dac3788f623eca7d340c9e30ad3': '0.4.1', # MacOS
- 'dbd84fa985de37f806f40bf6035f0603be9ee66b0df67a9612a182469a7531e2': '0.4.1' # Windows
-}
-
+from zipfile import ZipFile
class GameInfo:
def __init__(self, jarPath):
@@ -17,16 +11,8 @@ def __init__(self, jarPath):
def detectVersion(self):
ui.log.log("Loading game information...")
-
- hasher = hashlib.sha256()
- with open(self.jarPath, 'rb') as f:
- hasher.update(f.read())
-
- hash = hasher.hexdigest()
-
- if hash in KNOWN_VERSIONS:
- self.version = KNOWN_VERSIONS[hash]
- else:
- self.version = None
-
- ui.log.log(" Version: {} (hash {})".format(self.version, hash))
+ with ZipFile(self.jarPath, "r") as spacehaven:
+ self.version = spacehaven.read('version.txt').decode('utf-8').split('\n')[0].strip()
+ # second line is "alpha 8, which is useless. Don't know where the "build 3" comes from
+
+ ui.log.log(" Version: {}".format(self.version))
diff --git a/ui/launcher.py b/ui/launcher.py
index 7d1f37cd..3dc68842 100644
--- a/ui/launcher.py
+++ b/ui/launcher.py
@@ -3,16 +3,19 @@
import sys
import subprocess
+import ui.log
def launchAndWait(path):
"""Launch the game and wait for it to exit"""
-
- if sys.platform == 'win32':
- subprocess.call(path)
- elif sys.platform == 'darwin':
+ ui.log.updateBackgroundState("Running")
+
+ # FIXME cloud credentials aren't found when launching from the modloader.
+ # cwd issue ?? apparently not as the cwd doesnt change anything...
+ from_dir = os.path.dirname(path)
+ if sys.platform == 'darwin':
subprocess.call(["open", path, "-W"])
else:
- subprocess.call(path)
+ subprocess.call(path, cwd = from_dir)
def open(path):
diff --git a/ui/log.py b/ui/log.py
index 0878c646..7e8dd640 100644
--- a/ui/log.py
+++ b/ui/log.py
@@ -27,11 +27,17 @@ def logInitialInfo(self):
def log(self, message=""):
print("[LOG] {}".format(message))
self.localLog.write(message + "\n")
-
+ self.localLog.flush()
+
if self.gameLog:
self.gameLog.write(message + "\n")
-
+ self.gameLog.flush()
+
+ def updateBackgroundState(self, message):
+ self.backgroundState = message
+
logger = Logger()
log = logger.log
-setGameModPath = logger.setGameModPath
\ No newline at end of file
+updateBackgroundState = updateLaunchState = logger.updateBackgroundState
+setGameModPath = logger.setGameModPath
diff --git a/version.py b/version.py
index e39fb146..9d2c0024 100644
--- a/version.py
+++ b/version.py
@@ -1,2 +1,2 @@
-version = "0.0.3"
+version = "0.0.7"