Browse Source

Merge pull request #128 from Hoikas/python-pak

Python Packing
pull/133/head
Adam Johnson 6 years ago committed by GitHub
parent
commit
f2c87c9d4e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 8
      installer/Installer.nsi
  2. 4
      korman/__init__.py
  3. 148
      korman/addon_prefs.py
  4. 1
      korman/exporter/__init__.py
  5. 49
      korman/exporter/convert.py
  6. 9
      korman/exporter/explosions.py
  7. 31
      korman/exporter/logger.py
  8. 86
      korman/exporter/manager.py
  9. 429
      korman/exporter/outfile.py
  10. 177
      korman/exporter/python.py
  11. 73
      korman/exporter/sumfile.py
  12. 36
      korman/korlib/__init__.py
  13. 202
      korman/korlib/python.py
  14. 129
      korman/nodes/node_python.py
  15. 145
      korman/operators/op_export.py
  16. 16
      korman/operators/op_nodes.py
  17. 15
      korman/operators/op_ui.py
  18. 60
      korman/operators/op_world.py
  19. 16
      korman/plasma_attributes.py
  20. 220
      korman/plasma_magic.py
  21. 2
      korman/properties/__init__.py
  22. 3
      korman/properties/modifiers/gui.py
  23. 2
      korman/properties/modifiers/logic.py
  24. 3
      korman/properties/modifiers/sound.py
  25. 22
      korman/properties/prop_text.py
  26. 18
      korman/properties/prop_world.py
  27. 1
      korman/ui/__init__.py
  28. 28
      korman/ui/ui_text.py
  29. 71
      korman/ui/ui_world.py

8
installer/Installer.nsi

@ -211,6 +211,14 @@ SectionGroup /e "Korman"
SectionEnd SectionEnd
SectionGroupEnd 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 Section #TheRemover
WriteRegStr HKLM "Software\Korman" "" $INSTDIR WriteRegStr HKLM "Software\Korman" "" $INSTDIR
WriteUninstaller "$INSTDIR\korman_uninstall.exe" WriteUninstaller "$INSTDIR\korman_uninstall.exe"

4
korman/__init__.py

@ -14,6 +14,7 @@
# along with Korman. If not, see <http://www.gnu.org/licenses/>. # along with Korman. If not, see <http://www.gnu.org/licenses/>.
import bpy import bpy
from . import addon_prefs
from . import exporter, render from . import exporter, render
from . import properties, ui from . import properties, ui
from . import nodes from . import nodes
@ -26,8 +27,7 @@ bl_info = {
"location": "File > Import-Export", "location": "File > Import-Export",
"description": "Exporter for Cyan Worlds' Plasma Engine", "description": "Exporter for Cyan Worlds' Plasma Engine",
"warning": "beta", "warning": "beta",
"category": "System", # Eventually, we will hide some of the default "category": "System",
# Blender panels (think materials)
} }

148
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 <http://www.gnu.org/licenses/>.
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)

1
korman/exporter/__init__.py

@ -18,4 +18,5 @@ from PyHSPlasma import *
from .convert import * from .convert import *
from .explosions import * from .explosions import *
from .python import *
from . import utils from . import utils

49
korman/exporter/convert.py

