diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index d73e8fe..b786932 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -7,22 +7,21 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - name: Set up Python 3.10 - uses: actions/setup-python@v2 - with: - python-version: "3.10" - - - name: Install requirements - run: | - pip install pipx - pipx install cookiecutter - pipx runpip cookiecutter install -r requirements.txt + - uses: actions/checkout@v3 + - name: Set up Python 3.10 + uses: actions/setup-python@v4 + with: + python-version: "3.10" - - name: Linting code by flake8 - run: | - pipx run flake8 --show-source --statistics - - - name: Check types by pyright - run: | - pipx run pyright + - name: Install requirements + run: | + pip install flake8 pyright + pip install -r requirements.txt + + - name: Linting code by flake8 + run: | + flake8 --show-source --statistics + + - name: Check types by pyright + run: | + pyright diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d9f7ab9..fafb00b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -13,7 +13,8 @@ repos: hooks: - id: black language_version: python3.10 - - repo: https://github.com/PyCQA/flake8 - rev: 6.0.0 + - repo: https://github.com/charliermarsh/ruff-pre-commit + rev: 'v0.0.261' hooks: - - id: flake8 + - id: ruff + args: [--fix, --exit-non-zero-on-fix] diff --git a/system/bytestream.py b/system/bytestream.py index d2176af..1b49c7a 100644 --- a/system/bytestream.py +++ b/system/bytestream.py @@ -1,17 +1,15 @@ import io -from typing import Literal, Optional, Union +from typing import Literal, Optional class Reader(io.BytesIO): def __init__( self, - buffer: bytes = b"", - endian: Union[Literal["little"], Literal["big"]] = "little", + initial_buffer: bytes = b"", + endian: Literal["little", "big"] = "little", ): - super().__init__(buffer) - - self.buffer = buffer - self.endian = endian + super().__init__(initial_buffer) + self.endian: Literal["little", "big"] = endian def read_integer(self, length: int, signed=False) -> int: return int.from_bytes(self.read(length), self.endian, signed=signed) @@ -47,10 +45,10 @@ def read_string(self) -> str: class Writer(io.BytesIO): def __init__(self, endian: Literal["little", "big"] = "little"): super().__init__() - self.endian = endian + self._endian: Literal["little", "big"] = endian def write_int(self, integer: int, length: int = 1, signed: bool = False): - self.write(integer.to_bytes(length, self.endian, signed=signed)) + self.write(integer.to_bytes(length, self._endian, signed=signed)) def write_ubyte(self, integer: int): self.write_int(integer) diff --git a/system/languages/en-EU.json b/system/languages/en-EU.json index dfe21e8..24b183b 100644 --- a/system/languages/en-EU.json +++ b/system/languages/en-EU.json @@ -48,12 +48,12 @@ "clean_dirs_description": "Clear In and Out dirs", "not_latest": "Not the latest version installed", - "collecting_inf": "Collecting information...", + "collecting_inf": "File: %s. Collecting information...", "about_sc": "About texture. Filename: %s (%d), Pixel type: %d, Size: %sx%s", "decompression_error": "Error while decompressing! Trying to decode as is...", "skip_not_installed": "%s isn't installed! Reinitialize", - "detected_comp": "Detected %s compression!", - "unk_type": "Unknown pixel type: %s", + "detected_comp": "%s compression detected", + "unknown_pixel_type": "Unknown pixel type: %s", "crt_pic": "Creating picture...", "join_pic": "Joining picture...", "png_save": "Saving to png...", @@ -73,7 +73,6 @@ "cut_sprites_process": "Cutting sprites... (%d/%d)", "place_sprites_process": "Placing sprites... (%d/%d)", "not_implemented": "This feature will be added in future updates.\nYou can follow XCoder updates here: github.com/Vorono4ka/XCoder", - "dec_sc": "Decoding .sc file...", "error": "ERROR! (%s.%s: %s)", "e1sc1": "Overwrite SC sprites", "cgl": "Changelog:\n%s", @@ -88,4 +87,4 @@ "disabled": "Disabled", "install_to_unlock": "Install '%s' to unlock more functions!" -} \ No newline at end of file +} diff --git a/system/languages/ru-RU.json b/system/languages/ru-RU.json index e581663..7b33541 100644 --- a/system/languages/ru-RU.json +++ b/system/languages/ru-RU.json @@ -48,12 +48,12 @@ "clean_dirs_description": "Очистить In и Out папки", "not_latest": "Установлена не последняя версия", - "collecting_inf": "Сбор информации...", + "collecting_inf": "Файл: %s. Сбор информации...", "about_sc": "О текстуре. Файл: %s (%d), Тип пикселя: %d, Размеры: %dx%d", "decompression_error": "Ошибка распаковки! Проведите повторную инициализацию!", "skip_not_installed": "%s не установлен! Пропускаем...", - "detected_comp": "Обнаружено %s сжатие!", - "unk_type": "Неизвестный подтип: %s", + "detected_comp": "Обнаружено сжатие: %s", + "unknown_pixel_type": "Неизвестный подтип: %s", "crt_pic": "Создаём картинку...", "join_pic": "Соединяем картинку...", "png_save": "Сохраняем в png...", @@ -73,7 +73,6 @@ "cut_sprites_process": "Вырезаем спрайты... (%d/%d)", "place_sprites_process": "Ставим спрайты на место... (%d/%d)", "not_implemented": "Данная возможность будет добавлена в будущих обновлениях.\nЗа обновлениями XCoder вы можете следить здесь: github.com/Vorono4ka/XCoder", - "dec_sc": "Декодируем SC...", "error": "ОШИБКА! (%s.%s: %s)", "e1sc1": "Перезапись спрайтов", "cgl": "Список изменений: \n%s", @@ -88,4 +87,4 @@ "disabled": "Выключено", "install_to_unlock": "Установите '%s' чтобы открыть больше функций!" -} \ No newline at end of file +} diff --git a/system/languages/ua-UA.json b/system/languages/ua-UA.json index c65364b..6a26fb6 100644 --- a/system/languages/ua-UA.json +++ b/system/languages/ua-UA.json @@ -48,12 +48,12 @@ "clean_dirs_description": "Очистити папки і файли в них", "not_latest": "Встановлена не остання версія", - "collecting_inf": "Збираємо інформацію...", + "collecting_inf": "Файл: %s. Збираємо інформацію...", "about_sc": "Про текстуру. Назва файлу: %s (%d), Тип пікселів: %d, Величина: %sx%s", "decompression_error": "Помилка при розпакуванні! Намагаємся розпакувати як є...", "skip_not_installed": "%s не встановлений, повторіть налаштування", - "detected_comp": "Виявлено %s компресію!", - "unk_type": "Невідомий вид пікселів: %s", + "detected_comp": "Виявлено компресію: %s", + "unknown_pixel_type": "Невідомий вид пікселів: %s", "crt_pic": "Створюємо зоображеня...", "join_pic": "З'єднуємо зоображення...", "png_save": "Зберігаємо PNG...", @@ -73,7 +73,6 @@ "cut_sprites_process": "Обрізаємо спрайти... (%d/%d)", "place_sprites_process": "Вставляємо спрайти... (%d/%d)", "not_implemented": "Ця функція буде додана у наступних оновленнях.\nТи можеш сладкувати за оновленнями тут: github.com/Vorono4ka/XCoder", - "dec_sc": "Розпаковуємо .sc файл...", "error": "Помилка! (%s.%s: %s)", "e1sc1": "Переписати SC спрайти", "cgl": "Список змін:\n%s", @@ -88,4 +87,4 @@ "disabled": "Виключено", "install_to_unlock": "Встанови '%s' щоб розблокувати більше функції!" -} \ No newline at end of file +} diff --git a/system/lib/__init__.py b/system/lib/__init__.py index b361089..167cc5a 100644 --- a/system/lib/__init__.py +++ b/system/lib/__init__.py @@ -1,17 +1,17 @@ import sys import time +from loguru import logger + from system import clear from system.lib.config import config from system.lib.console import Console from system.lib.features.directories import clear_directories from system.lib.features.initialization import initialize -from system.lib.features.update.check import check_update, check_for_outdated, get_tags -from system.lib.menu import menu, Menu +from system.lib.features.update.check import check_for_outdated, check_update, get_tags +from system.lib.menu import Menu, menu from system.localization import locale -from loguru import logger - logger.remove() logger.add( "./logs/info/{time:YYYY-MM-DD}.log", @@ -64,7 +64,9 @@ def refill_menu(): import sc_compression del sc_compression - + except ImportError: + logger.warning(locale.install_to_unlock % "sc-compression") + else: from system.lib.features.csv.compress import compress_csv from system.lib.features.csv.decompress import decompress_csv @@ -72,91 +74,111 @@ def refill_menu(): import PIL del PIL - - from system.lib.features.sc.decode import sc_decode - from system.lib.features.sc.encode import sc_encode - from system.lib.features.sc.decode_and_cut import decode_and_cut - from system.lib.features.sc.assembly_encode import sc1_encode + except ImportError: + logger.warning(locale.install_to_unlock % "PILLOW") + else: + from system.lib.features.sc.decode import ( + decode_and_render_objects, + decode_textures_only, + ) + from system.lib.features.sc.encode import ( + collect_objects_and_encode, + encode_textures_only, + ) sc_category = Menu.Category(0, locale.sc_label) sc_category.add( - Menu.Item(locale.decode_sc, locale.decode_sc_description, sc_decode) + Menu.Item( + name=locale.decode_sc, + description=locale.decode_sc_description, + handler=decode_textures_only, + ) ) sc_category.add( - Menu.Item(locale.encode_sc, locale.encode_sc_description, sc_encode) + Menu.Item( + name=locale.encode_sc, + description=locale.encode_sc_description, + handler=encode_textures_only, + ) ) sc_category.add( Menu.Item( - locale.decode_by_parts, - locale.decode_by_parts_description, - decode_and_cut, + name=locale.decode_by_parts, + description=locale.decode_by_parts_description, + handler=decode_and_render_objects, ) ) sc_category.add( Menu.Item( - locale.encode_by_parts, - locale.encode_by_parts_description, - sc1_encode, + name=locale.encode_by_parts, + description=locale.encode_by_parts_description, + handler=collect_objects_and_encode, ) ) sc_category.add( Menu.Item( - locale.overwrite_by_parts, - locale.overwrite_by_parts_description, - lambda: sc1_encode(True), + name=locale.overwrite_by_parts, + description=locale.overwrite_by_parts_description, + handler=lambda: collect_objects_and_encode(True), ) ) menu.add_category(sc_category) - except ImportError: - logger.warning(locale.install_to_unlock % "PILLOW") csv_category = Menu.Category(1, locale.csv_label) csv_category.add( Menu.Item( - locale.decompress_csv, locale.decompress_csv_description, decompress_csv + name=locale.decompress_csv, + description=locale.decompress_csv_description, + handler=decompress_csv, ) ) csv_category.add( Menu.Item( - locale.compress_csv, locale.compress_csv_description, compress_csv + name=locale.compress_csv, + description=locale.compress_csv_description, + handler=compress_csv, ) ) menu.add_category(csv_category) - except ImportError: - logger.warning(locale.install_to_unlock % "sc-compression") other = Menu.Category(10, locale.other_features_label) other.add( - Menu.Item(locale.check_update, locale.version % config.version, check_update) + Menu.Item( + name=locale.check_update, + description=locale.version % config.version, + handler=check_update, + ) ) - other.add(Menu.Item(locale.check_for_outdated, None, check_for_outdated)) + other.add(Menu.Item(name=locale.check_for_outdated, handler=check_for_outdated)) other.add( Menu.Item( - locale.reinit, - locale.reinit_description, - lambda: (initialize(), refill_menu()), + name=locale.reinit, + description=locale.reinit_description, + handler=lambda: (initialize(), refill_menu()), ) ) other.add( Menu.Item( - locale.change_language, - locale.change_lang_description % config.language, - lambda: (config.change_language(locale.change()), refill_menu()), + name=locale.change_language, + description=locale.change_lang_description % config.language, + handler=lambda: (config.change_language(locale.change()), refill_menu()), ) ) other.add( Menu.Item( - locale.clear_directories, - locale.clean_dirs_description, - lambda: clear_directories() if Console.question(locale.clear_qu) else -1, + name=locale.clear_directories, + description=locale.clean_dirs_description, + handler=lambda: clear_directories() + if Console.question(locale.clear_qu) + else -1, ) ) other.add( Menu.Item( - locale.toggle_update_auto_checking, - locale.enabled if config.auto_update else locale.disabled, - lambda: (config.toggle_auto_update(), refill_menu()), + name=locale.toggle_update_auto_checking, + description=locale.enabled if config.auto_update else locale.disabled, + handler=lambda: (config.toggle_auto_update(), refill_menu()), ) ) - other.add(Menu.Item(locale.exit, None, lambda: (clear(), exit()))) + other.add(Menu.Item(name=locale.exit, handler=lambda: (clear(), exit()))) menu.add_category(other) diff --git a/system/lib/features/cut_sprites.py b/system/lib/features/cut_sprites.py index 5a9c25e..5ab1bb3 100644 --- a/system/lib/features/cut_sprites.py +++ b/system/lib/features/cut_sprites.py @@ -1,36 +1,39 @@ import os +from pathlib import Path from system.lib.console import Console from system.lib.swf import SupercellSWF from system.localization import locale -def cut_sprites(swf: SupercellSWF, export_folder: str): - os.makedirs(f"{export_folder}/overwrite", exist_ok=True) - os.makedirs(f"{export_folder}/shapes", exist_ok=True) - os.makedirs(f"{export_folder}/movie_clips", exist_ok=True) +def render_objects(swf: SupercellSWF, output_folder: Path): + os.makedirs(output_folder / "overwrite", exist_ok=True) + os.makedirs(output_folder / "shapes", exist_ok=True) + os.makedirs(output_folder / "movie_clips", exist_ok=True) # TODO: Too slow, fix it - movie_clips_skipped = 0 - movie_clip_count = len(swf.movie_clips) - for movie_clip_index in range(movie_clip_count): - movie_clip = swf.movie_clips[movie_clip_index] - - rendered_movie_clip = movie_clip.render(swf) - if sum(rendered_movie_clip.size) >= 2: - clip_name = movie_clip.export_name or movie_clip.id - rendered_movie_clip.save(f"{export_folder}/movie_clips/{clip_name}.png") - else: - # For debug: - # logger.warning(f'MovieClip {movie_clip.id} cannot be rendered.') - movie_clips_skipped += 1 - - Console.progress_bar( - "Rendering movie clips (%d/%d). Skipped count: %d" - % (movie_clip_index + 1, movie_clip_count, movie_clips_skipped), - movie_clip_index, - movie_clip_count, - ) + # movie_clips_skipped = 0 + # movie_clip_count = len(swf.movie_clips) + # for movie_clip_index in range(movie_clip_count): + # movie_clip = swf.movie_clips[movie_clip_index] + # + # rendered_movie_clip = movie_clip.render(swf) + # if sum(rendered_movie_clip.size) >= 2: + # clip_name = movie_clip.export_name or movie_clip.id + # rendered_movie_clip.save(f"{export_folder}/movie_clips/{clip_name}.png") + # else: + # # For debug: + # # logger.warning(f'MovieClip {movie_clip.id} cannot be rendered.') + # movie_clips_skipped += 1 + # + # Console.progress_bar( + # "Rendering movie clips (%d/%d). Skipped count: %d" + # % (movie_clip_index + 1, movie_clip_count, movie_clips_skipped), + # movie_clip_index, + # movie_clip_count, + # ) + + print() shapes_count = len(swf.shapes) swf.xcod_writer.write_uint16(shapes_count) @@ -45,14 +48,14 @@ def cut_sprites(swf: SupercellSWF, export_folder: str): ) rendered_shape = shape.render() - rendered_shape.save(f"{export_folder}/shapes/{shape.id}.png") + rendered_shape.save(f"{output_folder}/shapes/{shape.id}.png") regions_count = len(shape.regions) for region_index in range(regions_count): region = shape.regions[region_index] rendered_region = region.render(use_original_size=True) - rendered_region.save(f"{export_folder}/shape_{shape.id}_{region_index}.png") + rendered_region.save(f"{output_folder}/shape_{shape.id}_{region_index}.png") for shape_index in range(shapes_count): shape = swf.shapes[shape_index] diff --git a/system/lib/features/files.py b/system/lib/features/files.py index 8b9d9c2..7dd97a4 100644 --- a/system/lib/features/files.py +++ b/system/lib/features/files.py @@ -1,19 +1,20 @@ -import struct +import os from loguru import logger -from sc_compression import decompress, compress +from sc_compression import compress, decompress from sc_compression.signatures import Signatures from system.localization import locale -def write_sc(output_filename: str, buffer: bytes, use_lzham: bool): +def write_sc(output_filename: str | os.PathLike, buffer: bytes, use_lzham: bool): with open(output_filename, "wb") as file_out: logger.info(locale.header_done) if use_lzham: logger.info(locale.compressing_with % "LZHAM") - file_out.write(struct.pack("<4sBI", b"SCLZ", 18, len(buffer))) + # Why is this here? It's included in the compression module + # file_out.write(struct.pack("<4sBI", b"SCLZ", 18, len(buffer))) compressed = compress(buffer, Signatures.SCLZ) file_out.write(compressed) @@ -22,13 +23,12 @@ def write_sc(output_filename: str, buffer: bytes, use_lzham: bool): compressed = compress(buffer, Signatures.SC, 3) file_out.write(compressed) logger.info(locale.compression_done) + print() -def open_sc(input_filename: str): - decompressed_data = None +def open_sc(input_filename: str) -> tuple[bytes, bool]: use_lzham = False - logger.info(locale.collecting_inf) with open(input_filename, "rb") as f: file_data = f.read() f.close() @@ -37,9 +37,10 @@ def open_sc(input_filename: str): if b"START" in file_data: file_data = file_data[: file_data.index(b"START")] decompressed_data, signature = decompress(file_data) - # - # logger.info(locale.detected_comp % signature.upper()) - # + + if signature.name != Signatures.NONE: + logger.info(locale.detected_comp % signature.name.upper()) + if signature == Signatures.SCLZ: use_lzham = True except TypeError: diff --git a/system/lib/features/place_sprites.py b/system/lib/features/place_sprites.py index f928acd..0a039a7 100644 --- a/system/lib/features/place_sprites.py +++ b/system/lib/features/place_sprites.py @@ -1,22 +1,23 @@ import os -from typing import List, Tuple +from pathlib import Path +from typing import List from PIL import Image, ImageDraw from system.lib import Console from system.lib.helper import get_sides, get_size from system.lib.images import get_format_by_pixel_type -from system.lib.xcod import FileInfo, parse_info +from system.lib.xcod import FileInfo from system.localization import locale +MASK_COLOR = 255 -def place_sprites( - xcod_path: str, folder: str, overwrite: bool = False -) -> Tuple[List[Image.Image], FileInfo]: - file_info, xcod = parse_info(xcod_path) - files_to_overwrite = os.listdir(f'{folder}{"/overwrite" if overwrite else ""}') - texture_files = os.listdir(f"{folder}/textures") +def place_sprites( + file_info: FileInfo, folder: Path, overwrite: bool = False +) -> List[Image.Image]: + files_to_overwrite = os.listdir(folder / ("overwrite" if overwrite else "")) + texture_files = os.listdir(folder / "textures") sheets = [] for i in range(len(file_info.sheets)): @@ -30,74 +31,68 @@ def place_sprites( ) ) - shapes_count = xcod.read_ushort() - for shape_index in range(shapes_count): + shapes_count = len(file_info.shapes) + for shape_index, shape_info in enumerate(file_info.shapes): Console.progress_bar( locale.place_sprites_process % (shape_index + 1, shapes_count), shape_index, shapes_count, ) - shape_id = xcod.read_ushort() - - regions_count = xcod.read_ushort() - for region_index in range(regions_count): - texture_id, points_count = xcod.read_uchar(), xcod.read_uchar() - texture_width, texture_height = ( - sheets[texture_id].width, - sheets[texture_id].height, + + for region_index, region_info in enumerate(shape_info.regions): + texture_size = ( + sheets[region_info.texture_id].width, + sheets[region_info.texture_id].height, ) - points = [ - (xcod.read_ushort(), xcod.read_ushort()) for _ in range(points_count) - ] - mirroring, rotation = xcod.read_uchar() == 1, xcod.read_char() * 90 - filename = f"shape_{shape_id}_{region_index}.png" + filename = f"shape_{shape_info.id}_{region_index}.png" if filename not in files_to_overwrite: continue - img_mask = Image.new("L", (texture_width, texture_height), 0) - color = 255 - ImageDraw.Draw(img_mask).polygon(points, fill=color) + img_mask = Image.new("L", texture_size, 0) + ImageDraw.Draw(img_mask).polygon(region_info.points, fill=MASK_COLOR) bbox = img_mask.getbbox() if not bbox: - min_x = min(i[0] for i in points) - min_y = min(i[1] for i in points) - max_x = max(i[0] for i in points) - max_y = max(i[1] for i in points) + min_x = min(i[0] for i in region_info.points) + min_y = min(i[1] for i in region_info.points) + max_x = max(i[0] for i in region_info.points) + max_y = max(i[1] for i in region_info.points) if max_y - min_y != 0: for _y in range(max_y - min_y): - img_mask.putpixel((max_x - 1, min_y + _y - 1), color) + img_mask.putpixel((max_x - 1, min_y + _y - 1), MASK_COLOR) elif max_x - min_x != 0: for _x in range(max_x - min_x): - img_mask.putpixel((min_x + _x - 1, max_y - 1), color) + img_mask.putpixel((min_x + _x - 1, max_y - 1), MASK_COLOR) else: - img_mask.putpixel((max_x - 1, max_y - 1), color) + img_mask.putpixel((max_x - 1, max_y - 1), MASK_COLOR) - left, top, right, bottom = get_sides(points) + left, top, right, bottom = get_sides(region_info.points) if left == right: right += 1 if top == bottom: bottom += 1 width, height = get_size(left, top, right, bottom) + left = int(left) + top = int(top) bbox = int(left), int(top), int(right), int(bottom) tmp_region = Image.open( f'{folder}{"/overwrite" if overwrite else ""}/{filename}' ).convert("RGBA") - if mirroring: + if region_info.is_mirrored: tmp_region = tmp_region.transpose(Image.FLIP_LEFT_RIGHT) - tmp_region = tmp_region.rotate(rotation, expand=True) + tmp_region = tmp_region.rotate(region_info.rotation, expand=True) tmp_region = tmp_region.resize((width, height), Image.ANTIALIAS) - sheets[texture_id].paste( + sheets[region_info.texture_id].paste( Image.new("RGBA", (width, height)), (left, top), img_mask.crop(bbox) ) - sheets[texture_id].paste(tmp_region, (left, top), tmp_region) + sheets[region_info.texture_id].paste(tmp_region, (left, top), tmp_region) print() - return sheets, file_info + return sheets diff --git a/system/lib/features/sc/__init__.py b/system/lib/features/sc/__init__.py index 3258848..f07e999 100644 --- a/system/lib/features/sc/__init__.py +++ b/system/lib/features/sc/__init__.py @@ -1,6 +1,6 @@ -import os import struct -from typing import List, Optional +from pathlib import Path +from typing import List from loguru import logger from PIL import Image @@ -8,67 +8,52 @@ from system.bytestream import Writer from system.lib.console import Console from system.lib.features.files import write_sc -from system.lib.images import get_pixel_size, save_texture, split_image +from system.lib.images import get_byte_count_by_pixel_type, save_texture, split_image from system.lib.xcod import FileInfo from system.localization import locale def compile_sc( - _dir, + output_folder: Path, file_info: FileInfo, - sheets: Optional[List[Image.Image]] = None, - output_folder: Optional[str] = None, + sheets: List[Image.Image], ): - name = _dir.split("/")[-2] - - if sheets: - files = sheets - else: - files = [] - [files.append(i) if i.endswith(".png") else None for i in os.listdir(_dir)] - files.sort() - if not files: - return logger.info(locale.dir_empty % _dir.split("/")[-2]) - files = [Image.open(f"{_dir}{i}") for i in files] - - logger.info(locale.collecting_inf) sc = Writer() - use_lzham = file_info.use_lzham - - for picture_index in range(len(files)): + for picture_index in range(len(sheets)): sheet_info = file_info.sheets[picture_index] - img = files[picture_index] - print() + sheet = sheets[picture_index] file_type = sheet_info.file_type pixel_type = sheet_info.pixel_type - if img.size != sheet_info.size: + if sheet.size != sheet_info.size: logger.info( locale.illegal_size - % (sheet_info.width, sheet_info.height, img.width, img.height) + % (sheet_info.width, sheet_info.height, sheet.width, sheet.height) ) if Console.question(locale.resize_qu): logger.info(locale.resizing) - img = img.resize(sheet_info.size, Image.ANTIALIAS) + sheet = sheet.resize(sheet_info.size, Image.ANTIALIAS) - width, height = img.size - pixel_size = get_pixel_size(pixel_type) + width, height = sheet.size + pixel_size = get_byte_count_by_pixel_type(pixel_type) file_size = width * height * pixel_size + 5 - logger.info(locale.about_sc % (name, picture_index, pixel_type, width, height)) + logger.info( + locale.about_sc % (file_info.name, picture_index, pixel_type, width, height) + ) sc.write(struct.pack(" Path: + objects_output_folder = output_folder / base_name + if os.path.isdir(objects_output_folder): + shutil.rmtree(objects_output_folder) + os.mkdir(objects_output_folder) + return objects_output_folder + + +def _save_textures(swf: SupercellSWF, textures_output: Path, base_name: str) -> None: + os.makedirs(textures_output, exist_ok=True) + for img_index in range(len(swf.textures)): + filename = base_name + "_" * img_index + swf.textures[img_index].image.save(textures_output / f"{filename}.png") + + +def _save_meta_file( + swf: SupercellSWF, objects_output_folder: Path, base_name: str, use_lzham: bool +) -> None: + with open(objects_output_folder / f"{base_name}.xcod", "wb") as xcod_file: + xcod_file.write(b"XCOD") + xcod_file.write(bool.to_bytes(use_lzham, 1, "big")) + xcod_file.write(int.to_bytes(len(swf.textures), 1, "big")) + xcod_file.write(swf.xcod_writer.getvalue()) diff --git a/system/lib/features/sc/decode_and_cut.py b/system/lib/features/sc/decode_and_cut.py deleted file mode 100644 index 2108e9e..0000000 --- a/system/lib/features/sc/decode_and_cut.py +++ /dev/null @@ -1,70 +0,0 @@ -import os -import shutil - -from loguru import logger - -from system.lib.features.cut_sprites import cut_sprites -from system.lib.swf import SupercellSWF -from system.localization import locale - - -def decode_and_cut(): - input_folder = "./SC/In-Compressed" - output_folder = "./SC/Out-Sprites" - files = os.listdir(input_folder) - - for file in files: - if file.endswith("_tex.sc"): - continue - - xcod_file = None - try: - base_name = os.path.basename(file).rsplit(".", 1)[0] - - logger.info(locale.dec_sc) - - swf = SupercellSWF() - texture_loaded, use_lzham = swf.load(f"{input_folder}/{file}") - if not texture_loaded: - logger.error(locale.not_found % (base_name + "_tex.sc")) - continue - - base_name = os.path.basename(swf.filename).rsplit(".", 1)[0] - if os.path.isdir(f"{output_folder}/{base_name}"): - shutil.rmtree(f"{output_folder}/{base_name}") - os.mkdir(f"{output_folder}/{base_name}") - os.makedirs(f"{output_folder}/{base_name}/textures", exist_ok=True) - - with open( - f"{output_folder}/{base_name}/{base_name}.xcod", "wb" - ) as xcod_file: - xcod_file.write( - b"XCOD" - + bool.to_bytes(use_lzham, 1, "big") - + int.to_bytes(len(swf.textures), 1, "big") - ) - - for img_index in range(len(swf.textures)): - filename = base_name + "_" * img_index - swf.textures[img_index].image.save( - f"{output_folder}/{base_name}/textures/{filename}.png" - ) - - logger.info(locale.dec_sc) - - cut_sprites(swf, f"{output_folder}/{base_name}") - xcod_file.write(swf.xcod_writer.getvalue()) - except Exception as exception: - if xcod_file is not None: - xcod_file.close() - - logger.exception( - locale.error - % ( - exception.__class__.__module__, - exception.__class__.__name__, - exception, - ) - ) - - print() diff --git a/system/lib/features/sc/encode.py b/system/lib/features/sc/encode.py index 21a2073..f95857d 100644 --- a/system/lib/features/sc/encode.py +++ b/system/lib/features/sc/encode.py @@ -1,33 +1,76 @@ import os +from pathlib import Path from loguru import logger +from PIL import Image +from system.lib.features.place_sprites import place_sprites from system.lib.features.sc import compile_sc from system.lib.xcod import parse_info from system.localization import locale +OUT_COMPRESSED_PATH = Path("./SC/Out-Compressed") +IN_DECOMPRESSED_PATH = Path("./SC/In-Decompressed") +IN_SPRITES_PATH = Path("./SC/In-Sprites/") -def sc_encode(): - input_folder = "./SC/In-Decompressed" - output_folder = "./SC/Out-Compressed" + +def encode_textures_only(): + input_folder = IN_DECOMPRESSED_PATH + output_folder = OUT_COMPRESSED_PATH for folder in os.listdir(input_folder): - try: - file_info, _ = parse_info(f"{input_folder}/{folder}/{folder}.xcod") - - compile_sc( - f"{input_folder}/{folder}/", file_info, output_folder=output_folder - ) - except FileNotFoundError: - logger.info(locale.xcod_not_found % folder) - except Exception as exception: - logger.exception( - locale.error - % ( - exception.__class__.__module__, - exception.__class__.__name__, - exception, - ) - ) + textures_input_folder = input_folder / folder + + if not os.path.isdir(textures_input_folder): + continue + + xcod_path = _ensure_metadata_exists(textures_input_folder, folder) + if xcod_path is None: + continue + + file_info = parse_info(xcod_path, False) + sheets = _load_sheets(textures_input_folder) + compile_sc(output_folder, file_info, sheets) + + +def collect_objects_and_encode(overwrite: bool = False) -> None: + input_folder = IN_SPRITES_PATH + output_folder = OUT_COMPRESSED_PATH + + for folder in os.listdir(input_folder): + objects_input_folder = input_folder / folder + + if not os.path.isdir(objects_input_folder): + continue + xcod_path = _ensure_metadata_exists(objects_input_folder, folder) + if xcod_path is None: + continue + + file_info = parse_info(xcod_path, True) + sheets = place_sprites(file_info, objects_input_folder, overwrite) + compile_sc(output_folder, file_info, sheets) + + +def _ensure_metadata_exists(input_folder: Path, file: str) -> Path | None: + metadata_file_name = f"{file}.xcod" + metadata_file_path = input_folder / metadata_file_name + + if not os.path.exists(metadata_file_path): + logger.error(locale.not_found % metadata_file_name) print() + return None + + return metadata_file_path + + +def _load_sheets(input_folder: Path) -> list[Image.Image]: + files = [] + for i in os.listdir(input_folder): + if i.endswith(".png"): + files.append(i) + files.sort() + + if not files: + raise RuntimeError(locale.dir_empty % input_folder.name) + return [Image.open(input_folder / file) for file in files] diff --git a/system/lib/helper.py b/system/lib/helper.py index 5690815..4c87e03 100644 --- a/system/lib/helper.py +++ b/system/lib/helper.py @@ -1,7 +1,9 @@ -from typing import List, Tuple, Union +from typing import List, Tuple, TypeAlias from system.lib.objects.point import Point +PointType: TypeAlias = Tuple[float, float] | Tuple[int, int] | Point + def get_size(left: float, top: float, right: float, bottom: float) -> Tuple[int, int]: """Returns width and height of given rect. @@ -16,7 +18,7 @@ def get_size(left: float, top: float, right: float, bottom: float) -> Tuple[int, def get_sides( - points: Union[List[Tuple[float, float]], List[Tuple[int, int]], List[Point]] + points: List[Tuple[float, float]] | List[Tuple[int, int]] | List[Point] ) -> Tuple[float, float, float, float]: """Calculates and returns rect sides. @@ -25,17 +27,17 @@ def get_sides( """ if len(points) > 0: - point = points[0] + point: PointType = points[0] if isinstance(point, Point): - left = min(point.x for point in points) - top = min(point.y for point in points) - right = max(point.x for point in points) - bottom = max(point.y for point in points) + left = min(point.x for point in points) # type: ignore + top = min(point.y for point in points) # type: ignore + right = max(point.x for point in points) # type: ignore + bottom = max(point.y for point in points) # type: ignore elif isinstance(point, tuple): - left = min(x for x, _ in points) - top = min(y for _, y in points) - right = max(x for x, _ in points) - bottom = max(y for _, y in points) + left = min(x for x, _ in points) # type: ignore + top = min(y for _, y in points) # type: ignore + right = max(x for x, _ in points) # type: ignore + bottom = max(y for _, y in points) # type: ignore else: raise TypeError("Unknown point type.") diff --git a/system/lib/images.py b/system/lib/images.py index 70a9c1a..242f0e6 100644 --- a/system/lib/images.py +++ b/system/lib/images.py @@ -1,11 +1,15 @@ import math -import struct import PIL.PyAccess from PIL import Image -from system.bytestream import Reader +from system.bytestream import Reader, Writer from system.lib.console import Console +from system.lib.pixel_utils import ( + get_channel_count_by_pixel_type, + get_read_function, + get_write_function, +) from system.localization import locale CHUNK_SIZE = 32 @@ -14,7 +18,7 @@ def load_image_from_buffer(img: Image.Image) -> None: width, height = img.size # noinspection PyTypeChecker - img_loaded: PIL.PyAccess.PyAccess = img.load() + img_loaded: PIL.PyAccess.PyAccess = img.load() # type: ignore with open("pixel_buffer", "rb") as pixel_buffer: channels_count = int.from_bytes(pixel_buffer.read(1), "little") @@ -30,7 +34,7 @@ def join_image(img: Image.Image) -> None: width, height = img.size # noinspection PyTypeChecker - loaded_img: PIL.PyAccess.PyAccess = img.load() + loaded_img: PIL.PyAccess.PyAccess = img.load() # type: ignore x_chunks_count = width // CHUNK_SIZE y_chunks_count = height // CHUNK_SIZE @@ -60,9 +64,9 @@ def add_pixel(pixel: tuple): width, height = img.size # noinspection PyTypeChecker - loaded_image: PIL.PyAccess.PyAccess = img.load() + loaded_image: PIL.PyAccess.PyAccess = img.load() # type: ignore # noinspection PyTypeChecker - loaded_clone: PIL.PyAccess.PyAccess = img.copy().load() + loaded_clone: PIL.PyAccess.PyAccess = img.copy().load() # type: ignore x_chunks_count = width // CHUNK_SIZE y_chunks_count = height // CHUNK_SIZE @@ -87,94 +91,44 @@ def add_pixel(pixel: tuple): Console.progress_bar(locale.split_pic, y_chunk, y_chunks_count + 1) -def get_pixel_size(_type): - if _type in (0, 1): +def get_byte_count_by_pixel_type(pixel_type: int) -> int: + if pixel_type in (0, 1): return 4 - elif _type in (2, 3, 4, 6): + elif pixel_type in (2, 3, 4, 6): return 2 - elif _type == 10: + elif pixel_type == 10: return 1 - raise Exception(locale.unk_type % _type) + raise Exception(locale.unknown_pixel_type % pixel_type) -def get_format_by_pixel_type(_type): - if _type in (0, 1, 2, 3): +def get_format_by_pixel_type(pixel_type: int) -> str: + if pixel_type in (0, 1, 2, 3): return "RGBA" - elif _type == 4: + elif pixel_type == 4: return "RGB" - elif _type == 6: + elif pixel_type == 6: return "LA" - elif _type == 10: + elif pixel_type == 10: return "L" - raise Exception(locale.unk_type % _type) + raise Exception(locale.unknown_pixel_type % pixel_type) -def load_texture(data: Reader, _type, img): - read_pixel = None - channels_count = 4 - if _type in (0, 1): - - def read_pixel(): - return ( - data.read_uchar(), - data.read_uchar(), - data.read_uchar(), - data.read_uchar(), - ) - - elif _type == 2: - - def read_pixel(): - p = data.read_ushort() - return ( - (p >> 12 & 15) << 4, - (p >> 8 & 15) << 4, - (p >> 4 & 15) << 4, - (p >> 0 & 15) << 4, - ) - - elif _type == 3: - - def read_pixel(): - p = data.read_ushort() - return ( - (p >> 11 & 31) << 3, - (p >> 6 & 31) << 3, - (p >> 1 & 31) << 3, - (p & 255) << 7, - ) - - elif _type == 4: - channels_count = 3 - - def read_pixel(): - p = data.read_ushort() - return (p >> 11 & 31) << 3, (p >> 5 & 63) << 2, (p & 31) << 3 - - elif _type == 6: - channels_count = 2 - - def read_pixel(): - return (data.read_uchar(), data.read_uchar())[::-1] - - elif _type == 10: - channels_count = 1 - - def read_pixel(): - return data.read_uchar() - +def load_texture(reader: Reader, pixel_type: int, img: Image.Image) -> None: + channel_count = get_channel_count_by_pixel_type(pixel_type) + read_pixel = get_read_function(pixel_type) if read_pixel is None: - return + raise Exception(locale.unknown_pixel_type % pixel_type) with open("pixel_buffer", "wb") as pixel_buffer: - pixel_buffer.write(channels_count.to_bytes(1, "little")) + pixel_buffer.write(channel_count.to_bytes(1, "little")) + print() width, height = img.size point = -1 for y in range(height): for x in range(width): - pixel = read_pixel() + pixel = read_pixel(reader) for channel in pixel: pixel_buffer.write(channel.to_bytes(1, "little")) @@ -184,50 +138,18 @@ def read_pixel(): point = curr -def save_texture(sc, img, _type): - if _type in (0, 1): - - def write_pixel(pixel): - return struct.pack("4B", *pixel) - - elif _type == 2: - - def write_pixel(pixel): - r, g, b, a = pixel - return struct.pack("> 4 | b >> 4 << 4 | g >> 4 << 8 | r >> 4 << 12) - - elif _type == 3: - - def write_pixel(pixel): - r, g, b, a = pixel - return struct.pack("> 7 | b >> 3 << 1 | g >> 3 << 6 | r >> 3 << 11) - - elif _type == 4: - - def write_pixel(pixel): - r, g, b = pixel - return struct.pack("> 3 | g >> 2 << 5 | r >> 3 << 11) - - elif _type == 6: - - def write_pixel(pixel): - return struct.pack("2B", *pixel[::-1]) - - elif _type == 10: - - def write_pixel(pixel): - return struct.pack("B", pixel) - - else: - return +def save_texture(writer: Writer, img: Image.Image, pixel_type: int): + write_pixel = get_write_function(pixel_type) + if write_pixel is None: + raise Exception(locale.unknown_pixel_type % pixel_type) width, height = img.size - pix = img.getdata() + pixels = img.getdata() point = -1 for y in range(height): for x in range(width): - sc.write(write_pixel(pix[y * width + x])) + writer.write(write_pixel(pixels[y * width + x])) curr = Console.percent(y, height) if curr > point: diff --git a/system/lib/menu.py b/system/lib/menu.py index 2cdf010..2ed0e38 100644 --- a/system/lib/menu.py +++ b/system/lib/menu.py @@ -9,7 +9,7 @@ def print_feature( - feature_id: int, name: str, description: str = None, console_width: int = -1 + feature_id: int, name: str, description: str | None = None, console_width: int = -1 ): text = f" {feature_id} {name}" if description: @@ -31,10 +31,14 @@ def print_category(text: str, background_width: int = 10): class Menu: class Item: def __init__( - self, name: str, description: str = None, handler: typing.Callable = None + self, + *, + name: str, + handler: typing.Callable, + description: str | None = None, ): self.name: str = name - self.description: str = description + self.description: str | None = description self.handler: typing.Callable = handler class Category: @@ -43,9 +47,9 @@ def __init__(self, _id: int, name: str): self.name = name self.items = [] - def item(self, name, description): - def wrapper(handler): - self.add(Menu.Item(name, description, handler)) + def item(self, name: str, description: str | None = None): + def wrapper(handler: typing.Callable): + self.add(Menu.Item(name=name, handler=handler, description=description)) return wrapper @@ -104,7 +108,7 @@ def choice(self): return None @staticmethod - def _print_divider_line(console_width: int): + def _print_divider_line(console_width: int) -> None: print((console_width - 1) * "-") diff --git a/system/lib/objects/movie_clip.py b/system/lib/objects/movie_clip.py index d20a453..937441d 100644 --- a/system/lib/objects/movie_clip.py +++ b/system/lib/objects/movie_clip.py @@ -1,5 +1,5 @@ from math import ceil -from typing import List, Optional, Tuple +from typing import TYPE_CHECKING, List, Tuple from PIL import Image @@ -8,11 +8,14 @@ from system.lib.matrices.matrix_bank import MatrixBank from system.lib.objects.shape import Shape +if TYPE_CHECKING: + from system.lib.swf import SupercellSWF + class MovieClipFrame: def __init__(self): self._elements_count: int = 0 - self._label: Optional[str] = None + self._label: str | None = None self._elements: List[Tuple[int, int, int]] = [] @@ -38,7 +41,7 @@ def __init__(self): super().__init__() self.id = -1 - self.export_name: Optional[str] = None + self.export_name: str | None = None self.fps: int = 30 self.frames_count: int = 0 self.frames: List[MovieClipFrame] = [] @@ -47,7 +50,7 @@ def __init__(self): self.binds: List[int] = [] self.matrix_bank_index: int = 0 - def load(self, swf, tag: int): + def load(self, swf: "SupercellSWF", tag: int): self.id = swf.reader.read_ushort() self.fps = swf.reader.read_char() @@ -106,8 +109,8 @@ def load(self, swf, tag: int): else: swf.reader.read(frame_length) - def render(self, swf, matrix=None) -> Image.Image: - matrix_bank: MatrixBank = swf.get_matrix_bank(self.matrix_bank_index) + def render(self, swf: "SupercellSWF", matrix=None) -> Image.Image: + matrix_bank = swf.get_matrix_bank(self.matrix_bank_index) # TODO: make it faster left, top, right, bottom = self.get_sides(swf) @@ -136,7 +139,7 @@ def render(self, swf, matrix=None) -> Image.Image: return image - def get_sides(self, swf) -> Tuple[float, float, float, float]: + def get_sides(self, swf: "SupercellSWF") -> Tuple[float, float, float, float]: matrix_bank: MatrixBank = swf.get_matrix_bank(self.matrix_bank_index) left = 0 diff --git a/system/lib/objects/shape.py b/system/lib/objects/shape.py index c91ae3e..42d45b8 100644 --- a/system/lib/objects/shape.py +++ b/system/lib/objects/shape.py @@ -1,5 +1,5 @@ from math import atan2, ceil, degrees -from typing import List, Optional, Tuple +from typing import TYPE_CHECKING, List, Optional, Tuple from PIL import Image, ImageDraw @@ -8,13 +8,16 @@ from system.lib.objects.point import Point from system.lib.objects.texture import SWFTexture +if TYPE_CHECKING: + from system.lib.swf import SupercellSWF + class Shape: def __init__(self): self.id = 0 self.regions: List[Region] = [] - def load(self, swf, tag: int): + def load(self, swf: "SupercellSWF", tag: int): self.id = swf.reader.read_ushort() swf.reader.read_ushort() # regions_count @@ -96,9 +99,9 @@ def __init__(self): self._uv_points: List[Point] = [] self._transformed_points: List[Point] = [] - self.texture: Optional[SWFTexture] + self.texture: SWFTexture - def load(self, swf, tag: int): + def load(self, swf: "SupercellSWF", tag: int): self.texture_index = swf.reader.read_uchar() self.texture = swf.textures[self.texture_index] @@ -169,7 +172,9 @@ def get_image(self) -> Image.Image: width, height = max(width, 1), max(height, 1) if width + height == 1: # The same speed as without this return return Image.new( - "RGBA", (width, height), color=self.texture.image.get_pixel(left, top) + "RGBA", + (width, height), + color=self.texture.image.get_pixel(left, top), # type: ignore ) if width == 1: @@ -243,7 +248,7 @@ def calculate_rotation( self, round_to_nearest: bool = False, custom_points: Optional[List[Point]] = None, - ) -> (int, bool): + ) -> tuple[int, bool]: """Calculates rotation and if region is mirrored. :param round_to_nearest: should round to a multiple of 90 @@ -286,4 +291,4 @@ def is_clockwise(points: List[Point]): if round_to_nearest: angle = round(angle / 90) * 90 - return angle, mirroring + return int(angle), mirroring diff --git a/system/lib/pixel_utils.py b/system/lib/pixel_utils.py new file mode 100644 index 0000000..4a43db5 --- /dev/null +++ b/system/lib/pixel_utils.py @@ -0,0 +1,120 @@ +import struct +from typing import Callable, TypeAlias + +from system.bytestream import Reader + +PixelChannels: TypeAlias = tuple[int, ...] +WriteFunction: TypeAlias = Callable[[PixelChannels], bytes] +ReadFunction: TypeAlias = Callable[[Reader], PixelChannels] + + +def get_read_function(pixel_type: int) -> ReadFunction | None: + if pixel_type in _read_functions: + return _read_functions[pixel_type] + return None + + +def get_write_function(pixel_type: int) -> WriteFunction | None: + if pixel_type in _write_functions: + return _write_functions[pixel_type] + return None + + +def get_channel_count_by_pixel_type(pixel_type: int) -> int: + if pixel_type == 4: + return 3 + elif pixel_type == 6: + return 2 + elif pixel_type == 10: + return 1 + return 4 + + +def _read_rgba8(reader: Reader) -> PixelChannels: + return ( + reader.read_uchar(), + reader.read_uchar(), + reader.read_uchar(), + reader.read_uchar(), + ) + + +def _read_rgba4(reader: Reader) -> PixelChannels: + p = reader.read_ushort() + return ( + (p >> 12 & 15) << 4, + (p >> 8 & 15) << 4, + (p >> 4 & 15) << 4, + (p >> 0 & 15) << 4, + ) + + +def _read_rgb5a1(reader: Reader) -> PixelChannels: + p = reader.read_ushort() + return ( + (p >> 11 & 31) << 3, + (p >> 6 & 31) << 3, + (p >> 1 & 31) << 3, + (p & 255) << 7, + ) + + +def _read_rgb565(reader: Reader) -> PixelChannels: + p = reader.read_ushort() + return (p >> 11 & 31) << 3, (p >> 5 & 63) << 2, (p & 31) << 3 + + +def _read_luminance8_alpha8(reader: Reader) -> PixelChannels: + return (reader.read_uchar(), reader.read_uchar())[::-1] + + +def _read_luminance8(reader: Reader) -> PixelChannels: + return (reader.read_uchar(),) + + +def _write_rgba8(pixel: PixelChannels) -> bytes: + return struct.pack("4B", *pixel) + + +def _write_rgba4(pixel: PixelChannels) -> bytes: + r, g, b, a = pixel + return struct.pack("> 4 | b >> 4 << 4 | g >> 4 << 8 | r >> 4 << 12) + + +def _write_rgb5a1(pixel: PixelChannels) -> bytes: + r, g, b, a = pixel + return struct.pack("> 7 | b >> 3 << 1 | g >> 3 << 6 | r >> 3 << 11) + + +def _write_rgb565(pixel: PixelChannels) -> bytes: + r, g, b = pixel + return struct.pack("> 3 | g >> 2 << 5 | r >> 3 << 11) + + +def _write_luminance8_alpha8(pixel: PixelChannels) -> bytes: + return struct.pack("2B", *pixel[::-1]) + + +def _write_luminance8(pixel: PixelChannels) -> bytes: + return struct.pack("B", pixel) + + +_write_functions: dict[int, WriteFunction] = { + 0: _write_rgba8, + 1: _write_rgba8, + 2: _write_rgba4, + 3: _write_rgb5a1, + 4: _write_rgb565, + 6: _write_luminance8_alpha8, + 10: _write_luminance8, +} + +_read_functions: dict[int, ReadFunction] = { + 0: _read_rgba8, + 1: _read_rgba8, + 2: _read_rgba4, + 3: _read_rgb5a1, + 4: _read_rgb565, + 6: _read_luminance8_alpha8, + 10: _read_luminance8, +} diff --git a/system/lib/swf.py b/system/lib/swf.py index b70e6e9..95322a5 100644 --- a/system/lib/swf.py +++ b/system/lib/swf.py @@ -1,5 +1,5 @@ import os -from typing import List, Optional, Tuple +from typing import List, Tuple from loguru import logger @@ -21,12 +21,10 @@ class SupercellSWF: TEXTURE_EXTENSION = "_tex.sc" def __init__(self): - self.filename: Optional[str] = None - self.reader: Optional[Reader] = None + self.filename: str + self.reader: Reader self.use_lowres_texture: bool = False - self.use_uncommon_texture: bool = False - self.uncommon_texture_path: Optional[str] = None self.shapes: List[Shape] = [] self.movie_clips: List[MovieClip] = [] @@ -34,34 +32,37 @@ def __init__(self): self.xcod_writer = Writer("big") - self._filepath: Optional[str] = None + self._filepath: str + self._uncommon_texture_path: str self._lowres_suffix: str = DEFAULT_LOWRES_SUFFIX self._highres_suffix: str = DEFAULT_HIGHRES_SUFFIX - self._shape_count: int - self._movie_clip_count: int - self._texture_count: int - self._text_field_count: int + self._use_uncommon_texture: bool = False - self._export_count: int + self._shape_count: int = 0 + self._movie_clip_count: int = 0 + self._texture_count: int = 0 + self._text_field_count: int = 0 + + self._export_count: int = 0 self._export_ids: List[int] = [] self._export_names: List[str] = [] self._matrix_banks: List[MatrixBank] = [] self._matrix_bank: MatrixBank - def load(self, filepath: str) -> Tuple[bool, bool]: - self._filepath = filepath + def load(self, filepath: str | os.PathLike) -> Tuple[bool, bool]: + self._filepath = str(filepath) texture_loaded, use_lzham = self._load_internal( - filepath, filepath.endswith("_tex.sc") + self._filepath, self._filepath.endswith("_tex.sc") ) if not texture_loaded: - if self.use_uncommon_texture: + if self._use_uncommon_texture: texture_loaded, use_lzham = self._load_internal( - self.uncommon_texture_path, True + self._uncommon_texture_path, True ) else: texture_path = self._filepath[:-3] + SupercellSWF.TEXTURE_EXTENSION @@ -69,14 +70,16 @@ def load(self, filepath: str) -> Tuple[bool, bool]: return texture_loaded, use_lzham - def _load_internal(self, filepath: str, is_texture: bool) -> Tuple[bool, bool]: + def _load_internal(self, filepath: str, is_texture_file: bool) -> Tuple[bool, bool]: self.filename = os.path.basename(filepath) + logger.info(locale.collecting_inf % self.filename) + decompressed_data, use_lzham = open_sc(filepath) self.reader = Reader(decompressed_data) del decompressed_data - if not is_texture: + if not is_texture_file: self._shape_count = self.reader.read_ushort() self._movie_clip_count = self.reader.read_ushort() self._texture_count = self.reader.read_ushort() @@ -108,7 +111,7 @@ def _load_internal(self, filepath: str, is_texture: bool) -> Tuple[bool, bool]: for _ in range(self._export_count): self._export_names.append(self.reader.read_string()) - loaded = self._load_tags() + loaded = self._load_tags(is_texture_file) for i in range(self._export_count): export_id = self._export_ids[i] @@ -117,12 +120,13 @@ def _load_internal(self, filepath: str, is_texture: bool) -> Tuple[bool, bool]: movie_clip = self.get_display_object( export_id, export_name, raise_error=True ) - movie_clip.export_name = export_name - print() + if isinstance(movie_clip, MovieClip): + movie_clip.export_name = export_name + return loaded, use_lzham - def _load_tags(self): + def _load_tags(self, is_texture_file: bool) -> bool: has_texture = True texture_id = 0 @@ -137,6 +141,11 @@ def _load_tags(self): if tag == 0: return has_texture elif tag in SupercellSWF.TEXTURES_TAGS: + # this is done to avoid loading the data file + # (although it does not affect the speed) + if is_texture_file and texture_id >= len(self.textures): + self.textures.append(SWFTexture()) + texture = self.textures[texture_id] texture.load(self, tag, has_texture) @@ -151,13 +160,11 @@ def _load_tags(self): texture.height, ) ) - print() self.xcod_writer.write_ubyte(tag) self.xcod_writer.write_ubyte(texture.pixel_type) self.xcod_writer.write_uint16(texture.width) self.xcod_writer.write_uint16(texture.height) - self.textures[texture_id] = texture texture_id += 1 elif tag in SupercellSWF.SHAPES_TAGS: self.shapes[shapes_loaded].load(self, tag) @@ -171,7 +178,7 @@ def _load_tags(self): elif tag == 26: has_texture = False elif tag == 30: - self.use_uncommon_texture = True + self._use_uncommon_texture = True highres_texture_path = ( self._filepath[:-3] + self._highres_suffix @@ -183,11 +190,11 @@ def _load_tags(self): + SupercellSWF.TEXTURE_EXTENSION ) - self.uncommon_texture_path = highres_texture_path + self._uncommon_texture_path = highres_texture_path if not os.path.exists(highres_texture_path) and os.path.exists( lowres_texture_path ): - self.uncommon_texture_path = lowres_texture_path + self._uncommon_texture_path = lowres_texture_path self.use_lowres_texture = True elif tag == 42: matrix_count = self.reader.read_ushort() @@ -202,8 +209,8 @@ def _load_tags(self): self.reader.read(length) def get_display_object( - self, target_id: int, name: Optional[str] = None, *, raise_error: bool = False - ): + self, target_id: int, name: str | None = None, *, raise_error: bool = False + ) -> Shape | MovieClip | None: for shape in self.shapes: if shape.id == target_id: return shape diff --git a/system/lib/xcod.py b/system/lib/xcod.py index 543fb56..79b07c8 100644 --- a/system/lib/xcod.py +++ b/system/lib/xcod.py @@ -1,7 +1,14 @@ +from __future__ import annotations + +import os from dataclasses import dataclass -from typing import List, Tuple +from pathlib import Path +from typing import Tuple + +from loguru import logger from system.bytestream import Reader +from system.localization import locale @dataclass @@ -19,31 +26,83 @@ def height(self) -> int: return self.size[1] +@dataclass +class RegionInfo: + texture_id: int + points: list[tuple[int, int]] + is_mirrored: bool + rotation: int + + +@dataclass +class ShapeInfo: + id: int + regions: list[RegionInfo] + + @dataclass class FileInfo: + name: str use_lzham: bool - sheets: List[SheetInfo] + sheets: list[SheetInfo] + shapes: list[ShapeInfo] -def parse_info(xcod_path: str) -> Tuple[FileInfo, Reader]: - with open(xcod_path, "rb") as file: - xcod = Reader(file.read(), "big") +def parse_info(metadata_file_path: Path, has_detailed_info: bool) -> FileInfo: + logger.info(locale.collecting_inf % metadata_file_path.name) + print() - magic = xcod.read(4) - if magic != b"XCOD": - raise IOError("Unknown file MAGIC: " + magic.hex()) + with open(metadata_file_path, "rb") as file: + reader = Reader(file.read(), "big") + + ensure_magic_known(reader) + + file_info = FileInfo(os.path.splitext(metadata_file_path.name)[0], False, [], []) + parse_base_info(file_info, reader) - use_lzham = xcod.read_uchar() == 1 + if has_detailed_info: + parse_detailed_info(file_info, reader) - file_info = FileInfo(use_lzham, []) + return file_info - sheets_count = xcod.read_uchar() + +def parse_base_info(file_info: FileInfo, reader: Reader) -> None: + use_lzham = reader.read_uchar() == 1 + file_info.use_lzham = use_lzham + sheets_count = reader.read_uchar() for i in range(sheets_count): - file_type = xcod.read_uchar() - pixel_type = xcod.read_uchar() - width = xcod.read_ushort() - height = xcod.read_ushort() + file_type = reader.read_uchar() + pixel_type = reader.read_uchar() + width = reader.read_ushort() + height = reader.read_ushort() file_info.sheets.append(SheetInfo(file_type, pixel_type, (width, height))) - return file_info, xcod + +def parse_detailed_info(file_info: FileInfo, reader: Reader) -> None: + shapes_count = reader.read_ushort() + for shape_index in range(shapes_count): + shape_id = reader.read_ushort() + + regions = [] + + regions_count = reader.read_ushort() + for region_index in range(regions_count): + texture_id, points_count = reader.read_uchar(), reader.read_uchar() + + points = [ + (reader.read_ushort(), reader.read_ushort()) + for _ in range(points_count) + ] + + is_mirrored, rotation = reader.read_uchar() == 1, reader.read_char() * 90 + + regions.append(RegionInfo(texture_id, points, is_mirrored, rotation)) + + file_info.shapes.append(ShapeInfo(shape_id, regions)) + + +def ensure_magic_known(reader: Reader) -> None: + magic = reader.read(4) + if magic != b"XCOD": + raise IOError("Unknown file MAGIC: " + magic.hex()) diff --git a/system/localization.py b/system/localization.py index 62c6a79..f20705d 100644 --- a/system/localization.py +++ b/system/localization.py @@ -1,100 +1,100 @@ import json import os -from typing import Optional + +DEFAULT_STRING = "NO LOCALE" class Locale: def __init__(self): - self.xcoder_header: Optional[str] = None - self.detected_os: Optional[str] = None - self.installing: Optional[str] = None - self.update_downloading: Optional[str] = None - self.crt_workspace: Optional[str] = None - self.verifying: Optional[str] = None - self.installed: Optional[str] = None - self.update_done: Optional[str] = None - self.not_installed: Optional[str] = None - self.clear_qu: Optional[str] = None - self.done: Optional[str] = None - self.done_qu: Optional[str] = None - self.choice: Optional[str] = None - self.to_continue: Optional[str] = None - self.experimental: Optional[str] = None - - self.sc_label: Optional[str] = None - self.decode_sc: Optional[str] = None - self.encode_sc: Optional[str] = None - self.decode_by_parts: Optional[str] = None - self.encode_by_parts: Optional[str] = None - self.overwrite_by_parts: Optional[str] = None - self.decode_sc_description: Optional[str] = None - self.encode_sc_description: Optional[str] = None - self.decode_by_parts_description: Optional[str] = None - self.encode_by_parts_description: Optional[str] = None - self.overwrite_by_parts_description: Optional[str] = None - - self.csv_label: Optional[str] = None - self.decompress_csv: Optional[str] = None - self.compress_csv: Optional[str] = None - self.decompress_csv_description: Optional[str] = None - self.compress_csv_description: Optional[str] = None - - self.other_features_label: Optional[str] = None - self.check_update: Optional[str] = None - self.check_for_outdated: Optional[str] = None - self.reinit: Optional[str] = None - self.change_language: Optional[str] = None - self.clear_directories: Optional[str] = None - self.toggle_update_auto_checking: Optional[str] = None - self.exit: Optional[str] = None - self.version: Optional[str] = None - self.reinit_description: Optional[str] = None - self.change_lang_description: Optional[str] = None - self.clean_dirs_description: Optional[str] = None - - self.not_latest: Optional[str] = None - self.collecting_inf: Optional[str] = None - self.about_sc: Optional[str] = None - self.skip_not_installed: Optional[str] = None - self.decompression_error: Optional[str] = None - self.detected_comp: Optional[str] = None - self.unk_type: Optional[str] = None - self.crt_pic: Optional[str] = None - self.join_pic: Optional[str] = None - self.png_save: Optional[str] = None - self.saved: Optional[str] = None - self.xcod_not_found: Optional[str] = None - self.illegal_size: Optional[str] = None - self.resize_qu: Optional[str] = None - self.resizing: Optional[str] = None - self.split_pic: Optional[str] = None - self.writing_pic: Optional[str] = None - self.header_done: Optional[str] = None - self.compressing_with: Optional[str] = None - self.compression_error: Optional[str] = None - self.compression_done: Optional[str] = None - self.dir_empty: Optional[str] = None - self.not_found: Optional[str] = None - self.cut_sprites_process: Optional[str] = None - self.place_sprites_process: Optional[str] = None - self.not_implemented: Optional[str] = None - self.dec_sc: Optional[str] = None - self.error: Optional[str] = None + self.xcoder_header: str = DEFAULT_STRING + self.detected_os: str = DEFAULT_STRING + self.installing: str = DEFAULT_STRING + self.update_downloading: str = DEFAULT_STRING + self.crt_workspace: str = DEFAULT_STRING + self.verifying: str = DEFAULT_STRING + self.installed: str = DEFAULT_STRING + self.update_done: str = DEFAULT_STRING + self.not_installed: str = DEFAULT_STRING + self.clear_qu: str = DEFAULT_STRING + self.done: str = DEFAULT_STRING + self.done_qu: str = DEFAULT_STRING + self.choice: str = DEFAULT_STRING + self.to_continue: str = DEFAULT_STRING + self.experimental: str = DEFAULT_STRING + + self.sc_label: str = DEFAULT_STRING + self.decode_sc: str = DEFAULT_STRING + self.encode_sc: str = DEFAULT_STRING + self.decode_by_parts: str = DEFAULT_STRING + self.encode_by_parts: str = DEFAULT_STRING + self.overwrite_by_parts: str = DEFAULT_STRING + self.decode_sc_description: str = DEFAULT_STRING + self.encode_sc_description: str = DEFAULT_STRING + self.decode_by_parts_description: str = DEFAULT_STRING + self.encode_by_parts_description: str = DEFAULT_STRING + self.overwrite_by_parts_description: str = DEFAULT_STRING + + self.csv_label: str = DEFAULT_STRING + self.decompress_csv: str = DEFAULT_STRING + self.compress_csv: str = DEFAULT_STRING + self.decompress_csv_description: str = DEFAULT_STRING + self.compress_csv_description: str = DEFAULT_STRING + + self.other_features_label: str = DEFAULT_STRING + self.check_update: str = DEFAULT_STRING + self.check_for_outdated: str = DEFAULT_STRING + self.reinit: str = DEFAULT_STRING + self.change_language: str = DEFAULT_STRING + self.clear_directories: str = DEFAULT_STRING + self.toggle_update_auto_checking: str = DEFAULT_STRING + self.exit: str = DEFAULT_STRING + self.version: str = DEFAULT_STRING + self.reinit_description: str = DEFAULT_STRING + self.change_lang_description: str = DEFAULT_STRING + self.clean_dirs_description: str = DEFAULT_STRING + + self.not_latest: str = DEFAULT_STRING + self.collecting_inf: str = DEFAULT_STRING + self.about_sc: str = DEFAULT_STRING + self.skip_not_installed: str = DEFAULT_STRING + self.decompression_error: str = DEFAULT_STRING + self.detected_comp: str = DEFAULT_STRING + self.unknown_pixel_type: str = DEFAULT_STRING + self.crt_pic: str = DEFAULT_STRING + self.join_pic: str = DEFAULT_STRING + self.png_save: str = DEFAULT_STRING + self.saved: str = DEFAULT_STRING + self.xcod_not_found: str = DEFAULT_STRING + self.illegal_size: str = DEFAULT_STRING + self.resize_qu: str = DEFAULT_STRING + self.resizing: str = DEFAULT_STRING + self.split_pic: str = DEFAULT_STRING + self.writing_pic: str = DEFAULT_STRING + self.header_done: str = DEFAULT_STRING + self.compressing_with: str = DEFAULT_STRING + self.compression_error: str = DEFAULT_STRING + self.compression_done: str = DEFAULT_STRING + self.dir_empty: str = DEFAULT_STRING + self.not_found: str = DEFAULT_STRING + self.cut_sprites_process: str = DEFAULT_STRING + self.place_sprites_process: str = DEFAULT_STRING + self.not_implemented: str = DEFAULT_STRING + self.error: str = DEFAULT_STRING # new - self.e1sc1: Optional[str] = None - self.cgl: Optional[str] = None - self.upd_av: Optional[str] = None - self.upd_qu: Optional[str] = None - self.upd: Optional[str] = None - self.upd_ck: Optional[str] = None - self.bkp: Optional[str] = None - self.stp: Optional[str] = None - - self.enabled: Optional[str] = None - self.disabled: Optional[str] = None - - self.install_to_unlock: Optional[str] = None + self.e1sc1: str = DEFAULT_STRING + self.cgl: str = DEFAULT_STRING + self.upd_av: str = DEFAULT_STRING + self.upd_qu: str = DEFAULT_STRING + self.upd: str = DEFAULT_STRING + self.upd_ck: str = DEFAULT_STRING + self.bkp: str = DEFAULT_STRING + self.stp: str = DEFAULT_STRING + + self.enabled: str = DEFAULT_STRING + self.disabled: str = DEFAULT_STRING + + self.install_to_unlock: str = DEFAULT_STRING def load(self, language: str): language_filepath = "./system/languages/" + language + ".json"