├── requirements.txt ├── .gitmodules ├── logger.py ├── api ├── runtime.py └── pdapi.py ├── .gitignore ├── README.md ├── loaders ├── pdlua.py ├── pds.py ├── pdfile.py ├── pdx.py ├── pdt.py ├── pdv.py ├── pdbin.py ├── pdz.py ├── pdi.py ├── pda.py └── pft.py └── pdemu.py /requirements.txt: -------------------------------------------------------------------------------- 1 | Cython>=0.29.32 2 | Pillow>=10.0.1 3 | pygame>=2.1.2 4 | qiling>=1.4.6 -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "lupa"] 2 | path = lupa 3 | url = https://github.com/scratchminer/lupa.git 4 | -------------------------------------------------------------------------------- /logger.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | def init_logging(level=logging.INFO): 4 | logging.basicConfig(style="{", format="[{levelname}] {name}: {message}") 5 | 6 | def get_logger(name): 7 | return logging.getLogger(name) -------------------------------------------------------------------------------- /api/runtime.py: -------------------------------------------------------------------------------- 1 | from lupa import LuaRuntime 2 | 3 | # this function will go into the PDEmulator class 4 | ''' 5 | def print_func(self, *args): 6 | if self.old_newline == "\n": print("[] ", end="") 7 | string_args = [] 8 | for arg in args: string_args.append(RUNTIME.globals().tostring(arg)) 9 | print(*string_args, end=self.newline, sep="\t") 10 | self.old_newline = self.newline 11 | ''' 12 | 13 | RUNTIME = LuaRuntime(unpack_returned_tuples=True) 14 | '''RUNTIME.set_global("print", self.print_func)''' -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Unit test / coverage reports 37 | htmlcov/ 38 | .tox/ 39 | .nox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *.cover 46 | *.py,cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # pyenv 51 | .python-version 52 | 53 | # pipenv 54 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 55 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 56 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 57 | # install all needed dependencies. 58 | #Pipfile.lock 59 | 60 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 61 | __pypackages__/ 62 | 63 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pd-emu - a Panic Playdate emulator 2 | 3 | ## ...but why? 4 | Yes, Panic already provides a piece of software called Playdate Simulator, but... 5 | - It's geared towards testing your own games rather than playing other people's. 6 | - It can't run device builds of games written in C. 7 | - It doesn't allow communication via the simulated Playdate's USB port. 8 | 9 | I decided to try and fix this with new, *open-source* software. 10 | 11 | ## Getting the repo 12 | 1. Run `git clone --recursive https://github.com/scratchminer/pd-emu.git` to clone this repo and its submodules. 13 | 2. `pip install -r requirements.txt` should install all the dependencies except Lupa and Lua. 14 | 3. `cd lupa && make` should build the forks of both Lupa and Lua without having to run `setup.py`. 15 | 4. Use your favorite Python package manager to install the wheel in the `lupa/dist` directory. 16 | 17 | ## Running 18 | You can't actually run this emulator now (since I have yet to add the Playdate API). 19 | 20 | What you *can* do now is dump Playdate applications (directories with a PDX extension) from the command line. 21 | 22 | ## Dumping Playdate applications/games 23 | To dump a PDX: 24 | - `cd` to the root directory of this repo 25 | - `python3 -m loaders.pdx (path to PDX) (dump location)` 26 | 27 | ## Decompiling the Lua files 28 | See [my fork of unluac](https://github.com/scratchminer/unluac) for instructions. 29 | 30 | --- 31 | 2024 scratchminer 32 | 33 | Not affiliated with Panic at all, just a neat little side project I've been doing for a while. 34 | -------------------------------------------------------------------------------- /loaders/pdlua.py: -------------------------------------------------------------------------------- 1 | from api.runtime import RUNTIME 2 | from sys import argv 3 | 4 | from loaders.pdfile import PDFile 5 | from logger import init_logging, get_logger 6 | 7 | LOGGER = get_logger("loaders.pdlua") 8 | 9 | def _filter(obj, attr_name, setting): 10 | raise AttributeError 11 | 12 | class PDLuaBytecodeFile(PDFile): 13 | MAGIC = b"\x1bLua\x54\x00\x19\x93\r\n\x1a\n\x04\x04\x04" # Lua 5.4.0 release 14 | MAGIC2 = b"\x1bLua\x03\xf8\x00\x19\x93\r\n\x1a\n\x04\x04\x04" # Lua 5.4.0 beta 15 | PD_FILE_EXT = "" 16 | NONPD_FILE_EXT = ".luac" 17 | 18 | def __init__(self, filename, parent_pdz, skip_magic=False): 19 | if not skip_magic: LOGGER.info(f"Decompiling Lua bytecode file {filename}...") 20 | super().__init__(filename, skip_magic) 21 | 22 | self.parent_pdz = parent_pdz 23 | self.seek(0) 24 | self.runtime = RUNTIME 25 | 26 | def execute(self, *args): 27 | self.runtime.set_global("import", self.import_func) 28 | result = self.runtime.execute(self.readbin(), *args) 29 | self.seek(0) 30 | return result 31 | 32 | def import_func(self, lib_name): 33 | self.parent_pdz.import_func(lib_name) 34 | 35 | def to_nonpdfile(self): 36 | data = self.readbin() 37 | self.seek(0) 38 | return data 39 | 40 | if __name__ == "__main__": 41 | init_logging() 42 | 43 | filename = argv[1] 44 | lua_file = PDLuaBytecodeFile(filename) 45 | LOGGER.warning("This file is Playdate Lua bytecode. You'll need a Lua decompiler to obtain the Lua source.") 46 | with open(filename + lua_file.NONPD_FILE_EXT, "wb") as f: 47 | f.write(lua_file.to_nonpdfile()) 48 | -------------------------------------------------------------------------------- /pdemu.py: -------------------------------------------------------------------------------- 1 | from os import environ 2 | from threading import Lock 3 | from time import time, perf_counter 4 | 5 | os.environ["PYGAME_HIDE_SUPPORT_PROMPT"] = 1 6 | import pygame as pg 7 | import pygame.locals as pgloc 8 | 9 | from loaders.pdx import PDXApplication 10 | from logger import init_logging, get_logger 11 | 12 | LOGGER = get_logger("pdemu") 13 | 14 | class PDEmulator: 15 | class ButtonValues: 16 | LEFT = 0x01 17 | RIGHT = 0x02 18 | UP = 0x04 19 | DOWN = 0x08 20 | B = 0x10 21 | A = 0x20 22 | MENU = 0x40 23 | LOCK = 0x80 24 | 25 | BUTTON_STRINGS = { 26 | "a": A, 27 | "b": B, 28 | "up": UP, 29 | "down": DOWN, 30 | "left": LEFT, 31 | "right": RIGHT 32 | } 33 | 34 | def button_constant(button): 35 | if type(button) == str and button.lower() in PDEmulator.ButtonValues.BUTTON_STRINGS: 36 | return PDEmulator.ButtonValues.BUTTON_STRINGS[button.lower()] 37 | elif type(button) == int and button < 0x100: return button 38 | else: 39 | LOGGER.error("Invalid button constant value") 40 | 41 | def __init__(self, app=None): 42 | if type(app) == PDXApplication: 43 | # load it up 44 | pass 45 | pg.init() 46 | 47 | self.display = pg.display.set_mode(size=(400, 240), flags=pg.SCALED) 48 | self.reset() 49 | 50 | def reset(self): 51 | self.call_update_lock = Lock() 52 | self.newline = "\n" 53 | 54 | self.buttons = { 55 | PDEmulator.ButtonValues.LEFT: False, 56 | PDEmulator.ButtonValues.RIGHT: False, 57 | PDEmulator.ButtonValues.UP: False, 58 | PDEmulator.ButtonValues.DOWN: False, 59 | PDEmulator.ButtonValues.B: False, 60 | PDEmulator.ButtonValues.A: False, 61 | PDEmulator.ButtonValues.MENU: False, 62 | PDEmulator.ButtonValues.LOCK: False 63 | } 64 | self.prev_buttons = self.buttons.copy() 65 | 66 | self.clock = pg.time.Clock() 67 | self.game_time = 0.0 68 | self.hires_time = perf_counter() 69 | 70 | # self.accel = 71 | # self.battery = 72 | # self.crank = 73 | # self.fps_font = 74 | # self.gc = 75 | # self.serial = 76 | # self.settings = 77 | # self.stats = 78 | # self.system_menu = 79 | 80 | def __del__(self): 81 | pg.quit() -------------------------------------------------------------------------------- /loaders/pds.py: -------------------------------------------------------------------------------- 1 | from json import dumps 2 | from os.path import splitext 3 | from sys import argv 4 | 5 | from loaders.pdfile import PDFile 6 | from logger import init_logging, get_logger 7 | 8 | LOGGER = get_logger("loaders.pds") 9 | 10 | class PDStringsFile(PDFile): 11 | 12 | MAGIC = b"Playdate STR" 13 | PD_FILE_EXT = ".pds" 14 | NONPD_FILE_EXT = ".strings" 15 | 16 | def __init__(self, filename, skip_magic=False): 17 | if not skip_magic: LOGGER.info(f"Decompiling strings file {filename}...") 18 | super().__init__(filename, skip_magic) 19 | 20 | flags = self.readu32() 21 | compressed = bool(flags & 0x80000000) 22 | if compressed: self.advance(16) 23 | self.decompress(compressed) 24 | 25 | self.num_keys = self.readu32() 26 | offsets = [0x00000000] 27 | self.string_table = {} 28 | 29 | for i in range(self.num_keys - 1): 30 | offsets.append(self.readu32()) 31 | header_end = self.tell() 32 | 33 | for i in range(len(offsets) - 1): 34 | self.seekrelto(header_end, offsets[i]) 35 | k = self.readstr() 36 | v = self.readstr() 37 | self.string_table[k] = v 38 | 39 | def to_dict(self): 40 | return self.string_table 41 | 42 | def to_stringsfile(self): 43 | data = b"-- Decompiled with the pd-emu decompilation tools" 44 | for k, v in self.string_table.items(): 45 | data += f"\n\"{k}\" = \"{v}\"".encode("utf-8") 46 | self.stringsfile = data 47 | return self.stringsfile 48 | 49 | def to_nonpdfile(self): 50 | return self.to_stringsfile() 51 | 52 | if __name__ == "__main__": 53 | init_logging() 54 | 55 | filename = argv[1] 56 | str_file = PDStringsFile(filename) 57 | with open(f"{splitext(filename)[0]}{str_file.NONPD_FILE_EXT}", "wb") as f: 58 | f.write(str_file.to_nonpdfile()) 59 | 60 | # From jaames/playdate-reverse-engineering 61 | 62 | # FILE HEADER (length 16) 63 | # 0: char[12]: constant "Playdate STR" 64 | # 12: uint32: file bitflags 65 | # flags & 0x80000000 = file is compressed 66 | 67 | # COMPRESSED FILE HEADER (length 16, inserted after the file header if the file is compressed) 68 | # 0: uint32: decompressed data size in bytes 69 | # (12 bytes of zeros) 70 | 71 | # STRING TABLE HEADER (length 2) 72 | # 0: uint32: number of table entries 73 | 74 | # (offsets for table entries are uint32) 75 | 76 | # ENTRY FORMAT 77 | # char *: key, null-terminated 78 | # char *: value, null-terminated -------------------------------------------------------------------------------- /loaders/pdfile.py: -------------------------------------------------------------------------------- 1 | from io import BytesIO 2 | from zlib import decompress 3 | from struct import unpack, error as struct_error 4 | 5 | class PDFile: 6 | def __init__(self, filename, skip_magic, mode="rb"): 7 | self.filename = filename 8 | self.data = b"" 9 | if type(filename) == str: 10 | with open(filename, mode) as f: self.data = f.read() 11 | else: 12 | self.data = filename 13 | self.handle = BytesIO(self.data) 14 | 15 | if not skip_magic: 16 | if not hasattr(self, "MAGIC2") and self.readbin(len(self.MAGIC)) != self.MAGIC: raise ValueError("incorrect magic number for Playdate file") 17 | elif hasattr(self, "MAGIC2"): 18 | if (self.data[:len(self.MAGIC)] == self.MAGIC): 19 | self.fallback = False 20 | self.advance(len(self.MAGIC)) 21 | elif (self.data[:len(self.MAGIC2)] == self.MAGIC2): 22 | self.fallback = True 23 | self.advance(len(self.MAGIC2)) 24 | else: raise ValueError("incorrect magic number for Playdate file") 25 | def decompress(self, compressed=True): 26 | self.compressed = compressed 27 | if self.compressed: 28 | self.zlib_data = decompress(self.handle.read()) 29 | self.handle.close() 30 | self.handle = BytesIO(self.zlib_data) 31 | def readbin(self, numbytes=-1): 32 | return self.handle.read(numbytes) 33 | def readu8(self): 34 | try: return unpack(" 2: dump_loc = argv[2] 103 | dump_loc = abspath(dump_loc) 104 | 105 | pdx_app.dump_files(dump_loc) 106 | -------------------------------------------------------------------------------- /loaders/pdt.py: -------------------------------------------------------------------------------- 1 | from PIL import Image 2 | 3 | from io import BytesIO 4 | from os.path import splitext 5 | from sys import argv 6 | 7 | from loaders.pdfile import PDFile 8 | from loaders.pdi import PDImageFile 9 | from logger import init_logging, get_logger 10 | 11 | LOGGER = get_logger("loaders.pdt") 12 | 13 | class PDImageTableFile(PDFile): 14 | 15 | MAGIC = b"Playdate IMT" 16 | PD_FILE_EXT = ".pdt" 17 | NONPD_FILE_EXT = ".png" 18 | 19 | def __init__(self, filename, skip_magic=False): 20 | if not skip_magic: LOGGER.info(f"Decompiling image table file {filename}...") 21 | super().__init__(filename, skip_magic) 22 | 23 | flags = self.readu32() 24 | compressed = bool(flags & 0x80000000) 25 | if compressed: self.advance(16) 26 | self.decompress(compressed) 27 | 28 | self.num_images = self.readu16() 29 | self.num_per_row = self.readu16() 30 | 31 | if self.num_images != self.num_per_row and self.num_per_row != 0: 32 | self.is_matrix = True 33 | self.num_rows = self.num_images // self.num_per_row 34 | LOGGER.debug(f"Matrix table, image count: {self.num_per_row} x {self.num_rows} images") 35 | else: 36 | self.is_matrix = False 37 | self.num_rows = 1 38 | LOGGER.debug(f"Sequential table, image count: {self.num_per_row} images") 39 | 40 | offsets = [0x00000000] 41 | self.image_table = [] 42 | 43 | for i in range(self.num_images): 44 | offsets.append(self.readu32()) 45 | 46 | header_end = self.tell() 47 | 48 | for y in range(self.num_rows): 49 | self.image_table.append([]) 50 | for x in range(self.num_per_row): 51 | offset = offsets[y * self.num_per_row + x] 52 | next_offset = offsets[1 + (y * self.num_per_row + x)] 53 | self.seekrelto(header_end, offset) 54 | self.image_table[y].append(PDImageFile(b"\0\0\0\0" + self.readbin(next_offset - offset), skip_magic=True)) 55 | 56 | def to_matrix(self): 57 | self.pil_img = Image.new("RGBA", (self.num_per_row * self.image_table[0][0].stored_width, self.num_rows * self.image_table[0][0].stored_height)) 58 | 59 | for y in range(self.num_rows): 60 | for x in range(self.num_per_row): 61 | self.image_table[y][x].to_pngfile() 62 | self.pil_img.paste(self.image_table[y][x].pil_img, (x * self.image_table[y][x].stored_width, y * self.image_table[y][x].stored_height)) 63 | fh = BytesIO() 64 | self.pil_img.save(fh, format="PNG") 65 | matrix = fh.getvalue() 66 | fh.close() 67 | 68 | return matrix 69 | 70 | def to_list(self): 71 | return_list = [] 72 | 73 | for y in range(self.num_rows): 74 | for x in range(self.num_per_row): 75 | return_list.append(self.image_table[y][x].to_nonpdfile()) 76 | return return_list 77 | 78 | def to_nonpdfile(self): 79 | if self.is_matrix: return self.to_matrix() 80 | else: return self.to_list() 81 | 82 | if __name__ == "__main__": 83 | init_logging() 84 | 85 | filename = argv[1] 86 | imt_file = PDImageTableFile(filename) 87 | img_list = imt_file.to_nonpdfile() 88 | if not imt_file.is_matrix: 89 | for i in range(len(img_list)): 90 | with open(f"{splitext(filename)[0]}-table-{i}{imt_file.NONPD_FILE_EXT}", "wb") as f: 91 | f.write(img_list[i]) 92 | else: 93 | with open(f"{splitext(filename)[0]}-table-{imt_file.image_table[0][0].stored_width}-{imt_file.image_table[0][0].stored_height}{imt_file.NONPD_FILE_EXT}", "wb") as f: 94 | f.write(img_list) 95 | 96 | # From jaames/playdate-reverse-engineering 97 | 98 | # FILE HEADER (length 16) 99 | # 0: char[12]: constant "Playdate IMT" 100 | # 12: uint32: file bitflags 101 | # flags & 0x80000000 = file is compressed 102 | 103 | # COMPRESSED FILE HEADER (length 16, inserted after the file header if the file is compressed) 104 | # 0: uint32: decompressed data size in bytes 105 | # 4: uint32: width of the first cell 106 | # 8: uint32: height of the first cell 107 | # 12: uint32: total number of cells 108 | 109 | # TABLE HEADER (length 4): 110 | # 0: uint16: total number of cells 111 | # 2: uint16: number of cells on each row 112 | 113 | # (offsets for cells are uint32) 114 | 115 | # (data, see image format) -------------------------------------------------------------------------------- /loaders/pdv.py: -------------------------------------------------------------------------------- 1 | from PIL import Image, GifImagePlugin 2 | 3 | from io import BytesIO 4 | from os.path import splitext 5 | from sys import argv 6 | from struct import unpack 7 | from zlib import decompress 8 | 9 | from loaders.pdfile import PDFile 10 | from loaders.pdi import PDImageFile 11 | from logger import init_logging, get_logger 12 | 13 | LOGGER = get_logger("loaders.pdv") 14 | 15 | PDV_PALETTE = (0x32, 0x2f, 0x28, 0xb1, 0xae, 0xa7) 16 | PDV_BW_PALETTE = (0x00, 0x00, 0x00, 0xff, 0xff, 0xff) 17 | 18 | PDV_FRAME_NONE = 0 19 | PDV_FRAME_IFRAME = 1 20 | PDV_FRAME_PFRAME = 2 21 | PDV_FRAME_COMBINED = 3 22 | 23 | GifImagePlugin.LOADING_STRATEGY = GifImagePlugin.LoadingStrategy.RGB_AFTER_DIFFERENT_PALETTE_ONLY 24 | 25 | class PDVideoFile(PDFile): 26 | 27 | MAGIC = b"Playdate VID" 28 | PD_FILE_EXT = ".pdv" 29 | NONPD_FILE_EXT = ".gif" 30 | 31 | def __init__(self, filename, skip_magic=False): 32 | if not skip_magic: LOGGER.info(f"Decompiling video file {filename}...") 33 | super().__init__(filename, skip_magic) 34 | 35 | self.advance(4) 36 | self.num_frames = self.readu16() 37 | self.advance(2) 38 | self.framerate = unpack("> 2) 52 | frame_type_table.append(value & 0x3) 53 | 54 | header_end = self.tell() 55 | 56 | for i in range(len(offsets) - 1): 57 | offset = offsets[i] 58 | next_offset = offsets[i + 1] 59 | self.seekrelto(header_end, offset) 60 | 61 | frame_data = decompress(self.readbin(next_offset - offset)) 62 | if frame_type_table[i] == PDV_FRAME_IFRAME: 63 | self.frame_table.append(PDImageFile.from_bytes(frame_data, self.width, self.height)) 64 | elif frame_type_table[i] == PDV_FRAME_PFRAME: 65 | prev_frame = self.frame_table[-1] 66 | 67 | frame_data = bytearray(frame_data) 68 | for i in range(len(frame_data)): 69 | frame_data[i] ^= prev_frame.raw[i] 70 | frame_data = bytes(frame_data) 71 | 72 | img_file = PDImageFile.from_bytes(frame_data, self.width, self.height) 73 | 74 | self.frame_table.append(img_file) 75 | elif frame_type_table[i] == PDV_FRAME_COMBINED: 76 | iframe_len = unpack("> 2 = frame offset in bytes 137 | # offset & 0x3 = frame type 138 | # 0 = end of file 139 | # 1 = Standalone bitmap frame 140 | # 2 = Frame based on previous frame 141 | # 3 = Frame based on standalone bitmap frame 142 | 143 | # FRAME TYPES (all compressed separately) 144 | # Type 1: I-frame 145 | # 1-bit pixel map (0 = black, 1 = white) 146 | 147 | # Type 2: P-frame 148 | # 1-bit change map (0 = no change since previous frame, 1 = pixel flipped since previous frame) 149 | 150 | # Type 3: Compound 151 | # uint16: length of pixel map 152 | # 1-bit pixel map (0 = black, 1 = white) 153 | # 1-bit change map (0 = no change from pixel map, 1 = pixel flipped from pixel map) -------------------------------------------------------------------------------- /loaders/pdbin.py: -------------------------------------------------------------------------------- 1 | from io import BytesIO 2 | from os.path import splitext 3 | from sys import argv 4 | 5 | from loaders.pdfile import PDFile 6 | from logger import init_logging, get_logger 7 | 8 | LOGGER = get_logger("loaders.pdbin") 9 | 10 | class PDBinFile(PDFile): 11 | 12 | MAGIC = b"Playdate PDX" 13 | MAGIC2 = b"Playdate BIN" 14 | PD_FILE_EXT = "pdex.bin" 15 | NONPD_FILE_EXT = ".elf" 16 | 17 | def __init__(self, filename, skip_magic=False): 18 | LOGGER.info(f"Converting {filename} to ELF...") 19 | super().__init__(filename, True) 20 | 21 | self.post2_0 = self.readbin(len(self.MAGIC)) in [self.MAGIC, self.MAGIC2] 22 | 23 | if self.post2_0: 24 | LOGGER.debug("Detected 2.0 binary format") 25 | 26 | self.advance(4) 27 | self.md5 = self.readbin(16) 28 | self.filesz = self.readu32() 29 | self.memsz = self.readu32() 30 | self.event_handler = self.readu32() 31 | relocs_len = self.readu32() 32 | 33 | self.decompress() 34 | self.code = self.readbin(self.code_size) 35 | self.relocs = [] 36 | 37 | for i in range(relocs_len): 38 | self.relocs.append(self.readu32()) 39 | else: 40 | LOGGER.debug("Detected legacy binary format") 41 | self.seek(0) 42 | 43 | self.event_handler = self.readu32() - 0x6000000c 44 | self.filesz = self.readu32() - 0x6000000c 45 | self.memsz = self.readu32() - 0x6000000c 46 | self.code = self.readbin(self.code_size) 47 | 48 | def to_elffile(self, revb=False): 49 | fh = BytesIO() 50 | 51 | SHSTRTAB = b"\0.text\0.bss\0.rel.text\0.symtab\0.strtab\0.shstrtab\0" 52 | 53 | # ELF header 54 | fh.write(b"\x7fELF\x01\x01\x01\0\0\0\0\0\0\0\0\0\x02\0\x28\0\x01\0\0\0") 55 | # Entry point 56 | fh.write(self.event_handler.to_bytes(4, byteorder="little")) 57 | # section header length 58 | fh.write(b"\x34\0\0\0") 59 | # section headers offset 60 | fh.write(((0x10021 + self.filesz + 8 * len(self.relocs) + len(SHSTRTAB) + 3) & ~3).to_bytes(4, byteorder="little")) 61 | # other necessary stuff 62 | fh.write(b"\0\x04\0\x05\x34\0\x20\0\x01\0\x28\0\x07\0\x06\0") 63 | 64 | # Program header 65 | fh.write(b"\x01\0\0\0\0\0\x01\0\0\0\0\0\0\0\0\0") 66 | # filesz and memsz 67 | fh.write(self.filesz.to_bytes(4, byteorder="little")) 68 | fh.write(self.memsz.to_bytes(4, byteorder="little")) 69 | fh.write(b"\x07\0\0\0\0\0\x01\0") 70 | 71 | # code 72 | while fh.tell() < 0x10000: fh.write(b"\0\0\0\0") 73 | fh.write(self.code) 74 | # relocation table 75 | for reloc in self.relocs: 76 | fh.write(reloc.to_bytes(4, byteorder="little")) 77 | fh.write(b"\x02\x01\0\0") 78 | # symbol table 79 | fh.write(b"\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0") 80 | fh.write(b"\0\0\0\0\0\0\0\0\0\0\0\0\x00\0\x01\0") 81 | # string table placeholder 82 | fh.write(b"\0") 83 | # section name table 84 | fh.write(SHSTRTAB) 85 | 86 | # section headers 87 | while fh.tell() % 4 != 0: fh.write(b"\0") 88 | fh.write(b"\0\0\0\0" * 10) 89 | 90 | fh.write((SHSTRTAB.index(b".text\0")).to_bytes(4, byteorder="little")) 91 | fh.write(b"\x01\0\0\0\x37\0\0\0\0\0\0\0\0\0\x01\0") 92 | fh.write(self.filesz.to_bytes(4, byteorder="little")) 93 | fh.write(b"\0\0\0\0\0\0\0\0\x08\0\0\0\0\0\0\0") 94 | 95 | fh.write((SHSTRTAB.index(b".bss\0")).to_bytes(4, byteorder="little")) 96 | fh.write(b"\x08\0\0\0\x03\0\0\0") 97 | fh.write(self.filesz.to_bytes(4, byteorder="little")) 98 | fh.write((0x10000 + self.filesz).to_bytes(4, byteorder="little")) 99 | fh.write((self.memsz - filesz).to_bytes(4, byteorder="little")) 100 | fh.write(b"\0\0\0\0\0\0\0\0\x04\0\0\0\0\0\0\0") 101 | 102 | fh.write((SHSTRTAB.index(b".rel.text\0")).to_bytes(4, byteorder="little")) 103 | fh.write(b"\x09\0\0\0\x40\0\0\0\0\0\0\0") 104 | fh.write((0x10000 + self.filesz).to_bytes(4, byteorder="little")) 105 | fh.write((8 * len(self.relocs)).to_bytes(4, byteorder="little")) 106 | fh.write(b"\x04\0\0\0\x01\0\0\0\x04\0\0\0\x08\0\0\0") 107 | 108 | fh.write((SHSTRTAB.index(b".symtab\0")).to_bytes(4, byteorder="little")) 109 | fh.write(b"\x02\0\0\0\0\0\0\0\0\0\0\0") 110 | fh.write((0x10000 + self.filesz + 8 * len(self.relocs)).to_bytes(4, byteorder="little")) 111 | fh.write(b"\x20\0\0\0\x05\0\0\0\x02\0\0\0\x04\0\0\0\x10\0\0\0") 112 | 113 | fh.write((SHSTRTAB.index(b".strtab\0")).to_bytes(4, byteorder="little")) 114 | fh.write(b"\x03\0\0\0\0\0\0\0\0\0\0\0") 115 | fh.write((0x10020 + self.filesz + 8 * len(self.relocs)).to_bytes(4, byteorder="little")) 116 | fh.write(b"\x01\0\0\0\0\0\0\0\0\0\0\0\x01\0\0\0\0\0\0\0") 117 | 118 | fh.write((SHSTRTAB.index(b".shstrtab\0")).to_bytes(4, byteorder="little")) 119 | fh.write(b"\x03\0\0\0\0\0\0\0\0\0\0\0") 120 | fh.write((0x10021 + self.filesz + 8 * len(self.relocs)).to_bytes(4, byteorder="little")) 121 | fh.write(len(SHSTRTAB).to_bytes(4, byteorder="little")) 122 | fh.write(b"\0\0\0\0\0\0\0\0\x01\0\0\0\0\0\0\0") 123 | 124 | self.elffile = fh.getvalue() 125 | fh.close() 126 | 127 | return self.elffile 128 | 129 | def to_nonpdfile(self): 130 | return self.to_elffile() 131 | 132 | if __name__ == "__main__": 133 | init_logging() 134 | 135 | filename = argv[1] 136 | bin_file = PDBinFile(filename) 137 | with open(f"{splitext(filename)[0]}{bin_file.NONPD_FILE_EXT}", "wb") as f: 138 | f.write(bin_file.to_nonpdfile()) 139 | 140 | # From my own research, as well as https://github.com/TheLogicMaster/Cranked/blob/master/src/Rom.cpp 141 | 142 | # Version 2 143 | # FILE HEADER (length 48): 144 | # 0: char[12]: constant "Playdate PDX" 145 | # 12: uint32: bitflags 146 | # 16: byte[16]: MD5 hash of code 147 | # 32: uint32: size of code 148 | # 36: uint32: size of code + BSS 149 | # 40: uint32: relative address of eventHandlerShim 150 | # 44: uint32: number of relocation entries 151 | 152 | # (compression boundary) 153 | 154 | # code 155 | # (relocation table: relative addresses are uint32, and relative to the beginning of the compressed data) 156 | 157 | # Version 1 158 | # FILE HEADER (length 12): 159 | # 0: uint32: absolute address of eventHandlerShim 160 | # 4: uint32: absolute code end address 161 | # 8: uint32: absolute code + BSS end address 162 | 163 | # code 164 | -------------------------------------------------------------------------------- /loaders/pdz.py: -------------------------------------------------------------------------------- 1 | from os import mkdir, sep as PATHSEP 2 | from os.path import abspath, basename, join as joinpath, normpath, splitext 3 | from sys import argv, exit 4 | from zlib import decompress 5 | 6 | from loaders.pdfile import PDFile 7 | from loaders.pdlua import PDLuaBytecodeFile 8 | from loaders.pda import PDAudioFile 9 | from loaders.pdi import PDImageFile 10 | from loaders.pds import PDStringsFile 11 | from loaders.pdt import PDImageTableFile 12 | from loaders.pdv import PDVideoFile 13 | from loaders.pft import PDFontFile 14 | from logger import init_logging, get_logger 15 | 16 | LOGGER = get_logger("loaders.pdz") 17 | 18 | PDZ_FILE_NONE = 0 19 | PDZ_FILE_LUABYTECODE = 1 20 | PDZ_FILE_IMAGE = 2 21 | PDZ_FILE_IMAGETABLE = 3 22 | # PDZ_FILE_UNUSED = 4 23 | PDZ_FILE_AUDIO = 5 24 | PDZ_FILE_STRINGS = 6 25 | PDZ_FILE_FONT = 7 26 | 27 | class PDZipEntry: 28 | def __init__(self, parent_pdz, filename, filetype=PDZ_FILE_NONE, data=bytes()): 29 | self.filename = filename 30 | self.filetype = filetype 31 | self.is_directory = False 32 | self.parent_pdz = parent_pdz 33 | 34 | if filetype == PDZ_FILE_NONE: 35 | self.data = {} 36 | self.is_directory = True 37 | self.extension = "" 38 | elif filetype == PDZ_FILE_LUABYTECODE: 39 | self.data = PDLuaBytecodeFile(data, parent_pdz) 40 | elif filetype == PDZ_FILE_IMAGE: 41 | self.data = PDImageFile(b"\0\0\0\0" + data, skip_magic=True) 42 | elif filetype == PDZ_FILE_IMAGETABLE: 43 | self.data = PDImageTableFile(b"\0\0\0\0" + data, skip_magic=True) 44 | elif filetype == PDZ_FILE_AUDIO: 45 | self.data = PDAudioFile(data, skip_magic=True) 46 | elif filetype == PDZ_FILE_STRINGS: 47 | self.data = PDStringsFile(b"\0\0\0\0" + data, skip_magic=True) 48 | elif filetype == PDZ_FILE_FONT: 49 | self.data = PDFontFile(b"\0\0\0\0" + data, skip_magic=True) 50 | else: 51 | LOGGER.error(f"Unknown file type: {hex(filetype)}") 52 | exit(-1) 53 | 54 | if filetype > PDZ_FILE_NONE: self.extension = self.data.NONPD_FILE_EXT 55 | 56 | def add_file(self, filename, filetype=PDZ_FILE_NONE, data=bytes()): 57 | if self.is_directory: 58 | path = normpath(filename).replace("\\", "/").split("/") 59 | directory = self 60 | 61 | for i in range(len(path) - 1): 62 | try: directory = directory.data[path[i]] 63 | except KeyError: 64 | if path[i] == "": continue 65 | directory.add_file(path[i], PDZ_FILE_NONE) 66 | directory = directory.data[path[i]] 67 | 68 | directory.data[path[-1]] = PDZipEntry(self.parent_pdz, path[-1], filetype, data) 69 | else: raise ValueError("Entry not a directory") 70 | 71 | def get_file(self, filename): 72 | path = normpath(filename).split("/") 73 | directory = self 74 | 75 | for i in range(len(path) - 1): 76 | try: directory = directory.data[path[i]] 77 | except KeyError: raise FileNotFoundError(f"PDZ entry not found: '{path[i]}'") 78 | 79 | try: return directory.data[path[-1]] 80 | except KeyError: raise FileNotFoundError(f"PDZ entry not found: '{path[-1]}'") 81 | 82 | def dump_files(self, path): 83 | try: mkdir(path) 84 | except FileExistsError: pass 85 | 86 | for filename in self.data.keys(): 87 | target = self.data[filename] 88 | if target.is_directory: 89 | target.dump_files(joinpath(path, target.filename)) 90 | else: 91 | if target.filetype > PDZ_FILE_NONE: non_pdfile = target.data.to_nonpdfile() 92 | else: non_pdfile = target.data 93 | 94 | if target.filetype == PDZ_FILE_IMAGETABLE: 95 | if not target.data.is_matrix: 96 | for i in range(len(non_pdfile)): 97 | with open(joinpath(path, f"{splitext(filename)[0]}-table-{i}{target.data.NONPD_FILE_EXT}"), "wb") as f: 98 | f.write(non_pdfile[i]) 99 | else: 100 | with open(joinpath(path, f"{splitext(filename)[0]}-table-{target.data.image_table[0][0].stored_width}-{target.data.image_table[0][0].stored_height}{target.extension}"), "wb") as f: 101 | f.write(non_pdfile) 102 | else: 103 | filename = target.filename + target.extension 104 | with open(joinpath(path, filename), "wb") as f: 105 | f.write(non_pdfile) 106 | 107 | class PDZipFile(PDFile): 108 | 109 | MAGIC = b"Playdate PDZ" 110 | PD_FILE_EXT = ".pdz" 111 | 112 | def __init__(self, filename, skip_magic=False): 113 | super().__init__(filename, skip_magic) 114 | 115 | self.advance(4) 116 | self.root_directory = PDZipEntry(self, "", PDZ_FILE_NONE) 117 | self.imported_files = [] 118 | 119 | flags = self.readu8() 120 | 121 | while flags is not None: 122 | compressed = bool(flags & 0x80) 123 | filetype = flags & 0x7f 124 | 125 | file_length = self.readu24() 126 | filename = self.readstr() 127 | 128 | self.align(4) 129 | 130 | data = self.readbin(file_length) 131 | 132 | if compressed: 133 | if filetype == PDZ_FILE_AUDIO: 134 | # Audio files have the sample rate and audio format uncompressed out front 135 | data = data[:4] + decompress(data[8:]) 136 | else: data = decompress(data[4:]) 137 | 138 | self.root_directory.add_file(filename, filetype, data) 139 | flags = self.readu8() 140 | 141 | def import_func(self, path): 142 | if path not in self.imported_files: 143 | self.imported_files.append(path) 144 | self.get_file(path).execute() 145 | 146 | def get_file(self, path): 147 | return self.root_directory.get_file(path) 148 | 149 | def dump_files(self, directory_name): 150 | self.root_directory.dump_files(directory_name) 151 | 152 | if __name__ == "__main__": 153 | init_logging() 154 | 155 | filename = argv[1] 156 | pdz_file = PDZipFile(filename) 157 | dump_loc = splitext(filename)[0] 158 | if len(argv) > 2: dump_loc = abspath(argv[2]) 159 | 160 | pdz_file.dump_files(dump_loc) 161 | 162 | # From jaames/playdate-reverse-engineering, and also some of my own research 163 | 164 | # FILE HEADER (length 16) 165 | # 0: char[12]: constant "Playdate PDZ" 166 | # 12: bitflags 167 | # flags & 0x40000000 = file is encrypted 168 | 169 | # FILE ENTRY 170 | # uint8: file bitflags 171 | # flags & 0x80 = file is compressed 172 | # flags & 0x7F = file type 173 | # 0 = none 174 | # 1 = Playdate Lua bytecode 175 | # 2 = image (PDI) 176 | # 3 = image table (PDT) 177 | # 4 = unused, probably was the old PFT format 178 | # 5 = audio (PDA) 179 | # 6 = strings (PDS) 180 | # 7 = font (PFT) 181 | # uint24: total data length 182 | # char *: filename, null-terminated 183 | # (padding to align to a multiple of 4 bytes) 184 | # uint24: audio sample rate in Hz (present only if the file is a compressed audio file) 185 | # enum SoundFormat: audio data format (present only if the file is a compressed audio file) 186 | # uint32: data length when decompressed (if the data is compressed, this is present and included in the data length) 187 | # (entry data) -------------------------------------------------------------------------------- /loaders/pdi.py: -------------------------------------------------------------------------------- 1 | import pygame as pg 2 | import pygame.locals as pgloc 3 | from PIL import Image 4 | 5 | from io import BytesIO 6 | from math import ceil 7 | from os.path import splitext 8 | from struct import pack 9 | from sys import argv 10 | 11 | from loaders.pdfile import PDFile 12 | from logger import init_logging, get_logger 13 | 14 | LOGGER = get_logger("loaders.pdi") 15 | 16 | PDI_PALETTE = ( 17 | (0x32, 0x2f, 0x28), 18 | (0xb1, 0xae, 0xa7) 19 | ) 20 | 21 | PDI_PALETTE_WITH_ALPHA = ( 22 | (0x32, 0x2f, 0x28, 0x00), 23 | (0xb1, 0xae, 0xa7, 0x00), 24 | (0x32, 0x2f, 0x28, 0xff), 25 | (0xb1, 0xae, 0xa7, 0xff) 26 | ) 27 | 28 | PDI_BW_PALETTE = ( 29 | (0x00, 0x00, 0x00), 30 | (0xff, 0xff, 0xff) 31 | ) 32 | 33 | PDI_BW_PALETTE_WITH_ALPHA = ( 34 | (0x00, 0x00, 0x00, 0x00), 35 | (0xff, 0xff, 0xff, 0x00), 36 | (0x00, 0x00, 0x00, 0xff), 37 | (0xff, 0xff, 0xff, 0xff), 38 | ) 39 | 40 | def _flatten2d(seq): 41 | return_seq = [] 42 | 43 | for i in range(len(seq)): 44 | for j in range(len(seq[i])): 45 | return_seq.append(seq[i][j]) 46 | 47 | return return_seq 48 | 49 | class PDImageFile(PDFile): 50 | 51 | MAGIC = b"Playdate IMG" 52 | PD_FILE_EXT = ".pdi" 53 | NONPD_FILE_EXT = ".png" 54 | 55 | def __init__(self, filename, skip_magic=False): 56 | if not skip_magic: LOGGER.info(f"Decompiling image file {filename}...") 57 | super().__init__(filename, skip_magic) 58 | 59 | if filename != bytes(): 60 | flags = self.readu32() 61 | compressed = bool(flags & 0x80000000) 62 | if compressed: self.advance(16) 63 | self.decompress(compressed) 64 | 65 | self.width = self.readu16() 66 | self.height = self.readu16() 67 | self.stride = self.readu16() 68 | self.clip_l = self.readu16() 69 | self.clip_r = self.readu16() 70 | self.clip_t = self.readu16() 71 | self.clip_b = self.readu16() 72 | flags = self.readu16() 73 | self.alpha = bool(flags & 0x3) 74 | 75 | LOGGER.debug(f"Image size: {self.width} x {self.height}") 76 | LOGGER.debug(f"Stored image size: {self.clip_r - self.clip_l} x {self.clip_t - self.clip_b}") 77 | LOGGER.debug(f"Mask image present: {'yes' if self.alpha else 'no'}") 78 | 79 | data_start = self.tell() 80 | self.raw = self.readbin() 81 | self.seek(data_start) 82 | 83 | self.pil_img = None 84 | self.surf = None 85 | self.pixels = [] 86 | 87 | should_alpha = bool(self.clip_l or self.clip_r or self.clip_t or self.clip_b) 88 | 89 | self.stored_width = self.width + self.clip_l + self.clip_r 90 | self.stored_height = self.height + self.clip_t + self.clip_b 91 | 92 | for y in range(self.clip_t): 93 | self.pixels.append([0] * self.stored_width) 94 | 95 | for y in range(self.clip_t, self.height + self.clip_t): 96 | self.pixels.append([]) 97 | row = self.readbin(self.stride) 98 | 99 | for x in range(self.clip_l): 100 | self.pixels[y].append(0x0) 101 | 102 | for x in range(self.clip_l, self.width + self.clip_l): 103 | x_rel = x - self.clip_l 104 | 105 | self.pixels[y].append((row[x_rel // 8] >> (7-(x_rel%8))) & 0x1) 106 | self.pixels[y][x] |= (int(should_alpha) << 0x1) 107 | 108 | for x in range(self.clip_r): 109 | self.pixels[y].append(0x0) 110 | 111 | for y in range(self.clip_b): 112 | self.pixels.append([0] * self.stored_width) 113 | 114 | if self.alpha or should_alpha: 115 | for y in range(self.clip_t, self.height + self.clip_t): 116 | if self.alpha: 117 | row = self.readbin(self.stride) 118 | while len(row) != self.stride: row += b"\0" 119 | 120 | for x in range(self.clip_l, self.width + self.clip_l): 121 | x_rel = x - self.clip_l 122 | 123 | self.pixels[y][x] &= 0x1 124 | if self.alpha: self.pixels[y][x] |= ((row[x_rel // 8] >> (7-(x_rel%8))) & 0x1) << 1 125 | else: self.pixels[y][x] |= 0x2 126 | 127 | def to_surf(self): 128 | self.surf = pg.Surface((len(self.pixels), len(self.pixels[0])), flags=(self.alpha * pgloc.SRCALPHA)) 129 | arr = pg.PixelArray(self.surf) 130 | 131 | for y in range(len(self.pixels)): 132 | for x in range(len(self.pixels[y])): 133 | if self.alpha: 134 | arr[x][y] = PDI_PALETTE_WITH_ALPHA[self.pixels[y][x]] 135 | else: 136 | arr[x][y] = PDI_PALETTE[self.pixels[y][x]] 137 | del arr 138 | 139 | return self.surf 140 | 141 | def to_pngfile(self, bw=False): 142 | if self.alpha: 143 | color = "RGBA" 144 | if bw: self.palette = _flatten2d(PDI_BW_PALETTE_WITH_ALPHA) 145 | else: self.palette = _flatten2d(PDI_PALETTE_WITH_ALPHA) 146 | else: 147 | color = "RGB" 148 | if bw: self.palette = _flatten2d(PDI_BW_PALETTE) 149 | else: self.palette = _flatten2d(PDI_PALETTE) 150 | 151 | self.pil_img = Image.new("P", (self.stored_width, self.stored_height)) 152 | self.pil_img.putpalette(self.palette, color) 153 | self.pil_img.putdata(_flatten2d(self.pixels)) 154 | 155 | fh = BytesIO() 156 | self.pil_img.save(fh, format="PNG") 157 | self.pngfile = fh.getvalue() 158 | fh.close() 159 | 160 | return self.pngfile 161 | 162 | def to_nonpdfile(self): 163 | return self.to_pngfile() 164 | 165 | @staticmethod 166 | def from_bytes(data, width, height, has_alpha=False): 167 | header = pack("> 1 52 | if nibble & 1: 53 | difference += self._step >> 2 54 | difference += self._step >> 3 55 | if nibble & 8: 56 | difference = -difference 57 | 58 | self.predictor += difference 59 | 60 | if self.predictor > 32767: 61 | self.predictor = 32767 62 | elif self.predictor < -32767: 63 | self.predictor = - 32767 64 | 65 | self.step_index += IMA_INDEX_TABLE[nibble] 66 | if self.step_index < 0: 67 | self.step_index = 0 68 | elif self.step_index > 88: 69 | self.step_index = 88 70 | self._step = IMA_STEP_TABLE[self.step_index] 71 | 72 | return self.predictor 73 | 74 | def decode_block(self, block): 75 | result = bytes() 76 | self._step = IMA_STEP_TABLE[self.step_index] 77 | 78 | result += self.predictor.to_bytes(2, byteorder="little", signed=True) 79 | 80 | for i in range(len(block)): 81 | original_sample = block[i] 82 | first_sample = original_sample >> 4 83 | second_sample = original_sample & 0xf 84 | 85 | result += self.decode(first_sample).to_bytes(2, byteorder="little", signed=True) 86 | result += self.decode(second_sample).to_bytes(2, byteorder="little", signed=True) 87 | 88 | return result 89 | 90 | class PDAudioFormat: 91 | @staticmethod 92 | def get_nchannels(fmt): 93 | if fmt >= FORMAT_LENGTH: raise ValueError("not a valid Playdate audio format") 94 | return (fmt & 1) + 1 95 | 96 | @staticmethod 97 | def get_sampwidth(fmt): 98 | if fmt < MONO_16: return 1 99 | elif fmt < MONO_ADPCM4: return 2 100 | elif fmt < FORMAT_LENGTH: return 2 101 | else: raise ValueError("not a valid Playdate audio format") 102 | 103 | class PDAudioFile(PDFile): 104 | MAGIC = b"Playdate AUD" 105 | PD_FILE_EXT = ".pda" 106 | NONPD_FILE_EXT = ".wav" 107 | 108 | def __init__(self, filename, skip_magic=False): 109 | if not skip_magic: LOGGER.info(f"Decompiling audio file {filename}...") 110 | super().__init__(filename, skip_magic) 111 | 112 | self.framerate = self.readu24() 113 | self.fmt = self.readu8() 114 | 115 | def to_wavfile(self): 116 | fh = BytesIO() 117 | 118 | try: 119 | self.nchannels = PDAudioFormat.get_nchannels(self.fmt) 120 | self.sampwidth = PDAudioFormat.get_sampwidth(self.fmt) 121 | except ValueError: 122 | LOGGER.error("Audio format invalid.") 123 | return b"" 124 | 125 | LOGGER.debug(f"Audio format: {'IMA ADPCM' if self.fmt > STEREO_16 else 'PCM'}") 126 | LOGGER.debug(f"Channels: {'2 (Stereo)' if self.nchannels == 2 else '1 (Mono)'}") 127 | 128 | wavfile = wave.open(fh, "wb") 129 | 130 | wavfile.setnchannels(self.nchannels) 131 | wavfile.setsampwidth(self.sampwidth) 132 | wavfile.setframerate(self.framerate) 133 | 134 | data = bytes() 135 | stereo = (self.nchannels == 2) 136 | if self.fmt < MONO_ADPCM4: 137 | LOGGER.debug("Data is already PCM, so no conversion needed.") 138 | data = self.readbin() 139 | elif self.fmt < FORMAT_LENGTH: 140 | LOGGER.debug("Data is ADPCM -- conversion needed!") 141 | 142 | block_size = self.readu16() 143 | 144 | saved_pos = self.tell() 145 | self.handle.seek(0, 2) 146 | file_end = self.tell() 147 | self.seek(saved_pos) 148 | 149 | if stereo: 150 | decoder_left = ADPCMDecoder() 151 | decoder_right = ADPCMDecoder() 152 | 153 | def decode_stereo(sample): 154 | sample_left = sample >> 4 155 | sample_right = sample & 0xf 156 | 157 | return decoder_left.decode(sample_left).to_bytes(2, byteorder="little", signed=True) + decoder_right.decode(sample_right).to_bytes(2, byteorder="little", signed=True) 158 | 159 | for i in range(saved_pos, file_end, block_size): 160 | decoder_left.predictor = self.reads16() 161 | decoder_left.step_index = self.readu8() 162 | self.advance(1) 163 | 164 | decoder_right.predictor = self.reads16() 165 | decoder_right.step_index = self.readu8() 166 | self.advance(1) 167 | 168 | data += decoder_left.predictor.to_bytes(2, byteorder="little", signed=True) 169 | data += decoder_right.predictor.to_bytes(2, byteorder="little", signed=True) 170 | 171 | block = self.readbin(block_size - 8) 172 | data += b"".join(map(decode_stereo, tuple(block))) 173 | 174 | else: 175 | decoder = ADPCMDecoder() 176 | 177 | for i in range(saved_pos, file_end, block_size): 178 | decoder.predictor = self.reads16() 179 | decoder.step_index = self.readu8() 180 | self.advance(1) 181 | data += decoder.decode_block(self.readbin(block_size - 4)) 182 | 183 | wavfile.writeframes(data) 184 | wavfile.close() 185 | self.wavfile = fh.getvalue() 186 | fh.close() 187 | 188 | return self.wavfile 189 | def to_nonpdfile(self): 190 | return self.to_wavfile() 191 | 192 | if __name__ == "__main__": 193 | init_logging() 194 | 195 | filename = argv[1] 196 | aud_file = PDAudioFile(filename) 197 | with open(f"{splitext(filename)[0]}{aud_file.NONPD_FILE_EXT}", "wb") as f: 198 | f.write(aud_file.to_wavfile()) 199 | 200 | # From my own research, but also jaames/playdate-reverse-engineering 201 | 202 | # FILE HEADER (length 16, but outside the compressed area if this file is contained in a PDZ) 203 | # 0: char[12]: constant "Playdate AUD" 204 | # 12: uint24: sample rate in Hz 205 | # 15: enum SoundFormat: sound format 206 | # 0 = mono 8-bit PCM 207 | # 1 = stereo 8-bit PCM 208 | # 2 = mono 16-bit signed PCM 209 | # 3 = stereo 16-bit signed PCM 210 | # 4 = mono IMA ADPCM with block headers (high nibble first) 211 | # 5 = stereo IMA ADPCM with block headers 212 | 213 | # 16: uint16: block alignment in bytes (only appears if the format is ADPCM) 214 | 215 | # BLOCK HEADER (length 4 * number of channels) 216 | # 0: int16: IMA ADPCM predictor 217 | # 2: uint8: IMA ADPCM step index 218 | # (1 byte of zeros) 219 | 220 | # then the sound data -- if the sound is ADPCM stereo, then the samples are nibble-interleaved, left channel first 221 | -------------------------------------------------------------------------------- /api/pdapi.py: -------------------------------------------------------------------------------- 1 | import pygame as pg 2 | 3 | from calendar import timegm 4 | from datetime import datetime, timezone 5 | 6 | from os import name as PLATFORM, system 7 | from time import gmtime, localtime, sleep, perf_counter 8 | from threading import Thread 9 | 10 | from loaders.pft import PFT_PALETTE 11 | from pdemu import EMULATOR 12 | from api.runtime import RUNTIME 13 | 14 | def pd_accelerometerIsRunning(): 15 | return EMULATOR.accel.running 16 | 17 | def pd_apiVersion(): 18 | return 11370, 11300 19 | 20 | def pd_buttonIsPressed(button): 21 | return EMULATOR.buttons[EMULATOR.ButtonValues.button_constant(button)] 22 | 23 | def pd_buttonJustPressed(button): 24 | return EMULATOR.buttons[EMULATOR.ButtonValues.button_constant(button)] and EMULATOR.buttons[EMULATOR.ButtonValues.button_constant(button)] != EMULATOR.prev_buttons[EMULATOR.ButtonValues.button_constant(button)] 25 | 26 | def pd_buttonJustReleased(button): 27 | return EMULATOR.prev_buttons[EMULATOR.ButtonValues.button_constant(button)] and EMULATOR.buttons[EMULATOR.ButtonValues.button_constant(button)] != EMULATOR.prev_buttons[EMULATOR.ButtonValues.button_constant(button)] 28 | 29 | def pd_clearConsole(): 30 | system("cls" if PLATFORM == "nt" else "clear") 31 | 32 | def pd_drawFPS(x, y): 33 | fps = str(round(EMULATOR.clock.get_fps())) 34 | fps_text = EMULATOR.fps_font.to_surf(fps) 35 | fps_surf = pg.Surface((fps_text.get_width() + 1, fps_text.get_height() + 2)) 36 | fps_surf.fill(PFT_PALETTE[3]) 37 | fps_surf.blit(fps_text, (0, 1)) 38 | EMULATOR.display.blit(fps_surf, (x, y)) 39 | 40 | def pd_epochFromGMTTime(time): 41 | dt = datetime(time.year, time.month, time.day, time.hour, time.minute, time.second, time.millisecond * 1000, timezone.utc) 42 | epoch = (dt - datetime(2000, 1, 1, tzinfo=timezone.utc)).total_seconds() 43 | return int(epoch), round((epoch - int(epoch)) * 1000) 44 | 45 | def pd_epochFromTime(time): 46 | dt = datetime(time.year, time.month, time.day, time.hour, time.minute, time.second, time.millisecond * 1000).astimezone(tz=None) 47 | epoch = (dt - datetime(2000, 1, 1, tzinfo=timezone.utc)).total_seconds() 48 | return int(epoch), round((epoch - int(epoch)) * 1000) 49 | 50 | def pd_getBatteryPercentage(): 51 | return EMULATOR.battery.pct 52 | 53 | def pd_getBatteryVoltage(): 54 | return EMULATOR.battery.voltage 55 | 56 | def pd_getButtonState(): 57 | current = 0 58 | pressed = 0 59 | released = 0 60 | 61 | for btn in range(6): 62 | if pd_buttonIsPressed(1 << btn): current |= (1 << btn) 63 | if pd_buttonJustPressed(1 << btn): pressed |= (1 << btn) 64 | if pd_buttonJustReleased(1 << btn): released |= (1 << btn) 65 | 66 | return current, pressed, released 67 | 68 | def pd_getCrankChange(): 69 | change = EMULATOR.crank.delta 70 | accelerated_change = change * EMULATOR.calculate_crank_velocity() 71 | return change, accelerated_change 72 | 73 | def pd_getCrankPosition(): 74 | return EMULATOR.crank.pos 75 | 76 | def pd_getCurrentTimeMilliseconds(): 77 | return EMULATOR.game_time 78 | 79 | def pd_getElapsedTime(): 80 | return perf_counter() - EMULATOR.hires_time 81 | 82 | def pd_getFlipped(): 83 | return EMULATOR.settings.upside_down 84 | 85 | def pd_getFPS(): 86 | return EMULATOR.clock.get_fps() 87 | 88 | def pd_getGMTTime(): 89 | dt = datetime.now(tz=timezone.utc) 90 | time = dt.timetuple() 91 | return RUNTIME.table_from({ 92 | "year": time.tm_year, 93 | "month": time.tm_month, 94 | "day": time.tm_mday, 95 | "weekday": time.tm_wday, 96 | "hour": time.tm_hour, 97 | "minute": time.tm_min, 98 | "second": time.tm_sec, 99 | "millisecond": round(dt.microsecond / 1000) 100 | }) 101 | 102 | def pd_getPowerStatus(): 103 | return RUNTIME.table_from({ 104 | "charging": EMULATOR.battery.charging 105 | "USB": EMULATOR.serial.enabled 106 | }) 107 | 108 | def pd_getReduceFlashing(): 109 | return EMULATOR.settings.reduce_flashing 110 | 111 | def pd_getSecondsSinceEpoch(): 112 | dt = datetime.now(tz=timezone.utc) 113 | epoch = (dt - datetime(2000, 1, 1, tzinfo=timezone.utc)).total_seconds() 114 | return int(epoch), round((epoch - int(epoch)) * 1000) 115 | 116 | # TODO: rework the garbage collector to be more like that of the device 117 | def pd_getStats(): 118 | return RUNTIME.table_from({ 119 | "GC": EMULATOR.stats.gc_time / EMULATOR.stats.interval 120 | "game": EMULATOR.stats.game_time / EMULATOR.stats.interval 121 | "audio": EMULATOR.stats.audio_time / EMULATOR.stats.interval 122 | "idle": (EMULATOR.clock.get_time() - EMULATOR.clock.get_rawtime()) / (EMULATOR.stats.interval * 1000) 123 | }) 124 | 125 | def pd_getSystemLanguage(): 126 | return EMULATOR.settings.language 127 | 128 | def pd_getSystemMenu(): 129 | return RUNTIME.table_from(EMULATOR.system_menu.formatted_dict) 130 | 131 | def pd_getTime(): 132 | dt = datetime.now(tz=timezone.utc).astimezone(tz=None) 133 | time = dt.timetuple() 134 | return RUNTIME.table_from({ 135 | "year": time.tm_year, 136 | "month": time.tm_month, 137 | "day": time.tm_mday, 138 | "weekday": time.tm_wday, 139 | "hour": time.tm_hour, 140 | "minute": time.tm_min, 141 | "second": time.tm_sec, 142 | "millisecond": round(dt.microsecond / 1000) 143 | }) 144 | 145 | def pd_GMTTimeFromEpoch(seconds, milliseconds): 146 | epoch = int(seconds) + (int(milliseconds) / 1000) 147 | time = datetime(2000, 1, 1, tzinfo=timezone.utc) + timedelta(seconds=epoch) 148 | return RUNTIME.table_from({ 149 | "year": time.tm_year, 150 | "month": time.tm_month, 151 | "day": time.tm_mday, 152 | "weekday": time.tm_wday, 153 | "hour": time.tm_hour, 154 | "minute": time.tm_min, 155 | "second": time.tm_sec, 156 | "millisecond": round((epoch - int(epoch)) * 1000) 157 | }) 158 | 159 | def pd_isCrankDocked(): 160 | return EMULATOR.crank.docked 161 | 162 | def pd_readAccelerometer(): 163 | return EMULATOR.accel.x, EMULATOR.accel.y, EMULATOR.accel.z 164 | 165 | # def pd_reboot(): 166 | 167 | def pd_resetElapsedTime(): 168 | EMULATOR.hires_time = perf_counter() 169 | 170 | def pd_setAutoLockDisabled(disable): 171 | EMULATOR.settings.auto_lock = not bool(disable) 172 | if not disable: EMULATOR.settings.auto_lock_timer = 60.0 173 | 174 | def pd_setCollectsGarbage(flag): 175 | EMULATOR.gc.enabled = bool(flag) 176 | 177 | def pd_setCrankSoundsDisabled(disable): 178 | EMULATOR.settings.crank_sounds = not bool(disable) 179 | 180 | def pd_setGCScaling(min, max): 181 | EMULATOR.gc.min_mem = float(min) 182 | EMULATOR.gc.max_mem = float(max) 183 | 184 | def pd_setMenuImage(image, xOffset=0): 185 | EMULATOR.system_menu.game_img = image.pdImg 186 | EMULATOR.system_menu.game_img_offset = int(xOffset) 187 | 188 | def pd_setMinimumGCTime(ms): 189 | EMULATOR.gc.min_time = int(ms) 190 | 191 | def pd_setNewlinePrinted(flag=True): 192 | EMULATOR.newline = "\n" if flag else "" 193 | 194 | def pd_setStatsInterval(seconds): 195 | if float(seconds) == 0.0: EMULATOR.stats.enabled = False 196 | else: 197 | EMULATOR.stats.enabled = True 198 | EMULATOR.stats.interval = float(seconds) 199 | 200 | def pd_shouldDisplay24HourTime(): 201 | return EMULATOR.settings.time_24hours 202 | 203 | def pd_start(): 204 | with EMULATOR.call_update_lock: EMULATOR.call_update = True 205 | 206 | def pd_startAccelerometer(): 207 | EMULATOR.accel.running = True 208 | 209 | def pd_stop(): 210 | with EMULATOR.call_update_lock: EMULATOR.call_update = False 211 | 212 | def pd_stopAccelerometer(): 213 | EMULATOR.accel.running = False 214 | 215 | def pd_timeFromEpoch(seconds, milliseconds): 216 | epoch = int(seconds) + int(milliseconds / 1000) 217 | time = datetime(2000, 1, 1).astimezone(tz=None) + timedelta(seconds=epoch) 218 | return RUNTIME.table_from({ 219 | "year": time.tm_year, 220 | "month": time.tm_month, 221 | "day": time.tm_mday, 222 | "weekday": time.tm_wday, 223 | "hour": time.tm_hour, 224 | "minute": time.tm_min, 225 | "second": time.tm_sec, 226 | "millisecond": round((epoch - int(epoch)) * 1000) 227 | }) 228 | 229 | def pd_wait_cb(millis): 230 | pd_stop() 231 | sleep(millis / 1000) 232 | pd_start() 233 | 234 | def pd_wait(millis): 235 | Thread(target=pd_wait_cb, name="playdate.wait()", args=(millis,), daemon=True) 236 | 237 | PLAYDATE_API = { 238 | "accelerometerIsRunning": pd_accelerometerIsRunning, 239 | "apiVersion": pd_apiVersion, 240 | "buttonIsPressed": pd_buttonIsPressed, 241 | "buttonJustPressed": pd_buttonJustPressed, 242 | "buttonJustReleased": pd_buttonJustReleased, 243 | "clearConsole": pd_clearConsole, 244 | "drawFPS": pd_drawFPS, 245 | "epochFromGMTTime": pd_epochFromGMTTime, 246 | "epochFromTime": pd_epochFromTime, 247 | # "exit": pd_exit, 248 | "getBatteryPercentage": pd_getBatteryPercentage, 249 | "getBatteryVoltage": pd_getBatteryVoltage, 250 | "getButtonState": pd_getButtonState, 251 | "getCrankChange": pd_getCrankChange, 252 | "getCrankPosition": pd_getCrankPosition, 253 | "getCurrentTimeMilliseconds": pd_getCurrentTimeMilliseconds, 254 | "getElapsedTime": pd_getElapsedTime, 255 | "getFlipped": pd_getFlipped, 256 | "getGMTTime": pd_getGMTTime, 257 | "getPowerStatus": pd_getPowerStatus, 258 | "getReduceFlashing": pd_getReduceFlashing, 259 | "getSecondsSinceEpoch": pd_getSecondsSinceEpoch, 260 | "getStats": pd_getStats, 261 | "getSystemLanguage": pd_getSystemLanguage, 262 | "getSystemMenu": pd_getSystemMenu, 263 | "getTime": pd_getTime, 264 | "GMTTimeFromEpoch": pd_GMTTimeFromEpoch, 265 | "isCrankDocked": pd_isCrankDocked, 266 | "kButtonA": EMULATOR.ButtonValues.A, 267 | "kButtonB": EMULATOR.ButtonValues.B, 268 | "kButtonDown": EMULATOR.ButtonValues.DOWN, 269 | "kButtonLeft": EMULATOR.ButtonValues.LEFT, 270 | "kButtonRight": EMULATOR.ButtonValues.RIGHT, 271 | "kButtonUp": EMULATOR.ButtonValues.UP, 272 | "readAccelerometer": pd_readAccelerometer, 273 | # "reboot": pd_reboot, 274 | "resetElapsedTime": pd_resetElapsedTime, 275 | "setAutoLockDisabled": pd_setAutoLockDisabled, 276 | "setCollectsGarbage": pd_setCollectsGarbage, 277 | "setCrankSoundsDisabled": pd_setCrankSoundsDisabled, 278 | "setGCScaling": pd_setGCScaling, 279 | "setMenuImage": pd_setMenuImage, 280 | "setMinimumGCTime": pd_setMinimumGCTime, 281 | "setNewlinePrinted": pd_setNewlinePrinted, 282 | "setStatsInterval": pd_setStatsInterval, 283 | "shouldDisplay24HourTime": pd_shouldDisplay24HourTime, 284 | "start": pd_start, 285 | "startAccelerometer": pd_startAccelerometer, 286 | "stop": pd_stop, 287 | "stopAccelerometer": pd_stopAccelerometer, 288 | "timeFromEpoch": pd_timeFromEpoch, 289 | "wait": pd_wait 290 | } -------------------------------------------------------------------------------- /loaders/pft.py: -------------------------------------------------------------------------------- 1 | import pygame as pg 2 | import pygame.locals as pgloc 3 | from PIL import Image 4 | 5 | from base64 import b64encode 6 | from io import BytesIO 7 | from math import ceil 8 | from os.path import splitext 9 | from struct import unpack 10 | from sys import argv 11 | from unicodedata import category 12 | 13 | from loaders.pdfile import PDFile 14 | from loaders.pdi import PDImageFile, PDI_PALETTE_WITH_ALPHA, PDI_BW_PALETTE_WITH_ALPHA 15 | from logger import init_logging, get_logger 16 | 17 | LOGGER = get_logger("loaders.pft") 18 | 19 | def _flatten2d(seq): 20 | return_seq = [] 21 | 22 | for i in range(len(seq)): 23 | for j in range(len(seq[i])): 24 | return_seq.append(seq[i][j]) 25 | 26 | return return_seq 27 | 28 | PFT_PALETTE = _flatten2d(PDI_PALETTE_WITH_ALPHA) 29 | PFT_BW_PALETTE = _flatten2d(PDI_BW_PALETTE_WITH_ALPHA) 30 | 31 | class PDFontPage: 32 | def __init__(self, data, page_num): 33 | num_glyphs_stored = data[3] 34 | self.number = page_num 35 | 36 | self.glyphs_stored = [] 37 | 38 | glyph_table = data[4:36] 39 | bits = 0 40 | for i in range(0x100): 41 | if i % 8 == 0: bits = glyph_table[i // 8] 42 | if (bits >> (i % 8)) & 1: 43 | self.glyphs_stored.append((self.number << 8) | i) 44 | 45 | offset_table_length = 2 * data[3] 46 | offset_table = data[36:36 + offset_table_length] 47 | offsets = [0x0000] 48 | 49 | for i in range(0, offset_table_length, 2): 50 | offsets.append(unpack(" 0: 75 | self.kerning_table[chr(data[kerning_table_length])] = unpack(" 0: 80 | other_codepoint = int.from_bytes(data[kerning_table_length:kerning_table_length+3], byteorder="little") 81 | self.kerning_table[chr(other_codepoint)] = unpack("> (i % 8)) & 1: self.pages_stored.append(i) 143 | 144 | offsets = [0x00000000] 145 | for i in range(len(self.pages_stored)): 146 | offsets.append(self.readu32()) 147 | 148 | header_end = self.tell() 149 | 150 | # to-do: reverse-engineer the wide font format 151 | 152 | self.pages = {} 153 | for page_num in range(len(offsets) - 1): 154 | self.seekrelto(header_end, offsets[page_num]) 155 | 156 | offset = offsets[page_num] 157 | next_offset = offsets[page_num + 1] 158 | 159 | self.pages[self.pages_stored[page_num]] = PDFontPage(self.readbin(next_offset - offset), self.pages_stored[page_num]) 160 | 161 | def get_glyph(self, glyph): 162 | if type(glyph) == str: glyph = ord(glyph) 163 | return self.pages[glyph >> 8].glyphs[glyph & 0xff] 164 | 165 | def get_width(self, text): 166 | text += "\0" 167 | 168 | return_accum = 0 169 | for i in range(len(text) - 1): 170 | char = text[i] 171 | if category(char) == "Zl" or char in "\r\n": return return_accum 172 | if category(char) in "CcCfCn": continue 173 | next_char = text[i + 1] 174 | return_accum += self.get_glyph(char).get_width(self.tracking, next_char) 175 | 176 | return return_accum 177 | 178 | def to_pngfile(self, text): 179 | text_width = self.max_width * 16 180 | text_height = self.max_height * ceil(len(text) / 16) 181 | 182 | pil_img = Image.new("P", (text_width, text_height)) 183 | pil_img.putpalette(PFT_BW_PALETTE, "RGBA") 184 | 185 | height_accum = 0 186 | width_accum = 0 187 | 188 | for i in range(len(text)): 189 | char = text[i] 190 | if i % 16 == 0 and i: 191 | height_accum += self.max_height 192 | width_accum = 0 193 | 194 | self.get_glyph(char).to_pngfile(0, "\0") 195 | pil_img.paste(self.get_glyph(char).pil_img, (width_accum, height_accum), self.get_glyph(char).pil_img.convert("RGBA", dither=Image.Dither.NONE)) 196 | width_accum += self.max_width 197 | 198 | fh = BytesIO() 199 | pil_img.save(fh, format="PNG") 200 | pngfile = fh.getvalue() 201 | fh.close() 202 | 203 | return pngfile 204 | 205 | def to_surf(self, text): 206 | text_width = self.get_width(text) 207 | text_height = self.max_height * (text.count("\n") + 1) 208 | 209 | surf = pg.Surface((text_width, text_height), pgloc.SRCALPHA) 210 | 211 | text += "\0" 212 | 213 | height_accum = 0 214 | width_accum = 0 215 | for i in range(len(text) - 1): 216 | char = text[i] 217 | next_char = text[i + 1] 218 | if char == "\n": 219 | height_accum += self.max_height 220 | width_accum = 0 221 | continue 222 | 223 | surf.blit(self.get_glyph(char).to_surf(self.tracking, next_char), (width_accum, height_accum)) 224 | width_accum += self.get_glyph(char).get_width(self.tracking, next_char) 225 | 226 | return surf 227 | 228 | def to_nonpdfile(self): 229 | text = "" 230 | widths = {} 231 | kerning = {} 232 | 233 | for page in self.pages.values(): 234 | for glyph in page.glyphs.values(): 235 | widths[glyph.utf8_char] = glyph.width 236 | if len(glyph.kerning_table): kerning[glyph.utf8_char] = glyph.kerning_table 237 | 238 | text += glyph.utf8_char 239 | text = text[:-1] 240 | 241 | png_data = b64encode(self.to_pngfile(text)) 242 | file_data = f''' 243 | -- Decompiled with the pd-emu decompilation tools 244 | 245 | -- Embedded PNG data 246 | datalen={len(png_data)} 247 | data={png_data.decode("utf-8")} 248 | width={self.max_width} 249 | height={self.max_height} 250 | 251 | tracking={self.tracking} 252 | 253 | -- Glyphs''' 254 | for glyph, width in widths.items(): 255 | if glyph == " ": 256 | glyph = "space" 257 | file_data += f"\n{glyph}\t\t{width}" 258 | 259 | file_data += "\n\n-- Kerning" 260 | for glyph, kerning_table in kerning.items(): 261 | for other_glyph, amount in kerning_table.items(): 262 | file_data += f"\n{glyph}{other_glyph}\t\t{amount}" 263 | 264 | return file_data.encode("utf-8") 265 | 266 | if __name__ == "__main__": 267 | init_logging() 268 | 269 | filename = argv[1] 270 | fnt_file = PDFontFile(argv[1]) 271 | with open(f"{splitext(filename)[0]}{fnt_file.NONPD_FILE_EXT}", "wb") as f: 272 | f.write(fnt_file.to_nonpdfile()) 273 | 274 | # From my own research, and also jaames/playdate-reverse-engineering#2 275 | 276 | # FILE HEADER (length 16) 277 | # 0: char[12]: constant "Playdate FNT" 278 | # 12: uint32: file bitflags 279 | # flags & 0x80000000 = file is compressed 280 | # flags & 0x00000001 = file contains characters above U+1FFFF 281 | 282 | # COMPRESSED FILE HEADER (length 16, inserted after the file header if the file is compressed) 283 | # 0: uint32: decompressed data size in bytes 284 | # 4: uint32: maximum glyph width 285 | # 8: uint32: maximum glyph height 286 | # (4 bytes of zeros) 287 | 288 | # OVERALL HEADER (length 68) 289 | # 0: uint8: glyph width 290 | # 1: uint8: glyph height 291 | # 2: uint16: tracking 292 | # 4: uint512: pages stored (bitmask) 293 | # start at U+00xx 294 | # if next bit (LSB first) = 0, the page isn't in this font 295 | # otherwise, page is in this font as a standalone bank 296 | 297 | # (offsets for pages are uint32) 298 | 299 | # PAGE HEADER (length 36) 300 | # (3 bytes of zeros) 301 | # 3: uint8: number of glyphs in page 302 | # 4: uint256: glyphs stored (bitmask) 303 | # start at U+xx00 304 | # if next bit (LSB first) = 0, character isn't in this font 305 | # otherwise, character is in this page 306 | 307 | # (offsets for glyphs are uint16) 308 | # (padding to align to a multiple of 4 bytes) 309 | 310 | # GLYPH FORMAT 311 | # 0: uint8: advance this many pixels 312 | # 1: uint8: number of short kerning table entries 313 | # 2: uint16: number of long kerning table entries 314 | # (kerning table) 315 | # (image data for the glyph without the 16-byte header; see the PDI documentation) 316 | 317 | # KERNING TABLE FORMAT 318 | # Short: 319 | # 0: uint8: second character codepoint 320 | # 1: int8: kerning in pixels 321 | # (padding to align to a multiple of 4 bytes) 322 | # Long: 323 | # 0: uint24: second character codepoint 324 | # 3: int8: kerning in pixels --------------------------------------------------------------------------------