@ -27,9 +27,9 @@ from . import image
from . import logger from . import logger
from . import manager from . import manager
from . import mesh from . import mesh
from . import outfile
from . import physics from . import physics
from . import rtlight from . import rtlight
from . import sumfile
from . import utils from . import utils
class Exporter: class Exporter:
@ -40,10 +40,6 @@ class Exporter:
self.node_trees_exported = set() self.node_trees_exported = set()
self.want_node_trees = {} self.want_node_trees = {}
@property
def age_name(self):
return Path(self._op.filepath).stem
def run(self): def run(self):
log = logger.ExportVerboseLogger if self._op.verbose else logger.ExportProgressLogger log = logger.ExportVerboseLogger if self._op.verbose else logger.ExportProgressLogger
with ConsoleToggler(self._op.show_console), log(self._op.filepath) as self.report: 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.physics = physics.PhysicsConverter(self)
self.light = rtlight.LightConverter(self) self.light = rtlight.LightConverter(self)
self.animation = animation.AnimationConverter(self) self.animation = animation.AnimationConverter(self)
self.sumfile = sumfile.SumFile() self.output = outfile.OutputFiles(self, self._op.filepath)
self.camera = camera.CameraConverter(self) self.camera = camera.CameraConverter(self)
self.image = image.ImageCache(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 Scene Objects")
self.report.progress_add_step("Exporting Logic Nodes") self.report.progress_add_step("Exporting Logic Nodes")
self.report.progress_add_step("Finalizing Plasma Logic") 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("Exporting Textures")
self.report.progress_add_step("Composing Geometry") self.report.progress_add_step("Composing Geometry")
self.report.progress_add_step("Saving Age Files") self.report.progress_add_step("Saving Age Files")
@ -98,6 +95,9 @@ class Exporter:
# processing that needs to inspect those objects # processing that needs to inspect those objects
self._post_process_scene_objects() self._post_process_scene_objects()
# Step 3.3: Ensure any helper Python files are packed
self._pack_ancillary_python()
# Step 4: Finalize... # Step 4: Finalize...
self.mesh.material.finalize() self.mesh.material.finalize()
self.mesh.finalize() self.mesh.finalize()
@ -111,6 +111,11 @@ class Exporter:
self.report.progress_end() self.report.progress_end()
self.report.save() 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): def _bake_static_lighting(self):
lighting_method = self._op.lighting_method lighting_method = self._op.lighting_method
if lighting_method != "skip": if lighting_method != "skip":
@ -345,15 +350,45 @@ class Exporter:
proc(self, bl_obj, sceneobject) proc(self, bl_obj, sceneobject)
inc_progress() 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): def _save_age(self):
self.report.progress_advance() self.report.progress_advance()
self.mgr.save_age(Path(self._op.filepath)) 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() 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 @property
def envmap_method(self): def envmap_method(self):
return bpy.context.scene.world.plasma_age.envmap_method return bpy.context.scene.world.plasma_age.envmap_method
@property
def python_method(self):
return bpy.context.scene.world.plasma_age.python_method
@property @property
def texcache_path(self): def texcache_path(self):
age = bpy.context.scene.world.plasma_age age = bpy.context.scene.world.plasma_age

9
korman/exporter/explosions.py

@ -13,6 +13,15 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with Korman. If not, see <http://www.gnu.org/licenses/>. # along with Korman. If not, see <http://www.gnu.org/licenses/>.
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): class ExportError(Exception):
def __init__(self, value="Undefined Export Error"): def __init__(self, value="Undefined Export Error"):
super(Exception, self).__init__(value) super(Exception, self).__init__(value)

31
korman/exporter/logger.py

@ -14,6 +14,7 @@
# along with Korman. If not, see <http://www.gnu.org/licenses/>. # along with Korman. If not, see <http://www.gnu.org/licenses/>.
from ..korlib import ConsoleToggler from ..korlib import ConsoleToggler
from .explosions import NonfatalExportError
from pathlib import Path from pathlib import Path
import threading import threading
import time import time
@ -24,6 +25,7 @@ _MAX_TIME_UNTIL_ELIPSES = 2.0
class _ExportLogger: class _ExportLogger:
def __init__(self, print_logs, age_path=None): def __init__(self, print_logs, age_path=None):
self._errors = []
self._porting = [] self._porting = []
self._warnings = [] self._warnings = []
self._age_path = Path(age_path) if age_path is not None else None 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): def __exit__(self, type, value, traceback):
if value is not None: if value is not None:
ConsoleToggler().keep_console = True ConsoleToggler().keep_console = not isinstance(value, NonfatalExportError)
self._file.close() self._file.close()
return False 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): def msg(self, *args, **kwargs):
assert args assert args
indent = kwargs.get("indent", 0) indent = kwargs.get("indent", 0)
@ -67,7 +82,8 @@ class _ExportLogger:
self._file.writelines((msg, "\n")) self._file.writelines((msg, "\n"))
if self._print_logs: if self._print_logs:
print(msg) 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): def progress_add_step(self, name):
@ -92,6 +108,14 @@ class _ExportLogger:
self.msg("Exporting '{}'", self._age_path.name) self.msg("Exporting '{}'", self._age_path.name)
self._time_start_overall = time.perf_counter() 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): def save(self):
# TODO # TODO
pass pass
@ -106,7 +130,8 @@ class _ExportLogger:
self._file.writelines((msg, "\n")) self._file.writelines((msg, "\n"))
if self._print_logs: if self._print_logs:
print(msg) 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): class ExportProgressLogger(_ExportLogger):

86
korman/exporter/manager.py

