Skip to content

Commit

Permalink
Add: automated mod merging
Browse files Browse the repository at this point in the history
  • Loading branch information
ezloj committed Sep 20, 2023
1 parent cb0b5b9 commit 8982fcf
Show file tree
Hide file tree
Showing 18 changed files with 477 additions and 10 deletions.
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@
.pylint_cache
*.pyc
**/__pycache__/*
output
output
experiment*
9 changes: 8 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,11 @@ RUN apt update && apt -y install vim

COPY ./requirements.txt .

RUN wine pip install --no-warn-script-location -r requirements.txt
# atomicwrites won't build without the --use-pep517 flag
# And atomicwrites is required by pywinauto
RUN wine pip install --no-warn-script-location --use-pep517 -r requirements.txt

# https://stackoverflow.com/questions/76583866/pywinauto-wine-and-docker-attributeerror-module-comtypes-gen-has-no-attrib
# ultra ugly solution to remove the UIA backend support from pywinauto
# the other way would be to find a way to install that missing backend through wine
RUN sed -i '34,38d' /opt/wineprefix/drive_c/Python310/Lib/site-packages/pywinauto/controls/__init__.py
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ all: local-container
# run locally
local-container:
@echo "Building local images..."
${DOCKER_COMPOSE} build wine tinker >/dev/null 2>&1
${DOCKER_COMPOSE} build wine tinker > /dev/null 2>&1

tinker: local-container
${TINKER}
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ The latter will drop you off in a shell with current directory (this repository)
From there on, to get a python shell - run `wine python3`
After that, to import settings module: `import src.config.settings`

### If you don't want docker
Use your own python and install venv. requirements.txt is in the project root
I strongly recommend learning to use docker. In this setup everything is automated (see above section for using docker and make)
Docker is a powerful tool for development that allows to seamlessly switch project components
and dependencies or even the operating system in minutes and go back instantly
With Makefile and docker-compose like in this project you don't have to switch between venvs (or anything else for that matter)

### Notes
#### Issues with volume mount path conversion
To avoid those do `export MSYS_NO_PATHCONV=1` before doing anything
Expand Down
8 changes: 8 additions & 0 deletions build.sh
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
#!/bin/bash
set -e

# Run pylint on each file just to make sure there are not syntax errors
# Pyinstaller doesn't care about syntax errors so if there are any - it will be silent
echo "Checking for syntax errors before building..."
wine pylint --errors-only --fail-on=F src
echo "No syntax errors found, building!"

rm -rf output

wine pyinstaller --clean --windowed --name tqma --specpath /tmp/spec --distpath /tmp/dist --workpath /tmp/work ${WORKDIR}/src/main.py
mkdir output
cp -R /tmp/dist/tqma/* output
Expand Down
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ requests==2.24.0
#black=20.8b1
PyQt6==6.5.0
pyinstaller==5.13.0
pywinauto==0.6.8
prospector[with_everything]==1.7.7
1 change: 0 additions & 1 deletion src/art_manager/README.md

This file was deleted.

1 change: 1 addition & 0 deletions src/binary_automation/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Binary tool automation code
Empty file.
42 changes: 42 additions & 0 deletions src/binary_automation/art_manager.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
"""
The code for art manager automation
"""
import logging
import os

from pywinauto.application import Application

logger = logging.getLogger("tqma")


class ArtManager:
""" Represents art manager tool from official TQAE install path """
def __init__(self, installation_path, settings_path):
logger.debug("Instantiating art manager object")
self.path = os.path.join(installation_path, "ArtManager.exe")
self.tools_ini = self.read_tools_ini(os.path.join(settings_path, "Tools.ini"))

def read_tools_ini(self, tools_ini_path):
""" Reads tools_ini and extracts some important settings related to work/build paths """
parsed_settings = {}
logger.debug(f"Tools.ini path: {tools_ini_path}")
with open(tools_ini_path, 'r', encoding='utf-8') as tools_ini:
for line in tools_ini.readlines():
if "=" in line:
parsed_settings[line.split("=")[0]] = line.strip().split("=")[1]
logger.debug(f"Working dir: {parsed_settings['localdir']}")
logger.debug(f"Build dir: {parsed_settings['builddir']}")
logger.debug(f"Tools dir: {parsed_settings['toolsdir']}")

return parsed_settings

def run(self):
""" Runs the ArtManager executable """
logger.debug("Starting art manager")
app = Application(backend="win32").start(self.path)
logger.debug(f"{app}")

def build(self, output_mod_name):
""" Builds the selected mod """
logger.debug(f"Building mod {output_mod_name}:)")
self.run()
20 changes: 20 additions & 0 deletions src/dbr_lib.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
""" DBR operations library """

def parse_dbr(dbr_file):
""" Takes a file path as an input and parses it as a dbr. Returns dictionary with DBR data"""
dbr_data = {}
with open(dbr_file, 'r', encoding='utf-8') as dbr_contents:
for line in dbr_contents.readlines():
dbr_data[line.split(',', 1)[0]] = line.split(',', 1)[1].strip()

return dbr_data

def write_dbr(dbr_data, output_file):
""" Takes a dictionary with DBR data and writes it into an output file as a dbr """
contents = ""
for key, value in dbr_data.items():
contents += key + "," + value + "\n"

with open(output_file, 'w', encoding='utf-8') as dbr_contents:
dbr_contents.write(contents)

94 changes: 92 additions & 2 deletions src/gui/frames/mod_merge_frame.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,17 @@
"""
import logging
import os
import re
import traceback

from PyQt6.QtWidgets import QListWidget, QAbstractItemView, QPushButton, QLabel
from PyQt6.QtCore import Qt
from PyQt6.QtWidgets import QListWidget, QAbstractItemView, QPushButton, QLabel, QLineEdit
from PyQt6.QtCore import Qt, QEventLoop
from src.gui.frames.common_frame import CommonFrame
from src.mods import Mod
from src.binary_automation.art_manager import ArtManager
from src.mod_merge import ModMerge
from src.gui.windows.conflict_resolution_window import ConflictResolutionWindow


logger = logging.getLogger("tqma")

Expand All @@ -18,11 +24,14 @@ def __init__(self, parent, settings):
super().__init__(parent, settings, objectName="Mod merge")
self.mods = []
self.selected_mods = []
self.good_style_sheet = None
self.bad_style_sheet = "border: 1px solid red;"
self.layout().setSpacing(0)

# Mod list label
mod_list_tooltip = "Use CTRL and SHIFT to select multiple"
self.mod_list_label = QLabel("Choose mods:")
self.good_style_sheet = self.mod_list_label.styleSheet()
self.mod_list_label.setToolTip(mod_list_tooltip)
self.layout().addWidget(self.mod_list_label, 0, 0, 1, 1, alignment=Qt.AlignmentFlag.AlignTop)

Expand All @@ -34,8 +43,19 @@ def __init__(self, parent, settings):
self.mod_list.itemSelectionChanged.connect(self.on_mod_list_change)
self.layout().addWidget(self.mod_list, 1, 0, 1, 1, alignment=Qt.AlignmentFlag.AlignTop)

# New mod name input label and field
new_mod_tooltip = "Mod name should consist of: a-zA-Z0-9_- and space character"
self.new_mod_name_label = QLabel("Merged mod name")
self.new_mod_name_label.setToolTip(new_mod_tooltip)
self.layout().addWidget(self.new_mod_name_label, 2, 0, 1, 1, alignment=Qt.AlignmentFlag.AlignTop)
self.new_mod_name_field = QLineEdit(self)
self.new_mod_name_field.setToolTip(new_mod_tooltip)
self.new_mod_name_field.textChanged.connect(self.new_mod_name_changed)
self.layout().addWidget(self.new_mod_name_field, 3, 0, 1, 1, alignment=Qt.AlignmentFlag.AlignTop)

# Build button
build_button = QPushButton('Build')
build_button.clicked.connect(self.merge_mods)
self.layout().addWidget(build_button, 4, 1, alignment=Qt.AlignmentFlag.AlignBottom)

# This moves thigs up and left a little so that it's a bit more cramped
Expand All @@ -44,6 +64,22 @@ def __init__(self, parent, settings):

self.show()

def resolve_conflicts(self, overlaps):
""" Generates and shows settings window from which the settings can be edited """

logger.debug("Calling creation of conflict resolution window")

conflict_resolution_window = ConflictResolutionWindow(overlaps)
conflict_resolution_window.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
conflict_resolution_window.setWindowModality(Qt.WindowModality.ApplicationModal)
conflict_resolution_window.show()

logger.debug("Waiting for conflict resolution")
wait_loop = QEventLoop()
conflict_resolution_window.destroyed.connect(wait_loop.quit)
wait_loop.exec()
logger.debug("Conflict resolution complete")

def load_mod_list(self):
""" Loads an instance of Mod for all directories in settings["Mod sources path"] """
if not self.settings.get_setting("Mod sources path"):
Expand All @@ -59,13 +95,67 @@ def load_mod_list(self):
self.mod_list.clear()
self.mod_list.addItems([mod.name for mod in self.mods])

def lock_controls(self):
""" Lock all the interactable controls within the frame """

def new_mod_name_changed(self):
""" Triggered by new_mod_name_field being changed by the user """
if self.validate_new_mod_name():
self.new_mod_name_field.setStyleSheet(self.good_style_sheet)
else:
self.new_mod_name_field.setStyleSheet(self.bad_style_sheet)

def on_mod_list_change(self):
""" Stores selected mods when mod list gets changed by user"""
self.selected_mods = []
self.mod_list.setStyleSheet(self.good_style_sheet)
selected_items = [item.text() for item in self.mod_list.selectedItems()]
for selected_item in selected_items:
for mod in self.mods:
if mod.name == selected_item:
self.selected_mods.append(mod)

logger.debug("Currently selected mods: %s", [mod.name for mod in self.selected_mods])

def merge_mods(self):
""" Merges selected mods with selected options using artmanager """
try:
if not self.selected_mods:
self.mod_list.setStyleSheet(self.bad_style_sheet)
logger.debug("No mods selected so can't really merge anything!")
return
if not self.validate_new_mod_name():
logger.debug("Provided mod name is either empty or doesn't conform to the naming regex")
self.new_mod_name_field.setStyleSheet(self.bad_style_sheet)
return

art_manager = ArtManager(
installation_path = self.settings.get_setting("TQAE path"),
settings_path = self.settings.get_setting("TQAE Save folder")
)

mod_merge = ModMerge(
output_path=art_manager.tools_ini['localdir'],
new_mod_name=self.new_mod_name_field.text()
)

mod_merge.set_mods(self.selected_mods)
mod_merge.process_database_files()
if mod_merge.conflicts:
logger.debug("Resolving conflicts...")
self.resolve_conflicts(mod_merge.overlaps)
mod_merge.merge()

art_manager.build(self.new_mod_name_field.text())

except Exception as exc:
traceback_formatted = traceback.format_exc()
logger.debug(traceback_formatted)
raise RuntimeError(traceback_formatted) from exc

def validate_new_mod_name(self):
""" Validates the mod name to be correct so that later a directory can be created with that name """
if re.match('^[a-zA-Z0-9_-]+$', self.new_mod_name_field.text()):
return True

return False
108 changes: 108 additions & 0 deletions src/gui/windows/conflict_resolution_window.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
"""
The code for all the windows that TQMA has
"""
import logging
import os

from PyQt6.QtWidgets import QWidget, QLabel, QPushButton, QHBoxLayout, QGroupBox, QGridLayout
from PyQt6.QtCore import Qt

from src.dbr_lib import parse_dbr


logger = logging.getLogger("tqma")


class ConflictResolutionWindow(QWidget):
""" Settings window to configure build/working paths, mod paths etc. """
def __init__(self, overlaps):
super().__init__()
self.currently_processed_overlap = None
self.currently_processed_key = None
self.number_of_conflicts_left = 0

# The layout
layout = QGridLayout(self)
layout.setSpacing(15)
self.setLayout(layout)

# Make a duplicate list of objects because we will be popping them
self.overlaps = overlaps.copy()
for overlap in self.overlaps:
if not overlap.conflicting_keys:
self.overlaps.remove(overlap)

# Count total number of conflicts
for overlap in self.overlaps:
self.number_of_conflicts_left += len(overlap.conflicting_keys)

# Start the conflict resolution right from init
self.resolve_next_overlap()

def clear_layout(self, layout):
""" Clears layout """
if layout is not None:
while layout.count():
child = layout.takeAt(0)
if child.widget() is not None:
child.widget().deleteLater()
elif child.layout() is not None:
self.clear_layout(child.layout())

def process_resolution_choice(self, choice):
""" This method is called when user presses the button for one of conflict value choices """
logger.debug(f"Resolution choice: {choice}")
self.currently_processed_overlap.resolved[self.currently_processed_key] = choice
self.clear_layout(self.layout())
self.number_of_conflicts_left -= 1
self.resolve_next_overlap()

def resolve_next_overlap(self):
"""
Pops next overlap from overlaps and runs conflict resolution for it
If there are no overlaps left the conflict resolution window is closed
"""
if not self.overlaps and not self.currently_processed_overlap.conflicting_keys:
logger.debug("No overlaps or conflicting keys left to process, closing")
self.close()
return

if not self.currently_processed_overlap or not self.currently_processed_overlap.conflicting_keys:
self.currently_processed_overlap = self.overlaps.pop()

self.currently_processed_key = self.currently_processed_overlap.conflicting_keys.pop()
self.run_conflict_resolution(self.currently_processed_overlap, self.currently_processed_key)

def run_conflict_resolution(self, overlap, conflicting_key):
""" Takes a single overlap, generates widgets with choices for conflicting key(s) and stores user input """
group_box_x_position = 2

self.setWindowTitle(f"TQMA conflicts left: {self.number_of_conflicts_left}")
logger.debug(
f"Processing {overlap.dbr_relpath} {conflicting_key}, conflicts left: {self.number_of_conflicts_left}"
)

conflict_dbr_info_label = QLabel(f"Resolving conflict for dbr {overlap.dbr_relpath}", self)
self.layout().addWidget(conflict_dbr_info_label, 0, 0, 2, 1, alignment=Qt.AlignmentFlag.AlignTop)
conflict_key_info_label = QLabel(f"Conflicting key: {conflicting_key}", self)
self.layout().addWidget(conflict_key_info_label, 1, 0, 2, 1, alignment=Qt.AlignmentFlag.AlignTop)

for mod in overlap.mods:
group_box = QGroupBox(self)
group_box_layout = QHBoxLayout()
group_box_layout.setSpacing(10)
group_box.setLayout(group_box_layout)

dbr_data = parse_dbr(os.path.join(mod.path, overlap.dbr_relpath))
# label with mod name
mod_name_label = QLabel(f"Mod name: {mod.name}", self)
group_box.layout().addWidget(mod_name_label)
# button with attribute value
value_button = QPushButton(f"{dbr_data[conflicting_key]}")
value_button.clicked.connect(
lambda checked, choice=dbr_data[conflicting_key]: self.process_resolution_choice(choice)
)
group_box.layout().addWidget(value_button)

self.layout().addWidget(group_box, group_box_x_position, 0, 2, 1, alignment=Qt.AlignmentFlag.AlignTop)
group_box_x_position += 1
1 change: 0 additions & 1 deletion src/gui/windows/main_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,6 @@ def show_frame_by_name(self, frame_name):
for frame in self.frames:
frame.hide()

#frame_classes = (ModMergeFrame, ModManagerFrame)
frame_to_show = self.findChild(QWidget, name=frame_name)
logger.debug(f"Found frame: {frame_to_show}")
if frame_to_show:
Expand Down
Loading

0 comments on commit 8982fcf

Please sign in to comment.