From fb8e3aab0ffb7c4a072982f2040661b741f2b05a Mon Sep 17 00:00:00 2001 From: LoafyLemon Date: Fri, 10 Feb 2023 23:12:16 +0000 Subject: [PATCH] Outfit import/export * Re-implemented image payload injection using Python3-compliant methods * Added sanity checks for image importing and exporting * Added compression for the payload * Added user input for exported image file names * Removed redundant layer for Tonks' ribbon bra * Updated MyMod clothing definition example. * Fixed regression in outfit clone functions --- .../tonks/clothes/bra/ribbon/backupskin.webp | 3 - game/mods/MyMod/script.rpym | 18 +- game/scripts/doll/clothes.rpy | 2 - game/scripts/doll/clothes_dynamic.rpy | 2 - game/scripts/doll/main.rpy | 11 +- game/scripts/doll/outfits.rpy | 16 +- game/scripts/utility/common_functions.rpy | 2 +- game/scripts/utility/punk.rpy | 187 +++++++++--------- game/scripts/wardrobe/wardrobe.rpy | 7 +- 9 files changed, 132 insertions(+), 116 deletions(-) delete mode 100644 game/characters/tonks/clothes/bra/ribbon/backupskin.webp diff --git a/game/characters/tonks/clothes/bra/ribbon/backupskin.webp b/game/characters/tonks/clothes/bra/ribbon/backupskin.webp deleted file mode 100644 index 75390b7f..00000000 --- a/game/characters/tonks/clothes/bra/ribbon/backupskin.webp +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:4bb47b9c5cc1eccb2342fa223bac89a92d3650d9939720d937c8af404ab242d4 -size 2338 diff --git a/game/mods/MyMod/script.rpym b/game/mods/MyMod/script.rpym index f675194b..662f2ce5 100644 --- a/game/mods/MyMod/script.rpym +++ b/game/mods/MyMod/script.rpym @@ -1,13 +1,19 @@ # Add new hairstyle for character as an instance of DollCloth, # make sure the variable name is unique, preferably starting with mod name. default MyMod_ponytail = DollCloth( - modpath="mods/mymod/", # shortened filepath to this file + modpath="MyMod", # File path; Usually a mod folder name. (case insensitive) name="hermione", # Character name (case sensitive) - categories=("head","hair"), # Main category and subcategory of the item - type="hair", # Item type - id="ponytail", # Item identificator + categories=("head","hair"), # Main category and subcategory of the item (case sensitive) + type="hair", # Item type (case sensitive) + id="ponytail", # Item identifier (case sensitive) unlocked=True, # True=Item is unlocked by default, False=Item is a part of the outfit and requries to be bought level=0, # Character whoring/friendship level required to wear this cloth. (Optional) - armfix=False, # If cloth images intersect with arm layers, set to True. (Optional) - color=[[152, 89, 48, 255], [195, 137, 89, 255]] # Python list with default colours in RGBA format applicable for each colourable file layer + color=["#985930", "#c38959"], # Python list with default colours in one of the formats listed down below. + zorder=None # Item zorder number, or None, if None default zorder for the slot will be used. ) + +# Example valid colour formats: +# RGB: [(255, 255, 255), (255, 255, 255)] +# RGBA: [(255, 255, 255, 255), (255, 255, 255, 255)] +# HEX: [("#985930", "#c38959")] +# HEXA: [("#985930FF", "#c38959FF")] diff --git a/game/scripts/doll/clothes.rpy b/game/scripts/doll/clothes.rpy index 855f61c3..2c054ca4 100644 --- a/game/scripts/doll/clothes.rpy +++ b/game/scripts/doll/clothes.rpy @@ -252,8 +252,6 @@ init python: def clone(self): """Creates a clone of this cloth object. Since it requires a parent object it should be used internally only to avoid object depth issue.""" - if self.parent: - return self return DollCloth(self.name, self.categories, self.type, self.id, [x for x in self.color] if self.color else None, self.zorder, self.unlocked, self.level, self.blacklist, self.modpath, self) def is_modded(self): diff --git a/game/scripts/doll/clothes_dynamic.rpy b/game/scripts/doll/clothes_dynamic.rpy index 0550d779..e163c2bf 100644 --- a/game/scripts/doll/clothes_dynamic.rpy +++ b/game/scripts/doll/clothes_dynamic.rpy @@ -183,6 +183,4 @@ init python: def clone(self): """Creates a clone of this cloth object. Since it requires a parent object it should be used internally only to avoid object depth issue.""" - if self.parent: - return self return DollClothDynamic(self.name, self.categories, self.type, self.id, [x for x in self.color] if self.color else None, self.zorder, self.unlocked, self.level, self.blacklist, self.modpath, self._tracking, self) diff --git a/game/scripts/doll/main.rpy b/game/scripts/doll/main.rpy index 6989ebb7..870b7da6 100644 --- a/game/scripts/doll/main.rpy +++ b/game/scripts/doll/main.rpy @@ -505,11 +505,11 @@ init python: # Grab data if fromfile: try: - imported = image_payload.decode(path) - except: - if image_payload._file: - image_payload._file.close() + imported = ImagePayload().extract(path) + except Exception as e: renpy.notify("Import failed: Corrupted file.") + print(e) + renpy.block_rollback() return None else: imported = get_clipboard() @@ -518,8 +518,9 @@ init python: if imported: try: imported = make_revertable(evaluate(imported)) - except: + except Exception as e: renpy.notify("Import failed: Corrupted outfit data.") + print(e) renpy.block_rollback() return None diff --git a/game/scripts/doll/outfits.rpy b/game/scripts/doll/outfits.rpy index 0ed5c0b6..a8f9d0ab 100644 --- a/game/scripts/doll/outfits.rpy +++ b/game/scripts/doll/outfits.rpy @@ -86,16 +86,21 @@ init python: def export_data(self, filename, tofile=True): """Exports outfit to .png file or clipboard text.""" exported = [self.group[0].name] - exported.extend([x.id, x.color] for x in self.group) + + for i in self.group: + if i.color: + color = [j.hexcode for j in i.color] + exported.append([i.id, color]) # Encode data if tofile: - path = "{}/game/outfits/".format(config.basedir) - fn = "{}.png".format(filename) + path = os.path.join(config.gamedir, "outfits") if not os.path.exists(path): os.makedirs(path) + path = os.path.join(path, "_temp.png") + d = Transform(self.image, crop=(210, 200, 700, 1000), anchor=(0.5, 1.0), align=(0.5, 1.0), xsize=310, ysize=470, fit="contain") d = Fixed( "interface/wardrobe/export_background.webp", @@ -105,8 +110,9 @@ init python: Text("Ver. {}".format(config.version), size=10, align=(0.99, 0.99)) ) - displayable_to_file(d, path+fn, size=(310, 470) ) - image_payload.encode(filename, str(exported)) + displayable_to_file(d, path, size=(310, 470) ) + ImagePayload().inject("_temp.png", filename, str(exported)) + os.remove(path) else: set_clipboard(exported) renpy.notify("Export successful!") diff --git a/game/scripts/utility/common_functions.rpy b/game/scripts/utility/common_functions.rpy index 3ba699ed..308feea8 100644 --- a/game/scripts/utility/common_functions.rpy +++ b/game/scripts/utility/common_functions.rpy @@ -97,7 +97,7 @@ init -1 python: try: return __import__('ast').literal_eval(txt) except Exception as e: - print("Error evaluating clipboard data:") + print("Error evaluating data:") print(e) def reset_variables(*args): diff --git a/game/scripts/utility/punk.rpy b/game/scripts/utility/punk.rpy index 356fc802..d510f9a6 100644 --- a/game/scripts/utility/punk.rpy +++ b/game/scripts/utility/punk.rpy @@ -7,131 +7,136 @@ init python: import binascii import struct + import zlib - class ImagePayload(object): + class ImagePayload(NoRollback): - _END_CHUNK_TYPE = 'IEND' - _PUNK_CHUNK_TYPE = 'wtSi' - _MAX_BYTES = 2147483647 - _chunks = dict() + CHUNK_TYPE_END = "IEND" + CHUNK_TYPE_PUNK = "wtSi" + MAX_BYTES = 2147483647 + SIGNATURE_BYTES = 8 + BYTES_IN_KB = 2014 def __init__(self): - self._mode = None - self._file = None - self._output = None - self._bytes_to_hide = None + pass - self._bytes_read = 0 + def bytes_to_hex(self, b): + return b.hex() - def decode(self, input_file): - self.__init__() - self._mode = 'decode' - self._file = open("{}/outfits/{}".format(config.gamedir, input_file), 'rb+') - #self._output = open(config.basedir+'/game/'+output_file+'.txt', 'wb+') + def bytes_to_utf(self, b): + return b.decode() - # First move cursor past the signature - self._read_bytes(8) + def bytes_to_int(self, b): + return int(self.bytes_to_hex(b), 16) - # Start reading chunks - self._read_next_chunk() - return self._output + def read_bytes(self, f, byte_count: int): + return f.read(byte_count) - def encode(self, input_file, bytes_to_hide): - self.__init__() - self._mode = 'encode' - self._file = open("{}/outfits/{}.png".format(config.gamedir, input_file), 'rb+') - self._bytes_to_hide = bytes_to_hide.encode('utf-8') + def rewind_bytes(self, f, byte_count): + f.seek(f.tell() - byte_count) - # First move cursor past the signature - self._read_bytes(8) + def get_file_length(self, f): + f.seek(0, os.SEEK_END) + file_length = f.tell() + f.seek(0) - # Start reading chunks - self._read_next_chunk() + return file_length - def _read_bytes_as_hex(self, position): - return self._read_bytes(position).encode('hex') + def read_chunk(self, f): + chunk_size = self.read_bytes(f, 4) + chunk_type = self.read_bytes(f, 4) + chunk_content = self.read_bytes(f, self.bytes_to_int(chunk_size)) + chunk_crc = self.read_bytes(f, 4) - def _read_bytes_as_ascii(self, position): - return self._read_bytes(position).encode('ascii') + return [chunk_size, chunk_type, chunk_content, chunk_crc] - def _read_bytes_as_int(self, position): - return int(self._read_bytes_as_hex(position), 16) + def inject_punk_chunk(self, f, content): + chunk_size = len(content) - def _read_bytes(self, byte_count): - self._bytes_read += byte_count - return self._file.read(byte_count) + if chunk_size > self.MAX_BYTES: + raise ValueError(f"Cannot inject more than {self.MAX_BYTES} bytes") - def _rewind_bytes(self, byte_count): - self._bytes_read -= byte_count - self._file.seek(self._bytes_read) + print(f"Injecting {self.CHUNK_TYPE_PUNK} chunk {chunk_size / self.BYTES_IN_KB} kb") - def _inject_punk_chunk(self): - # Move back 8 bytes. - self._rewind_bytes(8) - - chunk_size = len(self._bytes_to_hide) - print('Hiding', (chunk_size / 1024), 'kB (', chunk_size, 'bytes)') - - # Create a temporary byte array for the CRC check. + # Create a byte array to store our chunk data in. tmp_bytes = bytearray() - # First write the chunk type - tmp_bytes.extend(bytearray(self._PUNK_CHUNK_TYPE)) - + tmp_bytes.extend(self.CHUNK_TYPE_PUNK.encode()) # Now write the bytes of whatever we're trying to hide - tmp_bytes.extend(self._bytes_to_hide) - - #print 'Injecting punk chunk' + tmp_bytes.extend(content) # Write the chunk size - self._file.write(bytearray(struct.pack('!i', chunk_size))) + f.write(bytearray(struct.pack("!i", chunk_size))) - # And the type - self._file.write(bytearray(self._PUNK_CHUNK_TYPE)) - - self._file.write(self._bytes_to_hide) + # And the content + f.write(tmp_bytes) crc = binascii.crc32(tmp_bytes) - self._file.write(bytearray(struct.pack('!i', crc))) + crc_bytes = crc.to_bytes(4, "big") + print("Chunk CRC", self.bytes_to_hex(crc_bytes)) + f.write(crc_bytes) - # Write the end chunk. Start with the size. - self._file.write(bytearray(struct.pack('!i', 0))) - # Then the chunk type. - self._file.write(bytearray(self._END_CHUNK_TYPE)) + print("Chunk injected!") - crc = binascii.crc32(bytearray(self._END_CHUNK_TYPE)) - self._file.write(bytearray(struct.pack('!i', crc))) + def list(self, input): + path = os.path.join(config.gamedir, "outfits", input) - #print 'Punk chunk injected' + with open(path, "rb") as input_file: - def _read_next_chunk(self): - chunk_size = self._read_bytes_as_int(4) - print('Chunk size:', chunk_size) + input_file_length = self.get_file_length(input_file) + input_file.read(self.SIGNATURE_BYTES) - chunk_type = self._read_bytes_as_ascii(4) - print('Chunk type:', chunk_type) + while True: + chunk_size, chunk_type, chunk_content, chunk_crc = self.read_chunk(input_file) + chunk_type_str = self.bytes_to_utf(chunk_type) + print(f"Chunk {chunk_type_str}, {self.bytes_to_int(chunk_size)} bytes") - if self._mode == 'encode' and chunk_type == self._END_CHUNK_TYPE: - self._inject_punk_chunk() + if input_file.tell() >= input_file_length: + return - #print 'Reached EOF' - self._file.close() - return + def inject(self, input, output, content): + input_path = os.path.join(config.gamedir, "outfits", input) + output_path = os.path.join(config.gamedir, "outfits", output) + content = zlib.compress(str(content).encode()) - content = self._read_bytes(chunk_size) + with open(input_path, "rb") as input_file, open(output_path, "wb") as output_file: - crc = self._read_bytes_as_hex(4) - print('CRC:', crc) + input_file_length = self.get_file_length(input_file) + output_file.write(input_file.read(self.SIGNATURE_BYTES)) - if self._mode == 'decode' and chunk_type == self._PUNK_CHUNK_TYPE: - print("Found a chunk data", len(content), "bytes. Importing..") - #self._output.write(bytearray(content)) - #self._output.close() - self._output = content.decode('utf-8') - self._file.close() - print("Done.") - return True + while True: + chunk_size, chunk_type, chunk_content, chunk_crc = self.read_chunk(input_file) + chunk_type_str = self.bytes_to_utf(chunk_type) + print(f"Chunk {chunk_type_str}, {self.bytes_to_int(chunk_size)} bytes") - self._read_next_chunk() + if chunk_type_str == self.CHUNK_TYPE_END: + self.inject_punk_chunk(output_file, content) - image_payload = ImagePayload() + output_file.write(chunk_size) + output_file.write(chunk_type) + output_file.write(chunk_content) + output_file.write(chunk_crc) + + if input_file.tell() >= input_file_length: + return + + def extract(self, input): + print("Attempting to extract punked data from", input) + path = os.path.join(config.gamedir, "outfits", input) + + with open(path, "rb") as input_file: + + input_file_length = self.get_file_length(input_file) + input_file.read(self.SIGNATURE_BYTES) + + while True: + chunk_size, chunk_type, chunk_content, chunk_crc = self.read_chunk(input_file) + chunk_type_str = self.bytes_to_utf(chunk_type) + + if chunk_type_str == self.CHUNK_TYPE_PUNK: + print("Found a punk chunk worth", self.bytes_to_int(chunk_size), "bytes") + return zlib.decompress(chunk_content).decode() + + if input_file.tell() >= input_file_length: + print("No punked data found") + return diff --git a/game/scripts/wardrobe/wardrobe.rpy b/game/scripts/wardrobe/wardrobe.rpy index 01bdbf4e..65f65291 100644 --- a/game/scripts/wardrobe/wardrobe.rpy +++ b/game/scripts/wardrobe/wardrobe.rpy @@ -266,7 +266,12 @@ label wardrobe_menu(): elif _choice[0] == "export": python: - _choice[1].export_data(datetime.datetime.now().strftime("%d %b %Y-%H%M%S")) + filename = renpy.input("Save as:", datetime.datetime.now().strftime("%d %b %Y-%H%M%S")) + + if not filename.endswith(".png"): + filename += ".png" + + _choice[1].export_data(filename) achievements.unlock("export") elif _choice[0] == "import":