@ -19,6 +19,8 @@ from PyHSPlasma import *
import weakref import weakref
from . import explosions from . import explosions
from .. import korlib
from ..plasma_magic import *
# These objects have to be in the plSceneNode pool in order to be loaded... # 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. # 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): def create_builtins(self, age, textures):
# BuiltIn.prp # BuiltIn.prp
if bpy.context.scene.world.plasma_age.age_sdl: if bpy.context.scene.world.plasma_age.age_sdl:
builtin = self.create_page(age, "BuiltIn", -2, True) self._create_builtin_pages(age)
sdl = self.add_object(plSceneObject, name="AgeSDLHook", loc=builtin) self._pack_agesdl_hook(age)
pfm = self.add_object(plPythonFileMod, name="VeryVerySpecialPythonFileMod", so=sdl)
pfm.filename = age
# Textures.prp # Textures.prp
if textures: if textures:
self.create_page(age, "Textures", -1, True) 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): def create_page(self, age, name, id, builtin=False):
location = plLocation(self.mgr.getVer()) location = plLocation(self.mgr.getVer())
location.prefix = bpy.context.scene.world.plasma_age.seq_prefix location.prefix = bpy.context.scene.world.plasma_age.seq_prefix
@ -233,27 +239,46 @@ class ExportManager:
else: else:
return key.location return key.location
def save_age(self, path): def _pack_agesdl_hook(self, age):
ageName = path.stem get_text = bpy.data.texts.get
sumfile = self._exporter().sumfile output = self._exporter().output
sumfile.append(path) # AgeSDL Hook Python
self.mgr.WriteAge(str(path), self._age_info) fixed_agename = korlib.replace_python2_identifier(age)
self._write_fni(path) py_filename = "{}.py".format(fixed_agename)
self._write_pages(path) 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: def save_age(self):
sumpath = path.with_suffix(".sum") self._write_age()
sumfile.write(sumpath, self.getVer()) self._write_fni()
self._write_pages()
def _write_fni(self, path): def _write_age(self):
if self.mgr.getVer() <= pvMoul: f = "{}.age".format(self._age_info.name)
enc = plEncryptedStream.kEncXtea output = self._exporter().output
else:
enc = plEncryptedStream.kEncAES
fname = path.with_suffix(".fni")
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 fni = bpy.context.scene.world.plasma_fni
stream.writeLine("Graphics.Renderer.SetClearColor {} {} {}".format(*fni.clear_color)) stream.writeLine("Graphics.Renderer.SetClearColor {} {} {}".format(*fni.clear_color))
if fni.fog_method != "none": if fni.fog_method != "none":
@ -263,17 +288,14 @@ class ExportManager:
elif fni.fog_method == "exp2": elif fni.fog_method == "exp2":
stream.writeLine("Graphics.Renderer.Fog.SetDefExp2 {} {}".format(fni.fog_end, fni.fog_density)) stream.writeLine("Graphics.Renderer.Fog.SetDefExp2 {} {}".format(fni.fog_end, fni.fog_density))
stream.writeLine("Graphics.Renderer.SetYon {}".format(fni.yon)) 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(): for loc in self._pages.values():
page = self.mgr.FindPage(loc) # not cached because it's C++ owned 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 chapter = "_District_" if self.mgr.getVer() <= pvMoul else "_"
# to do some looping and stuff. This is easier. f = "{}{}{}.prp".format(age_name, chapter, page.page)
if self.mgr.getVer() <= pvMoul:
chapter = "_District_" with output.generate_dat_file(f) as stream:
else: self.mgr.WritePage(stream, page)
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)

429
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 <http://www.gnu.org/licenses/>.
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()

177
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 <http://www.gnu.org/licenses/>.
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()

73
korman/exporter/sumfile.py

@ -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 <http://www.gnu.org/licenses/>.
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)

36
korman/korlib/__init__.py

@ -73,8 +73,16 @@ else:
finally: finally:
from .console import ConsoleToggler from .console import ConsoleToggler
from .python import *
from .texture import TEX_DETAIL_ALPHA, TEX_DETAIL_ADD, TEX_DETAIL_MULTIPLY 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): def _wave_chunks(stream):
while not stream.eof(): while not stream.eof():
chunk_name = stream.read(4) chunk_name = stream.read(4)
@ -100,3 +108,31 @@ finally:
header.read(stream) header.read(stream)
return chunks[b"data"]["size"] 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 ""

202
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 <http://www.gnu.org/licenses/>.
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 = "<string>"
py_code_source = sys.stdin.read()
py_code_object = _compyle(module_name, py_code_source)
sys.stdout.write(py_code_object)

129
korman/nodes/node_python.py

