saturn-aseprite/aseprite.py
2025-08-30 23:23:33 -05:00

656 lines
19 KiB
Python

import struct
import sys
from dataclasses import dataclass
from pprint import pprint, pformat
import textwrap
from typing import Union, Optional
import zlib
from operator import itemgetter
class CustomInt:
value: int
def __init__(self, value):
self.value = value
def __int__(self):
return self.value
def __eq__(self, other):
return self.value == other
def __eq__(self, other):
return self.value == other
class HexInt(CustomInt):
def __repr__(self):
return hex(self.value)
class BinInt(CustomInt):
def __repr__(self):
return bin(self.value)
@dataclass
class Header:
file_size: int
magic_number: HexInt
frames: int
width_in_pixels: int
height_in_pixels: int
color_depth: int
flags: HexInt
speed: int
transparent_palette_index: int
number_of_colors: int
pixel_width: int
pixel_height: int
x_position_of_grid: int
y_position_of_grid: int
grid_width: int
grid_height: int
def dword(mem):
return struct.unpack("<I", mem[0:4])[0], mem[4:]
def word(mem):
return struct.unpack("<H", mem[0:2])[0], mem[2:]
def short(mem):
return struct.unpack("<h", mem[0:2])[0], mem[2:]
def byte(mem):
return struct.unpack("<B", mem[0:1])[0], mem[1:]
def skip(mem, i):
return mem[i:]
def uuid(mem):
return bytes(mem[0:16]), mem[16:]
def string(mem):
string_length, mem = word(mem)
byte = bytes(mem[0:string_length])
return byte, mem[string_length:]
def parse_header(mem):
file_size, mem = dword(mem)
magic_number, mem = word(mem)
frames, mem = word(mem)
width_in_pixels, mem = word(mem)
height_in_pixels, mem = word(mem)
color_depth, mem = word(mem)
flags, mem = dword(mem)
speed, mem = word(mem)
mem = skip(mem, 8)
transparent_palette_index, mem = byte(mem)
mem = skip(mem, 3)
number_of_colors, mem = word(mem)
pixel_width, mem = byte(mem)
pixel_height, mem = byte(mem)
x_position_of_grid, mem = short(mem)
y_position_of_grid, mem = short(mem)
grid_width, mem = word(mem)
grid_height, mem = word(mem)
mem = skip(mem, 84)
assert magic_number == 0xa5e0, magic_number
header = Header(
file_size = file_size,
magic_number = HexInt(magic_number),
frames = frames,
width_in_pixels = width_in_pixels,
height_in_pixels = height_in_pixels,
color_depth = color_depth,
flags = flags,
speed = speed,
transparent_palette_index = transparent_palette_index,
number_of_colors = number_of_colors,
pixel_width = pixel_width,
pixel_height = pixel_height,
x_position_of_grid = x_position_of_grid,
y_position_of_grid = y_position_of_grid,
grid_width = grid_width,
grid_height = grid_height,
)
return header, mem
@dataclass
class FrameHeader:
bytes_in_this_frame: int
magic_number: HexInt
number_of_chunks: int
frame_duration: int
def parse_frame_header(mem):
bytes_in_this_frame, mem = dword(mem)
magic_number, mem = word(mem)
old_number_of_chunks, mem = word(mem)
frame_duration, mem = word(mem)
mem = skip(mem, 2)
new_number_of_chunks, mem = dword(mem)
assert magic_number == 0xf1fa, magic_number
assert (
old_number_of_chunks == new_number_of_chunks or
(old_number_of_chunks == 0xffff and new_number_of_chunks != 0) or
(old_number_of_chunks < 0xffff and new_number_of_chunks == 0)
)
number_of_chunks = new_number_of_chunks if new_number_of_chunks != 0 else old_number_of_chunks
frame_header = FrameHeader(
bytes_in_this_frame = bytes_in_this_frame,
magic_number = HexInt(magic_number),
number_of_chunks = number_of_chunks,
frame_duration = frame_duration,
)
return frame_header, mem
@dataclass
class Chunk:
chunk_size: int
chunk_type: HexInt
data: memoryview
def parse_chunk(mem):
chunk_size, mem = dword(mem)
chunk_type, mem = word(mem)
assert chunk_size >= 6, chunk_size
data = mem[0:chunk_size - 6]
mem = skip(mem, chunk_size - 6)
chunk = Chunk(
chunk_size = chunk_size,
chunk_type = HexInt(chunk_type),
data = data
)
return chunk, mem
@dataclass
class PaletteChunkPacket:
entries_to_skip: int
number_of_colors: int
colors: list[tuple[int, int, int]]
@dataclass
class OldPaletteChunk:
number_of_packets: int
packets: list[PaletteChunkPacket]
def parse_old_palette_chunk(mem):
number_of_packets, mem = word(mem)
packets = []
for _ in range(number_of_packets):
entries_to_skip, mem = byte(mem)
number_of_colors, mem = byte(mem)
assert entries_to_skip == 0, entries_to_skip
colors = []
for _ in range(number_of_colors):
red, mem = byte(mem)
green, mem = byte(mem)
blue, mem = byte(mem)
colors.append((red, green, blue))
packets.append(PaletteChunkPacket(
entries_to_skip = entries_to_skip,
number_of_colors = number_of_colors,
colors = colors,
))
old_palette_chunk = OldPaletteChunk(
number_of_packets = number_of_packets,
packets = packets
)
return old_palette_chunk, mem
@dataclass
class PaletteChunkEntry:
red: int
green: int
blue: int
alpha: int
color_name: str
def parse_palette_chunk_entry(mem):
flag, mem = word(mem)
red, mem = byte(mem)
green, mem = byte(mem)
blue, mem = byte(mem)
alpha, mem = byte(mem)
color_name = None
if flag & (1 << 0):
color_name, mem = string(mem)
palette_chunk_entry = PaletteChunkEntry(
red = red,
green = green,
blue = blue,
alpha = alpha,
color_name = color_name
)
return palette_chunk_entry, mem
@dataclass
class PaletteChunk:
new_palette_size: int
first_color_index_to_change: int
last_color_index_to_change: int
entries: list[PaletteChunkEntry]
def parse_palette_chunk(mem):
new_palette_size, mem = dword(mem)
first_color_index_to_change, mem = dword(mem)
last_color_index_to_change, mem = dword(mem)
mem = skip(mem, 8)
length = last_color_index_to_change - first_color_index_to_change
assert length > 0, length
entries = []
for _ in range(length + 1):
palette_chunk_entry, mem = parse_palette_chunk_entry(mem)
entries.append(palette_chunk_entry)
palette_chunk = PaletteChunk(
new_palette_size = new_palette_size,
first_color_index_to_change = first_color_index_to_change,
last_color_index_to_change = last_color_index_to_change,
entries = entries
)
return palette_chunk, mem
@dataclass
class TilesetChunkExternal:
id_of_external_file: int
tileset_id_in_external_file: int
@dataclass
class TilesetChunkInternal:
data_length: int
pixel: memoryview
@dataclass
class TilesetChunk:
tileset_id: int
tileset_flags: HexInt
number_of_tiles: int
tile_width: int
tile_height: int
base_index: int
name_of_tileset: str
data: Union[TilesetChunkExternal, TilesetChunkInternal]
def parse_tileset_chunk(mem):
_link_to_external_file = (1 << 0)
_tiles_inside_this_file = (1 << 1)
tileset_id, mem = dword(mem)
tileset_flags, mem = dword(mem)
number_of_tiles, mem = dword(mem)
tile_width, mem = word(mem)
tile_height, mem = word(mem)
base_index, mem = short(mem)
mem = skip(mem, 14)
name_of_tileset, mem = string(mem)
assert (tileset_flags & 0b11) != 0, tileset_flags
data = None
if tileset_flags & _link_to_external_file:
id_of_external_file, mem = dword(mem)
tileset_id_in_external_file, mem = dword(mem)
data = TilesetChunkExternal(
id_of_external_file,
tileset_id_in_external_file,
)
elif tileset_flags & _tiles_inside_this_file:
data_length, mem = dword(mem)
pixel = mem[0:data_length]
mem = skip(mem, data_length)
data = TilesetChunkInternal(
data_length,
zlib.decompress(pixel),
)
tileset_chunk = TilesetChunk(
tileset_id = tileset_id,
tileset_flags = HexInt(tileset_flags),
number_of_tiles = number_of_tiles,
tile_width = tile_width,
tile_height = tile_height,
base_index = base_index,
name_of_tileset = name_of_tileset,
data = data,
)
return tileset_chunk, mem
@dataclass
class LayerChunk:
flags: int
layer_type: int
layer_child_level: int
default_layer_width_in_pixels: int
default_layer_height_in_pixels: int
blend_mode: int
opacity: int
layer_name: str
tileset_index: Optional[int]
layer_uuid: Optional[bytes]
def parse_layer_chunk(mem, header_flags):
flags, mem = word(mem)
layer_type, mem = word(mem)
layer_child_level, mem = word(mem)
default_layer_width_in_pixels, mem = word(mem)
default_layer_height_in_pixels, mem = word(mem)
blend_mode, mem = word(mem)
opacity, mem = byte(mem)
mem = skip(mem, 3)
layer_name, mem = string(mem)
tileset_index = None
if layer_type == 2:
tileset_index, mem = dword(mem)
layer_uuid = None
if header_flags & (1 << 3):
layer_uuid, mem = uuid(mem)
layer_chunk = LayerChunk(
flags = flags,
layer_type = layer_type,
layer_child_level = layer_child_level,
default_layer_width_in_pixels = default_layer_width_in_pixels,
default_layer_height_in_pixels = default_layer_height_in_pixels,
blend_mode = blend_mode,
opacity = opacity,
layer_name = layer_name,
tileset_index = tileset_index,
layer_uuid = layer_uuid,
)
return layer_chunk, mem
@dataclass
class CelChunk_RawImageData:
width_in_pixels: int
height_in_pixes: int
pixel: memoryview
@dataclass
class CelChunk_LinkedCell:
frame_position: int
@dataclass
class CelChunk_CompressedImage:
width_in_pixels: int
height_in_pixels: int
pixel: memoryview
@dataclass
class CelChunk_CompressedTilemap:
width_in_number_of_tiles: int
height_in_number_of_tiles: int
bits_per_tile: int
bitmask_for_tile_id: int
bitmask_for_x_flip: int
bitmask_for_y_flip: int
bitmask_for_diagonal_flip: int
tile: memoryview
@dataclass
class CelChunk:
layer_index: int
x_position: int
y_position: int
opacity_level: int
cel_type: int
z_index: int
data: Union[CelChunk_RawImageData,
CelChunk_LinkedCell,
CelChunk_CompressedImage,
CelChunk_CompressedTilemap]
def parse_cel_chunk(mem):
layer_index, mem = word(mem)
x_position, mem = short(mem)
y_position, mem = short(mem)
opacity_level, mem = byte(mem)
cel_type, mem = word(mem)
z_index, mem = short(mem)
mem = skip(mem, 5)
assert cel_type in {0, 1, 2, 3}, cel_type
data = None
if cel_type == 0:
width_in_pixels, mem = word(mem)
height_in_pixels, mem = word(mem)
pixel = mem
data = CelChunk_RawImageData(
width_in_pixels,
height_in_pixels,
pixel,
)
if cel_type == 1:
frame_position, mem = word(mem)
data = CelChunk_LinkedCell(frame_position)
if cel_type == 2:
width_in_pixels, mem = word(mem)
height_in_pixels, mem = word(mem)
pixel = memoryview(zlib.decompress(mem))
data = CelChunk_CompressedImage(
width_in_pixels,
height_in_pixels,
pixel,
)
if cel_type == 3:
width_in_number_of_tiles, mem = word(mem)
height_in_number_of_tiles, mem = word(mem)
bits_per_tile, mem = word(mem)
bitmask_for_tile_id, mem = dword(mem)
bitmask_for_x_flip, mem = dword(mem)
bitmask_for_y_flip, mem = dword(mem)
bitmask_for_diagonal_flip, mem = dword(mem)
mem = skip(mem, 10)
tile_mem = memoryview(zlib.decompress(mem))
assert len(tile_mem) % 4 == 0
tile = [
struct.unpack("<I", tile_mem[i*4:i*4+4])[0]
for i in range(len(tile_mem) // 4)
]
data = CelChunk_CompressedTilemap(
width_in_number_of_tiles = width_in_number_of_tiles,
height_in_number_of_tiles = height_in_number_of_tiles,
bits_per_tile = bits_per_tile,
bitmask_for_tile_id = HexInt(bitmask_for_tile_id),
bitmask_for_x_flip = HexInt(bitmask_for_x_flip),
bitmask_for_y_flip = HexInt(bitmask_for_y_flip),
bitmask_for_diagonal_flip = HexInt(bitmask_for_diagonal_flip),
tile = tile,
)
cel_chunk = CelChunk(
layer_index = layer_index,
x_position = x_position,
y_position = y_position,
opacity_level = opacity_level,
cel_type = cel_type,
z_index = z_index,
data = data,
)
return cel_chunk, mem
with open(sys.argv[1], 'rb') as f:
buf = f.read()
mem = memoryview(buf)
def pprinti(o, i):
s = pformat(o)
print(textwrap.indent(s, ' ' * i))
def pack_bgr555(red, green, blue):
bgr = (
((red >> 3) << 0) |
((green >> 3) << 5) |
((blue >> 3) << 10)
)
return struct.pack(">H", bgr)
def pack_index(i):
return struct.pack(">I", i)
def pack_old_palette_chunk(old_palette_chunk):
with open("palette.bin", "wb") as f:
for color in old_palette_chunk.packets[0].colors:
f.write(pack_bgr555(*color))
def pack_palette_chunk(palette_chunk):
with open("palette.bin", "wb") as f:
assert palette_chunk.first_color_index_to_change == 0
for entry in palette_chunk.entries:
color = (entry.red, entry.green, entry.blue)
f.write(pack_bgr555(*color))
print("palette.bin", f.tell(), file=sys.stderr)
def pack_palette(palette):
if type(palette) is PaletteChunk:
pack_palette_chunk(palette)
elif type(palette) is OldPaletteChunk:
pack_old_palette_chunk(palette)
else:
assert False, type(palette)
def pack_character_2x2(tileset_chunk, offset):
#tileset_chunk.number_of_tiles,
#tileset_chunk.tile_width,
#tileset_chunk.tile_height,
assert tileset_chunk.tile_width == 16
assert tileset_chunk.tile_height == 16
assert type(tileset_chunk.data) == TilesetChunkInternal
buf = bytearray(16 * 16)
for cell_ix in range(4):
for y in range(8):
for x in range(8):
tileset_x = 8 * (cell_ix % 2) + x
tileset_y = 8 * (cell_ix // 2) + y
px = tileset_chunk.data.pixel[offset + tileset_y * 16 + tileset_x]
buf[cell_ix * 8 * 8 + y * 8 + x] = px
return bytes(buf)
def pack_character_patterns_2x2(filename, tileset_chunk):
with open(filename, "wb") as f:
for i in range(tileset_chunk.number_of_tiles):
offset = tileset_chunk.tile_width * tileset_chunk.tile_height * i
buf = pack_character_2x2(tileset_chunk, offset)
f.write(buf)
print(filename, f.tell(), file=sys.stderr)
def pack_pattern_name_table_2x2(filename, cel_chunk):
with open(filename, "wb") as f:
assert type(cel_chunk.data) == CelChunk_CompressedTilemap
assert cel_chunk.data.width_in_number_of_tiles <= 64
assert cel_chunk.data.height_in_number_of_tiles <= 64
tile_width = cel_chunk.data.width_in_number_of_tiles
tile_height = cel_chunk.data.height_in_number_of_tiles
h_pages = ((tile_width + 31) & (~31)) // 32
v_pages = ((tile_height + 31) & (~31)) // 32
for v_page in range(v_pages):
for h_page in range(h_pages):
for y in range(32):
for x in range(32):
tx = (h_page * 32) + x
ty = (v_page * 32) + y
if tx >= tile_width or ty >= tile_height:
f.write(pack_index(0))
else:
cel_chunk_ix = ty * tile_width + tx
tile_data = cel_chunk.data.tile[cel_chunk_ix]
tile_id = tile_data & cel_chunk.data.bitmask_for_tile_id.value
x_flip = (tile_data & cel_chunk.data.bitmask_for_x_flip.value) != 0
y_flip = (tile_data & cel_chunk.data.bitmask_for_y_flip.value) != 0
pattern = (int(y_flip) << 31) | (int(x_flip) << 30) | tile_id
f.write(pack_index(pattern))
print(filename, f.tell(), file=sys.stderr)
header, mem = parse_header(mem)
#pprint(header)
assert header.color_depth == 8, header.color_depth
frame_header, mem = parse_frame_header(mem)
#pprint(frame_header)
tilesets = dict() # by tileset id
layers = []
palette = None
cel_chunks = dict() # by layer index
for _ in range(frame_header.number_of_chunks):
chunk, mem = parse_chunk(mem)
#pprinti(chunk, 1)
if chunk.chunk_type == 0x4:
old_palette_chunk, _ = parse_old_palette_chunk(chunk.data)
#pprinti(old_palette_chunk, 2)
assert palette is None
palette = old_palette_chunk
elif chunk.chunk_type == 0x2019:
palette_chunk, _ = parse_palette_chunk(chunk.data)
#pprinti(palette_chunk, 2)
assert palette is None
palette = palette_chunk
elif chunk.chunk_type == 0x2023:
tileset_chunk, _ = parse_tileset_chunk(chunk.data)
assert tileset_chunk.tileset_id not in tilesets
tilesets[tileset_chunk.tileset_id] = tileset_chunk
#pprinti(tileset_chunk, 2)
elif chunk.chunk_type == 0x2004:
layer_chunk, _ = parse_layer_chunk(chunk.data, header.flags)
assert layer_chunk.layer_type == 2
layers.append(layer_chunk)
#pprinti(layer_chunk, 2)
elif chunk.chunk_type == 0x2005:
cel_chunk, _ = parse_cel_chunk(chunk.data)
#pprinti(cel_chunk, 2)
assert cel_chunk.layer_index not in cel_chunks
cel_chunks[cel_chunk.layer_index] = cel_chunk
elif chunk.chunk_type == 0x2020:
# user data
pass
else:
print("unhandled chunk: ")
pprinti(chunk, 1)
assert palette is not None
pack_palette(palette)
for tileset_index, tileset_chunk in sorted(tilesets.items(), key=itemgetter(0)):
filename = f"character_pattern__tileset_{tileset_index}.bin"
pack_character_patterns_2x2(filename, tileset_chunk)
for layer_index, cel_chunk in sorted(cel_chunks.items(), key=itemgetter(0)):
filename = f"pattern_name_table__layer_{layer_index}.bin"
pack_pattern_name_table_2x2(filename, cel_chunk)
for layer_index, layer_chunk in enumerate(layers):
print(f"layer={layer_index} tileset={layer_chunk.tileset_index}");