diff --git a/korman/addon_prefs.py b/korman/addon_prefs.py index 5d9252b..5bcc5a5 100644 --- a/korman/addon_prefs.py +++ b/korman/addon_prefs.py @@ -33,6 +33,23 @@ class PlasmaGame(bpy.types.PropertyGroup): items=game_versions, options=set()) + player = StringProperty(name="Player", + description="Name of the player to use when launching the game", + options=set()) + ki = IntProperty(name="KI", + description="KI Number of the player to use when launching the game", + options=set(), min=0) + serverini = StringProperty(name="Server INI", + description="Name of the server configuation to use when launching the game", + options=set()) + + @property + def can_launch(self): + if self.version == "pvMoul": + return self.is_property_set("ki") and self.ki + else: + return self.is_property_set("player") and bool(self.player.strip()) + class KormanAddonPreferences(bpy.types.AddonPreferences): bl_idname = __package__ @@ -120,14 +137,23 @@ class KormanAddonPreferences(bpy.types.AddonPreferences): col.label("Game Configuration:") box = col.box().column() - box.prop(active_game, "path", emboss=False) - box.prop(active_game, "version") - box.separator() - row = box.row(align=True) - op = row.operator("world.plasma_game_add", icon="FILE_FOLDER", text="Change Path") + row.prop(active_game, "path") + op = row.operator("world.plasma_game_add", icon="FILE_FOLDER", text="") op.filepath = active_game.path op.game_index = active_game_index + box.prop(active_game, "version") + + box.separator() + box.separator() + if active_game.version == "pvMoul": + box.alert = not active_game.is_property_set("ki") + box.prop(active_game, "ki", slider=False) + box.alert = False + box.prop(active_game, "serverini") + else: + box.alert = not active_game.is_property_set("player") + box.prop(active_game, "player") # Python Installs assert self.python_validated diff --git a/korman/exporter/__init__.py b/korman/exporter/__init__.py index 60123e0..d9dee54 100644 --- a/korman/exporter/__init__.py +++ b/korman/exporter/__init__.py @@ -19,5 +19,6 @@ from PyHSPlasma import * from .convert import * from .explosions import * from .locman import * +from .logger import * from .python import * from . import utils diff --git a/korman/exporter/explosions.py b/korman/exporter/explosions.py index 29102b1..63e9774 100644 --- a/korman/exporter/explosions.py +++ b/korman/exporter/explosions.py @@ -42,6 +42,16 @@ class ExportAssertionError(ExportError): super(ExportError, self).__init__("Assertion failed") +class PlasmaLaunchError(ExportError): + def __init__(self, *args, **kwargs): + if not args: + super(Exception, self).__init__("Failed to start Plasma") + elif len(args) > 1: + super(Exception, self).__init__(args[0].format(*args[1:], **kwargs)) + else: + super(Exception, self).__init__(args[0]) + + class TooManyUVChannelsError(ExportError): def __init__(self, obj, mat, numUVTexs, maxUVTexCount=8): msg = "There are too many UV Textures on the material '{}' associated with object '{}'. You can have at most {} (there are {})".format( diff --git a/korman/exporter/logger.py b/korman/exporter/logger.py index 9089dee..7a91316 100644 --- a/korman/exporter/logger.py +++ b/korman/exporter/logger.py @@ -34,18 +34,18 @@ class _ExportLogger: self._time_start_overall = 0 def __enter__(self): - assert self._age_path is not None - - # Make the log file name from the age file path -- this ensures we're not trying to write - # the log file to the same directory Blender.exe is in, which might be a permission error - my_path = self._age_path.with_name("{}_export".format(self._age_path.stem)).with_suffix(".log") - self._file = open(str(my_path), "w") + if self._age_path is not None: + # Make the log file name from the age file path -- this ensures we're not trying to write + # the log file to the same directory Blender.exe is in, which might be a permission error + my_path = self._age_path.with_name("{}_export".format(self._age_path.stem)).with_suffix(".log") + self._file = open(str(my_path), "w") return self def __exit__(self, type, value, traceback): if value is not None: ConsoleToggler().keep_console = not isinstance(value, NonfatalExportError) - self._file.close() + if self._file is not None: + self._file.close() return False def error(self, *args, **kwargs): diff --git a/korman/operators/op_export.py b/korman/operators/op_export.py index f5fa58e..7ccfa8b 100644 --- a/korman/operators/op_export.py +++ b/korman/operators/op_export.py @@ -19,11 +19,12 @@ import cProfile from pathlib import Path from PyHSPlasma import * import pstats +import subprocess from ..addon_prefs import game_versions from .. import exporter from ..helpers import UiHelper -from .. import korlib +from .. import korlib, plasma_launcher from ..properties.prop_world import PlasmaAge class ExportOperator: @@ -54,10 +55,6 @@ class PlasmaAgeExportOperator(ExportOperator, bpy.types.Operator): # over on the PlasmaAge world properties. We've got a helper so we can access them like they're actually on us... # If you want a volatile property, register it directly on this operator! _properties = { - "profile_export": (BoolProperty, {"name": "Profile", - "description": "Profiles the exporter using cProfile", - "default": False}), - "verbose": (BoolProperty, {"name": "Display Verbose Log", "description": "Shows the verbose export log in the console", "default": False}), @@ -119,19 +116,49 @@ class PlasmaAgeExportOperator(ExportOperator, bpy.types.Operator): default=True, options={"HIDDEN"}) + actions = EnumProperty(name="Actions", + description="Actions for the exporter to perform", + default={"EXPORT"}, + items=[("EXPORT", "Export", "Export the age data"), + ("PROFILE", "Profile", "Profile the exporter"), + ("LAUNCH", "Launch Age", "Launch the age in Plasma")], + options={"ENUM_FLAG"}) + + ki = IntProperty(name="KI", + description="KI Number of the player to use when launching the game", + options=set()) + + player = StringProperty(name="Player", + description="Name of the player to use when launching the game", + options=set()) + + serverini = StringProperty(name="Server INI", + description="Name of the server configuation to use when launching the game", + options=set()) + def draw(self, context): layout = self.layout age = context.scene.world.plasma_age # The crazy mess we're doing with props on the fly means we have to explicitly draw them :( + layout.prop(self, "actions") layout.prop(self, "version") + if "LAUNCH" in self.actions: + if self.version == "pvMoul": + layout.alert = not self.ki + layout.prop(self, "ki") + layout.alert = False + layout.prop(self, "serverini") + else: + layout.alert = not self.player.strip() + layout.prop(self, "player") + layout.alert = False layout.prop(age, "texcache_method", text="") layout.prop(age, "lighting_method") row = layout.row() row.enabled = korlib.ConsoleToggler.is_platform_supported() row.prop(age, "show_console") layout.prop(age, "verbose") - layout.prop(age, "profile_export") def __getattr__(self, attr): if attr in self._properties: @@ -146,6 +173,10 @@ class PlasmaAgeExportOperator(ExportOperator, bpy.types.Operator): def execute(self, context): # Before we begin, do some basic sanity checking... + if not self.actions: + self.report({"ERROR"}, "Nothing to do?") + return {"CANCELLED"} + path = Path(self.filepath) if not self.filepath: self.error = "No file specified" @@ -168,32 +199,48 @@ class PlasmaAgeExportOperator(ExportOperator, bpy.types.Operator): self.report({"ERROR"}, "The Age name conflicts with the Python keyword '{}'".format(ageName)) return {"CANCELLED"} - # Separate blender operator and actual export logic for my sanity - with UiHelper(context) as _ui: - e = exporter.Exporter(self) + # This prevents us from finding out at the very end that very, very bad things happened... + if "LAUNCH" in self.actions: try: - self.export_active = True - if self.profile_export: - profile_path = str(path.with_name("{}_cProfile".format(ageName))) - profile = cProfile.runctx("e.run()", globals(), locals(), profile_path) - else: - e.run() + self._sanity_check_run_plasma() except exporter.ExportError as error: self.report({"ERROR"}, str(error)) return {"CANCELLED"} - except exporter.NonfatalExportError as error: + + # Separate blender operator and actual export logic for my sanity + if "EXPORT" in self.actions: + with UiHelper(context) as _ui: + e = exporter.Exporter(self) + try: + self.export_active = True + if "PROFILE" in self.actions: + profile_path = str(path.with_name("{}_cProfile".format(ageName))) + profile = cProfile.runctx("e.run()", globals(), locals(), profile_path) + else: + e.run() + except exporter.ExportError as error: + self.report({"ERROR"}, str(error)) + return {"CANCELLED"} + except exporter.NonfatalExportError as error: + self.report({"ERROR"}, str(error)) + else: + if "PROFILE" in self.actions: + stats_out = path.with_name("{}_profile.log".format(ageName)) + with open(str(stats_out), "w") as out: + stats = pstats.Stats(profile_path, stream=out) + stats = stats.sort_stats("time", "calls") + stats.print_stats() + finally: + self.export_active = False + + if "LAUNCH" in self.actions: + try: + self._run_plasma(context) + except exporter.ExportError as error: self.report({"ERROR"}, str(error)) - return {"FINISHED"} - else: - if self.profile_export: - stats_out = path.with_name("{}_profile.log".format(ageName)) - with open(str(stats_out), "w") as out: - stats = pstats.Stats(profile_path, stream=out) - stats = stats.sort_stats("time", "calls") - stats.print_stats() - return {"FINISHED"} - finally: - self.export_active = False + return {"CANCELLED"} + + return {"FINISHED"} def invoke(self, context, event): # Called when a user hits "export" from the menu @@ -217,11 +264,52 @@ class PlasmaAgeExportOperator(ExportOperator, bpy.types.Operator): # Now do the majick setattr(PlasmaAge, name, prop(**age_options)) + def _sanity_check_run_plasma(self): + if not bpy.app.binary_path_python: + raise exporter.PlasmaLaunchError("Can't Launch Plasma: No Python executable available") + if self.version == "pvMoul": + if not self.ki: + raise exporter.PlasmaLaunchError("Can't Launch Plasma: Player KI not set") + else: + if not self.player: + raise exporter.PlasmaLaunchError("Can't Launch Plasma: Player Name not set") + + def _run_plasma(self, context): + path = Path(self.filepath) + client_dir = path.parent.parent + + # It would be nice to launch URU right here. Unfortunately, for single player URUs, we will + # need to actually wait for the whole rigamaroll to finish. Therefore, we need to kick + # open a separate python exe to launch URU and wait. + args = [bpy.app.binary_path_python, plasma_launcher.__file__, + str(client_dir), path.stem, self.version] + if self.version == "pvMoul": + if self.serverini: + args.append("--serverini") + args.append(self.serverini) + args.append(str(self.ki)) + else: + args.append(self.player) + + with exporter.ExportVerboseLogger() as log: + proc = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + cwd=str(client_dir), universal_newlines=True) + while True: + line = proc.stdout.readline().strip() + if line == "DIE": + raise exporter.PlasmaLaunchError(proc.stderr.read().strip()) + elif line in {"PLASMA_RUNNING", "DONE"}: + break + elif proc.returncode is not None: + break + elif line: + log.msg(line) + class PlasmaLocalizationExportOperator(ExportOperator, bpy.types.Operator): bl_idname = "export.plasma_loc" bl_label = "Export Localization" - bl_description = "Export Age Localization Data" + bl_description = "Export Age localization data" filepath = StringProperty(subtype="DIR_PATH") filter_glob = StringProperty(default="*.pak", options={'HIDDEN'}) @@ -268,8 +356,8 @@ class PlasmaLocalizationExportOperator(ExportOperator, bpy.types.Operator): class PlasmaPythonExportOperator(ExportOperator, bpy.types.Operator): bl_idname = "export.plasma_pak" - bl_label = "Package Scripts" - bl_description = "Package Age Python scripts" + bl_label = "Export Python Scripts" + bl_description = "Export Age python script package" filepath = StringProperty(subtype="FILE_PATH") filter_glob = StringProperty(default="*.pak", options={'HIDDEN'}) diff --git a/korman/plasma_launcher.py b/korman/plasma_launcher.py new file mode 100644 index 0000000..4937b87 --- /dev/null +++ b/korman/plasma_launcher.py @@ -0,0 +1,201 @@ +# This file is part of Korman. +# +# Korman is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Korman is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Korman. If not, see . + +import argparse +from pathlib import Path +from PyHSPlasma import * +import shutil +import subprocess +import sys +import time +import traceback + +main_parser = argparse.ArgumentParser(description="Korman Plasma Launcher") +main_parser.add_argument("cwd", type=Path, help="Working directory of the client") +main_parser.add_argument("age", type=str, help="Name of the age to launch into") + +sub_parsers = main_parser.add_subparsers(title="Plasma Version", dest="version",) +moul_parser = sub_parsers.add_parser("pvMoul") +moul_parser.add_argument("ki", type=int, help="KI Number of the desired player") +moul_parser.add_argument("--serverini", type=str, default="server.ini") +sp_parser = sub_parsers.add_parser("pvPots", aliases=["pvPrime"]) +sp_parser.add_argument("player", type=str, help="Name of the desired player") + +autolink_chron_name = "OfflineKIAutoLink" + +if sys.platform == "win32": + client_executables = { + "pvMoul": "plClient.exe", + "pvPots": "UruExplorer.exe" + } +else: + client_executables = { + "pvMoul": "plClient", + "pvPots": "UruExplorer" + } + +def die(*args, **kwargs): + assert args + if len(args) == 1 and not kwargs: + sys.stderr.write(args[0]) + else: + sys.stderr.write(args[0].format(*args[1:], **kwargs)) + sys.stdout.write("DIE\n") + sys.exit(1) + +def write(*args, **kwargs): + assert args + if len(args) == 1 and not kwargs: + sys.stdout.write(args[0]) + else: + sys.stdout.write(args[0].format(*args[1:], **kwargs)) + sys.stdout.write("\n") + # And this is why we aren't using print()... + sys.stdout.flush() + +def backup_vault_dat(path): + backup_path = path.with_suffix(".dat.korman_backup") + shutil.copy2(str(path), str(backup_path)) + write("DBG: Copied vault backup: {}", backup_path) + +def set_link_chronicle(store, new_value, cond_value=None): + chron_folder = next((i for i in store.getChildren(store.firstNodeID) + if getattr(i, "folderType", None) == plVault.kChronicleFolder), None) + if chron_folder is None: + die("Could not locate vault chronicle folder.") + autolink_chron = next((i for i in store.getChildren(chron_folder.nodeID) + if getattr(i, "entryName", None) == autolink_chron_name), None) + if autolink_chron is None: + write("DBG: Creating AutoLink chronicle...") + autolink_chron = plVaultChronicleNode() + autolink_chron.entryName = autolink_chron_name + previous_value = "" + store.addRef(chron_folder.nodeID, store.lastNodeID + 1) + else: + write("DBG: Found AutoLink chronicle...") + previous_value = autolink_chron.entryValue + + # Have to submit the changed node to the store + if cond_value is None or previous_value == cond_value: + write("DBG: AutoLink = '{}' (previously: '{}')", new_value, previous_value) + autolink_chron.entryValue = new_value + store.addNode(autolink_chron) + else: + write("DBG: ***Not*** changing chronicle! AutoLink = '{}' (expected: '{}')", previous_value, cond_value) + + return previous_value + +def find_player_vault(cwd, name): + sav_dir = cwd.joinpath("sav") + if not sav_dir.is_dir(): + die("Could not locate sav directory.") + for i in sav_dir.iterdir(): + if not i.is_dir(): + continue + current_dir = i.joinpath("current") + if not current_dir.is_dir(): + continue + vault_dat = current_dir.joinpath("vault.dat") + if not vault_dat.is_file(): + continue + + store = plVaultStore() + store.Import(str(vault_dat)) + + # First node is the Player node... + playerNode = store[store.firstNodeID] + if playerNode.playerName == name: + write("DBG: Vault found: {}", vault_dat) + return vault_dat, store + die("Could not locate the requested player vault.") + +def main(): + print("DBG: alive") + args = main_parser.parse_args() + + executable = args.cwd.joinpath(client_executables[args.version]) + if not executable.is_file(): + die("Failed to locate client executable.") + + # Have to find and mod the single player vault... + if args.version == "pvPots": + vault_path, vault_store = find_player_vault(args.cwd, args.player) + backup_vault_dat(vault_path) + vault_prev_autolink = set_link_chronicle(vault_store, args.age) + write("DBG: Saving vault...") + vault_store.Export(str(vault_path)) + + # Update init file for this schtuff... + init_path = args.cwd.joinpath("init", "net_age.fni") + with plEncryptedStream().open(str(init_path), fmWrite, plEncryptedStream.kEncXtea) as ini: + ini.writeLine("# This file was automatically generated by Korman.") + ini.writeLine("Nav.PageInHoldList GlobalAnimations") + ini.writeLine("Net.SetPlayer {}".format(vault_store.firstNodeID)) + ini.writeLine("Net.SetPlayerByName \"{}\"".format(args.player)) + # BUT WHY??? You ask... + # Because, sayeth Hoikas, if this command is not executed, you will remain ensconsed + # in the black void of the Link... forever... Sadly, it accepts no arguments and determines + # whether to link to AvatarCustomization, Cleft, Demo (whee!), or Personal all by itself. + ini.writeLine("Net.JoinDefaultAge") + + # When URU runs, the player may change the vault. Remove any temptation to play with + # the stale vault... + del vault_store + + # Sigh... + time.sleep(1.0) + + # EXE args + plasma_args = [str(executable), "-iinit", "To_Dni"] + else: + write("DBG: Using a superior client :) :) :)") + plasma_args = [str(executable), "-LocalData", "-SkipLoginDialog", "-ServerIni={}".format(args.serverini), + "-PlayerId={}".format(args.ki), "-Age={}".format(args.age)] + try: + proc = subprocess.Popen(plasma_args, cwd=str(args.cwd), shell=True) + + # signal everything is a-ok -- causes blender to detach + write("PLASMA_RUNNING") + + # Wait for things to finish + proc.wait() + finally: + # Restore sp vault, if needed. + if args.version == "pvPots": + # Path of the Shell seems to have some sort of weird racing with the vault.dat around + # shutdown. This delay helps somewhat in that regard. + time.sleep(1.0) + + vault_store = plVaultStore() + vault_store.Import(str(vault_path)) + new_prev_autolink = set_link_chronicle(vault_store, vault_prev_autolink, args.age) + if new_prev_autolink != args.age: + write("DBG: ***Not*** resaving the vault!") + else: + write("DBG: Resaving vault...") + vault_store.Export(str(vault_path)) + + # All good! + write("DONE") + sys.exit(0) + +if __name__ == "__main__": + try: + main() + except Exception as e: + if isinstance(e, SystemExit): + raise + else: + die(traceback.format_exc()) diff --git a/korman/ui/ui_world.py b/korman/ui/ui_world.py index 220466c..5443248 100644 --- a/korman/ui/ui_world.py +++ b/korman/ui/ui_world.py @@ -45,7 +45,7 @@ class PlasmaGameHelper: if active_game is None: return "" age_name = bpy.context.world.plasma_age.age_name - return str((Path(active_game.path) / dirname / age_name).with_suffix(ext)) + return Path(active_game.path, dirname, age_name).with_suffix(ext) @property def legal_game(self): @@ -55,6 +55,7 @@ class PlasmaGameHelper: class PlasmaGameExportMenu(PlasmaGameHelper, bpy.types.Menu): bl_label = "Plasma Export Menu" + bl_description = "Additional export methods" def draw(self, context): layout = self.layout @@ -62,7 +63,7 @@ class PlasmaGameExportMenu(PlasmaGameHelper, bpy.types.Menu): active_game = self.active_game legal_game = self.legal_game - # Localization + # Export Localization row = layout.row() row.operator_context = "EXEC_DEFAULT" row.enabled = legal_game @@ -71,15 +72,41 @@ class PlasmaGameExportMenu(PlasmaGameHelper, bpy.types.Menu): op.filepath = active_game.path op.version = active_game.version - # Python + # Export Python row = layout.row() row.operator_context = "EXEC_DEFAULT" row.enabled = legal_game and active_game.version != "pvMoul" op = row.operator("export.plasma_pak", icon="FILE_SCRIPT") - op.filepath = self.format_path("Python", ".pak") + op.filepath = str(self.format_path("Python", ".pak")) if active_game is not None: op.version = active_game.version + # Launch Age + row = layout.row() + row.operator_context = "EXEC_DEFAULT" + age_path = self.format_path() + row.active = legal_game and active_game.can_launch and age_path.exists() + op = row.operator("export.plasma_age", icon="RENDER_ANIMATION", text="Launch Age") + if active_game is not None: + op.actions = {"LAUNCH"} + op.dat_only = False + op.filepath = str(age_path) + op.version = active_game.version + op.player = active_game.player + op.ki = active_game.ki + op.serverini = active_game.serverini + + # Package Age + row = layout.row() + row.operator_context = "INVOKE_DEFAULT" + row.enabled = legal_game + op = row.operator("export.plasma_age", icon="PACKAGE", text="Package Age") + if active_game is not None: + op.actions = {"EXPORT"} + op.dat_only = False + op.filepath = "{}.zip".format(age_name) + op.version = active_game.version + class PlasmaGamePanel(AgeButtonsPanel, PlasmaGameHelper, bpy.types.Panel): bl_label = "Plasma Games" @@ -106,22 +133,29 @@ class PlasmaGamePanel(AgeButtonsPanel, PlasmaGameHelper, bpy.types.Panel): row.enabled = legal_game op = row.operator("export.plasma_age", icon="EXPORT") if active_game is not None: + op.actions = {"EXPORT"} op.dat_only = False - op.filepath = self.format_path() + op.filepath = str(self.format_path()) op.version = active_game.version - # Package Age + # Test Age (exports and tests the age) row = row.row(align=True) + row.operator_context = "EXEC_DEFAULT" + # Sadly, if we nuke this row, the menu is nuked as well... row.enabled = legal_game - row.operator_context = "INVOKE_DEFAULT" - op = row.operator("export.plasma_age", icon="PACKAGE", text="Package Age") - op.dat_only = False - op.filepath = "{}.zip".format(age.age_name) + op = row.operator("export.plasma_age", icon="RENDER_ANIMATION", text="Test Age") if active_game is not None: + op.actions = {"EXPORT", "LAUNCH"} + op.dat_only = False + op.filepath = str(self.format_path()) op.version = active_game.version + op.player = active_game.player + op.ki = active_game.ki + op.serverini = active_game.serverini # Special Menu row = row.row(align=True) + row.enabled = True row.menu("PlasmaGameExportMenu", icon='DOWNARROW_HLT', text="")