@ -15,10 +15,12 @@
import bpy import bpy
from bpy.props import * from bpy.props import *
from contextlib import contextmanager
from pathlib import Path from pathlib import Path
from PyHSPlasma import * from PyHSPlasma import *
from .node_core import * from .node_core import *
from .node_deprecated import PlasmaVersionedNode
from .. import idprops from .. import idprops
_single_user_attribs = { _single_user_attribs = {
@ -166,30 +168,40 @@ class PlasmaAttribute(bpy.types.PropertyGroup):
simple_value = property(_get_simple_value, _set_simple_value) 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_category = "PYTHON"
bl_idname = "PlasmaPythonFileNode" bl_idname = "PlasmaPythonFileNode"
bl_label = "Python File" bl_label = "Python File"
bl_width_default = 210 bl_width_default = 290
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
def _update_pyfile(self, context): 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.attributes.clear()
self.inputs.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 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)
filename = StringProperty(name="File", # This property exists for UI purposes ONLY
description="Python Filename") package = BoolProperty(options={"HIDDEN", "SKIP_SAVE"})
filepath = StringProperty(update=_update_pyfile,
options={"HIDDEN"})
attributes = CollectionProperty(type=PlasmaAttribute, options={"HIDDEN"}) attributes = CollectionProperty(type=PlasmaAttribute, options={"HIDDEN"})
no_update = BoolProperty(default=False, options={"HIDDEN", "SKIP_SAVE"}) 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 } return { i.attribute_id: i for i in self.attributes }
def draw_buttons(self, context, layout): def draw_buttons(self, context, layout):
row = layout.row(align=True) main_row = layout.row(align=True)
if self.filename: row = main_row.row(align=True)
row.prop(self, "filename") row.alert = self.text_id is None and bool(self.filename)
try: row.prop(self, "text_id", text="Script")
if Path(self.filepath).exists(): # open operator
operator = row.operator("node.plasma_attributes_to_node", icon="FILE_REFRESH", text="") operator = main_row.operator("file.plasma_file_picker", icon="FILESEL", 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)
operator.filter_glob = "*.py" operator.filter_glob = "*.py"
operator.data_path = self.node_path operator.data_path = self.node_path
operator.filepath_property = "filepath"
operator.filename_property = "filename" 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): def get_key(self, exporter, so):
return exporter.mgr.find_create_key(plPythonFileMod, name=self.key_name, so=so) return exporter.mgr.find_create_key(plPythonFileMod, name=self.key_name, so=so)
def export(self, exporter, bo, so): def export(self, exporter, bo, so):
pfm = self.get_key(exporter, so).object 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) attrib_sockets = (i for i in self.inputs if i.is_linked)
for socket in attrib_sockets: for socket in attrib_sockets:
attrib = socket.attribute_type attrib = socket.attribute_type
@ -273,10 +310,18 @@ class PlasmaPythonFileNode(PlasmaNodeBase, bpy.types.Node):
if not is_init and new_pos != old_pos: if not is_init and new_pos != old_pos:
self.inputs.move(old_pos, new_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): def update(self):
if self.no_update: if self.no_update:
return 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 # 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... # happens in PlasmaAttribNodeBase, but we can link much more than those node types...
toasty_sockets = [] toasty_sockets = []
@ -312,6 +357,26 @@ class PlasmaPythonFileNode(PlasmaNodeBase, bpy.types.Node):
while len(unconnected) > 1: while len(unconnected) > 1:
self.inputs.remove(unconnected.pop()) 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): class PlasmaPythonFileNodeSocket(PlasmaNodeSocketBase, bpy.types.NodeSocket):
attribute_id = IntProperty(options={"HIDDEN"}) attribute_id = IntProperty(options={"HIDDEN"})

145
korman/operators/op_export.py

