From 7af86f409712a5730a3e2b4ac72e0e197a979528 Mon Sep 17 00:00:00 2001 From: audinowho <2676737+audinowho@users.noreply.github.com> Date: Fri, 20 Dec 2024 12:49:57 -0800 Subject: [PATCH] Screen effect support (#551) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * parser for screen effect * screen effect documentation * ruff * linting fix * update readme --------- Co-authored-by: Marco Köpcke --- skytemple_files/common/types/file_types.py | 2 + .../graphics/effect_screen/README.rst | 87 +++++++++ .../graphics/effect_screen/__init__.py | 22 +++ .../graphics/effect_screen/handler.py | 40 +++++ .../graphics/effect_screen/model.py | 166 ++++++++++++++++++ .../graphics/effect_screen/sheets.py | 82 +++++++++ 6 files changed, 399 insertions(+) create mode 100644 skytemple_files/graphics/effect_screen/README.rst create mode 100644 skytemple_files/graphics/effect_screen/__init__.py create mode 100644 skytemple_files/graphics/effect_screen/handler.py create mode 100644 skytemple_files/graphics/effect_screen/model.py create mode 100644 skytemple_files/graphics/effect_screen/sheets.py diff --git a/skytemple_files/common/types/file_types.py b/skytemple_files/common/types/file_types.py index df321bdac..7ed942aff 100644 --- a/skytemple_files/common/types/file_types.py +++ b/skytemple_files/common/types/file_types.py @@ -79,6 +79,7 @@ from skytemple_files.graphics.bg_list_dat.handler import BgListDatHandler from skytemple_files.graphics.sma.handler import SmaHandler from skytemple_files.graphics.w16.handler import W16Handler +from skytemple_files.graphics.effect_screen.handler import ScreenEffectHandler from skytemple_files.graphics.wan_wat.handler import WanHandler from skytemple_files.graphics.wte.handler import WteHandler from skytemple_files.graphics.wtu.handler import WtuHandler @@ -148,6 +149,7 @@ class FileType: WTE = WteHandler WTU = WtuHandler + SCREEN_FX = ScreenEffectHandler WAN = WanHandler WAT = WanHandler diff --git a/skytemple_files/graphics/effect_screen/README.rst b/skytemple_files/graphics/effect_screen/README.rst new file mode 100644 index 000000000..6dbaf3f3d --- /dev/null +++ b/skytemple_files/graphics/effect_screen/README.rst @@ -0,0 +1,87 @@ +Screen Effect File Format +=============== +The file ``/EFFECT/effect.bin`` contains vfx used for dungeon battle, weather effects, and some ground mode vfx. + +The file contains 293 effect entries, most of which are WAN format. +Specifically the files effect0268-00289 are not WAN. +They are used for screen effects in moves and cutscenes. + + +The file uses SIR0 headers to store its pointers. General SIR0 details can be found in the main SIR0 documentation. + ++-----------------------+-----------------------------+---------------------+------------------------------+--------------------------------------------------------------------------------------------------+ +| Name | Offset | Size (Per Element) | # of Elements | Description | ++=======================+=============================+=====================+==============================+==================================================================================================+ +| SIR0 Header | 0x00 | 16 Bytes | 1 | Details in the SIR0 documentation | ++-----------------------+-----------------------------+---------------------+------------------------------+--------------------------------------------------------------------------------------------------+ +| Animation Data | Pointed by Animation Ptrs | Varies | Specified by Content Header | A 36-byte header with draw parameters, plus a list of draw instructions to put textures onscreen | ++-----------------------+-----------------------------+---------------------+------------------------------+--------------------------------------------------------------------------------------------------+ +| Animation Pointers | Pointed by Content Header | 4 Bytes | Specified by Content Header | List of pointers to each frame of Animation Data | ++-----------------------+-----------------------------+---------------------+------------------------------+--------------------------------------------------------------------------------------------------+ +| Palette Data | Pointed by Content Header | 64 Bytes | 16 | A block of palette data that is separated into 16 palettes, each with 16 colors of 4 bytes each. | ++-----------------------+-----------------------------+---------------------+------------------------------+--------------------------------------------------------------------------------------------------+ +| Image Data | Pointed by Content Header | Varies | 1 | One continuous block of image data that is read nibble-by-nibble. | ++-----------------------+-----------------------------+---------------------+------------------------------+--------------------------------------------------------------------------------------------------+ +| Content Header | Pointed by SIR0 Header | 32 Bytes | 1 | Contains the pointers to Animation Data, Image Data, Palette Data, and the number of animations. | ++-----------------------+-----------------------------+---------------------+------------------------------+--------------------------------------------------------------------------------------------------+ +| Pointer Offsets List | Pointed by SIR0 Header | 1 Byte | Varies | Details in the SIR0 documentation | ++-----------------------+-----------------------------+---------------------+------------------------------+--------------------------------------------------------------------------------------------------+ +| SIR0 Padding | After Pointer Offsets List | Varies | --- | Details in the SIR0 documentation | ++-----------------------+-----------------------------+---------------------+------------------------------+--------------------------------------------------------------------------------------------------+ + + +Content Header +~~~~~~~~~~~~~~ + +The 32-byte header appears to be split into 6 sections, each with 4 bytes: + +1. Number of frames in the animation +2. Pointer to start of Animation Data +3. Unknown +4. Pointer to image data +5. Pointer to palette data +6. Unknown + +Animation Data +~~~~~~~~~~~~~~ + +Represents one frame of screen animation per element. +It is always a header of 36 bytes, followed by a variable number of 2-byte draw instructions. +This frame of animation is drawn tile-by-tile: Starting from the top-left, row by row, then column by column. +Each tile is 8x8 texture. First the number of tiles to draw are specified by the header (rows x columns) +Then, by reading each two-byte value and interpreting it as either a draw instruction that advances one tile, +or a skip instruction that advances the specified number of tiles. +Once all tiles (rows x columns) have been traversed, the frame has finished drawing and will not be read any further. + +Header +------ + +The header 36 bytes is split up into the following regions: + +AA AA BB BB CC CC DD DD EE EE 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 FF FF 00 00 00 GG 00 00 + +A: Always a multiple of section A +B: Always a multiple of section D +C: Number of textures per row. +D: Number of textures per column. +E: Frame duration in 1/60th of a second. +F: Transparency. +G: Unknown. One of various possible numbers: 0x00,0x40,0x60,0x7F,0x80,0xC0,0xF0,0xFF + +Draw Instructions +----------------- + +Two bytes that tell the game how to draw a single 8x8 texture in the current position, or how many tiles to skip. +The draw instruction is a little endian, 16 bit value made up of 4 parts. + +AAA0 BCDD DDDD DDDD + +A: If 1, draw the texture specified in D. If 0, skip a number of tiles specified in D. +B: Flip the source image on Y axis before drawing +C: Flip the source image on X axis before drawing +D: Draw value. If selected as a tile to draw, interpret this as the point in imgData to start reading an 8x8 texture. + + +Credits +------- +Thanks to BitDrifter for experimenting and deducing Header and Draw Instructions. \ No newline at end of file diff --git a/skytemple_files/graphics/effect_screen/__init__.py b/skytemple_files/graphics/effect_screen/__init__.py new file mode 100644 index 000000000..8ed491c02 --- /dev/null +++ b/skytemple_files/graphics/effect_screen/__init__.py @@ -0,0 +1,22 @@ +# Copyright 2020-2024 Capypara and the SkyTemple Contributors +# +# This file is part of SkyTemple. +# +# SkyTemple 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. +# +# SkyTemple 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 SkyTemple. If not, see . + +from __future__ import annotations + +BANNER_FONT_ENTRY_LEN = 0x8 +BANNER_FONT_DATA_LEN = 576 +BANNER_FONT_SIZE = 24 diff --git a/skytemple_files/graphics/effect_screen/handler.py b/skytemple_files/graphics/effect_screen/handler.py new file mode 100644 index 000000000..939c0d4bd --- /dev/null +++ b/skytemple_files/graphics/effect_screen/handler.py @@ -0,0 +1,40 @@ +# Copyright 2020-2024 Capypara and the SkyTemple Contributors +# +# This file is part of SkyTemple. +# +# SkyTemple 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. +# +# SkyTemple 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 SkyTemple. If not, see . +from __future__ import annotations + +from skytemple_files.common.types.data_handler import DataHandler +from skytemple_files.common.util import OptionalKwargs +from skytemple_files.graphics.effect_screen.model import ScreenEffectFile +from skytemple_files.graphics.effect_screen.sheets import ExportSheets + + +class ScreenEffectHandler(DataHandler[ScreenEffectFile]): + @classmethod + def deserialize(cls, data: bytes, **kwargs: OptionalKwargs) -> ScreenEffectFile: + from skytemple_files.common.types.file_types import FileType + + return FileType.SIR0.unwrap_obj(FileType.SIR0.deserialize(data), ScreenEffectFile) # type: ignore + + @classmethod + def serialize(cls, data: ScreenEffectFile, **kwargs: OptionalKwargs) -> bytes: + from skytemple_files.common.types.file_types import FileType + + return FileType.SIR0.serialize(FileType.SIR0.wrap_obj(data)) # type: ignore + + @classmethod + def export_sheets(cls, out_dir: str, screen_effect: ScreenEffectFile, include_alpha: bool) -> None: + return ExportSheets(out_dir, screen_effect, include_alpha) # type: ignore diff --git a/skytemple_files/graphics/effect_screen/model.py b/skytemple_files/graphics/effect_screen/model.py new file mode 100644 index 000000000..f044d4e8c --- /dev/null +++ b/skytemple_files/graphics/effect_screen/model.py @@ -0,0 +1,166 @@ +# Copyright 2020-2024 Capypara and the SkyTemple Contributors +# +# This file is part of SkyTemple. +# +# SkyTemple 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. +# +# SkyTemple 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 SkyTemple. If not, see . +# mypy: ignore-errors +from __future__ import annotations + +from range_typed_integers import u32 + +from io import BytesIO + +from skytemple_files.container.sir0.sir0_serializable import Sir0Serializable + +TEX_SIZE = 8 +SCREEN_ATTR_DrawMask = 0x8000 # 1000 0000 0000 0000 +SCREEN_ATTR_FlipYMask = 0x0800 # 0000 1000 0000 0000 +SCREEN_ATTR_FlipXMask = 0x0400 # 0000 0100 0000 0000 +SCREEN_ATTR_ValueMask = 0x03FF # 0000 0011 1111 1111 + +DEBUG_PRINT = False + + +class ScreenEffectFile(Sir0Serializable): + def __init__(self, data: bytes | None = None, header_pnt: int = 0): + if data is None: + self.imgData = None + self.animData = None + self.customPalette = None + else: + self.ImportScreenEffect(data, header_pnt) + + @classmethod + def sir0_unwrap( + cls, + content_data: bytes, + data_pointer: int, + ) -> Sir0Serializable: + return cls(content_data, data_pointer) + + def sir0_serialize_parts(self) -> tuple[bytes, list[u32], u32 | None]: + raise NotImplementedError("Serialization not currently supported.") + + def ImportScreenEffect(self, data, ptrEffect=0): + in_file = BytesIO() + in_file.write(data) + in_file.seek(0) + + ##Read Effect header: ptr to AnimData, ptr to ImgData, PaletteData + in_file.seek(ptrEffect) + nbFrames = int.from_bytes(in_file.read(4), "little") + ptrAnimData = int.from_bytes(in_file.read(4), "little") + updateUnusedStats([], "Unk#3", int.from_bytes(in_file.read(4), "little")) + ptrImgData = int.from_bytes(in_file.read(4), "little") + ptrPaletteDataBlock = int.from_bytes(in_file.read(4), "little") + updateUnusedStats([], "Unk#1", int.from_bytes(in_file.read(2), "little")) + updateUnusedStats([], "Unk#2", int.from_bytes(in_file.read(2), "little")) + + ##Read palette info + nbColorsPerRow = 16 + in_file.seek(ptrPaletteDataBlock) + totalColors = (ptrImgData - ptrPaletteDataBlock) // 4 + totalPalettes = totalColors // nbColorsPerRow + self.customPalette = [] + for ii in range(totalPalettes): + palette = [] + for jj in range(nbColorsPerRow): + red = int.from_bytes(in_file.read(1), "little") + blue = int.from_bytes(in_file.read(1), "little") + green = int.from_bytes(in_file.read(1), "little") + in_file.read(1) + palette.append((red, blue, green, 255)) + self.customPalette.append(palette) + + ##read image data + self.imgData = [] + in_file.seek(ptrImgData) + while in_file.tell() < ptrEffect: + px = int.from_bytes(in_file.read(1), "little") + self.imgData.append(px % 16) + self.imgData.append(px // 16) + + ptrFrames = [] + in_file.seek(ptrAnimData) + for idx in range(nbFrames): + ##read the location + ptrFrame = int.from_bytes(in_file.read(4), "little") + ptrFrames.append(ptrFrame) + + self.animData = [] + for frame_idx, ptrFrame in enumerate(ptrFrames): + in_file.seek(ptrFrame) + + updateUnusedStats([], "Unk#5", int.from_bytes(in_file.read(2), "little")) + updateUnusedStats([], "Unk#7", int.from_bytes(in_file.read(2), "little")) + + # Must be 0x21 or else the animation doesn't play + updateUnusedStats([], "Unk#6", int.from_bytes(in_file.read(2), "little")) + row_height = int.from_bytes(in_file.read(2), "little") + frame_dur = int.from_bytes(in_file.read(2), "little") + in_file.read(18) + alpha = int.from_bytes(in_file.read(2), "little") + in_file.read(3) + updateUnusedStats([], "Unk#4", int.from_bytes(in_file.read(1), "little")) + in_file.read(2) + + pieces = [] + totalSlots = 0 + while True: + drawValue = int.from_bytes(in_file.read(2), "little") + skip = (SCREEN_ATTR_DrawMask & drawValue) == 0 + flipX = (SCREEN_ATTR_FlipXMask & drawValue) != 0 + flipY = (SCREEN_ATTR_FlipYMask & drawValue) != 0 + drawArg = SCREEN_ATTR_ValueMask & drawValue + + pieces.append(ScreenPiece(drawArg, flipX, flipY, skip)) + if skip: + totalSlots += drawArg + else: + totalSlots += 1 + + if totalSlots >= row_height * 33: + break + + end_ptr = ptrAnimData + if frame_idx < len(ptrFrames) - 1: + end_ptr = ptrFrames[frame_idx + 1] + + cur_pos = in_file.tell() + if cur_pos != end_ptr and cur_pos != end_ptr - 2: + raise Exception() + + self.animData.append(ScreenFrame(frame_dur, alpha, row_height, pieces)) + + +class ScreenFrame(object): + def __init__(self, duration, alpha, rowHeight, pieces): + self.duration = duration + self.alpha = alpha + self.rowHeight = rowHeight + self.pieces = pieces + + +class ScreenPiece(object): + def __init__(self, index, flipX, flipY, skip): + self.index = index + self.flipX = flipX + self.flipY = flipY + self.skip = skip + + +def updateUnusedStats(log_params, name, val): + # stats.append([log_params[0], log_params[1], name, log_params[2:], val]) + if DEBUG_PRINT and val != 0: + print(" " + name + ":" + str(val)) diff --git a/skytemple_files/graphics/effect_screen/sheets.py b/skytemple_files/graphics/effect_screen/sheets.py new file mode 100644 index 000000000..36d75f099 --- /dev/null +++ b/skytemple_files/graphics/effect_screen/sheets.py @@ -0,0 +1,82 @@ +# Copyright 2020-2024 Capypara and the SkyTemple Contributors +# +# This file is part of SkyTemple. +# +# SkyTemple 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. +# +# SkyTemple 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 SkyTemple. If not, see . +# mypy: ignore-errors + +from __future__ import annotations + +import os + +from PIL import Image + +from skytemple_files.graphics.effect_screen.model import TEX_SIZE + + +def ExportSheets(outDir, effectData, includeAlpha): + if not os.path.isdir(outDir): + os.makedirs(outDir) + + for frameIndex, frame in enumerate(effectData.animData): + img = GenerateScreenFrame(effectData.imgData, frame, effectData.customPalette, includeAlpha) + img.save(os.path.join(outDir, "F-" + format(frameIndex, "02d") + ".png")) + + +def GenerateScreenFrame(imgData, frame, inPalette, includeAlpha): + screen_width = 256 + screen_height = 160 + + newImg = Image.new("RGBA", (screen_width, screen_height), (0, 0, 0, 0)) + + curBlockIdx = 0 + for screenPiece in frame.pieces: + if screenPiece.skip: + curBlockIdx += screenPiece.index + else: + alpha = 255 + if includeAlpha: + alpha = frame.alpha // 256 + piece = GenerateScreenPiece(imgData, screenPiece, inPalette, alpha) + if screenPiece.flipX: + piece = piece.transpose(Image.FLIP_LEFT_RIGHT) + if screenPiece.flipY: + piece = piece.transpose(Image.FLIP_TOP_BOTTOM) + + blockX = curBlockIdx % 33 + blockY = curBlockIdx // 33 + newImg.paste(piece, (blockX * TEX_SIZE, blockY * TEX_SIZE), piece) + curBlockIdx += 1 + + return newImg + + +def GenerateScreenPiece(imgData, screenPiece, inPalette, alpha): + newImg = Image.new("RGBA", (TEX_SIZE, TEX_SIZE), (0, 0, 0, 0)) + datas = [(0, 0, 0, 0)] * (TEX_SIZE * TEX_SIZE) + + texPosition = screenPiece.index * TEX_SIZE * TEX_SIZE + ##iterate the elements of the block and assign pixels + for py in range(TEX_SIZE): + for px in range(TEX_SIZE): + paletteElement = imgData[texPosition + py * TEX_SIZE + px] + if paletteElement == 0: + color = (0, 0, 0, 0) + else: + color = inPalette[0][paletteElement] + color = (color[0], color[1], color[2], alpha) + datas[py * TEX_SIZE + px] = color + + newImg.putdata(datas) + return newImg