diff --git a/installer/Installer.nsi b/installer/Installer.nsi
index 8b96969..c172dcd 100644
--- a/installer/Installer.nsi
+++ b/installer/Installer.nsi
@@ -211,6 +211,14 @@ SectionGroup /e "Korman"
SectionEnd
SectionGroupEnd
+Section "Python 2.2"
+ SectionIn 1 2
+
+ SetOutPath "$TEMP\Korman"
+ File "Files\x86\Python-2.2.3.exe"
+ ExecWait "$TEMP\Korman\Python-2.2.3.exe /S"
+SectionEnd
+
Section #TheRemover
WriteRegStr HKLM "Software\Korman" "" $INSTDIR
WriteUninstaller "$INSTDIR\korman_uninstall.exe"
diff --git a/korman/__init__.py b/korman/__init__.py
index f7b0df8..824cfd8 100644
--- a/korman/__init__.py
+++ b/korman/__init__.py
@@ -14,6 +14,7 @@
# along with Korman. If not, see .
import bpy
+from . import addon_prefs
from . import exporter, render
from . import properties, ui
from . import nodes
@@ -26,8 +27,7 @@ bl_info = {
"location": "File > Import-Export",
"description": "Exporter for Cyan Worlds' Plasma Engine",
"warning": "beta",
- "category": "System", # Eventually, we will hide some of the default
- # Blender panels (think materials)
+ "category": "System",
}
diff --git a/korman/addon_prefs.py b/korman/addon_prefs.py
new file mode 100644
index 0000000..5d9252b
--- /dev/null
+++ b/korman/addon_prefs.py
@@ -0,0 +1,148 @@
+# 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 bpy
+from bpy.props import *
+from . import korlib
+
+game_versions = [("pvPrime", "Ages Beyond Myst (63.11)", "Targets the original Uru (Live) game"),
+ ("pvPots", "Path of the Shell (63.12)", "Targets the most recent offline expansion pack"),
+ ("pvMoul", "Myst Online: Uru Live (70)", "Targets the most recent online game")]
+
+class PlasmaGame(bpy.types.PropertyGroup):
+ name = StringProperty(name="Name",
+ description="Name of the Plasma Game",
+ options=set())
+ path = StringProperty(name="Path",
+ description="Path to this Plasma Game",
+ options=set())
+ version = EnumProperty(name="Version",
+ description="Plasma version of this game",
+ items=game_versions,
+ options=set())
+
+
+class KormanAddonPreferences(bpy.types.AddonPreferences):
+ bl_idname = __package__
+
+ games = CollectionProperty(type=PlasmaGame)
+ active_game_index = IntProperty(options={"SKIP_SAVE"})
+
+ def _check_py22_exe(self, context):
+ if self._ensure_abspath((2, 2)):
+ self._check_python((2, 2))
+ def _check_py23_exe(self, context):
+ if self._ensure_abspath((2, 3)):
+ self._check_python((2, 3))
+ def _check_py27_exe(self, context):
+ if self._ensure_abspath((2, 7)):
+ self._check_python((2, 7))
+
+ python22_executable = StringProperty(name="Python 2.2",
+ description="Path to the Python 2.2 executable",
+ options=set(),
+ subtype="FILE_PATH",
+ update=_check_py22_exe)
+ python23_executable = StringProperty(name="Python 2.3",
+ description="Path to the Python 2.3 executable",
+ options=set(),
+ subtype="FILE_PATH",
+ update=_check_py23_exe)
+ python27_executable = StringProperty(name="Python 2.7",
+ description="Path to the Python 2.7 executable",
+ options=set(),
+ subtype="FILE_PATH",
+ update=_check_py27_exe)
+
+ def _validate_py_exes(self):
+ if not self.is_property_set("python22_valid"):
+ self._check_python((2, 2))
+ if not self.is_property_set("python23_valid"):
+ self._check_python((2, 3))
+ if not self.is_property_set("python27_valid"):
+ self._check_python((2, 7))
+ return True
+
+ # Internal error states
+ python22_valid = BoolProperty(options={"HIDDEN", "SKIP_SAVE"})
+ python23_valid = BoolProperty(options={"HIDDEN", "SKIP_SAVE"})
+ python27_valid = BoolProperty(options={"HIDDEN", "SKIP_SAVE"})
+ python_validated = BoolProperty(get=_validate_py_exes, options={"HIDDEN", "SKIP_SAVE"})
+
+ def _check_python(self, py_version):
+ py_exe = getattr(self, "python{}{}_executable".format(*py_version))
+ if py_exe:
+ valid = korlib.verify_python(py_version, py_exe)
+ else:
+ valid = True
+ setattr(self, "python{}{}_valid".format(*py_version), valid)
+
+ def _ensure_abspath(self, py_version):
+ attr = "python{}{}_executable".format(*py_version)
+ path = getattr(self, attr)
+ if path.startswith("//"):
+ setattr(self, attr, bpy.path.abspath(path))
+ return False
+ return True
+
+ def draw(self, context):
+ layout = self.layout
+ split = layout.split()
+ main_col = split.column()
+
+ main_col.label("Plasma Games:")
+ row = main_col.row()
+ row.template_list("PlasmaGameListRW", "games", self, "games", self,
+ "active_game_index", rows=3)
+ col = row.column(align=True)
+ col.operator("world.plasma_game_add", icon="ZOOMIN", text="")
+ col.operator("world.plasma_game_remove", icon="ZOOMOUT", text="")
+ col.operator("world.plasma_game_convert", icon="IMPORT", text="")
+
+ # Game Properties
+ active_game_index = self.active_game_index
+ if bool(self.games) and active_game_index < len(self.games):
+ active_game = self.games[active_game_index]
+
+ col = split.column()
+ 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")
+ op.filepath = active_game.path
+ op.game_index = active_game_index
+
+ # Python Installs
+ assert self.python_validated
+ col = layout.column()
+ col.label("Python Executables:")
+ col.alert = not self.python22_valid
+ col.prop(self, "python22_executable")
+ col.alert = not self.python23_valid
+ col.prop(self, "python23_executable")
+ col.alert = not self.python27_valid
+ col.prop(self, "python27_executable")
+
+ @classmethod
+ def register(cls):
+ # Register the old-timey per-world Plasma Games for use in the conversion
+ # operator. What fun. I guess....
+ from .properties.prop_world import PlasmaGames
+ PlasmaGames.games = CollectionProperty(type=PlasmaGame)
diff --git a/korman/exporter/__init__.py b/korman/exporter/__init__.py
index 0ec72c5..e8652a8 100644
--- a/korman/exporter/__init__.py
+++ b/korman/exporter/__init__.py
@@ -18,4 +18,5 @@ from PyHSPlasma import *
from .convert import *
from .explosions import *
+from .python import *
from . import utils
diff --git a/korman/exporter/convert.py b/korman/exporter/convert.py
index 7dc9ae8..a55e18d 100644
--- a/korman/exporter/convert.py
+++ b/korman/exporter/convert.py
@@ -27,9 +27,9 @@ from . import image
from . import logger
from . import manager
from . import mesh
+from . import outfile
from . import physics
from . import rtlight
-from . import sumfile
from . import utils
class Exporter:
@@ -40,10 +40,6 @@ class Exporter:
self.node_trees_exported = set()
self.want_node_trees = {}
- @property
- def age_name(self):
- return Path(self._op.filepath).stem
-
def run(self):
log = logger.ExportVerboseLogger if self._op.verbose else logger.ExportProgressLogger
with ConsoleToggler(self._op.show_console), log(self._op.filepath) as self.report:
@@ -53,7 +49,7 @@ class Exporter:
self.physics = physics.PhysicsConverter(self)
self.light = rtlight.LightConverter(self)
self.animation = animation.AnimationConverter(self)
- self.sumfile = sumfile.SumFile()
+ self.output = outfile.OutputFiles(self, self._op.filepath)
self.camera = camera.CameraConverter(self)
self.image = image.ImageCache(self)
@@ -66,6 +62,7 @@ class Exporter:
self.report.progress_add_step("Exporting Scene Objects")
self.report.progress_add_step("Exporting Logic Nodes")
self.report.progress_add_step("Finalizing Plasma Logic")
+ self.report.progress_add_step("Handling Snakes")
self.report.progress_add_step("Exporting Textures")
self.report.progress_add_step("Composing Geometry")
self.report.progress_add_step("Saving Age Files")
@@ -98,6 +95,9 @@ class Exporter:
# processing that needs to inspect those objects
self._post_process_scene_objects()
+ # Step 3.3: Ensure any helper Python files are packed
+ self._pack_ancillary_python()
+
# Step 4: Finalize...
self.mesh.material.finalize()
self.mesh.finalize()
@@ -111,6 +111,11 @@ class Exporter:
self.report.progress_end()
self.report.save()
+ # Step 5.2: If any nonfatal errors were encountered during the export, we will
+ # raise them here, now that everything is finished, to draw attention
+ # to whatever the problem might be.
+ self.report.raise_errors()
+
def _bake_static_lighting(self):
lighting_method = self._op.lighting_method
if lighting_method != "skip":
@@ -345,15 +350,45 @@ class Exporter:
proc(self, bl_obj, sceneobject)
inc_progress()
+ def _pack_ancillary_python(self):
+ texts = bpy.data.texts
+ self.report.progress_advance()
+ self.report.progress_range = len(texts)
+ inc_progress = self.report.progress_increment
+
+ for i in texts:
+ if i.name.endswith(".py") and self.output.want_py_text(i):
+ self.output.add_python_code(i.name, text_id=i)
+ inc_progress()
+
def _save_age(self):
self.report.progress_advance()
- self.mgr.save_age(Path(self._op.filepath))
- self.image.save()
+ self.report.msg("\nWriting Age data...")
+
+ # If something bad happens in the final flush, it would be a shame to
+ # simply toss away the potentially freshly regenerated texture cache.
+ try:
+ self.mgr.save_age()
+ self.output.save()
+ finally:
+ self.image.save()
+
+ @property
+ def age_name(self):
+ return Path(self._op.filepath).stem
+
+ @property
+ def dat_only(self):
+ return self._op.dat_only
@property
def envmap_method(self):
return bpy.context.scene.world.plasma_age.envmap_method
+ @property
+ def python_method(self):
+ return bpy.context.scene.world.plasma_age.python_method
+
@property
def texcache_path(self):
age = bpy.context.scene.world.plasma_age
diff --git a/korman/exporter/explosions.py b/korman/exporter/explosions.py
index 331c173..48151e3 100644
--- a/korman/exporter/explosions.py
+++ b/korman/exporter/explosions.py
@@ -13,6 +13,15 @@
# You should have received a copy of the GNU General Public License
# along with Korman. If not, see .
+class NonfatalExportError(Exception):
+ def __init__(self, *args, **kwargs):
+ assert args
+ if len(args) > 1:
+ super(Exception, self).__init__(args[0].format(*args[1:], **kwargs))
+ else:
+ super(Exception, self).__init__(args[0])
+
+
class ExportError(Exception):
def __init__(self, value="Undefined Export Error"):
super(Exception, self).__init__(value)
diff --git a/korman/exporter/logger.py b/korman/exporter/logger.py
index 7d4e232..98f96ec 100644
--- a/korman/exporter/logger.py
+++ b/korman/exporter/logger.py
@@ -14,6 +14,7 @@
# along with Korman. If not, see .
from ..korlib import ConsoleToggler
+from .explosions import NonfatalExportError
from pathlib import Path
import threading
import time
@@ -24,6 +25,7 @@ _MAX_TIME_UNTIL_ELIPSES = 2.0
class _ExportLogger:
def __init__(self, print_logs, age_path=None):
+ self._errors = []
self._porting = []
self._warnings = []
self._age_path = Path(age_path) if age_path is not None else None
@@ -42,10 +44,23 @@ class _ExportLogger:
def __exit__(self, type, value, traceback):
if value is not None:
- ConsoleToggler().keep_console = True
+ ConsoleToggler().keep_console = not isinstance(value, NonfatalExportError)
self._file.close()
return False
+ def error(self, *args, **kwargs):
+ assert args
+ indent = kwargs.get("indent", 0)
+ msg = "{}ERROR: {}".format(" " * indent, args[0])
+ if len(args) > 1:
+ msg = msg.format(*args[1:], **kwargs)
+ if self._file is not None:
+ self._file.writelines((msg, "\n"))
+ if self._print_logs:
+ print(msg)
+ cache = args[0] if len(args) == 1 else args[0].format(*args[1:])
+ self._errors.append(cache)
+
def msg(self, *args, **kwargs):
assert args
indent = kwargs.get("indent", 0)
@@ -67,7 +82,8 @@ class _ExportLogger:
self._file.writelines((msg, "\n"))
if self._print_logs:
print(msg)
- self._porting.append(args[0])
+ cache = args[0] if len(args) == 1 else args[0].format(*args[1:])
+ self._porting.append(cache)
def progress_add_step(self, name):
@@ -92,6 +108,14 @@ class _ExportLogger:
self.msg("Exporting '{}'", self._age_path.name)
self._time_start_overall = time.perf_counter()
+ def raise_errors(self):
+ num_errors = len(self._errors)
+ if num_errors == 1:
+ raise NonfatalExportError(self._errors[0])
+ elif num_errors:
+ raise NonfatalExportError("""{} errors were encountered during export. Check the export log for more details:
+ {}""", num_errors, self._file.name)
+
def save(self):
# TODO
pass
@@ -106,7 +130,8 @@ class _ExportLogger:
self._file.writelines((msg, "\n"))
if self._print_logs:
print(msg)
- self._warnings.append(args[0])
+ cache = args[0] if len(args) == 1 else args[0].format(*args[1:])
+ self._warnings.append(cache)
class ExportProgressLogger(_ExportLogger):
diff --git a/korman/exporter/manager.py b/korman/exporter/manager.py
index 6ff74b3..380caa6 100644
--- a/korman/exporter/manager.py
+++ b/korman/exporter/manager.py
@@ -19,6 +19,8 @@ from PyHSPlasma import *
import weakref
from . import explosions
+from .. import korlib
+from ..plasma_magic import *
# These objects have to be in the plSceneNode pool in order to be loaded...
# NOTE: We are using Factory indices because I doubt all of these classes are implemented.
@@ -136,15 +138,19 @@ class ExportManager:
def create_builtins(self, age, textures):
# BuiltIn.prp
if bpy.context.scene.world.plasma_age.age_sdl:
- builtin = self.create_page(age, "BuiltIn", -2, True)
- sdl = self.add_object(plSceneObject, name="AgeSDLHook", loc=builtin)
- pfm = self.add_object(plPythonFileMod, name="VeryVerySpecialPythonFileMod", so=sdl)
- pfm.filename = age
+ self._create_builtin_pages(age)
+ self._pack_agesdl_hook(age)
# Textures.prp
if textures:
self.create_page(age, "Textures", -1, True)
+ def _create_builtin_pages(self, age):
+ builtin = self.create_page(age, "BuiltIn", -2, True)
+ sdl = self.add_object(plSceneObject, name="AgeSDLHook", loc=builtin)
+ pfm = self.add_object(plPythonFileMod, name="VeryVerySpecialPythonFileMod", so=sdl)
+ pfm.filename = age
+
def create_page(self, age, name, id, builtin=False):
location = plLocation(self.mgr.getVer())
location.prefix = bpy.context.scene.world.plasma_age.seq_prefix
@@ -233,27 +239,46 @@ class ExportManager:
else:
return key.location
- def save_age(self, path):
- ageName = path.stem
- sumfile = self._exporter().sumfile
+ def _pack_agesdl_hook(self, age):
+ get_text = bpy.data.texts.get
+ output = self._exporter().output
- sumfile.append(path)
- self.mgr.WriteAge(str(path), self._age_info)
- self._write_fni(path)
- self._write_pages(path)
+ # AgeSDL Hook Python
+ fixed_agename = korlib.replace_python2_identifier(age)
+ py_filename = "{}.py".format(fixed_agename)
+ age_py = get_text(py_filename, None)
+ if output.want_py_text(age_py):
+ py_code = age_py.as_string()
+ else:
+ py_code = very_very_special_python.format(age_name=fixed_agename).lstrip()
+ output.add_python_mod(py_filename, text_id=age_py, str_data=py_code)
+
+ # AgeSDL
+ sdl_filename = "{}.sdl".format(fixed_agename)
+ age_sdl = get_text(sdl_filename)
+ if age_sdl is not None:
+ sdl_code = None
+ else:
+ sdl_code = very_very_special_sdl.format(age_name=fixed_agename).lstrip()
+ output.add_sdl(sdl_filename, text_id=age_sdl, str_data=sdl_code)
- if self.getVer() != pvMoul:
- sumpath = path.with_suffix(".sum")
- sumfile.write(sumpath, self.getVer())
+ def save_age(self):
+ self._write_age()
+ self._write_fni()
+ self._write_pages()
- def _write_fni(self, path):
- if self.mgr.getVer() <= pvMoul:
- enc = plEncryptedStream.kEncXtea
- else:
- enc = plEncryptedStream.kEncAES
- fname = path.with_suffix(".fni")
+ def _write_age(self):
+ f = "{}.age".format(self._age_info.name)
+ output = self._exporter().output
- with plEncryptedStream(self.mgr.getVer()).open(str(fname), fmWrite, enc) as stream:
+ with output.generate_dat_file(f, enc=plEncryptedStream.kEncAuto) as stream:
+ self._age_info.writeToStream(stream)
+
+ def _write_fni(self):
+ f = "{}.fni".format(self._age_info.name)
+ output = self._exporter().output
+
+ with output.generate_dat_file(f, enc=plEncryptedStream.kEncAuto) as stream:
fni = bpy.context.scene.world.plasma_fni
stream.writeLine("Graphics.Renderer.SetClearColor {} {} {}".format(*fni.clear_color))
if fni.fog_method != "none":
@@ -263,17 +288,14 @@ class ExportManager:
elif fni.fog_method == "exp2":
stream.writeLine("Graphics.Renderer.Fog.SetDefExp2 {} {}".format(fni.fog_end, fni.fog_density))
stream.writeLine("Graphics.Renderer.SetYon {}".format(fni.yon))
- self._exporter().sumfile.append(fname)
- def _write_pages(self, path):
+ def _write_pages(self):
+ age_name = self._age_info.name
+ output = self._exporter().output
for loc in self._pages.values():
page = self.mgr.FindPage(loc) # not cached because it's C++ owned
- # I know that plAgeInfo has its own way of doing this, but we'd have
- # to do some looping and stuff. This is easier.
- if self.mgr.getVer() <= pvMoul:
- chapter = "_District_"
- else:
- chapter = "_"
- f = path.with_name("{}{}{}".format(path.stem, chapter, page.page)).with_suffix(".prp")
- self.mgr.WritePage(str(f), page)
- self._exporter().sumfile.append(f)
+ chapter = "_District_" if self.mgr.getVer() <= pvMoul else "_"
+ f = "{}{}{}.prp".format(age_name, chapter, page.page)
+
+ with output.generate_dat_file(f) as stream:
+ self.mgr.WritePage(stream, page)
diff --git a/korman/exporter/outfile.py b/korman/exporter/outfile.py
new file mode 100644
index 0000000..1842850
--- /dev/null
+++ b/korman/exporter/outfile.py
@@ -0,0 +1,429 @@
+# 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 .
+
+from contextlib import contextmanager
+import enum
+from hashlib import md5
+from .. import korlib
+import locale
+import os
+from pathlib import Path
+from ..plasma_magic import plasma_python_glue
+from PyHSPlasma import *
+import shutil
+import time
+import weakref
+import zipfile
+
+_CHUNK_SIZE = 0xA00000
+_encoding = locale.getpreferredencoding(False)
+
+def _hashfile(filename, hasher, block=0xFFFF):
+ with open(str(filename), "rb") as handle:
+ h = hasher()
+ data = handle.read(block)
+ while data:
+ h.update(data)
+ data = handle.read(block)
+ return h.digest()
+
+@enum.unique
+class _FileType(enum.Enum):
+ generated_dat = 0
+ sfx = 1
+ sdl = 2
+ python_code = 3
+ generated_ancillary = 4
+
+
+class _OutputFile:
+ def __init__(self, **kwargs):
+ self.file_type = kwargs.get("file_type")
+ self.dirname = kwargs.get("dirname")
+ self.filename = kwargs.get("filename")
+ self.skip_hash = kwargs.get("skip_hash", False)
+ self.internal = kwargs.get("internal", False)
+ self.file_path = None
+ self.mod_time = None
+
+ if self.file_type in (_FileType.generated_dat, _FileType.generated_ancillary):
+ self.file_data = kwargs.get("file_data", None)
+ self.file_path = kwargs.get("file_path", None)
+ self.mod_time = Path(self.file_path).stat().st_mtime if self.file_path else None
+
+ # need either a data buffer OR a file path
+ assert bool(self.file_data) ^ bool(self.file_path)
+
+ if self.file_type == _FileType.sfx:
+ self.id_data = kwargs.get("id_data")
+ path = Path(self.id_data.filepath)
+ try:
+ if path.exists():
+ self.file_path = str(path.resolve())
+ self.mod_time = path.stat().st_mtime
+ except OSError:
+ pass
+
+ if self.id_data.packed_file is not None:
+ self.file_data = self.id_data.packed_file.data
+ else:
+ self.file_data = None
+
+ if self.file_type in (_FileType.sdl, _FileType.python_code):
+ self.id_data = kwargs.get("id_data")
+ self.file_data = kwargs.get("file_data")
+ self.needs_glue = kwargs.get("needs_glue", True)
+ assert bool(self.id_data) or bool(self.file_data)
+
+ self.mod_time = None
+ self.file_path = None
+ if self.id_data is not None:
+ path = Path(self.id_data.filepath)
+ try:
+ if path.exists():
+ self.file_path = str(path.resolve())
+ self.mod_time = path.stat().st_mtime
+ except OSError:
+ pass
+
+ if self.file_data is None:
+ self.file_data = self.id_data.as_string()
+
+ # Last chance for encryption...
+ enc = kwargs.get("enc", None)
+ if enc is not None:
+ self._encrypt(enc)
+
+ def _encrypt(self, enc):
+ backing_stream = hsRAMStream()
+ with plEncryptedStream().open(backing_stream, fmCreate, enc) as enc_stream:
+ if self.file_path:
+ if plEncryptedStream.IsFileEncrypted(self.file_path):
+ with plEncryptedStream().open(self.file_path, fmRead, plEncryptedStream.kEncAuto) as dec_stream:
+ self._enc_spin_wash(enc_stream, dec_stream)
+ else:
+ with hsFileStream().open(self.file_path, fmRead) as dec_stream:
+ self._enc_spin_wash(enc_stream, dec_stream)
+ elif self.file_data:
+ if isinstance(self.file_data, str):
+ enc_stream.write(self.file_data.encode(_encoding))
+ else:
+ enc_stream.write(self.file_data)
+ else:
+ raise RuntimeError()
+
+ self.file_data = backing_stream.buffer
+ # do NOT copy over an unencrypted file...
+ self.file_path = None
+
+ def _enc_spin_wash(self, enc_stream, dec_stream):
+ while True:
+ size_rem = dec_stream.size - dec_stream.pos
+ readsz = min(size_rem, _CHUNK_SIZE)
+ if readsz == 0:
+ break
+ data = dec_stream.read(readsz)
+ enc_stream.write(data)
+
+ def __eq__(self, rhs):
+ return str(self) == str(rhs)
+
+ def __hash__(self):
+ return hash(str(self))
+
+ def hash_md5(self):
+ if self.file_path:
+ with open(self.file_path, "rb") as handle:
+ h = md5()
+ data = handle.read(_CHUNK_SIZE)
+ while data:
+ h.update(data)
+ data = handle.read(_CHUNK_SIZE)
+ return h.digest()
+ elif self.file_data is not None:
+ if isinstance(self.file_data, str):
+ return md5(self.file_data.encode(_encoding)).digest()
+ else:
+ return md5(self.file_data).digest()
+ else:
+ raise RuntimeError()
+
+ def __str__(self):
+ return "{}/{}".format(self.dirname, self.filename)
+
+
+class OutputFiles:
+ def __init__(self, exporter, path):
+ self._exporter = weakref.ref(exporter)
+ self._export_file = Path(path).resolve()
+ if exporter.dat_only:
+ self._export_path = self._export_file.parent
+ else:
+ self._export_path = self._export_file.parent.parent
+ self._files = set()
+ self._is_zip = self._export_file.suffix.lower() == ".zip"
+ self._py_files = set()
+ self._time = time.time()
+
+ def add_python_code(self, filename, text_id=None, str_data=None):
+ assert filename not in self._py_files
+ of = _OutputFile(file_type=_FileType.python_code,
+ dirname="Python", filename=filename,
+ id_data=text_id, file_data=str_data,
+ skip_hash=True,
+ internal=(self._version != pvMoul),
+ needs_glue=False)
+ self._files.add(of)
+ self._py_files.add(filename)
+
+ def add_python_mod(self, filename, text_id=None, str_data=None):
+ assert filename not in self._py_files
+ of = _OutputFile(file_type=_FileType.python_code,
+ dirname="Python", filename=filename,
+ id_data=text_id, file_data=str_data,
+ skip_hash=True,
+ internal=(self._version != pvMoul),
+ needs_glue=True)
+ self._files.add(of)
+ self._py_files.add(filename)
+
+ def add_sdl(self, filename, text_id=None, str_data=None):
+ of = _OutputFile(file_type=_FileType.sdl,
+ dirname="SDL", filename=filename,
+ id_data=text_id, file_data=str_data,
+ enc=self.super_secure_encryption)
+ self._files.add(of)
+
+
+ def add_sfx(self, sound_id):
+ of = _OutputFile(file_type=_FileType.sfx,
+ dirname="sfx", filename=sound_id.name,
+ id_data=sound_id)
+ self._files.add(of)
+
+ @contextmanager
+ def generate_dat_file(self, filename, **kwargs):
+ dat_only = self._exporter().dat_only
+ dirname = kwargs.get("dirname", "dat")
+ bogus = dat_only and dirname != "dat"
+
+ if self._is_zip or bogus:
+ stream = hsRAMStream(self._version)
+ else:
+ if dat_only:
+ file_path = str(self._export_file.parent / filename)
+ else:
+ file_path = str(self._export_path / dirname / filename)
+ stream = hsFileStream(self._version)
+ stream.open(file_path, fmCreate)
+ backing_stream = stream
+
+ # No sense in wasting time encrypting data that isn't going to be used in the export
+ if not bogus:
+ enc = kwargs.get("enc", None)
+ if enc is not None:
+ stream = plEncryptedStream(self._version)
+ stream.open(backing_stream, fmCreate, enc)
+
+ # The actual export code is run at the "yield" statement. If an error occurs, we
+ # do not want to track this file. Note that the except block is required for the
+ # else block to be legal. ^_^
+ try:
+ yield stream
+ except:
+ raise
+ else:
+ # Must call the EncryptedStream close to actually encrypt the data
+ stream.close()
+ if not stream is backing_stream:
+ backing_stream.close()
+
+ # Not passing enc as a keyword argument to the output file definition. It makes more
+ # sense to yield an encrypted stream from this context manager and encrypt as we go
+ # instead of doing lots of buffer copying to encrypt as a post step.
+ if not bogus:
+ kwargs = {
+ "file_type": _FileType.generated_dat if dirname == "dat" else
+ _FileType.generated_ancillary,
+ "dirname": dirname,
+ "filename": filename,
+ "skip_hash": kwargs.get("skip_hash", False),
+ "internal": kwargs.get("internal", False),
+ }
+ if isinstance(backing_stream, hsRAMStream):
+ kwargs["file_data"] = backing_stream.buffer
+ else:
+ kwargs["file_path"] = file_path
+ self._files.add(_OutputFile(**kwargs))
+
+ def _generate_files(self, func=None):
+ dat_only = self._exporter().dat_only
+ for i in self._files:
+ if dat_only and i.dirname != "dat":
+ continue
+ if func is not None:
+ if func(i):
+ yield i
+ else:
+ yield i
+
+ def _package_compyled_python(self):
+ func = lambda x: x.file_type == _FileType.python_code
+ report = self._exporter().report
+ version = self._version
+
+ # There can be some debate about what the correct Python version for pvMoul is.
+ # I, quite frankly, don't give a rat's ass at the moment because CWE will only
+ # load Python.pak and no ancillary packages. Maybe someone should fix that, mm?
+ if version <= pvPots:
+ py_version = (2, 2)
+ else:
+ py_version = (2, 3)
+
+ try:
+ pyc_objects = []
+ for i in self._generate_files(func):
+ if i.needs_glue:
+ py_code = "{}\n\n{}\n".format(i.file_data, plasma_python_glue)
+ else:
+ py_code = i.file_data
+ result, pyc = korlib.compyle(i.filename, py_code, py_version, report, indent=1)
+ if result:
+ pyc_objects.append((i.filename, pyc))
+ except korlib.PythonNotAvailableError as error:
+ report.warn("Python {} is not available. Your Age scripts were not packaged.", error, indent=1)
+ else:
+ if pyc_objects:
+ with self.generate_dat_file("{}.pak".format(self._exporter().age_name),
+ dirname="Python", enc=self.super_secure_encryption) as stream:
+ korlib.package_python(stream, pyc_objects)
+
+ def save(self):
+ # At this stage, all Plasma data has been generated from whatever crap is in
+ # Blender. The only remaining part is to make sure any external dependencies are
+ # copied or packed into the appropriate format OR the asset hashes are generated.
+ version = self._version
+
+ # Step 1: Handle Python
+ if self._exporter().python_method != "none" and version != pvMoul:
+ self._package_compyled_python()
+
+ # Step 2: Generate sumfile
+ if self._version != pvMoul:
+ self._write_sumfile()
+
+ # Step 3: Ensure errbody is gut
+ if self._is_zip:
+ self._write_zipfile()
+ else:
+ self._write_deps()
+
+ @property
+ def super_secure_encryption(self):
+ version = self._version
+ if version == pvEoa:
+ return plEncryptedStream.kEncAes
+ elif version == pvMoul:
+ # trollface.jpg
+ return None
+ else:
+ return plEncryptedStream.kEncXtea
+
+ def want_py_text(self, text_id):
+ if text_id is None:
+ return False
+ method = self._exporter().python_method
+ if method == "none":
+ return False
+ elif method == "all":
+ return text_id.name not in self._py_files
+ else:
+ return text_id.plasma_text.package and text_id.name not in self._py_files
+
+ def _write_deps(self):
+ times = (self._time, self._time)
+ func = lambda x: not x.internal and x.file_type not in (_FileType.generated_ancillary, _FileType.generated_dat)
+ report = self._exporter().report
+
+ for i in self._generate_files(func):
+ # Will only ever run for non-"dat" directories.
+ dst_path = str(self._export_path / i.dirname / i.filename)
+ if i.file_data:
+ mode = "w" if isinstance(i.file_data, str) else "wb"
+ with open(dst_path, mode) as handle:
+ handle.write(i.file_data)
+ os.utime(dst_path, times)
+ elif i.file_path:
+ shutil.copy2(i.file_path, dst_path)
+ else:
+ report.warn("No data found for dependency file '{}'. It will not be copied into the export directory.",
+ str(i.dirname / i.filename), indent=1)
+
+ def _write_sumfile(self):
+ version = self._version
+ dat_only = self._exporter().dat_only
+ enc = plEncryptedStream.kEncAes if version >= pvEoa else plEncryptedStream.kEncXtea
+ filename = "{}.sum".format(self._exporter().age_name)
+ if dat_only:
+ func = lambda x: (not x.skip_hash and not x.internal) and x.dirname == "dat"
+ else:
+ func = lambda x: not x.skip_hash and not x.internal
+
+ with self.generate_dat_file(filename, enc=enc, skip_hash=True) as stream:
+ files = list(self._generate_files(func))
+ stream.writeInt(len(files))
+ stream.writeInt(0)
+ for i in files:
+ # ABM and UU don't want the directory for PRPs... Bug?
+ extension = Path(i.filename).suffix.lower()
+ if extension == ".prp" and version < pvPots:
+ filename = i.filename
+ else:
+ filename = "{}\\{}".format(i.dirname, i.filename)
+ mod_time = i.mod_time if i.mod_time else self._time
+ hash_md5 = i.hash_md5()
+
+ stream.writeSafeStr(filename)
+ stream.write(hash_md5)
+ stream.writeInt(int(mod_time))
+ stream.writeInt(0)
+
+ def _write_zipfile(self):
+ dat_only = self._exporter().dat_only
+ export_time = time.localtime(self._time)[:6]
+ if dat_only:
+ func = lambda x: x.dirname == "dat" and not x.internal
+ else:
+ func = lambda x: not x.internal
+ report = self._exporter().report
+
+ with zipfile.ZipFile(str(self._export_file), 'w', zipfile.ZIP_DEFLATED) as zf:
+ for i in self._generate_files(func):
+ arcpath = i.filename if dat_only else "{}/{}".format(i.dirname, i.filename)
+ if i.file_data:
+ if isinstance(i.file_data, str):
+ data = i.file_data.encode(_encoding)
+ else:
+ data = i.file_data
+ zi = zipfile.ZipInfo(arcpath, export_time)
+ zf.writestr(zi, data)
+ elif i.file_path:
+ zf.write(i.file_path, arcpath)
+ else:
+ report.warn("No data found for dependency file '{}'. It will not be archived.", arcpath, indent=1)
+
+ @property
+ def _version(self):
+ return self._exporter().mgr.getVer()
diff --git a/korman/exporter/python.py b/korman/exporter/python.py
new file mode 100644
index 0000000..e397d95
--- /dev/null
+++ b/korman/exporter/python.py
@@ -0,0 +1,177 @@
+# 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 bpy
+from pathlib import Path
+from PyHSPlasma import *
+
+from .explosions import ExportError
+from . import logger
+from .. import korlib
+from ..plasma_magic import plasma_python_glue, very_very_special_python
+
+class PythonPackageExporter:
+ def __init__(self, filepath, version):
+ self._filepath = filepath
+ self._modules = {}
+ self._pfms = {}
+ self._version = version
+
+ def _compyle(self, report):
+ report.progress_advance()
+ report.progress_range = len(self._modules) + len(self._pfms)
+ inc_progress = report.progress_increment
+
+ age = bpy.context.scene.world.plasma_age
+ Text = bpy.types.Text
+ if self._version <= pvPots:
+ py_version = (2, 2)
+ else:
+ py_version = (2, 3)
+ py_code = []
+
+ for filename, source in self._pfms.items():
+ if isinstance(source, Text):
+ if not source.plasma_text.package and age.python_method != "all":
+ inc_progress()
+ continue
+ code = source.as_string()
+ else:
+ code = source
+
+ code = "{}\n\n{}\n".format(code, plasma_python_glue)
+ success, result = korlib.compyle(filename, code, py_version, report, indent=1)
+ if not success:
+ raise ExportError("Failed to compyle '{}':\n{}".format(filename, result))
+ py_code.append((filename, result))
+ inc_progress()
+
+ for filename, source in self._modules.items():
+ if isinstance(source, Text):
+ if not source.plasma_text.package and age.python_method != "all":
+ inc_progress()
+ continue
+ code = source.as_string()
+ else:
+ code = source
+
+ # no glue needed here, ma!
+ success, result = korlib.compyle(filename, code, py_version, report, indent=1)
+ if not success:
+ raise ExportError("Failed to compyle '{}':\n{}".format(filename, result))
+ py_code.append((filename, result))
+ inc_progress()
+
+ # man that was ugly...
+ return py_code
+
+ def _ensure_age_sdl_hook(self, report):
+ age_props = bpy.context.scene.world.plasma_age
+ if age_props.age_sdl:
+ fixed_agename = korlib.replace_python2_identifier(age_props.age_name)
+ py_filename = "{}.py".format(fixed_agename)
+ age_py = self._modules.get(py_filename)
+ if age_py is not None:
+ del self._modules[py_filename]
+ if age_py.plasma_text.package or age.python_method == "all":
+ self._pfms[py_filename] = age_py
+ else:
+ report.warn("AgeSDL Python Script provided, but not requested for packing... Using default Python.", indent=1)
+ self._pfms[py_filename] = very_very_special_python.format(age_name=fixed_agename)
+ else:
+ report.msg("Packing default AgeSDL Python", indent=1)
+ very_very_special_python.format(age_name=age_props.age_name)
+ self._pfms[py_filename] = very_very_special_python.format(age_name=fixed_agename)
+
+ def _harvest_pfms(self, report):
+ objects = bpy.context.scene.objects
+ report.progress_advance()
+ report.progress_range = len(objects)
+ inc_progress = report.progress_increment
+
+ for i in objects:
+ logic = i.plasma_modifiers.advanced_logic
+ if i.plasma_object.enabled and logic.enabled:
+ for j in logic.logic_groups:
+ tree_versions = (globals()[version] for version in j.version)
+ if self._version in tree_versions:
+ self._harvest_tree(j.node_tree)
+ inc_progress()
+
+ def _harvest_modules(self, report):
+ texts = bpy.data.texts
+ report.progress_advance()
+ report.progress_range = len(texts)
+ inc_progress = report.progress_increment
+
+ for i in texts:
+ if i.name.endswith(".py") and i.name not in self._pfms:
+ self._modules.setdefault(i.name, i)
+ inc_progress()
+
+ def _harvest_tree(self, tree):
+ # Search the node tree for any python file nodes. Any that we find are PFMs
+ for i in tree.nodes:
+ if i.bl_idname == "PlasmaPythonFileNode":
+ if i.filename and i.text_id:
+ self._pfms.setdefault(i.filename, i.text_id)
+
+ def run(self):
+ """Runs a stripped-down version of the Exporter that only handles Python files"""
+ age_props = bpy.context.scene.world.plasma_age
+ log = logger.ExportVerboseLogger if age_props.verbose else logger.ExportProgressLogger
+ with korlib.ConsoleToggler(age_props.show_console), log(self._filepath) as report:
+ report.progress_add_step("Harvesting Plasma PythonFileMods")
+ report.progress_add_step("Harvesting Helper Python Modules")
+ report.progress_add_step("Compyling Python Code")
+ report.progress_add_step("Packing Compyled Code")
+ report.progress_start("PACKING PYTHON")
+
+ # Harvest the Python code
+ self._harvest_pfms(report)
+ self._harvest_modules(report)
+ self._ensure_age_sdl_hook(report)
+
+ # Compyle and package the Python
+ self._package_python(report)
+
+ # DONE
+ report.progress_end()
+ report.raise_errors()
+
+ def _package_python(self, report):
+ py_code = self._compyle(report)
+ if not py_code:
+ report.error("No Python files were packaged.")
+ self._write_python_pak(py_code, report)
+
+ def _write_python_pak(self, py_code, report):
+ report.progress_advance()
+
+ if self._version == pvEoa:
+ enc = plEncryptedStream.kEncAes
+ elif self._version == pvMoul:
+ enc = None
+ else:
+ enc = plEncryptedStream.kEncXtea
+
+ if enc is None:
+ stream = hsFileStream(self._version).open(self._filepath, fmCreate)
+ else:
+ stream = plEncryptedStream(self._version).open(self._filepath, fmCreate, enc)
+ try:
+ korlib.package_python(stream, py_code)
+ finally:
+ stream.close()
diff --git a/korman/exporter/sumfile.py b/korman/exporter/sumfile.py
deleted file mode 100644
index 0bd84c8..0000000
--- a/korman/exporter/sumfile.py
+++ /dev/null
@@ -1,73 +0,0 @@
-# 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 hashlib
-from pathlib import Path
-from PyHSPlasma import *
-
-def _hashfile(filename, hasher, block=0xFFFF):
- with open(str(filename), "rb") as handle:
- h = hasher()
- data = handle.read(block)
- while data:
- h.update(data)
- data = handle.read(block)
- return h.digest()
-
-class SumFile:
- def __init__(self):
- self._files = set()
-
- def append(self, filename):
- self._files.add(filename)
-
- def _collect_files(self, version):
- files = []
- for file in self._files:
- filename, extension = file.name, file.suffix.lower()
- if extension in {".age", ".csv", ".fni", ".loc", ".node", ".p2f", ".pfp", ".sub"}:
- filename = Path("dat") / filename
- elif extension == ".prp" and version > pvPrime:
- # ABM and UU don't want the directory for PRPs... Bug?
- filename = Path("dat") / filename
- elif extension in {".pak", ".py"}:
- filename = Path("Python") / filename
- elif extension in {".avi", ".bik", ".oggv", ".webm"}:
- filename = Path("avi") / filename
- elif extension in {".ogg", ".opus", ".wav"}:
- filename = Path("sfx") / filename
- elif extension == ".sdl":
- filename = Path("SDL") / filename
- # else the filename has no directory prefix... oh well
-
- md5 = _hashfile(file, hashlib.md5)
- timestamp = file.stat().st_mtime
- files.append((str(filename), md5, int(timestamp)))
- return files
-
-
- def write(self, sumpath, version):
- """Writes a .sum file for Uru ABM, PotS, Myst 5, etc."""
- files = self._collect_files(version)
- enc = plEncryptedStream.kEncAes if version >= pvEoa else plEncryptedStream.kEncXtea
-
- with plEncryptedStream(version).open(str(sumpath), fmWrite, enc) as stream:
- stream.writeInt(len(files))
- stream.writeInt(0)
- for file in files:
- stream.writeSafeStr(str(file[0]))
- stream.write(file[1])
- stream.writeInt(file[2])
- stream.writeInt(0)
diff --git a/korman/korlib/__init__.py b/korman/korlib/__init__.py
index 89006c0..78a0fe9 100644
--- a/korman/korlib/__init__.py
+++ b/korman/korlib/__init__.py
@@ -73,8 +73,16 @@ else:
finally:
from .console import ConsoleToggler
+ from .python import *
from .texture import TEX_DETAIL_ALPHA, TEX_DETAIL_ADD, TEX_DETAIL_MULTIPLY
+ _IDENTIFIER_RANGES = ((ord('0'), ord('9')), (ord('A'), ord('Z')), (ord('a'), ord('z')))
+ from keyword import kwlist as _kwlist
+ _KEYWORDS = set(_kwlist)
+ # Python 2.x keywords
+ _KEYWORDS.add("exec")
+ _KEYWORDS.add("print")
+
def _wave_chunks(stream):
while not stream.eof():
chunk_name = stream.read(4)
@@ -100,3 +108,31 @@ finally:
header.read(stream)
return chunks[b"data"]["size"]
+
+ def is_legal_python2_identifier(identifier):
+ if not identifier:
+ return False
+
+ # FIXME: str.isascii in Python 3.7
+ if any(ord(i) > 0x7F for i in identifier):
+ return False
+ if is_python_keyword(identifier):
+ return False
+ return identifier.isidentifier()
+
+ is_python_keyword = _KEYWORDS.__contains__
+
+ def replace_python2_identifier(identifier):
+ """Replaces illegal characters in a Python identifier with a replacement character"""
+
+ def process(identifier):
+ # No leading digits in identifiers, so skip the first range element (0...9)
+ yield next((identifier[0] for low, high in _IDENTIFIER_RANGES[1:]
+ if low <= ord(identifier[0]) <= high), '_')
+ for i in identifier[1:]:
+ yield next((i for low, high in _IDENTIFIER_RANGES if low <= ord(i) <= high), '_')
+
+ if identifier:
+ return "".join(process(identifier))
+ else:
+ return ""
diff --git a/korman/korlib/python.py b/korman/korlib/python.py
new file mode 100644
index 0000000..5869539
--- /dev/null
+++ b/korman/korlib/python.py
@@ -0,0 +1,202 @@
+# 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 .
+
+from __future__ import generators # Python 2.2
+import marshal
+import os.path
+import sys
+
+_python_executables = {}
+
+class PythonNotAvailableError(Exception):
+ pass
+
+
+def compyle(file_name, py_code, py_version, report=None, indent=0):
+ # NOTE: Should never run under Python 2.x
+ my_version = sys.version_info[:2]
+ assert my_version == (2, 7) or my_version[0] > 2
+
+ # Remember: Python 2.2 file, so no single line if statements...
+ idx = file_name.find('.')
+ if idx == -1:
+ module_name = file_name
+ else:
+ module_name = file_name[:idx]
+
+ if report is not None:
+ report.msg("Compyling {}", file_name, indent=indent)
+
+ if my_version != py_version:
+ import subprocess
+
+ py_executable = _find_python(py_version)
+ args = (py_executable, __file__, module_name)
+ try:
+ py_code = py_code.encode("utf-8")
+ except UnicodeError:
+ if report is not None:
+ report.error("Could not encode '{}'", file_name, indent=indent+1)
+ return (False, "Could not encode file")
+ result = subprocess.run(args, input=py_code, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
+ if result.returncode != 0:
+ try:
+ error = result.stdout.decode("utf-8").replace('\r\n', '\n')
+ except UnicodeError:
+ error = result.stdout
+ if report is not None:
+ report.error("Compylation Error in '{}'\n{}", file_name, error, indent=indent+1)
+ return (result.returncode == 0, result.stdout)
+ else:
+ raise NotImplementedError()
+
+def _compyle(module_name, py_code):
+ # Old python versions have major issues with Windows style newlines.
+ # Also, bad things happen if there is no newline at the end.
+ py_code += '\n' # sigh, this is slow on old Python...
+ py_code = py_code.replace('\r\n', '\n')
+ py_code = py_code.replace('\r', '\n')
+ code_object = compile(py_code, module_name, "exec")
+
+ # The difference between us and the py_compile module is twofold:
+ # 1) py_compile compyles to a file. We might be exporting to memory, so that's
+ # not what we want.
+ # 2) py_compile saves a *pyc format file containing information such as compyle
+ # time and marshal format version. These items are not included in Cyan's
+ # Python.pak format.
+ # Therefore, we simply return the marshalled data as a string.
+ return marshal.dumps(code_object)
+
+def _find_python(py_version):
+ def find_executable(py_version):
+ # First, try to use Blender to find the Python executable
+ try:
+ import bpy
+ except ImportError:
+ pass
+ else:
+ userprefs = bpy.context.user_preferences.addons["korman"].preferences
+ py_executable = getattr(userprefs, "python{}{}_executable".format(*py_version), None)
+ if verify_python(py_version, py_executable):
+ return py_executable
+
+ # Second, try looking Python up in the registry.
+ try:
+ import winreg
+ except ImportError:
+ pass
+ else:
+ py_executable = _find_python_reg(winreg.HKEY_CURRENT_USER, py_version)
+ if verify_python(py_version, py_executable):
+ return py_executable
+ py_executable = _find_python_reg(winreg.HKEY_LOCAL_MACHINE, py_version)
+ if verify_python(py_version, py_executable):
+ return py_executable
+
+ # I give up, you win.
+ return None
+
+ py_executable = _python_executables.setdefault(py_version, find_executable(py_version))
+ if py_executable:
+ return py_executable
+ else:
+ raise PythonNotAvailableError("{}.{}".format(*py_version))
+
+def _find_python_reg(reg_key, py_version):
+ import winreg
+ subkey_name = "Software\\Python\\PythonCore\\{}.{}\\InstallPath".format(*py_version)
+ try:
+ python_dir = winreg.QueryValue(reg_key, subkey_name)
+ except FileNotFoundError:
+ return None
+ else:
+ return os.path.join(python_dir, "python.exe")
+
+def package_python(stream, pyc_objects):
+ # Python.pak format:
+ # uint32_t numFiles
+ # - safeStr filename
+ # - uint32_t offset
+ # ~~~~~
+ # uint32_t filesz
+ # uint8_t data[filesz]
+ if not pyc_objects:
+ stream.writeInt(0)
+ return
+
+ # `stream` might be a plEncryptedStream, which doesn't seek very well at all.
+ # Therefore, we will go ahead and calculate the size of the index block so
+ # there is no need to seek around to write offset values
+ base_offset = 4 # uint32_t numFiles
+ data_offset = 0
+ pyc_info = [] # sad, but makes life easier...
+ for module_name, compyled_code in pyc_objects:
+ pyc_info.append((module_name, data_offset, compyled_code))
+
+ # index offset overall
+ base_offset += 2 # writeSafeStr length
+ # NOTE: This assumes that libHSPlasma's hsStream::writeSafeStr converts
+ # the Python unicode/string object to UTF-8. Currently, this is true.
+ base_offset += len(module_name.encode("utf-8")) # writeSafeStr
+ base_offset += 4
+
+ # current file data offset
+ data_offset += 4 # uint32_t filesz
+ data_offset += len(compyled_code)
+
+ stream.writeInt(len(pyc_info))
+ for module_name, data_offset, compyled_code in pyc_info:
+ stream.writeSafeStr(module_name)
+ # offset of data == index size (base_offset) + offset to data blob (data_offset)
+ stream.writeInt(base_offset + data_offset)
+ for module_name, data_offset, compyled_code in pyc_info:
+ stream.writeInt(len(compyled_code))
+ stream.write(compyled_code)
+
+def verify_python(py_version, py_exe):
+ if not py_exe:
+ return False
+
+ import subprocess
+ try:
+ args = (py_exe, "-V")
+ result = subprocess.run(args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=5)
+ except OSError:
+ return False
+ else:
+ output = result.stdout.decode()
+ try:
+ py_str, py_check = output[:6], output[7:10]
+ except IndexError:
+ return False
+ else:
+ if py_str != "Python":
+ return False
+ return "{}.{}".format(*py_version) == py_check
+
+if __name__ == "__main__":
+ # Python tries to be "helpful" on Windows by converting \n to \r\n.
+ # Therefore we must change the mode of stdout.
+ if sys.platform == "win32":
+ import os, msvcrt
+ msvcrt.setmode(sys.stdout.fileno(), os.O_BINARY)
+
+ try:
+ module_name = sys.argv[1]
+ except IndexError:
+ module_name = ""
+ py_code_source = sys.stdin.read()
+ py_code_object = _compyle(module_name, py_code_source)
+ sys.stdout.write(py_code_object)
diff --git a/korman/nodes/node_python.py b/korman/nodes/node_python.py
index 60f3583..26ef9e1 100644
--- a/korman/nodes/node_python.py
+++ b/korman/nodes/node_python.py
@@ -15,10 +15,12 @@
import bpy
from bpy.props import *
+from contextlib import contextmanager
from pathlib import Path
from PyHSPlasma import *
from .node_core import *
+from .node_deprecated import PlasmaVersionedNode
from .. import idprops
_single_user_attribs = {
@@ -166,30 +168,40 @@ class PlasmaAttribute(bpy.types.PropertyGroup):
simple_value = property(_get_simple_value, _set_simple_value)
-class PlasmaPythonFileNode(PlasmaNodeBase, bpy.types.Node):
+class PlasmaPythonFileNode(PlasmaVersionedNode, bpy.types.Node):
bl_category = "PYTHON"
bl_idname = "PlasmaPythonFileNode"
bl_label = "Python File"
- bl_width_default = 210
-
- class _NoUpdate:
- def __init__(self, node):
- self._node = node
- def __enter__(self):
- self._node.no_update = True
- def __exit__(self, type, value, traceback):
- self._node.no_update = False
+ bl_width_default = 290
def _update_pyfile(self, context):
- with self._NoUpdate(self) as _hack:
+ if self.no_update:
+ return
+ text_id = bpy.data.texts.get(self.filename, None)
+ if text_id:
+ self.text_id = text_id
+
+ def _update_pytext(self, context):
+ if self.no_update:
+ return
+ with self.NoUpdate():
+ self.filename = self.text_id.name
self.attributes.clear()
self.inputs.clear()
- bpy.ops.node.plasma_attributes_to_node(node_path=self.node_path, python_path=self.filepath)
+ if self.text_id is not None:
+ bpy.ops.node.plasma_attributes_to_node(node_path=self.node_path, text_path=self.text_id.name)
- filename = StringProperty(name="File",
- description="Python Filename")
- filepath = StringProperty(update=_update_pyfile,
- options={"HIDDEN"})
+ filename = StringProperty(name="File Name",
+ description="Python Filename",
+ update=_update_pyfile)
+ filepath = StringProperty(options={"HIDDEN"})
+ text_id = PointerProperty(name="Script File",
+ description="Script file datablock",
+ type=bpy.types.Text,
+ update=_update_pytext)
+
+ # This property exists for UI purposes ONLY
+ package = BoolProperty(options={"HIDDEN", "SKIP_SAVE"})
attributes = CollectionProperty(type=PlasmaAttribute, options={"HIDDEN"})
no_update = BoolProperty(default=False, options={"HIDDEN", "SKIP_SAVE"})
@@ -199,30 +211,55 @@ class PlasmaPythonFileNode(PlasmaNodeBase, bpy.types.Node):
return { i.attribute_id: i for i in self.attributes }
def draw_buttons(self, context, layout):
- row = layout.row(align=True)
- if self.filename:
- row.prop(self, "filename")
- try:
- if Path(self.filepath).exists():
- operator = row.operator("node.plasma_attributes_to_node", icon="FILE_REFRESH", text="")
- operator.python_path = self.filepath
- operator.node_path = self.node_path
- except OSError:
- pass
-
- op_text = "" if self.filename else "Select"
- operator = row.operator("file.plasma_file_picker", icon="SCRIPT", text=op_text)
+ main_row = layout.row(align=True)
+ row = main_row.row(align=True)
+ row.alert = self.text_id is None and bool(self.filename)
+ row.prop(self, "text_id", text="Script")
+ # open operator
+ operator = main_row.operator("file.plasma_file_picker", icon="FILESEL", text="")
operator.filter_glob = "*.py"
operator.data_path = self.node_path
- operator.filepath_property = "filepath"
operator.filename_property = "filename"
+ # package button
+ row = main_row.row(align=True)
+ if self.text_id is not None:
+ row.enabled = True
+ icon = "PACKAGE" if self.text_id.plasma_text.package else "UGLYPACKAGE"
+ row.prop(self.text_id.plasma_text, "package", icon=icon, text="")
+ else:
+ row.enabled = False
+ row.prop(self, "package", text="", icon="UGLYPACKAGE")
+ # rescan operator
+ row = main_row.row(align=True)
+ row.enabled = self.text_id is not None
+ operator = row.operator("node.plasma_attributes_to_node", icon="FILE_REFRESH", text="")
+ if self.text_id is not None:
+ operator.text_path = self.text_id.name
+ operator.node_path = self.node_path
+
+ # This could happen on an upgrade
+ if self.text_id is None and self.filename:
+ layout.label(text="Script '{}' is not loaded in Blender".format(self.filename), icon="ERROR")
def get_key(self, exporter, so):
return exporter.mgr.find_create_key(plPythonFileMod, name=self.key_name, so=so)
def export(self, exporter, bo, so):
pfm = self.get_key(exporter, so).object
- pfm.filename = Path(self.filename).stem
+ py_name = Path(self.filename).stem
+ pfm.filename = py_name
+
+ # Check to see if we should pack this file
+ if exporter.output.want_py_text(self.text_id):
+ exporter.report.msg("Including Python '{}' for package", self.filename, indent=3)
+ exporter.output.add_python_mod(self.filename, text_id=self.text_id)
+ # PFMs can have their own SDL...
+ sdl_text = bpy.data.texts.get("{}.sdl".format(py_name), None)
+ if sdl_text is not None:
+ exporter.report.msg("Including corresponding SDL '{}'", sdl_text.name, indent=3)
+ exporter.output.add_sdl(sdl_text.name, text_id=sdl_text)
+
+ # Handle exporting the Python Parameters
attrib_sockets = (i for i in self.inputs if i.is_linked)
for socket in attrib_sockets:
attrib = socket.attribute_type
@@ -273,10 +310,18 @@ class PlasmaPythonFileNode(PlasmaNodeBase, bpy.types.Node):
if not is_init and new_pos != old_pos:
self.inputs.move(old_pos, new_pos)
+ @contextmanager
+ def NoUpdate(self):
+ self.no_update = True
+ try:
+ yield self
+ finally:
+ self.no_update = False
+
def update(self):
if self.no_update:
return
- with self._NoUpdate(self) as _no_recurse:
+ with self.NoUpdate():
# First, we really want to make sure our junk matches up. Yes, this does dupe what
# happens in PlasmaAttribNodeBase, but we can link much more than those node types...
toasty_sockets = []
@@ -312,6 +357,26 @@ class PlasmaPythonFileNode(PlasmaNodeBase, bpy.types.Node):
while len(unconnected) > 1:
self.inputs.remove(unconnected.pop())
+ @property
+ def latest_version(self):
+ return 2
+
+ def upgrade(self):
+ # In version 1 of this node, Python scripts were referenced by their filename in the
+ # python package and by their path on the local machine. This created an undue dependency
+ # on the artist's environment. In version 2, we will use Blender's text data blocks to back
+ # Python scripts. It is still legal to export Python File nodes that are not backed by a script.
+ if self.version == 1:
+ text_id = bpy.data.texts.get(self.filename, None)
+ if text_id is None:
+ path = Path(self.filepath)
+ if path.exists():
+ text_id = bpy.data.texts.load(self.filepath)
+ with self.NoUpdate():
+ self.text_id = text_id
+ self.property_unset("filepath")
+ self.version = 2
+
class PlasmaPythonFileNodeSocket(PlasmaNodeSocketBase, bpy.types.NodeSocket):
attribute_id = IntProperty(options={"HIDDEN"})
diff --git a/korman/operators/op_export.py b/korman/operators/op_export.py
index 3a098f9..e4df68e 100644
--- a/korman/operators/op_export.py
+++ b/korman/operators/op_export.py
@@ -17,14 +17,34 @@ import bpy
from bpy.props import *
import cProfile
from pathlib import Path
+from PyHSPlasma import *
import pstats
+from ..addon_prefs import game_versions
from .. import exporter
from ..helpers import UiHelper
-from ..properties.prop_world import PlasmaAge, game_versions
-from ..korlib import ConsoleToggler
+from .. import korlib
+from ..properties.prop_world import PlasmaAge
-class ExportOperator(bpy.types.Operator):
+class ExportOperator:
+ def _get_default_path(self, context):
+ blend_filepath = context.blend_data.filepath
+ if not blend_filepath:
+ blend_filepath = context.scene.world.plasma_age.age_name
+ if not blend_filepath:
+ blend_filepath = "Korman"
+ return blend_filepath
+
+ @property
+ def has_reports(self):
+ return hasattr(self.report)
+
+ @classmethod
+ def poll(cls, context):
+ return context.scene.render.engine == "PLASMA_GAME"
+
+
+class PlasmaAgeExportOperator(ExportOperator, bpy.types.Operator):
"""Exports ages for Cyan Worlds' Plasma Engine"""
bl_idname = "export.plasma_age"
@@ -71,6 +91,14 @@ class ExportOperator(bpy.types.Operator):
("perengine", "Export Supported EnvMaps", "Only environment maps supported by the selected game engine are exported")],
"default": "dcm2dem"}),
+ "python_method": (EnumProperty, {"name": "Python",
+ "description": "Specifies how Python should be packed",
+ "items": [("none", "Pack Nothing", "Don't pack any Python files."),
+ ("as_requested", "Pack Requested Scripts", "Packs any script both linked as a Text file and requested for packaging."),
+ ("all", "Pack All Scripts", "Packs all Python files linked as a Text file.")],
+ "default": "as_requested",
+ "options": set()}),
+
"export_active": (BoolProperty, {"name": "INTERNAL: Export currently running",
"default": False,
"options": {"SKIP_SAVE"}}),
@@ -78,7 +106,7 @@ class ExportOperator(bpy.types.Operator):
# This wigs out and very bad things happen if it's not directly on the operator...
filepath = StringProperty(subtype="FILE_PATH")
- filter_glob = StringProperty(default="*.age", options={'HIDDEN'})
+ filter_glob = StringProperty(default="*.age;*.zip", options={'HIDDEN'})
version = EnumProperty(name="Version",
description="Plasma version to export this age for",
@@ -86,6 +114,11 @@ class ExportOperator(bpy.types.Operator):
default="pvPots",
options=set())
+ dat_only = BoolProperty(name="Export Only PRPs",
+ description="Only the Age PRPs should be exported",
+ default=True,
+ options={"HIDDEN"})
+
def draw(self, context):
layout = self.layout
age = context.scene.world.plasma_age
@@ -95,7 +128,7 @@ class ExportOperator(bpy.types.Operator):
layout.prop(age, "texcache_method", text="")
layout.prop(age, "lighting_method")
row = layout.row()
- row.enabled = ConsoleToggler.is_platform_supported()
+ row.enabled = korlib.ConsoleToggler.is_platform_supported()
row.prop(age, "show_console")
layout.prop(age, "verbose")
layout.prop(age, "profile_export")
@@ -111,14 +144,6 @@ class ExportOperator(bpy.types.Operator):
else:
super().__setattr__(attr, value)
- @property
- def has_reports(self):
- return hasattr(self.report)
-
- @classmethod
- def poll(cls, context):
- return context.scene.render.engine == "PLASMA_GAME"
-
def execute(self, context):
# Before we begin, do some basic sanity checking...
path = Path(self.filepath)
@@ -132,13 +157,18 @@ class ExportOperator(bpy.types.Operator):
except:
self.report({"ERROR"}, "Failed to create export directory")
return {"CANCELLED"}
+ path.touch()
# We need to back out of edit mode--this ensures that all changes are committed
if context.mode != "OBJECT":
bpy.ops.object.mode_set(mode="OBJECT")
- # Separate blender operator and actual export logic for my sanity
ageName = path.stem
+ if korlib.is_python_keyword(ageName):
+ 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)
try:
@@ -151,6 +181,9 @@ class ExportOperator(bpy.types.Operator):
except exporter.ExportError as error:
self.report({"ERROR"}, str(error))
return {"CANCELLED"}
+ except exporter.NonfatalExportError as error:
+ self.report({"ERROR"}, str(error))
+ return {"FINISHED"}
else:
if self.profile_export:
stats_out = path.with_name("{}_profile.log".format(ageName))
@@ -166,12 +199,8 @@ class ExportOperator(bpy.types.Operator):
# Called when a user hits "export" from the menu
# We will prompt them for the export info, then call execute()
if not self.filepath:
- blend_filepath = context.blend_data.filepath
- if not blend_filepath:
- blend_filepath = context.scene.world.plasma_age.age_name
- if not blend_filepath:
- blend_filepath = "Korman"
- self.filepath = str(Path(blend_filepath).with_suffix(".age"))
+ bfp = self._get_default_path(context)
+ self.filepath = str(Path(bfp).with_suffix(".age"))
context.window_manager.fileselect_add(self)
return {"RUNNING_MODAL"}
@@ -189,11 +218,85 @@ class ExportOperator(bpy.types.Operator):
setattr(PlasmaAge, name, prop(**age_options))
+class PlasmaPythonExportOperator(ExportOperator, bpy.types.Operator):
+ bl_idname = "export.plasma_pak"
+ bl_label = "Package Scripts"
+ bl_description = "Package Age Python scripts"
+
+ filepath = StringProperty(subtype="FILE_PATH")
+ filter_glob = StringProperty(default="*.pak", options={'HIDDEN'})
+
+ version = EnumProperty(name="Version",
+ description="Plasma version to export this age for",
+ items=game_versions,
+ default="pvPots",
+ 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 :(
+ row = layout.row()
+ row.alert = age.python_method == "none"
+ row.prop(age, "python_method")
+ layout.prop(self, "version")
+ row = layout.row()
+ row.enabled = korlib.ConsoleToggler.is_platform_supported()
+ row.prop(age, "show_console")
+ layout.prop(age, "verbose")
+
+ def execute(self, context):
+ path = Path(self.filepath)
+ if not self.filepath:
+ self.report({"ERROR"}, "No file specified")
+ return {"CANCELLED"}
+ else:
+ if not path.exists:
+ try:
+ path.mkdir(parents=True)
+ except OSError:
+ self.report({"ERROR"}, "Failed to create export directory")
+ return {"CANCELLED"}
+ path.touch()
+
+ # Age names cannot be python keywords
+ age_name = context.scene.world.plasma_age.age_name
+ if korlib.is_python_keyword(age_name):
+ self.report({"ERROR"}, "The Age name conflicts with the Python keyword '{}'".format(age_name))
+ return {"CANCELLED"}
+
+ # Bonus Fun: Implement Profile-mode here (later...)
+ e = exporter.PythonPackageExporter(filepath=self.filepath,
+ version=globals()[self.version])
+ try:
+ e.run()
+ except exporter.ExportError as error:
+ self.report({"ERROR"}, str(error))
+ return {"CANCELLED"}
+ except korlib.PythonNotAvailableError as error:
+ self.report({"ERROR"}, "Python Version {} not found".format(error))
+ return {"CANCELLED"}
+ except exporter.NonfatalExportError as error:
+ self.report({"WARNING"}, str(error))
+ return {"FINISHED"}
+ else:
+ return {"FINISHED"}
+
+ def invoke(self, context, event):
+ if not self.filepath:
+ bfp = self._get_default_path(context)
+ self.filepath = str(Path(bfp).with_suffix(".pak"))
+ context.window_manager.fileselect_add(self)
+ return {"RUNNING_MODAL"}
+
+
# Add the export operator to the Export menu :)
def menu_cb(self, context):
if context.scene.render.engine == "PLASMA_GAME":
self.layout.operator_context = "INVOKE_DEFAULT"
- self.layout.operator(ExportOperator.bl_idname, text="Plasma Age (.age)")
+ self.layout.operator(PlasmaAgeExportOperator.bl_idname, text="Plasma Age (.age)")
+ self.layout.operator(PlasmaPythonExportOperator.bl_idname, text="Plasma Scripts (.pak)")
def register():
diff --git a/korman/operators/op_nodes.py b/korman/operators/op_nodes.py
index 64e0391..6fa1e96 100644
--- a/korman/operators/op_nodes.py
+++ b/korman/operators/op_nodes.py
@@ -26,6 +26,7 @@ class NodeOperator:
class SelectFileOperator(NodeOperator, bpy.types.Operator):
bl_idname = "file.plasma_file_picker"
bl_label = "Select"
+ bl_description = "Load a file"
filter_glob = StringProperty(options={"HIDDEN"})
filepath = StringProperty(subtype="FILE_PATH")
@@ -41,6 +42,11 @@ class SelectFileOperator(NodeOperator, bpy.types.Operator):
setattr(dest, self.filepath_property, self.filepath)
if self.filename_property:
setattr(dest, self.filename_property, self.filename)
+
+ if bpy.data.texts.get(self.filename, None) is None:
+ bpy.data.texts.load(self.filepath)
+ else:
+ self.report({"WARNING"}, "A file named '{}' is already loaded. It will be used.".format(self.filename))
return {"FINISHED"}
def invoke(self, context, event):
@@ -91,15 +97,17 @@ pyAttribArgMap= {
class PlPyAttributeNodeOperator(NodeOperator, bpy.types.Operator):
bl_idname = "node.plasma_attributes_to_node"
- bl_label = "R"
+ bl_label = "Refresh Sockets"
+ bl_description = "Refresh the Python File node's attribute sockets"
bl_options = {"INTERNAL"}
- python_path = StringProperty(subtype="FILE_PATH")
+ text_path = StringProperty()
node_path = StringProperty()
def execute(self, context):
- from ..plasma_attributes import get_attributes
- attribs = get_attributes(self.python_path)
+ from ..plasma_attributes import get_attributes_from_str
+ text_id = bpy.data.texts[self.text_path]
+ attribs = get_attributes_from_str(text_id.as_string())
node = eval(self.node_path)
node_attrib_map = node.attribute_map
diff --git a/korman/operators/op_ui.py b/korman/operators/op_ui.py
index 0f23da4..ffa3b17 100644
--- a/korman/operators/op_ui.py
+++ b/korman/operators/op_ui.py
@@ -13,6 +13,7 @@
# You should have received a copy of the GNU General Public License
# along with Korman. If not, see .
+import addon_utils
import bpy
from bpy.props import *
@@ -87,3 +88,17 @@ class CollectionRemoveOperator(UIOperator, bpy.types.Operator):
return {"FINISHED"}
else:
return {"CANCELLED"}
+
+
+class OpenAddonPrefs(UIOperator, bpy.types.Operator):
+ bl_idname = "ui.korman_open_prefs"
+ bl_label = "Open Korman Preferences"
+ bl_description = "Opens the Korman User Preferences"
+
+ def execute(self, context):
+ bpy.ops.screen.userpref_show("INVOKE_DEFAULT")
+ context.user_preferences.active_section = "ADDONS"
+ context.window_manager.addon_filter = "System"
+ korman_addon = addon_utils.addons_fake_modules["korman"]
+ addon_utils.module_bl_info(korman_addon)["show_expanded"] = True
+ return {"FINISHED"}
diff --git a/korman/operators/op_world.py b/korman/operators/op_world.py
index 7affe66..866a580 100644
--- a/korman/operators/op_world.py
+++ b/korman/operators/op_world.py
@@ -32,38 +32,35 @@ class GameAddOperator(AgeOperator, bpy.types.Operator):
game_index = IntProperty(default=-1, options={"HIDDEN"})
def execute(self, context):
- w = context.world
- if w:
- # First, verify this is a valid Uru directory...
- path = Path(self.filepath)
-
- # Blendsucks likes to tack filenames onto our doggone directories...
- if not path.is_dir():
- path = path.parent
- if not ((path / "UruExplorer.exe").is_file() or (path / "plClient.exe").is_file()):
- self.report({"ERROR"}, "The selected directory is not a copy of URU.")
- return {"CANCELLED"}
+ prefs = context.user_preferences.addons["korman"].preferences
- # New game?
- games = w.plasma_games
- new_game = self.game_index == -1
- if new_game:
- games.active_game_index = len(games.games)
- game = games.games.add()
- else:
- game = games.games[self.game_index]
+ # First, verify this is a valid Uru directory...
+ path = Path(self.filepath)
- # Setup game...
- game.path = str(path)
- if (path / "cypython22.dll").is_file():
- game.version = "pvPots"
- else:
- game.version = "pvMoul"
- game.name = path.name
+ # Blendsucks likes to tack filenames onto our doggone directories...
+ if not path.is_dir():
+ path = path.parent
+ if not ((path / "UruExplorer.exe").is_file() or (path / "plClient.exe").is_file()):
+ self.report({"ERROR"}, "The selected directory is not a copy of URU.")
+ return {"CANCELLED"}
- return {"FINISHED"}
+ # New game?
+ new_game = self.game_index == -1
+ if new_game:
+ prefs.active_game_index = len(prefs.games)
+ game = prefs.games.add()
else:
- return {"CANCELLED"}
+ game = prefs.games[self.game_index]
+
+ # Setup game...
+ game.path = str(path)
+ if (path / "cypython22.dll").is_file():
+ game.version = "pvPots"
+ else:
+ game.version = "pvMoul"
+ game.name = path.name
+
+ return {"FINISHED"}
def invoke(self, context, event):
@@ -71,20 +68,53 @@ class GameAddOperator(AgeOperator, bpy.types.Operator):
return {"RUNNING_MODAL"}
+class GameConvertOperator(AgeOperator, bpy.types.Operator):
+ bl_idname = "world.plasma_game_convert"
+ bl_label = "This will save your User Preferences file!"
+ bl_description = "Load old per-file Plasma Games into your user preferences"
+
+ def draw(self, context):
+ self.layout.label("test")
+
+ def execute(self, context):
+ prefs = context.user_preferences.addons["korman"].preferences
+ w = context.scene.world
+
+ for old_game in w.plasma_games.games:
+ # don't add dupe games
+ match = next((i for i in prefs.games if i.path == old_game.path), None)
+ if match is not None:
+ continue
+
+ new_game = prefs.games.add()
+ new_game.name = old_game.name
+ new_game.path = old_game.path
+ new_game.version = old_game.version
+
+ w.plasma_games.games.clear()
+ bpy.ops.wm.save_userpref()
+ return {"FINISHED"}
+
+ def invoke(self, context, event):
+ return context.window_manager.invoke_confirm(self, event)
+
+ @classmethod
+ def poll(cls, context):
+ return super().poll(context) and bool(context.scene.world.plasma_games.games)
+
+
class GameRemoveOperator(AgeOperator, bpy.types.Operator):
bl_idname = "world.plasma_game_remove"
bl_label = "Remove Plasma Game"
def execute(self, context):
- w = context.world
- if w:
- games = w.plasma_games
- if games.active_game_index >= len(games.games):
- return {"CANCELLED"}
- games.games.remove(games.active_game_index)
- return {"FINISHED"}
- else:
+ prefs = context.user_preferences.addons["korman"].preferences
+
+ if prefs.active_game_index >= len(prefs.games):
return {"CANCELLED"}
+ prefs.games.remove(prefs.active_game_index)
+ prefs.active_game_index = max(prefs.active_game_index - 1, -1)
+ return {"FINISHED"}
class PageAddOperator(AgeOperator, bpy.types.Operator):
diff --git a/korman/plasma_attributes.py b/korman/plasma_attributes.py
index b2dfd34..70408fb 100644
--- a/korman/plasma_attributes.py
+++ b/korman/plasma_attributes.py
@@ -102,20 +102,22 @@ class PlasmaAttributeVisitor(ast.NodeVisitor):
ast.NodeVisitor.generic_visit(self, node)
-def get_attributes(scriptFile):
+def get_attributes_from_file(filepath):
"""Scan the file for assignments matching our regex, let our visitor parse them, and return the
file's ptAttribs, if any."""
- attribs = None
- with open(str(scriptFile)) as script:
- results = funcregex.findall(script.read())
- if results:
- # We'll fake the ptAttribs being all alone in a module...
- assigns = ast.parse("\n".join(results))
- v = PlasmaAttributeVisitor()
- v.visit(assigns)
- if v._attributes:
- attribs = v._attributes
- return attribs
+ with open(str(filepath)) as script:
+ return get_attributes_from_str(script.read())
+
+def get_attributes_from_str(code):
+ results = funcregex.findall(code)
+ if results:
+ # We'll fake the ptAttribs being all alone in a module...
+ assigns = ast.parse("\n".join(results))
+ v = PlasmaAttributeVisitor()
+ v.visit(assigns)
+ if v._attributes:
+ return v._attributes
+ return {}
if __name__ == "__main__":
import json
@@ -129,7 +131,7 @@ if __name__ == "__main__":
files = Path(readpath).glob("*.py")
ptAttribs = {}
for scriptFile in files:
- attribs = get_attributes(scriptFile)
+ attribs = get_attributes_from_file(scriptFile)
if attribs:
ptAttribs[scriptFile.stem] = attribs
diff --git a/korman/plasma_magic.py b/korman/plasma_magic.py
new file mode 100644
index 0000000..ed89987
--- /dev/null
+++ b/korman/plasma_magic.py
@@ -0,0 +1,220 @@
+# 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 .
+
+very_very_special_python = """
+from Plasma import *
+from PlasmaTypes import *
+
+class {age_name}(ptResponder):
+ pass
+"""
+
+very_very_special_sdl = """
+#==============================================================
+# This VeryVerySpecial SDL File was automatically generated
+# by Korman. Have a nice day!
+#
+# READ: When modifying an SDL record, do *not* modify the
+# existing record. You must copy and paste a new version
+# below the current one and make your changes there.
+#==============================================================
+
+STATEDESC {age_name}
+{{
+ VERSION 0
+}}
+
+"""
+
+# Copypasta (with small fixes for str.format) of glue.py from CWE's moul-scripts
+plasma_python_glue = """
+glue_cl = None
+glue_inst = None
+glue_params = None
+glue_paramKeys = None
+try:
+ x = glue_verbose
+except NameError:
+ glue_verbose = 0
+def glue_getClass():
+ global glue_cl
+ if glue_cl == None:
+ try:
+ cl = globals()[glue_name]
+ if issubclass(cl,ptModifier):
+ glue_cl = cl
+ else:
+ if glue_verbose:
+ print "Class %s is not derived from modifier" % (cl.__name__)
+ except:
+ if glue_verbose:
+ try:
+ print "Could not find class %s" % (glue_name)
+ except NameError:
+ print "Filename/classname not set!"
+ return glue_cl
+def glue_getInst():
+ global glue_inst
+ if type(glue_inst) == type(None):
+ cl = glue_getClass()
+ if cl != None:
+ glue_inst = cl()
+ return glue_inst
+def glue_delInst():
+ global glue_inst
+ global glue_cl
+ global glue_params
+ global glue_paramKeys
+ if type(glue_inst) != type(None):
+ del glue_inst
+ glue_cl = None
+ glue_params = None
+ glue_paramKeys = None
+def glue_getVersion():
+ inst = glue_getInst()
+ ver = inst.version
+ glue_delInst()
+ return ver
+def glue_findAndAddAttribs(obj, glue_params):
+ if isinstance(obj,ptAttribute):
+ if glue_params.has_key(obj.id):
+ if glue_verbose:
+ print "WARNING: Duplicate attribute ids!"
+ print "%s has id %d which is already defined in %s" % (obj.name, obj.id, glue_params[obj.id].name)
+ else:
+ glue_params[obj.id] = obj
+ elif type(obj) == type([]):
+ for o in obj:
+ glue_findAndAddAttribs(o, glue_params)
+ elif type(obj) == type(dict()):
+ for o in obj.values():
+ glue_findAndAddAttribs(o, glue_params)
+ elif type(obj) == type( () ):
+ for o in obj:
+ glue_findAndAddAttribs(o, glue_params)
+
+def glue_getParamDict():
+ global glue_params
+ global glue_paramKeys
+ if type(glue_params) == type(None):
+ glue_params = dict()
+ gd = globals()
+ for obj in gd.values():
+ glue_findAndAddAttribs(obj, glue_params)
+ # rebuild the parameter sorted key list
+ glue_paramKeys = glue_params.keys()
+ glue_paramKeys.sort()
+ glue_paramKeys.reverse()
+ return glue_params
+def glue_getClassName():
+ cl = glue_getClass()
+ if cl != None:
+ return cl.__name__
+ if glue_verbose:
+ print "Class not found in %s.py" % (glue_name)
+ return None
+def glue_getBlockID():
+ inst = glue_getInst()
+ if inst != None:
+ return inst.id
+ if glue_verbose:
+ print "Instance could not be created in %s.py" % (glue_name)
+ return None
+def glue_getNumParams():
+ pd = glue_getParamDict()
+ if pd != None:
+ return len(pd)
+ if glue_verbose:
+ print "No attributes found in %s.py" % (glue_name)
+ return 0
+def glue_getParam(number):
+ global glue_paramKeys
+ pd = glue_getParamDict()
+ if pd != None:
+ # see if there is a paramKey list
+ if type(glue_paramKeys) == type([]):
+ if number >= 0 and number < len(glue_paramKeys):
+ return pd[glue_paramKeys[number]].getdef()
+ else:
+ print "glue_getParam: Error! %d out of range of attribute list" % (number)
+ else:
+ pl = pd.values()
+ if number >= 0 and number < len(pl):
+ return pl[number].getdef()
+ else:
+ if glue_verbose:
+ print "glue_getParam: Error! %d out of range of attribute list" % (number)
+ if glue_verbose:
+ print "GLUE: Attribute list error"
+ return None
+def glue_setParam(id,value):
+ pd = glue_getParamDict()
+ if pd != None:
+ if pd.has_key(id):
+ try:
+ pd[id].__setvalue__(value)
+ except AttributeError:
+ if isinstance(pd[id],ptAttributeList):
+ try:
+ if type(pd[id].value) != type([]):
+ pd[id].value = []
+ except AttributeError:
+ pd[id].value = []
+ pd[id].value.append(value)
+ else:
+ pd[id].value = value
+ else:
+ if glue_verbose:
+ print "setParam: can't find id=",id
+ else:
+ print "setParma: Something terribly has gone wrong. Head for the cover."
+def glue_isNamedAttribute(id):
+ pd = glue_getParamDict()
+ if pd != None:
+ try:
+ if isinstance(pd[id],ptAttribNamedActivator):
+ return 1
+ if isinstance(pd[id],ptAttribNamedResponder):
+ return 2
+ except KeyError:
+ if glue_verbose:
+ print "Could not find id=%d attribute" % (id)
+ return 0
+def glue_isMultiModifier():
+ inst = glue_getInst()
+ if isinstance(inst,ptMultiModifier):
+ return 1
+ return 0
+def glue_getVisInfo(number):
+ global glue_paramKeys
+ pd = glue_getParamDict()
+ if pd != None:
+ # see if there is a paramKey list
+ if type(glue_paramKeys) == type([]):
+ if number >= 0 and number < len(glue_paramKeys):
+ return pd[glue_paramKeys[number]].getVisInfo()
+ else:
+ print "glue_getVisInfo: Error! %d out of range of attribute list" % (number)
+ else:
+ pl = pd.values()
+ if number >= 0 and number < len(pl):
+ return pl[number].getVisInfo()
+ else:
+ if glue_verbose:
+ print "glue_getVisInfo: Error! %d out of range of attribute list" % (number)
+ if glue_verbose:
+ print "GLUE: Attribute list error"
+ return None
+"""
diff --git a/korman/properties/__init__.py b/korman/properties/__init__.py
index d8ea5e6..741a519 100644
--- a/korman/properties/__init__.py
+++ b/korman/properties/__init__.py
@@ -21,6 +21,7 @@ from .prop_lamp import *
from . import modifiers
from .prop_object import *
from .prop_scene import *
+from .prop_text import *
from .prop_texture import *
from .prop_world import *
@@ -32,6 +33,7 @@ def register():
bpy.types.Object.plasma_net = bpy.props.PointerProperty(type=PlasmaNet)
bpy.types.Object.plasma_object = bpy.props.PointerProperty(type=PlasmaObject)
bpy.types.Scene.plasma_scene = bpy.props.PointerProperty(type=PlasmaScene)
+ bpy.types.Text.plasma_text = bpy.props.PointerProperty(type=PlasmaText)
bpy.types.Texture.plasma_layer = bpy.props.PointerProperty(type=PlasmaLayer)
bpy.types.World.plasma_age = bpy.props.PointerProperty(type=PlasmaAge)
bpy.types.World.plasma_fni = bpy.props.PointerProperty(type=PlasmaFni)
diff --git a/korman/properties/modifiers/gui.py b/korman/properties/modifiers/gui.py
index 257c3bb..2c42f9d 100644
--- a/korman/properties/modifiers/gui.py
+++ b/korman/properties/modifiers/gui.py
@@ -20,8 +20,8 @@ import mathutils
from bpy.props import *
from PyHSPlasma import *
+from ...addon_prefs import game_versions
from .base import PlasmaModifierProperties, PlasmaModifierLogicWiz
-from .logic import game_versions
from ... import idprops
@@ -180,15 +180,16 @@ class PlasmaJournalBookModifier(PlasmaModifierProperties, PlasmaModifierLogicWiz
# Assign journal script based on target version
journal_pfm = journal_pfms[version]
journalnode = nodes.new("PlasmaPythonFileNode")
- journalnode.filename = journal_pfm["filename"]
-
- # Manually add required attributes to the PFM
- journal_attribs = journal_pfm["attribs"]
- for attr in journal_attribs:
- new_attr = journalnode.attributes.add()
- new_attr.attribute_id = attr["id"]
- new_attr.attribute_type = attr["type"]
- new_attr.attribute_name = attr["name"]
+ with journalnode.NoUpdate():
+ journalnode.filename = journal_pfm["filename"]
+
+ # Manually add required attributes to the PFM
+ journal_attribs = journal_pfm["attribs"]
+ for attr in journal_attribs:
+ new_attr = journalnode.attributes.add()
+ new_attr.attribute_id = attr["id"]
+ new_attr.attribute_type = attr["type"]
+ new_attr.attribute_name = attr["name"]
journalnode.update()
if version == pvPrime:
diff --git a/korman/properties/modifiers/logic.py b/korman/properties/modifiers/logic.py
index 2dea10e..261bc02 100644
--- a/korman/properties/modifiers/logic.py
+++ b/korman/properties/modifiers/logic.py
@@ -17,8 +17,8 @@ import bpy
from bpy.props import *
from PyHSPlasma import *
+from ...addon_prefs import game_versions
from .base import PlasmaModifierProperties
-from ..prop_world import game_versions
from ...exporter import ExportError
from ... import idprops
diff --git a/korman/properties/modifiers/sound.py b/korman/properties/modifiers/sound.py
index db3a297..49d5893 100644
--- a/korman/properties/modifiers/sound.py
+++ b/korman/properties/modifiers/sound.py
@@ -166,6 +166,9 @@ class PlasmaSound(idprops.IDPropMixin, bpy.types.PropertyGroup):
header, dataSize = self._get_sound_info()
length = dataSize / header.avgBytesPerSec
+ # HAX: Ensure that the sound file is copied to game, if applicable.
+ exporter.output.add_sfx(self._sound)
+
# There is some bug in the MOUL code that causes a crash if this does not match the expected
# result. There's no sense in debugging that though--the user should never specify
# streaming vs static. That's an implementation detail.
diff --git a/korman/properties/prop_text.py b/korman/properties/prop_text.py
new file mode 100644
index 0000000..3746cf7
--- /dev/null
+++ b/korman/properties/prop_text.py
@@ -0,0 +1,22 @@
+# 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 bpy
+from bpy.props import *
+
+class PlasmaText(bpy.types.PropertyGroup):
+ package = BoolProperty(name="Export",
+ description="Package this file in the age export",
+ options=set())
diff --git a/korman/properties/prop_world.py b/korman/properties/prop_world.py
index 9f32b43..1e159a5 100644
--- a/korman/properties/prop_world.py
+++ b/korman/properties/prop_world.py
@@ -17,9 +17,7 @@ import bpy
from bpy.props import *
from PyHSPlasma import *
-game_versions = [("pvPrime", "Ages Beyond Myst (63.11)", "Targets the original Uru (Live) game"),
- ("pvPots", "Path of the Shell (63.12)", "Targets the most recent offline expansion pack"),
- ("pvMoul", "Myst Online: Uru Live (70)", "Targets the most recent online game")]
+from ..addon_prefs import game_versions
class PlasmaFni(bpy.types.PropertyGroup):
bl_idname = "world.plasma_fni"
@@ -58,23 +56,9 @@ class PlasmaFni(bpy.types.PropertyGroup):
min=1)
-class PlasmaGame(bpy.types.PropertyGroup):
- name = StringProperty(name="Name",
- description="Name of the Plasma Game",
- options=set())
- path = StringProperty(name="Path",
- description="Path to this Plasma Game",
- options=set())
- version = EnumProperty(name="Version",
- description="Plasma version of this game",
- items=game_versions,
- options=set())
-
-
class PlasmaGames(bpy.types.PropertyGroup):
bl_idname = "world.plasma_games"
- games = CollectionProperty(type=PlasmaGame)
active_game_index = IntProperty(options={"HIDDEN"})
@property
diff --git a/korman/ui/__init__.py b/korman/ui/__init__.py
index 3369bb7..a292060 100644
--- a/korman/ui/__init__.py
+++ b/korman/ui/__init__.py
@@ -21,6 +21,7 @@ from .ui_menus import *
from .ui_modifiers import *
from .ui_object import *
from .ui_render_layer import *
+from .ui_text import *
from .ui_texture import *
from .ui_toolbox import *
from .ui_world import *
diff --git a/korman/ui/ui_text.py b/korman/ui/ui_text.py
new file mode 100644
index 0000000..4d3fd12
--- /dev/null
+++ b/korman/ui/ui_text.py
@@ -0,0 +1,28 @@
+# 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 bpy
+
+class PlasmaTextEditorHeader(bpy.types.Header):
+ bl_space_type = "TEXT_EDITOR"
+
+ def draw(self, context):
+ layout, text = self.layout, context.space_data.text
+
+ if text is not None:
+ is_py = text.name.endswith(".py")
+ row = layout.row(align=True)
+ row.enabled = is_py
+ row.prop(text.plasma_text, "package")
diff --git a/korman/ui/ui_world.py b/korman/ui/ui_world.py
index c5580c4..6618bfb 100644
--- a/korman/ui/ui_world.py
+++ b/korman/ui/ui_world.py
@@ -16,7 +16,7 @@
import bpy
from pathlib import Path
-from ..korlib import ConsoleToggler
+from .. import korlib
class AgeButtonsPanel:
@@ -34,41 +34,56 @@ class PlasmaGamePanel(AgeButtonsPanel, bpy.types.Panel):
def draw(self, context):
layout = self.layout
+ prefs = context.user_preferences.addons["korman"].preferences
games = context.world.plasma_games
age = context.world.plasma_age
row = layout.row()
- row.template_list("PlasmaGameList", "games", games, "games", games,
+ # Remember: game storage moved to addon preferences!
+ row.template_list("PlasmaGameListRO", "games", prefs, "games", games,
"active_game_index", rows=2)
- col = row.column(align=True)
- col.operator("world.plasma_game_add", icon="ZOOMIN", text="")
- col.operator("world.plasma_game_remove", icon="ZOOMOUT", text="")
+ row.operator("ui.korman_open_prefs", icon="PREFERENCES", text="")
- # Game Properties
+ # Game Tools
active_game_index = games.active_game_index
- if active_game_index < len(games.games):
- active_game = games.games[active_game_index]
-
- layout.separator()
- box = layout.box()
+ if active_game_index < len(prefs.games):
+ active_game = prefs.games[active_game_index]
+ else:
+ active_game = None
- 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")
- op.filepath = active_game.path
- op.game_index = active_game_index
- row = row.row(align=True)
- row.operator_context = "EXEC_DEFAULT"
- row.enabled = bool(age.age_name.strip())
- op = row.operator("export.plasma_age", icon="EXPORT")
+ layout.separator()
+ row = layout.row(align=True)
+ legal_game = bool(age.age_name.strip()) and active_game is not None
+
+ row.operator_context = "EXEC_DEFAULT"
+ row.enabled = legal_game
+ op = row.operator("export.plasma_age", icon="EXPORT")
+ if active_game is not None:
+ op.dat_only = False
op.filepath = str((Path(active_game.path) / "dat" / age.age_name).with_suffix(".age"))
op.version = active_game.version
+ row = row.row(align=True)
+ row.enabled = legal_game
+ row.operator_context = "INVOKE_DEFAULT"
+ op = row.operator("export.plasma_age", icon="PACKAGE", text="Package Age")
+ if active_game is not None:
+ op.dat_only = False
+ op.filepath = "{}.zip".format(age.age_name)
+ op.version = active_game.version
+ row = row.row(align=True)
+ row.operator_context = "EXEC_DEFAULT"
+ row.enabled = legal_game and active_game.version != "pvMoul"
+ op = row.operator("export.plasma_pak", icon="FILE_SCRIPT")
+ if active_game is not None:
+ op.filepath = str((Path(active_game.path) / "Python" / age.age_name).with_suffix(".pak"))
+ op.version = active_game.version
+
+class PlasmaGameListRO(bpy.types.UIList):
+ def draw_item(self, context, layout, data, item, icon, active_data, active_property, index=0, flt_flag=0):
+ layout.label(item.name, icon="BOOKMARKS")
-class PlasmaGameList(bpy.types.UIList):
+class PlasmaGameListRW(bpy.types.UIList):
def draw_item(self, context, layout, data, item, icon, active_data, active_property, index=0, flt_flag=0):
layout.prop(item, "name", text="", emboss=False, icon="BOOKMARKS")
@@ -113,6 +128,9 @@ class PlasmaAgePanel(AgeButtonsPanel, bpy.types.Panel):
col.prop(active_page, "seq_suffix")
col.prop_menu_enum(active_page, "version")
+ # Age Names should really be legal Python 2.x identifiers for AgeSDLHooks
+ legal_identifier = korlib.is_legal_python2_identifier(age.age_name)
+
# Core settings
layout.separator()
split = layout.split()
@@ -125,15 +143,23 @@ class PlasmaAgePanel(AgeButtonsPanel, bpy.types.Panel):
col = split.column()
col.label("Age Settings:")
col.prop(age, "seq_prefix", text="ID")
- col.alert = not age.age_name.strip()
+ col.alert = not legal_identifier or '_' in age.age_name
col.prop(age, "age_name", text="")
+ # Display a hint if the identifier is illegal
+ if not legal_identifier:
+ if korlib.is_python_keyword(age.age_name):
+ layout.label(text="Ages should not be named the same as a Python keyword", icon="ERROR")
+ elif age.age_sdl:
+ fixed_identifier = korlib.replace_python2_identifier(age.age_name)
+ layout.label(text="Age's SDL will use the name '{}'".format(fixed_identifier), icon="ERROR")
+
layout.separator()
split = layout.split()
col = split.column()
col.label("Export Settings:")
- col.enabled = ConsoleToggler.is_platform_supported()
+ col.enabled = korlib.ConsoleToggler.is_platform_supported()
col.prop(age, "verbose")
col.prop(age, "show_console")
@@ -145,6 +171,7 @@ class PlasmaAgePanel(AgeButtonsPanel, bpy.types.Panel):
layout.separator()
layout.prop(age, "envmap_method")
layout.prop(age, "lighting_method")
+ layout.prop(age, "python_method")
layout.prop(age, "texcache_method")