@ -17,14 +17,34 @@ import bpy
from bpy.props import * from bpy.props import *
import cProfile import cProfile
from pathlib import Path from pathlib import Path
from PyHSPlasma import *
import pstats import pstats
from ..addon_prefs import game_versions
from .. import exporter from .. import exporter
from ..helpers import UiHelper from ..helpers import UiHelper
from ..properties.prop_world import PlasmaAge, game_versions from .. import korlib
from ..korlib import ConsoleToggler 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""" """Exports ages for Cyan Worlds' Plasma Engine"""
bl_idname = "export.plasma_age" 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")], ("perengine", "Export Supported EnvMaps", "Only environment maps supported by the selected game engine are exported")],
"default": "dcm2dem"}), "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", "export_active": (BoolProperty, {"name": "INTERNAL: Export currently running",
"default": False, "default": False,
"options": {"SKIP_SAVE"}}), "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... # This wigs out and very bad things happen if it's not directly on the operator...
filepath = StringProperty(subtype="FILE_PATH") filepath = StringProperty(subtype="FILE_PATH")
filter_glob = StringProperty(default="*.age", options={'HIDDEN'}) filter_glob = StringProperty(default="*.age;*.zip", options={'HIDDEN'})
version = EnumProperty(name="Version", version = EnumProperty(name="Version",
description="Plasma version to export this age for", description="Plasma version to export this age for",
@ -86,6 +114,11 @@ class ExportOperator(bpy.types.Operator):
default="pvPots", default="pvPots",
options=set()) 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): def draw(self, context):
layout = self.layout layout = self.layout
age = context.scene.world.plasma_age age = context.scene.world.plasma_age
@ -95,7 +128,7 @@ class ExportOperator(bpy.types.Operator):
layout.prop(age, "texcache_method", text="") layout.prop(age, "texcache_method", text="")
layout.prop(age, "lighting_method") layout.prop(age, "lighting_method")
row = layout.row() row = layout.row()
row.enabled = ConsoleToggler.is_platform_supported() row.enabled = korlib.ConsoleToggler.is_platform_supported()
row.prop(age, "show_console") row.prop(age, "show_console")
layout.prop(age, "verbose") layout.prop(age, "verbose")
layout.prop(age, "profile_export") layout.prop(age, "profile_export")
@ -111,14 +144,6 @@ class ExportOperator(bpy.types.Operator):
else: else:
super().__setattr__(attr, value) 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): def execute(self, context):
# Before we begin, do some basic sanity checking... # Before we begin, do some basic sanity checking...
path = Path(self.filepath) path = Path(self.filepath)
@ -132,13 +157,18 @@ class ExportOperator(bpy.types.Operator):
except: except:
self.report({"ERROR"}, "Failed to create export directory") self.report({"ERROR"}, "Failed to create export directory")
return {"CANCELLED"} return {"CANCELLED"}
path.touch()
# We need to back out of edit mode--this ensures that all changes are committed # We need to back out of edit mode--this ensures that all changes are committed
if context.mode != "OBJECT": if context.mode != "OBJECT":
bpy.ops.object.mode_set(mode="OBJECT") bpy.ops.object.mode_set(mode="OBJECT")
# Separate blender operator and actual export logic for my sanity
ageName = path.stem 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: with UiHelper(context) as _ui:
e = exporter.Exporter(self) e = exporter.Exporter(self)
try: try:
@ -151,6 +181,9 @@ class ExportOperator(bpy.types.Operator):
except exporter.ExportError as error: except exporter.ExportError as error:
self.report({"ERROR"}, str(error)) self.report({"ERROR"}, str(error))
return {"CANCELLED"} return {"CANCELLED"}
except exporter.NonfatalExportError as error:
self.report({"ERROR"}, str(error))
return {"FINISHED"}
else: else:
if self.profile_export: if self.profile_export:
stats_out = path.with_name("{}_profile.log".format(ageName)) 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 # Called when a user hits "export" from the menu
# We will prompt them for the export info, then call execute() # We will prompt them for the export info, then call execute()
if not self.filepath: if not self.filepath:
blend_filepath = context.blend_data.filepath bfp = self._get_default_path(context)
if not blend_filepath: self.filepath = str(Path(bfp).with_suffix(".age"))
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"))
context.window_manager.fileselect_add(self) context.window_manager.fileselect_add(self)
return {"RUNNING_MODAL"} return {"RUNNING_MODAL"}
@ -189,11 +218,85 @@ class ExportOperator(bpy.types.Operator):
setattr(PlasmaAge, name, prop(**age_options)) 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 :) # Add the export operator to the Export menu :)
def menu_cb(self, context): def menu_cb(self, context):
if context.scene.render.engine == "PLASMA_GAME": if context.scene.render.engine == "PLASMA_GAME":
self.layout.operator_context = "INVOKE_DEFAULT" 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(): def register():

16
korman/operators/op_nodes.py

@ -26,6 +26,7 @@ class NodeOperator:
class SelectFileOperator(NodeOperator, bpy.types.Operator): class SelectFileOperator(NodeOperator, bpy.types.Operator):
bl_idname = "file.plasma_file_picker" bl_idname = "file.plasma_file_picker"
bl_label = "Select" bl_label = "Select"
bl_description = "Load a file"
filter_glob = StringProperty(options={"HIDDEN"}) filter_glob = StringProperty(options={"HIDDEN"})
filepath = StringProperty(subtype="FILE_PATH") filepath = StringProperty(subtype="FILE_PATH")
@ -41,6 +42,11 @@ class SelectFileOperator(NodeOperator, bpy.types.Operator):
setattr(dest, self.filepath_property, self.filepath) setattr(dest, self.filepath_property, self.filepath)
if self.filename_property: if self.filename_property:
setattr(dest, self.filename_property, self.filename) 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"} return {"FINISHED"}
def invoke(self, context, event): def invoke(self, context, event):
@ -91,15 +97,17 @@ pyAttribArgMap= {
class PlPyAttributeNodeOperator(NodeOperator, bpy.types.Operator): class PlPyAttributeNodeOperator(NodeOperator, bpy.types.Operator):
bl_idname = "node.plasma_attributes_to_node" 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"} bl_options = {"INTERNAL"}
python_path = StringProperty(subtype="FILE_PATH") text_path = StringProperty()
node_path = StringProperty() node_path = StringProperty()
def execute(self, context): def execute(self, context):
from ..plasma_attributes import get_attributes from ..plasma_attributes import get_attributes_from_str
attribs = get_attributes(self.python_path) text_id = bpy.data.texts[self.text_path]
attribs = get_attributes_from_str(text_id.as_string())
node = eval(self.node_path) node = eval(self.node_path)
node_attrib_map = node.attribute_map node_attrib_map = node.attribute_map

15
korman/operators/op_ui.py

@ -13,6 +13,7 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with Korman. If not, see <http://www.gnu.org/licenses/>. # along with Korman. If not, see <http://www.gnu.org/licenses/>.
import addon_utils
import bpy import bpy
from bpy.props import * from bpy.props import *
@ -87,3 +88,17 @@ class CollectionRemoveOperator(UIOperator, bpy.types.Operator):
return {"FINISHED"} return {"FINISHED"}
else: else:
return {"CANCELLED"} 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"}

60
korman/operators/op_world.py

@ -32,8 +32,8 @@ class GameAddOperator(AgeOperator, bpy.types.Operator):
game_index = IntProperty(default=-1, options={"HIDDEN"}) game_index = IntProperty(default=-1, options={"HIDDEN"})
def execute(self, context): def execute(self, context):
w = context.world prefs = context.user_preferences.addons["korman"].preferences
if w:
# First, verify this is a valid Uru directory... # First, verify this is a valid Uru directory...
path = Path(self.filepath) path = Path(self.filepath)
@ -45,13 +45,12 @@ class GameAddOperator(AgeOperator, bpy.types.Operator):
return {"CANCELLED"} return {"CANCELLED"}
# New game? # New game?
games = w.plasma_games
new_game = self.game_index == -1 new_game = self.game_index == -1
if new_game: if new_game:
games.active_game_index = len(games.games) prefs.active_game_index = len(prefs.games)
game = games.games.add() game = prefs.games.add()
else: else:
game = games.games[self.game_index] game = prefs.games[self.game_index]
# Setup game... # Setup game...
game.path = str(path) game.path = str(path)
@ -62,8 +61,6 @@ class GameAddOperator(AgeOperator, bpy.types.Operator):
game.name = path.name game.name = path.name
return {"FINISHED"} return {"FINISHED"}
else:
return {"CANCELLED"}
def invoke(self, context, event): def invoke(self, context, event):
@ -71,20 +68,53 @@ class GameAddOperator(AgeOperator, bpy.types.Operator):
return {"RUNNING_MODAL"} 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): class GameRemoveOperator(AgeOperator, bpy.types.Operator):
bl_idname = "world.plasma_game_remove" bl_idname = "world.plasma_game_remove"
bl_label = "Remove Plasma Game" bl_label = "Remove Plasma Game"
def execute(self, context): def execute(self, context):
w = context.world prefs = context.user_preferences.addons["korman"].preferences
if w:
games = w.plasma_games if prefs.active_game_index >= len(prefs.games):
if games.active_game_index >= len(games.games):
return {"CANCELLED"} return {"CANCELLED"}
games.games.remove(games.active_game_index) prefs.games.remove(prefs.active_game_index)
prefs.active_game_index = max(prefs.active_game_index - 1, -1)
return {"FINISHED"} return {"FINISHED"}
else:
return {"CANCELLED"}
class PageAddOperator(AgeOperator, bpy.types.Operator): class PageAddOperator(AgeOperator, bpy.types.Operator):

16
korman/plasma_attributes.py

@ -102,20 +102,22 @@ class PlasmaAttributeVisitor(ast.NodeVisitor):
ast.NodeVisitor.generic_visit(self, node) 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 """Scan the file for assignments matching our regex, let our visitor parse them, and return the
file's ptAttribs, if any.""" file's ptAttribs, if any."""
attribs = None with open(str(filepath)) as script:
with open(str(scriptFile)) as script: return get_attributes_from_str(script.read())
results = funcregex.findall(script.read())
def get_attributes_from_str(code):
results = funcregex.findall(code)
if results: if results:
# We'll fake the ptAttribs being all alone in a module... # We'll fake the ptAttribs being all alone in a module...
assigns = ast.parse("\n".join(results)) assigns = ast.parse("\n".join(results))
v = PlasmaAttributeVisitor() v = PlasmaAttributeVisitor()
v.visit(assigns) v.visit(assigns)
if v._attributes: if v._attributes:
attribs = v._attributes return v._attributes
return attribs return {}
if __name__ == "__main__": if __name__ == "__main__":
import json import json
@ -129,7 +131,7 @@ if __name__ == "__main__":
files = Path(readpath).glob("*.py") files = Path(readpath).glob("*.py")
ptAttribs = {} ptAttribs = {}
for scriptFile in files: for scriptFile in files:
attribs = get_attributes(scriptFile) attribs = get_attributes_from_file(scriptFile)
if attribs: if attribs:
ptAttribs[scriptFile.stem] = attribs ptAttribs[scriptFile.stem] = attribs

220
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 <http://www.gnu.org/licenses/>.
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
"""

2
korman/properties/__init__.py

@ -21,6 +21,7 @@ from .prop_lamp import *
from . import modifiers from . import modifiers
from .prop_object import * from .prop_object import *
from .prop_scene import * from .prop_scene import *
from .prop_text import *
from .prop_texture import * from .prop_texture import *
from .prop_world 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_net = bpy.props.PointerProperty(type=PlasmaNet)
bpy.types.Object.plasma_object = bpy.props.PointerProperty(type=PlasmaObject) bpy.types.Object.plasma_object = bpy.props.PointerProperty(type=PlasmaObject)
bpy.types.Scene.plasma_scene = bpy.props.PointerProperty(type=PlasmaScene) 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.Texture.plasma_layer = bpy.props.PointerProperty(type=PlasmaLayer)
bpy.types.World.plasma_age = bpy.props.PointerProperty(type=PlasmaAge) bpy.types.World.plasma_age = bpy.props.PointerProperty(type=PlasmaAge)
bpy.types.World.plasma_fni = bpy.props.PointerProperty(type=PlasmaFni) bpy.types.World.plasma_fni = bpy.props.PointerProperty(type=PlasmaFni)

3
korman/properties/modifiers/gui.py

@ -20,8 +20,8 @@ import mathutils
from bpy.props import * from bpy.props import *
from PyHSPlasma import * from PyHSPlasma import *
from ...addon_prefs import game_versions
from .base import PlasmaModifierProperties, PlasmaModifierLogicWiz from .base import PlasmaModifierProperties, PlasmaModifierLogicWiz
from .logic import game_versions
from ... import idprops from ... import idprops
@ -180,6 +180,7 @@ class PlasmaJournalBookModifier(PlasmaModifierProperties, PlasmaModifierLogicWiz
# Assign journal script based on target version # Assign journal script based on target version
journal_pfm = journal_pfms[version] journal_pfm = journal_pfms[version]
journalnode = nodes.new("PlasmaPythonFileNode") journalnode = nodes.new("PlasmaPythonFileNode")
with journalnode.NoUpdate():
journalnode.filename = journal_pfm["filename"] journalnode.filename = journal_pfm["filename"]
# Manually add required attributes to the PFM # Manually add required attributes to the PFM

2
korman/properties/modifiers/logic.py

@ -17,8 +17,8 @@ import bpy
from bpy.props import * from bpy.props import *
from PyHSPlasma import * from PyHSPlasma import *
from ...addon_prefs import game_versions
from .base import PlasmaModifierProperties from .base import PlasmaModifierProperties
from ..prop_world import game_versions
from ...exporter import ExportError from ...exporter import ExportError
from ... import idprops from ... import idprops

3
korman/properties/modifiers/sound.py

@ -166,6 +166,9 @@ class PlasmaSound(idprops.IDPropMixin, bpy.types.PropertyGroup):
header, dataSize = self._get_sound_info() header, dataSize = self._get_sound_info()
length = dataSize / header.avgBytesPerSec 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 # 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 # result. There's no sense in debugging that though--the user should never specify
# streaming vs static. That's an implementation detail. # streaming vs static. That's an implementation detail.

22
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 <http://www.gnu.org/licenses/>.
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())

18
korman/properties/prop_world.py

@ -17,9 +17,7 @@ import bpy
from bpy.props import * from bpy.props import *
from PyHSPlasma import * from PyHSPlasma import *
game_versions = [("pvPrime", "Ages Beyond Myst (63.11)", "Targets the original Uru (Live) game"), from ..addon_prefs import game_versions
("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 PlasmaFni(bpy.types.PropertyGroup): class PlasmaFni(bpy.types.PropertyGroup):
bl_idname = "world.plasma_fni" bl_idname = "world.plasma_fni"
@ -58,23 +56,9 @@ class PlasmaFni(bpy.types.PropertyGroup):
min=1) 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): class PlasmaGames(bpy.types.PropertyGroup):
bl_idname = "world.plasma_games" bl_idname = "world.plasma_games"
games = CollectionProperty(type=PlasmaGame)
active_game_index = IntProperty(options={"HIDDEN"}) active_game_index = IntProperty(options={"HIDDEN"})
@property @property

1
korman/ui/__init__.py

@ -21,6 +21,7 @@ from .ui_menus import *
from .ui_modifiers import * from .ui_modifiers import *
from .ui_object import * from .ui_object import *
from .ui_render_layer import * from .ui_render_layer import *
from .ui_text import *
from .ui_texture import * from .ui_texture import *
from .ui_toolbox import * from .ui_toolbox import *
from .ui_world import * from .ui_world import *

28
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 <http://www.gnu.org/licenses/>.
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")

71
korman/ui/ui_world.py

@ -16,7 +16,7 @@
import bpy import bpy
from pathlib import Path from pathlib import Path
from ..korlib import ConsoleToggler from .. import korlib
class AgeButtonsPanel: class AgeButtonsPanel:
@ -34,41 +34,56 @@ class PlasmaGamePanel(AgeButtonsPanel, bpy.types.Panel):
def draw(self, context): def draw(self, context):
layout = self.layout layout = self.layout
prefs = context.user_preferences.addons["korman"].preferences
games = context.world.plasma_games games = context.world.plasma_games
age = context.world.plasma_age age = context.world.plasma_age
row = layout.row() 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) "active_game_index", rows=2)
col = row.column(align=True) row.operator("ui.korman_open_prefs", icon="PREFERENCES", text="")
col.operator("world.plasma_game_add", icon="ZOOMIN", text="")
col.operator("world.plasma_game_remove", icon="ZOOMOUT", text="")
# Game Properties # Game Tools
active_game_index = games.active_game_index active_game_index = games.active_game_index
if active_game_index < len(games.games): if active_game_index < len(prefs.games):
active_game = games.games[active_game_index] active_game = prefs.games[active_game_index]
else:
active_game = None
layout.separator() layout.separator()
box = layout.box() row = layout.row(align=True)
legal_game = bool(age.age_name.strip()) and active_game is not 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.operator_context = "EXEC_DEFAULT"
row.enabled = bool(age.age_name.strip()) row.enabled = legal_game
op = row.operator("export.plasma_age", icon="EXPORT") 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.filepath = str((Path(active_game.path) / "dat" / age.age_name).with_suffix(".age"))
op.version = active_game.version 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): 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") 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(active_page, "seq_suffix")
col.prop_menu_enum(active_page, "version") 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 # Core settings
layout.separator() layout.separator()
split = layout.split() split = layout.split()
@ -125,15 +143,23 @@ class PlasmaAgePanel(AgeButtonsPanel, bpy.types.Panel):
col = split.column() col = split.column()
col.label("Age Settings:") col.label("Age Settings:")
col.prop(age, "seq_prefix", text="ID") 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="") 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() layout.separator()
split = layout.split() split = layout.split()
col = split.column() col = split.column()
col.label("Export Settings:") col.label("Export Settings:")
col.enabled = ConsoleToggler.is_platform_supported() col.enabled = korlib.ConsoleToggler.is_platform_supported()
col.prop(age, "verbose") col.prop(age, "verbose")
col.prop(age, "show_console") col.prop(age, "show_console")
@ -145,6 +171,7 @@ class PlasmaAgePanel(AgeButtonsPanel, bpy.types.Panel):
layout.separator() layout.separator()
layout.prop(age, "envmap_method") layout.prop(age, "envmap_method")
layout.prop(age, "lighting_method") layout.prop(age, "lighting_method")
layout.prop(age, "python_method")
layout.prop(age, "texcache_method") layout.prop(age, "texcache_method")

Loading…
Cancel
Save