Repository: jaames/playdate-reverse-engineering Branch: main Commit: dff24945249a Files: 22 Total size: 94.0 KB Directory structure: gitextract_ijazx7lw/ ├── .github/ │ └── FUNDING.yml ├── .gitignore ├── LICENSE ├── formats/ │ ├── fnt.md │ ├── luac.md │ ├── pda.md │ ├── pdex.md │ ├── pdi.md │ ├── pds.md │ ├── pdt.md │ ├── pdv.md │ ├── pdz.md │ └── pft.md ├── readme.md ├── server/ │ └── api.md ├── tools/ │ ├── pdex2elf.py │ ├── pdi2png.py │ ├── pdz.py │ └── usbeval.py └── usb/ ├── stream.md ├── usb.md └── usb_unlocked.md ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/FUNDING.yml ================================================ # These are supported funding model platforms github: jaames ================================================ FILE: .gitignore ================================================ testing tools/*lua .obsidian ================================================ FILE: LICENSE ================================================ Creative Commons Legal Code CC0 1.0 Universal CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED HEREUNDER. Statement of Purpose The laws of most jurisdictions throughout the world automatically confer exclusive Copyright and Related Rights (defined below) upon the creator and subsequent owner(s) (each and all, an "owner") of an original work of authorship and/or a database (each, a "Work"). Certain owners wish to permanently relinquish those rights to a Work for the purpose of contributing to a commons of creative, cultural and scientific works ("Commons") that the public can reliably and without fear of later claims of infringement build upon, modify, incorporate in other works, reuse and redistribute as freely as possible in any form whatsoever and for any purposes, including without limitation commercial purposes. These owners may contribute to the Commons to promote the ideal of a free culture and the further production of creative, cultural and scientific works, or to gain reputation or greater distribution for their Work in part through the use and efforts of others. For these and/or other purposes and motivations, and without any expectation of additional consideration or compensation, the person associating CC0 with a Work (the "Affirmer"), to the extent that he or she is an owner of Copyright and Related Rights in the Work, voluntarily elects to apply CC0 to the Work and publicly distribute the Work under its terms, with knowledge of his or her Copyright and Related Rights in the Work and the meaning and intended legal effect of CC0 on those rights. 1. Copyright and Related Rights. A Work made available under CC0 may be protected by copyright and related or neighboring rights ("Copyright and Related Rights"). Copyright and Related Rights include, but are not limited to, the following: i. the right to reproduce, adapt, distribute, perform, display, communicate, and translate a Work; ii. moral rights retained by the original author(s) and/or performer(s); iii. publicity and privacy rights pertaining to a person's image or likeness depicted in a Work; iv. rights protecting against unfair competition in regards to a Work, subject to the limitations in paragraph 4(a), below; v. rights protecting the extraction, dissemination, use and reuse of data in a Work; vi. database rights (such as those arising under Directive 96/9/EC of the European Parliament and of the Council of 11 March 1996 on the legal protection of databases, and under any national implementation thereof, including any amended or successor version of such directive); and vii. other similar, equivalent or corresponding rights throughout the world based on applicable law or treaty, and any national implementations thereof. 2. Waiver. To the greatest extent permitted by, but not in contravention of, applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and unconditionally waives, abandons, and surrenders all of Affirmer's Copyright and Related Rights and associated claims and causes of action, whether now known or unknown (including existing as well as future claims and causes of action), in the Work (i) in all territories worldwide, (ii) for the maximum duration provided by applicable law or treaty (including future time extensions), (iii) in any current or future medium and for any number of copies, and (iv) for any purpose whatsoever, including without limitation commercial, advertising or promotional purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each member of the public at large and to the detriment of Affirmer's heirs and successors, fully intending that such Waiver shall not be subject to revocation, rescission, cancellation, termination, or any other legal or equitable action to disrupt the quiet enjoyment of the Work by the public as contemplated by Affirmer's express Statement of Purpose. 3. Public License Fallback. Should any part of the Waiver for any reason be judged legally invalid or ineffective under applicable law, then the Waiver shall be preserved to the maximum extent permitted taking into account Affirmer's express Statement of Purpose. In addition, to the extent the Waiver is so judged Affirmer hereby grants to each affected person a royalty-free, non transferable, non sublicensable, non exclusive, irrevocable and unconditional license to exercise Affirmer's Copyright and Related Rights in the Work (i) in all territories worldwide, (ii) for the maximum duration provided by applicable law or treaty (including future time extensions), (iii) in any current or future medium and for any number of copies, and (iv) for any purpose whatsoever, including without limitation commercial, advertising or promotional purposes (the "License"). The License shall be deemed effective as of the date CC0 was applied by Affirmer to the Work. Should any part of the License for any reason be judged legally invalid or ineffective under applicable law, such partial invalidity or ineffectiveness shall not invalidate the remainder of the License, and in such case Affirmer hereby affirms that he or she will not (i) exercise any of his or her remaining Copyright and Related Rights in the Work or (ii) assert any associated claims and causes of action with respect to the Work, in either case contrary to Affirmer's express Statement of Purpose. 4. Limitations and Disclaimers. a. No trademark or patent rights held by Affirmer are waived, abandoned, surrendered, licensed or otherwise affected by this document. b. Affirmer offers the Work as-is and makes no representations or warranties of any kind concerning the Work, express, implied, statutory or otherwise, including without limitation warranties of title, merchantability, fitness for a particular purpose, non infringement, or the absence of latent or other defects, accuracy, or the present or absence of errors, whether or not discoverable, all to the greatest extent permissible under applicable law. c. Affirmer disclaims responsibility for clearing rights of other persons that may apply to the Work or any use thereof, including without limitation any person's Copyright and Related Rights in the Work. Further, Affirmer disclaims responsibility for obtaining any necessary consents, permissions or other rights required for any use of the Work. d. Affirmer understands and acknowledges that Creative Commons is not a party to this document and has no duty or obligation with respect to this CC0 or use of the Work. ================================================ FILE: formats/fnt.md ================================================ A file with the `.fnt` extension contains font data created by [playdate caps](https://play.date/caps/). It is a line oriented UTF-8 plaintext file format, suitable for editing with normal text editors and optionally includes a base64 encoded PNG sprite sheet. Each line is one of: 1. String key value data separated by an equals character `=` 2. Glyph widths: A UTF-8 character, whitespace and pixel width of the character 3. Kerning data: Two UTF-8 characters, whitespace and pixel offset for this kerning pair. 4. Lua style comments beginning with `--` and empty lines which are skipped ## Glyph Widths ```` 8 8 9 8 space 5 � 9 ```` In its most basic form a fnt file is one or more lines comprising an index to specify characters and widths for each glyphs in an accompanying PNG sprite sheet. Pairs of UTF-8 glyphs and their widths are separated by any amount of whitespace, one per line, specified in the order they appear in the sprite sheet. (Left to Right, Top to Buttom) Playdate supports all code points in the first four Unicode planes, up to U+3FFFF. Because these glyphs and widths are whitespace separated, a special string `space` is substited for the ` ` space glyph. ## External PNG Data The PNG may be external file or included internally in the fnt file. When external, the accompanying PNG must be named to match the font file. For example the PNG for `pantspants.fnt` would named pantspants-table-9-12.png assuming the fonts glyphs are 9 pixels wide and 12 pixels tall. ## Internal PNG Data Alternatively the PNG data may be included within the fnt file itself, base64 encoded along with the necessary metadata (height/width) required to process the PNG sprite sheet. ``` datalen=2052 data=iVBORw0KGgoAAAANSUhEUgAAABQAAAAKCAYAAAC0VX7mAAAAAXNSR0IArs4c6QAAAF5JREFUOE+tktsKACAIQ/X/P7owGHjJUMm3pJ02k2lei4jYy0OjyBcYyjAmQO/MnLtAOBMdQLoXZ/CIrGPquCb+1KEA4dLMsgsUceb0gCdAQPUc719eXBlc+7qH6dsbK4QPCz6OhZ4AAAAASUVORK5CYII= ``` | Key | Value Detail | |:-----------|:-----------------------------------------| | `datalen=` | Integer length of `data` data value which follows (as ASCII numbers) | `data=` | PNG file data, Base64 encoded | `width=` | Maximum pixel width of each glyph | `height=` | Maximum pixel height of each glyph A 1bit B+W PNG image contains a fixed-size sprite sheet of the individual glyphs. If the glyphs themselves are narrower than the width their pixels are left justified. ## Kerning Pairs (optional) ``` To -2 Te -4 ``` Kerning pairs may be specified, one line per pair with: two characters, whitespace and the offset. ## Tracking info (optional) | Key | Value Detail | |:-----------|:-----------------------------------------| | `tracking=`| Number of pixels of horizontal whitespace between glyphs within a string. Defaults to 1 ## CAPS Metadata (optional) ``` --metrics={"baseline":10,"xHeight":0,"capHeight":0,"pairs":{"Te":[-6,0]},"left":[],"right":[]}``` ``` The metrics line embeds a JSON object in a lua-style comment to store relevant CAPS editor metadata. This data is ignored by `pdc`. The [baseline](https://en.wikipedia.org/wiki/Baseline_(typography)), [xHeight](https://en.wikipedia.org/wiki/X-height) and [capHeight](https://en.wikipedia.org/wiki/Cap_height) are displayed while using the Caps Glyph editor, `left` and `right` contain an array of strings where each string includes a list of characters which have equivalent left or right-most columns and thus will use identical kerning information. The pairs object includes kerning pairs specified by the Caps "Auto-Kern" functionality. ================================================ FILE: formats/luac.md ================================================ Lua-based Playdate games use a tweaked version of Lua 5.4.3. You will only find compiled Lua bytecode in [`.pdz`](/formats/pdz.md) files. ### Compile Flags `#define LUA_32BITS = 1` set in `luaconf.h`. ### Header Differences TODO ### Opcode Differences To maintain backwards-compatibility with Lua 5.4-beta bytecode from [pre-release versions of the Playdate SDK](#pre-sdk-version-180), additional Lua 5.4.3 opcodes were appended to the opcode map like so: ``` LUAI_DDEF const lu_byte luaP_opmodes[NUM_OPCODES] = { /* MM OT IT T A mode opcode */ opmode(0, 0, 0, 0, 1, iABC) /* OP_MOVE */ ,opmode(0, 0, 0, 0, 1, iAsBx) /* OP_LOADI */ ,opmode(0, 0, 0, 0, 1, iAsBx) /* OP_LOADF */ ,opmode(0, 0, 0, 0, 1, iABx) /* OP_LOADK */ ,opmode(0, 0, 0, 0, 1, iABx) /* OP_LOADKX */ ,opmode(0, 0, 0, 0, 1, iABx) /* OP_UNKNOWN */ ,opmode(0, 0, 0, 0, 1, iABC) /* OP_LOADNIL */ ,opmode(0, 0, 0, 0, 1, iABC) /* OP_GETUPVAL */ ,opmode(0, 0, 0, 0, 0, iABC) /* OP_SETUPVAL */ ,opmode(0, 0, 0, 0, 1, iABC) /* OP_GETTABUP */ ,opmode(0, 0, 0, 0, 1, iABC) /* OP_GETTABLE */ ,opmode(0, 0, 0, 0, 1, iABC) /* OP_GETI */ ,opmode(0, 0, 0, 0, 1, iABC) /* OP_GETFIELD */ ,opmode(0, 0, 0, 0, 0, iABC) /* OP_SETTABUP */ ,opmode(0, 0, 0, 0, 0, iABC) /* OP_SETTABLE */ ,opmode(0, 0, 0, 0, 0, iABC) /* OP_SETI */ ,opmode(0, 0, 0, 0, 0, iABC) /* OP_SETFIELD */ ,opmode(0, 0, 0, 0, 1, iABC) /* OP_NEWTABLE */ ,opmode(0, 0, 0, 0, 1, iABC) /* OP_SELF */ ,opmode(0, 0, 0, 0, 1, iABC) /* OP_ADDI */ ,opmode(0, 0, 0, 0, 1, iABC) /* OP_ADDK */ ,opmode(0, 0, 0, 0, 1, iABC) /* OP_SUBK */ ,opmode(0, 0, 0, 0, 1, iABC) /* OP_MULK */ ,opmode(0, 0, 0, 0, 1, iABC) /* OP_MODK */ ,opmode(0, 0, 0, 0, 1, iABC) /* OP_POWK */ ,opmode(0, 0, 0, 0, 1, iABC) /* OP_DIVK */ ,opmode(0, 0, 0, 0, 1, iABC) /* OP_IDIVK */ ,opmode(0, 0, 0, 0, 1, iABC) /* OP_BANDK */ ,opmode(0, 0, 0, 0, 1, iABC) /* OP_BORK */ ,opmode(0, 0, 0, 0, 1, iABC) /* OP_BXORK */ ,opmode(0, 0, 0, 0, 1, iABC) /* OP_SHRI */ ,opmode(0, 0, 0, 0, 1, iABC) /* OP_SHLI */ ,opmode(0, 0, 0, 0, 1, iABC) /* OP_ADD */ ,opmode(0, 0, 0, 0, 1, iABC) /* OP_SUB */ ,opmode(0, 0, 0, 0, 1, iABC) /* OP_MUL */ ,opmode(0, 0, 0, 0, 1, iABC) /* OP_MOD */ ,opmode(0, 0, 0, 0, 1, iABC) /* OP_POW */ ,opmode(0, 0, 0, 0, 1, iABC) /* OP_DIV */ ,opmode(0, 0, 0, 0, 1, iABC) /* OP_IDIV */ ,opmode(0, 0, 0, 0, 1, iABC) /* OP_BAND */ ,opmode(0, 0, 0, 0, 1, iABC) /* OP_BOR */ ,opmode(0, 0, 0, 0, 1, iABC) /* OP_BXOR */ ,opmode(0, 0, 0, 0, 1, iABC) /* OP_SHL */ ,opmode(0, 0, 0, 0, 1, iABC) /* OP_SHR */ ,opmode(1, 0, 0, 0, 0, iABC) /* OP_MMBIN */ ,opmode(1, 0, 0, 0, 0, iABC) /* OP_MMBINI*/ ,opmode(1, 0, 0, 0, 0, iABC) /* OP_MMBINK*/ ,opmode(0, 0, 0, 0, 1, iABC) /* OP_UNM */ ,opmode(0, 0, 0, 0, 1, iABC) /* OP_BNOT */ ,opmode(0, 0, 0, 0, 1, iABC) /* OP_NOT */ ,opmode(0, 0, 0, 0, 1, iABC) /* OP_LEN */ ,opmode(0, 0, 0, 0, 1, iABC) /* OP_CONCAT */ ,opmode(0, 0, 0, 0, 0, iABC) /* OP_CLOSE */ ,opmode(0, 0, 0, 0, 0, iABC) /* OP_TBC */ ,opmode(0, 0, 0, 0, 0, isJ) /* OP_JMP */ ,opmode(0, 0, 0, 1, 0, iABC) /* OP_EQ */ ,opmode(0, 0, 0, 1, 0, iABC) /* OP_LT */ ,opmode(0, 0, 0, 1, 0, iABC) /* OP_LE */ ,opmode(0, 0, 0, 1, 0, iABC) /* OP_EQK */ ,opmode(0, 0, 0, 1, 0, iABC) /* OP_EQI */ ,opmode(0, 0, 0, 1, 0, iABC) /* OP_LTI */ ,opmode(0, 0, 0, 1, 0, iABC) /* OP_LEI */ ,opmode(0, 0, 0, 1, 0, iABC) /* OP_GTI */ ,opmode(0, 0, 0, 1, 0, iABC) /* OP_GEI */ ,opmode(0, 0, 0, 1, 0, iABC) /* OP_TEST */ ,opmode(0, 0, 0, 1, 1, iABC) /* OP_TESTSET */ ,opmode(0, 1, 1, 0, 1, iABC) /* OP_CALL */ ,opmode(0, 1, 1, 0, 1, iABC) /* OP_TAILCALL */ ,opmode(0, 0, 1, 0, 0, iABC) /* OP_RETURN */ ,opmode(0, 0, 0, 0, 0, iABC) /* OP_RETURN0 */ ,opmode(0, 0, 0, 0, 0, iABC) /* OP_RETURN1 */ ,opmode(0, 0, 0, 0, 1, iABx) /* OP_FORLOOP */ ,opmode(0, 0, 0, 0, 1, iABx) /* OP_FORPREP */ ,opmode(0, 0, 0, 0, 0, iABx) /* OP_TFORPREP */ ,opmode(0, 0, 0, 0, 0, iABC) /* OP_TFORCALL */ ,opmode(0, 0, 0, 0, 1, iABx) /* OP_TFORLOOP */ ,opmode(0, 0, 1, 0, 0, iABC) /* OP_SETLIST */ ,opmode(0, 0, 0, 0, 1, iABx) /* OP_CLOSURE */ ,opmode(0, 1, 0, 0, 1, iABC) /* OP_VARARG */ ,opmode(0, 0, 1, 0, 1, iABC) /* OP_VARARGPREP */ ,opmode(0, 0, 0, 0, 0, iAx) /* OP_EXTRAARG */ // opcodes appended: ,opmode(0, 0, 0, 0, 1, iABC) /* OP_LOADFALSE */ ,opmode(0, 0, 0, 0, 1, iABC) /* OP_LFALSESKIP */ ,opmode(0, 0, 0, 0, 1, iABC) /* OP_LOADTRUE */ }; ``` ## Pre SDK Version 1.8.0 Prior to SDK version 1.8.0 (which was only available to developer preview members - 1.9.0 was the first publicly available SDK release), the Playdate Lua runtime was based on [prerelease/beta version of Lua 5.4](https://github.com/lua/lua/tree/6c0e44464b9eef4be42e2c8181aabfb3301617ad). This version used a slightly nonstandard bytecode header structure. It is possible to execute Playdate Lua bytecode from this version of the SDK by compiling the Lua 5.4-beta with `#define LUA_32BITS = 1` set in `luaconf.h`. ### Header | Offset | Type | Detail | |:-------|:--------|:-------| | `0` | `byte[4]` | Constant `LUA_SIGNATURE` (hex `1B 4C 75 61`) | | `4` | `uint16` | Version (`0x03F8` = 5.4.0 prerelease) | | `6` | `byte` | Constant `LUAC_FORMAT` (hex `00`) | | `7` | `byte[6]` | Constant `LUAC_DATA` (hex `19 93 0D 0A 1A 0A`) | | `13` | `uint8` | Instruction size (always `4`) | | `14` | `uint8` | Integer size (always `4`) | | `15` | `uint8` | Number size (always `4`) | | `16` | `lua int` | Constant `LUA_INT` (`0x5678`) | | `20` | `lua float` | Constant `LUA_NUM` (`370.5`) | ================================================ FILE: formats/pda.md ================================================ A file with the `.pda` extension represents audio data that has been compiled by `pdc`. This format uses little endian byte order. ## Header | Offset | Type | Detail | |:-------|:---------|:-------| | `0` | `char[12]` | Ident "Playdate AUD" | | `12` | `uint24` | Sample rate (in Hz) | | `15` | `uint8` | [Audio data format](#audio-data-format) | ### Audio Data Format The audio data format field in the file header seems to map to the `playdate.sound` constants in the official SDK: | Value | SDK Constant | Detail | |:------|:-------------|:-------| | `0` | `kFormat8bitMono` | unsigned 8-bit PCM, one channel | | `1` | `kFormat8bitStereo` | unsigned 8-bit PCM, two channels | | `2` | `kFormat16bitMono` | signed 16-bit little endian PCM, one channel | | `3` | `kFormat16bitStereo` | signed 16-bit little endian PCM, two channels | | `4` | `kFormatADPCMMono` | 4-bit IMA ADPCM, one channel | | `5` | `kFormatADPCMStereo` | 4-bit IMA ADPCM, two channels | ## Audio Data The format flag in the file header indicates how the audio is stored: ### 4-bit IMA ADPCM In this format, the audio is encoded in blocks. The audio data begins with an `uint16` which gives the size of a single block, and the start of the first block begins immediately after. Each block also begins with a small header consisting of 4 bytes for each audio channel: | Type | Detail | |:-------|:-------| | `uint16` | ADPCM predictor | | `uint8` | ADPCM step index | | `uint8` | Reserved, should always be zero | The rest of the block contains regular 4-bit IMA ADPCM audio samples. For stereo audio, the left channel uses the high nibble of every byte, while the right channel uses the low nibble. ### 8-bit PCM Standard 8-bit PCM. Each sample can be converted to signed 16-bit PCM with `(sample - 0x80) << 8`. For stereo audio, the channels are interleaved so you read one sample for the left channel, the next one for the right, next one for the left, and so on. ### 16-bit PCM Standard signed 16-bit PCM in little-endian byte order. As with the 8-bit format, for stereo audio, the channels are interleaved so you read one sample for the left channel, the next one for the right, next one for the left, and so on. ================================================ FILE: formats/pdex.md ================================================ The `pdex.bin` file represents information and executable code copied by `pdc` from a `pdex.elf` ELF file compiled for ARM32 (Thumb, EABI v5, hard-float). It is usually the entry point of Playdate games created using the C API. The file format uses little endian byte order. Supplementary reading: - https://en.wikipedia.org/wiki/Executable_and_Linkable_Format - https://www.man7.org/linux/man-pages/man5/elf.5.html # File structure 1. [File header](#file-header) 2. [Program header](#program-header) 3. [Program data](#program-data) 1. [Program segment](#program-segment) 2. [Relocation entries](#relocation-entries) ## File header | Offset | Type | Detail | |:-------|:-----------|:-------------------------------| | `0x00` | `char[12]` | File signature: `Playdate PDX` | | `0x0C` | `uint32` | [Flags](#flags) | | `0x10` | - | End of file header (size) | ### Flags | Bitmask | Detail | |:--------------------|:------------------------------------------------------| | `flag & 0x40000000` | If `> 0`, all data after the file header is encrypted | Encryption is (at the time of writing) only used by Catalog games as a form of DRM. The encryption method is not yet known. ## Program header | Offset | Type | Detail | |:-------|:------------|:---------------------------------------------------| | `0x00` | `uint8[16]` | MD5 checksum of program segment | | `0x10` | `uint32` | Size of program segment in file image; `p_filesz` | | `0x14` | `uint32` | Size of program segment in memory image; `p_memsz` | | `0x18` | `uint32` | Entry point address; `e_entry` | | `0x1C` | `uint32` | Number of relocation entries | | `0x20` | - | End of program header (size) | ## Program data The program data is zlib-compressed and consists of [a single program segment](#program-segment) immediately followed by [relocation entries](#relocation-entries). ### Program segment The first `p_filesz` bytes of the uncompressed program data is the program segment, usually consisting of the `.text` (executable code) and `.data` (initialized global variables) sections of the original ELF file. The `.bss` (uninitialized global variables) section does not occupy any space in the file image; its size in memory can be computed via `p_memsz - p_filesz`. ### Relocation entries The next ` * 4` bytes of the uncompressed program data are the relocation entries, usually from the `.rel.text` and/or `.rel.data` sections of the original ELF file. Each entry is a single `uint32` denoting a byte offset from the beginning of the program segment where a relocation should take place, corresponding to the `r_offset` member of an `Elf32_Rel` relocation entry. ================================================ FILE: formats/pdi.md ================================================ A file with the `.pdi` extension represents a 1-bit bitmap image that has been compiled by `pdc`. This format uses little endian byte order. ## Header | Offset | Type | Detail | |:-------|:----------|:---------------------| | `0` | `char[12]` | Ident `Playdate IMG` | | `12` | `uint32` | [Flags](#flags) | ### Flags | Bitmask | Detail | |:--------------------|:--------------------------------------------| | `flags & 0x80000000` | If `> 0`, the data in this file is compressed | ## Image Header If the compression flag is set, this image header follows the file header. Everything after the image header is zlib-compressed. | Offset | Type | Detail | |:-------|:---------|:--------------------------------| | `0` | `uint32` | Size of image data section when decompressed | | `4` | `uint32` | Image width (in pixels) | | `8` | `uint32` | Image height (in pixels) | | `12` | `uint32` | Unknown/reserved? Seen as 0 | ## Image Data `.pdi` image data comprises of a single [Image Cell](#image-cell). ## Image Cell The `pdi`, [`.pdt`](formats/pdi.md) and [`.pft`](formats/pft.md) formats store pixels as "cells", where transparent edges are cropped out to save on space. ### Cell Header | Offset | Type | Detail | |:-------|:---------|:-------| | `0` | `uint16` | Cell clip width (in pixels) | | `2` | `uint16` | Cell clip height (in pixels) | | `4` | `uint16` | Cell stride (bytes per image row) | | `6` | `uint16` | Cell clip left (in pixels) | | `8` | `uint16` | Cell clip right (in pixels) | | `10` | `uint16` | Cell clip top (in pixels) | | `12` | `uint16` | Cell clip bottom (in pixels) | | `14` | `uint16` | [Cell bitflags](#cell-bitflags) | ### Cell Bitflags | Bitmask | Detail | |:--------------------|:---------------------------| | `flags & 0x3` | If `> 0`, cell uses transparency | ### Cell Pixels Cells contain at least one 1-bit bitmap for black/white color (`0` for black and `1` for white). If the transparency flag is set, this will be followed by an additional 1-bit bitmap for the image alpha (`0` for transparent and `1` for opaque). The number of bytes used by a cell bitmap will be equal to `stride * clip height`. The number of pixels in each row of the cell bitmap will be equal to `clip width`. Transparent edges are not stored, and must be added back to the cell based on the values given in the cell header. The stride is always a multiple of 4, ensuring that the data can be accessed with 32-bit reads. ![Transparent edges are removed from the image to reduce its size](https://github.com/jaames/playdate-reverse-engineering/blob/main/_images/bitmap-clip.png) The final image width will equal `clip left + clip width + clip right`, likewise the height will equal `clip top + clip height + clip bottom`, ================================================ FILE: formats/pds.md ================================================ A file with the `.pds` extension represents a collection localization strings that have been compiled by `pdc`. This format uses little endian byte order. ## Header | Offset | Type | Detail | |:-------|:---------|:-------| | `0` | `char[12]` | Ident "Playdate STR" | | `12` | `uint32` | [Flags](#flags) | ### Flags | Bitmask | Detail | |:--------------------|:--------------------------------------------| | `flag & 0x80000000` | If `> 0`, the data in this file is compressed | ### String header If the compression flag is set, there's an extra string data header after the file header. Everything after this is zlib-compressed. | Offset | Type | Detail | |:-------|:---------|:-------| | `0` | `uint32` | Size of decompressed string data | | `4` | `uint32` | Unused/reserved, seen as 0 | | `8` | `uint32` | Unused/reserved, seen as 0 | | `12` | `uint32` | Unused/reserved, seen as 0 | ## String Data ### Table Header | Offset | Type | Detail | |:-------|:--------|:-------| | `0` | `uint32` | Number of string entries | ### Table After this header, there is a table of int32 offsets for each string entry aside from the first one, as well as an offset to the end of the data. Offsets are relative to the end of the table, and the first string entry always begins directly after the table. ### String Entries Each string entry contains an utf8 string key, followed by a null byte, followed by a utf8 string value, followed by another null byte. ================================================ FILE: formats/pdt.md ================================================ A file with the `.pdt` extension represents a 1-bit bitmap image table containing multiple sub-images (like a spritesheet or animation) that has been compiled by `pdc`. This format uses little endian byte order. ## Header | Offset | Type | Detail | |:-------|:---------|:-------| | `0` | `char[12]` | Ident `Playdate IMT` | | `12` | `uint32` | [Flags](#flags) | ### Flags | Bitmask | Detail | |:--------------------|:--------------------------------------------| | `flag & 0x80000000` | If `> 0`, the data in this file is compressed | ## Image Header If the compression flag is set, there's an extra header after the file header: | Offset | Type | Detail | |:-------|:---------|:--------------------------------| | `0` | `uint32` | Size of decompressed image data | | `4` | `uint32` | Image width (in pixels) | | `8` | `uint32` | Image height (in pixels) | | `12` | `uint32` | Number of cells | The image width and height are for the first image only. In sequential image tables, the following images may be of different sizes. In matrix image tables, all images must be the same size. If the compression flag is set, then this section is zlib-compressed. ## Image Data ### Table Header | Offset | Type | Detail | |:-------|:--------|:-------| | `0` | `uint16` | Num cells | | `2` | `uint16` | Num cells per row | For sequential image tables, the values will be the same. For matrix image tables, the second value will be the number of cells on each row. ### Table After this header, there is a table of uint32 offsets for each [image cell](#image-cell) aside from the first one, as well as an offset to the end of the data. Offsets are relative to the end of the table, and the first cell always begins directly after the table. ### Image Cell See: [Image Cell](/formats/pdi.md#image-cell) ================================================ FILE: formats/pdv.md ================================================ A file with the `.pdv` extension represents a 1-bit video that has been converted by `1bitvideo.app`. This format uses little endian byte order. ## Header | Offset | Type | Detail | |:-------|:---------|:-------| | `0` | `char[12]` | Ident `Playdate VID` | | `12` | `uint32` | Reserved, always 0 | | `16` | `uint16` | Number of frames | | `18` | `uint16` | Reserved, always 0 | | `20` | `float32` | Framerate, measured in frames per second | | `24` | `uint16` | Frame width (in pixels) | | `26` | `uint16` | Frame height (in pixels) | In 1bitvideo.app the frame width and height seem to be hardcoded to `400` and `240` respectively, at least at the time of writing. ## Frame Table Following the header is a series of `uint32` values, one for each frame, and one additional to mark the end of the data. So if the number of frames is 16, there will be 17 entries in this table. These values contain the frame data offset as well as the frame type: | Value | Detail | |:------|:-------| | `value >> 2` | Offset | | `value & 0x3` | Frame type | ### Frame Types | Type | Detail | |:-----|:-------| | `0` | No frame | | `1` | [I-frame](https://en.wikipedia.org/wiki/Video_compression_picture_types) | | `2` | [P-frame](https://en.wikipedia.org/wiki/Video_compression_picture_types) | | `3` | Combined I-frame and P-frame | A `0` type frame is placed at the end to identify where the preceeding frame's data ends. There is no actual data following it. ## Frame Data Frame data begins immediately after the frame table. Each frame is z-lib compressed separately. Decompressed, the frame contains a 1-bit pixel map where `0` is black and `1` is white. ### P-frames Frame type `2` is for P-frames (frames that are based on previous frames), and these only store the pixels that have changed since the previous frame. The full image can be resolved by looping through each pixel in the frame and doing a logical XOR against the same pixel from the previous resolved frame. For example in C this would be something like: ```c for (int i = 0; i < sizeof(frame); i++) { frame[i] ^= prevFrame[i]; } ``` ### Combined I-frame and P-frame Frame type 3 contains both I-frame and P-frame data for the same frame. This is so you can step backwards from an I-frame without having to jump to the previous I-frame then apply P-frames all the way forward. The frame data for this frame will start with an `uint16` giving the length of the I-frame data, followed by the I-frame data, and then the P-frame data. ================================================ FILE: formats/pdz.md ================================================ A file with the `.pdz` extension represents a file container that has been compiled by `pdc`. They mostly contain compiled Lua bytecode, but they can sometimes include other assets such as images or fonts. This format uses little endian byte order. ## Header | Offset | Type | Detail | |:-------|:---------|:-------| | `0` | `char[12]` | Ident `Playdate PDZ` | | `12` | `uint32` | [Flags](#flags) | ### Flags | Bitmask | Detail | |:--------------------|:--------------------------------------------| | `flag & 0x40000000` | If `> 0`, the data in this file is encrypted | File encryption is (at the time of writing) only used by Catalog games' `main.pdz` file as a form of DRM. The encryption method isn't known. ## File Entries Following the header is a list of file entries. Each entry has a header. | Type | Detail | |:--------|:-------| | `uint8` | [Entry Flags](#entry-flags) | | `uint24` | [Entry Data](#entry-data) length | | `string` | Filename as null-terminated C string | | `-` | Optional null-padding if needed to align to the next multiple of 4 bytes | If the [Entry Type](#entry-type) flag is `5` (for a `.pda` audio file), some additional values are included: | Type | Detail | |:--------|:-------| | `uint24` | Audio sample rate in Hz | | `uint8` | [Audio Data Format](/format/pda.md#audio-data-format) | ### Entry Flags | Flag | Detail | |:-------|:-------| | `flags & 0x80` | If `> 0`, file entry data is compressed | | `flags & 0x7F` | [Entry Type](#entry-type) | ### Entry Type | Flag | Detail | |:-------|:-------| | `0` | Unknown/unused | | `1` | Compiled Lua bytecode ([`.luac`](/formats/luac.md)) | | `2` | Static image ([`.pdi`](/formats/pdi.md)) | | `3` | Animated image ([`.pdt`](/formats/pdt.md)) | | `4` | Video ([`.pdv`](/formats/pdv.md)) | | `5` | Audio ([`.pda`](/formats/pda.md)) | | `6` | Text strings ([`.pds`](/formats/pds.md)) | | `7` | Font ([`.pft`](/formats/pft.md)) | ## Entry Data The data for a given file entry is immediately after the entry's file header. If the file's compression flag is set, this will begin with a `uint32` giving the decompressed size of the data, followed by zlib-compressed data. All of the asset entries (`.pdi`, `.pdt`, `.pdv`, `.pda`, `.pds`, `.pft`), will be missing the first 16 bytes of the header, since for most of these formats this just contains a 12-byte format ident string and some compression flags. This is why `.pda` entries have additional header fields for the sample rate and audio format. ================================================ FILE: formats/pft.md ================================================ A file with the `.pft` extension represents a 1-bit bitmap font that has been compiled by `pdc`. This format uses little endian byte order. ## Header | Offset | Type | Detail | |:-------|:----------|:---------------------| | `0` | `char[12]` | Ident `Playdate FNT` | | `12` | `uint32` | [Flags](#flags) | ### Flags | Bitmask | Detail | |:--------------------|:--------------------------------------------| | `flags & 0x80000000` | If `> 0`, the data in this file is compressed | | `flags & 0x00000001` | If `> 0`, the font contains characters above U+1FFFF | ## Font Header If the compression flag is set, there's an extra font header after the file header. Everything after this is zlib-compressed. | Offset | Type | Detail | |:-------|:---------|:--------------------------------| | `0` | `uint32` | Size of font data section when decompressed | | `4` | `uint32` | Maximum glyph width (in pixels) | | `8` | `uint32` | Maximum glyph height (in pixels) | ## Page List ### Page List Header | Offset | Type | Detail | |:-------|:---------|:--------------------------------| | `0` | `uint8` | Glyph width (in pixels) | | `1` | `uint8` | Glyph height (in pixels) | | `2` | `uint16` | Tracking (in pixels) | | `4` | `64 bytes` | [Page Usage Flags](#page-usage-flags) | Font glyphs are grouped into pages based on their unicode codepoints. Each page covers a span of 256 glyphs. Pages are only stored if they have glyphs present in the font. The page index for a given glyph codepoint will be `codepoint >> 8`. Following this header is a list of `uint32` offsets for all of the pages present in the file, with the pages following immediately after. ### Page Usage Flags This contains bitflags for each page, starting at the lowest significant bit in the first byte. If a page's corresponding usage flag is `1`, then it is present in the file. ## Page ### Page Header | Offset | Type | Detail | |:-------|:---------|:--------------------------------| | `0` | `uint24` | Reserved? Seen as `0` | | `3` | `uint8` | Number of glyphs | | `4` | `32 bytes` | [Glyph Usage Flags](#glyph-usage-flags) | After the page header is a series of [Glyphs](#glyph) for the page. ### Glyph Usage Flags This contains bitflags for each glyph, starting at the lowest significant bit in the first byte. If a glyph's corresponding usage flag is `1`, then it is present in the page. ## Glyph Each glyph is comprised of: - a header - a short kerning table - (if necessary) padding bytes to align to the next multiple of 4 - a long kerning table - pixel data ### Glyph Header | Offset | Type | Detail | |:-------|:---------|:--------------------------------| | `0` | `uint8` | Glyph advance / width (in pixels) | | `1` | `uint8` | Number of [Short Kerning Table Entries](#short-kerning-table-entries) | | `2` | `uint16` | Number of [Long Kerning Table Entries](#long-kerning-table-entries) | ### Short Kerning Table Entries I think this is for codepoints within the same page? | Offset | Type | Detail | |:-------|:---------|:--------------------------------| | `0` | `uint8` | Other glyph codepoint | | `1` | `int8` | Kerning (in pixels) | ### Long Kerning Table Entries This supports any unicode codepoint within the whole font | Offset | Type | Detail | |:-------|:---------|:--------------------------------| | `0` | `uint24` | Other glyph codepoint | | `3` | `int8` | Kerning (in pixels) | ### Glyph pixels Stored as an [Image Cell](/formats/pdi.md#image-cell). ================================================ FILE: readme.md ================================================ Unofficial Playdate reverse-engineering notes/tools - covers file formats, server API and USB serial commands > ⚠️ This documentation is unofficial and is not affiliated with Panic. All of the content herein was gleaned from reverse-engineering Playdate tools and game files, and as such there may be mistakes or missing information. ## Documentation - **File Formats** - **Playdate game formats** - [**pdex.bin**](formats/pdex.md) - Executable code - [**.luac**](formats/luac.md) - Lua bytecode - [**.pdz**](formats/pdz.md) - File container - [**.pda**](formats/pda.md) - Audio file - [**.pdi**](formats/pdi.md) - Image file - [**.pdt**](formats/pdt.md) - Imagetable file - [**.pdv**](formats/pdv.md) - Video file - [**.pds**](formats/pds.md) - Strings file - [**.pft**](formats/pft.md) - Font file - **Other formats** - [**.fnt**](formats/fnt.md) - Font source file - **.strings** - Strings source file (TODO) - **Server** - [**Playdate API**](server/api.md) - Main Playdate server API - **Misc** - [**USB**](usb/usb.md) - USB serial interface - [**Streaming**](usb/stream.md) - Video/audio streaming protocol (via USB serial), used by Playdate Mirror ## Tools - [**`pdz.py`**](tools/pdz.py) - Unpacks all files from a `.pdz` file container. - [**`pdex2elf.py`**](tools/pdex2elf.py) - Converts a `pdex.bin` to an ELF file that can be analyzed in tools such as readelf, objdump or Ghidra, or compiled back to the same original `pdex.bin` by `pdc`. - [**`pdi2png.py`**](tools/pdi2png.py) - Converts a `.pdi` image file to a `.png` image. - [**`usbeval.py`**](tools/usbeval.py) - Uses the Playdate's USB `eval` command to evaluate a Lua script over USB. Has access to the Lua runtime of the currently loaded game, except for system apps. ## Related Projects and Resources - [**pd-usb**](https://github.com/jaames/pd-usb) - JavaScript library for interacting with the Playdate's serial API from a WebUSB-compatible web browser. - [**unluac**](https://github.com/scratchminer/unluac) - Fork of the unluac Lua decompiler, modified to support Playdate-flavoured Lua. - [**lua54**](https://github.com/scratchminer/lua54) - Fork of Lua that aims to match the custom tweaks that Panic added for Playdate-flavoured Lua. ## Special Thanks - [Zhuowei](https://github.com/zhuowei) for this [script for unpacking Playdate .pdx executables](https://gist.github.com/zhuowei/666c7e6d21d842dbb8b723e96164d9c3), which was the base for `pdz.py` - [Scratchminer](https://github.com/scratchminer) for their further reverse-engineering work on the Playdate's [file formats](https://github.com/scratchminer/pd-emu), streaming protocol and [Lua implementation](https://github.com/scratchminer/lua54). - [Simon](https://github.com/simontime) for helping with some ADPCM audio data reverse engineering - The folks at [Panic](https://panic.com/) for making such a wonderful and fascinating handheld! ---- 2022-2023 James Daniel Playdate is © [Panic Inc.](https://panic.com/) - this project isn't affiliated with or endorsed by them in any way. ================================================ FILE: server/api.md ================================================ This is the API that is used by the Playdate console for things like fetching game updates, scoreboards, player details, etc. It is available under `https://play.date/api/v2`. All API endpoints require [auth headers](#auth-headers). This list of endpoints was obtained by decompiling the Playdate Simulator app. Some haven't actually been seen in use, so they are only partially documented and there may be mistakes. ## Endpoints | Method | Path | |:-|:-| | `POST` | [`/auth_echo/`](#post-auth_echo) | | `GET` | [`/player/`](#get-player) | | `GET` | [`/player/:playerId/`](#get-playerplayerid) | | `POST` | `/player/avatar/` | | `GET` | [`/games/scheduled/`](#get-gamesscheduled) | | `GET` | [`/games/user/`](#get-gamesuser) | | `GET` | `/games/testing/` | | `GET` | [`/games/system/`](#get-gamessystem) | | `GET` | [`/games/purchased/`](#get-gamespurchased) | | `GET` | [`/games/catalog`](#get-gamescatalog) | | `GET` | [`/games/catalog/:idx`](#get-gamescatalogidx) | | `POST` | [`/games/:bundleId/purchase/`](#post-gamesbundleidpurchase) | | `POST` | [`/games/:bundleId/purchase/confirm`](#post-gamesbundleidpurchaseconfirm) | | `GET` | `/games/:bundleId/latest_build/` | | `GET` | `/games/:bundleId/boards/` | | `GET` | `/games/:bundleId/boards/:boardId/` | | `POST` | `/games/:bundleId/boards/:boardId/` | | `GET` | `/device/settings/` | ### POST /auth_echo Seems to just return whatever JSON body is sent to it. ### GET /player Returns the player profile for the user that owns the current access token. ### GET /player/:playerId Same as `/player`, but gets the player profile for another user, given their [Player ID](#player-id). ### GET /games/scheduled Returns an array of [Schedule](#Schedule) entries for any seasons that you have access to. ### GET /games/user Returns an array of [Game](#Game) entries for games that you have [sideloaded](https://help.play.date/games/sideloading/). ### GET /games/system Returns an array of [Game](#Game) entries for additional system applications, such as [Catalog](https://play.date/games/catalog/). ### GET /games/purchased Returns an array of [Game](#Game) entries for games that you have purchased through [Catalog](https://play.date/games/catalog/). ### GET /games/catalog Returns an array of [Catalog Game](#CatalogGame) entries for games that are available through [Catalog](https://play.date/games/catalog/). ### GET /games/catalog/:idx Returns [Catalog Game](#CatalogGame) entry for a specific [Catalog](https://play.date/games/catalog/) game. ### POST /games/:bundleId/purchase/ Initiates the purchase flow for a game. Returns instructions for confirming the purchase on another device. ### POST /games/:bundleId/purchase/confirm Completes the purchase flor for a game. ### GET /device/register/:serialNumber If the device hasn't already been registered, returns a JSON containing its serial number and pin. This endpoint requires an extra header: | Header | Value | |:-|:-| | `Idempotency-Key` | Random 16-character string. Allowed chars are `0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz`. | ### GET /device/register/:serialNumber/complete Returns a JSON with the device's registered status, access token, and serial number. Access token will only be available on the first request to this endpoint after registering the device. ## Schemas ### Schedule | Key | Type | Detail | |:----|:------|:------| | `name` | string | Schedule name (Season One is `Season-001`) | | `start_date` | string | Schedule start datetime, formatted as `E MMM d HH:mm:ss yyyy zzz` (e.g. `Mon Apr 18 00:00:00 2022 PDT`) | | `start_date_timestamp` | number | Schedule start as a UNIX timestamp | | `next_release_timestamp` | number | Time of next scheduled release as a UNIX timestamp, can be `null` | | `ended` | boolean | | | `games` | array | Array of available [Game](#Game) entries | ### Game | Key | Type | Detail | |:----|:------|:------| | `name` | string | Game name, will be displayed to the user | | `bundle_id` | string | Reverse-domain formatted bundle ID (e.g `com.jaames.playnote`) | | `short_description` | string | Few games currently have this (only seen on Flipper Lifter and Boogie Loops so far), often `null` | | `studio` | string | Game's publisher/developer | | `has_newer_build` | boolean | | | `decryption_key` | string | Only present for purchased games, otherwise `null`. Unclear what this is actually used for - at the time of writing purchased games do not appear to be encrypted and the key changes on each request. Seems to be base64-encoded. | | `latest_build` | [Build](#Build) | | ### Build | Key | Type | Detail | |:----|:------|:------| | `url` | string | Web URL for the build's .zip file | | `is_beta` | boolean | | | `version` | string | Human-friendly version string, taken from the game's pdxinfo file | | `build_number` | number | Incremental build number from the game's pdxinfo | | `filesize` | number | .zip file size, in bytes | | `upzipped_filesize` | number | Size of the .zip contents after decompression, in bytes | ### Catalog Game | Key | Type | Detail | |:----|:------|:------| | `name` | string | Game name, will be displayed to the user | | `bundle_id` | string | Reverse-domain formatted bundle ID (e.g `com.jaames.playnote`) | | `studio` | string | Game's publisher/developer | | `description` | string | Game's description | | `detail_url` | string | Path for this game's [`/games/catalog/:idx`](#get-gamescatalogidx) endpoint | | `price` | number | Price in USD | | `header_image` | string | Path to .pdi image file | | `list_image_size` | string | `"small"` | | `list_image` | string | Path to .pdi image file | | `animation_frame_duration` | unknown | Seen as `null` | | `animation_frame_timing` | unknown | Seen as `null` | | `accessibility` | string | Game accessibility information | | `rating` | string | Game age rating | | `screenshots` | [Catalog Screenshot](#CatalogScreenshot)[] | Game screenshots | | `build_size` | string | Download size, e.g. `"22.8 MB"` | | `published_date` | string | | | `updated_date` | string | | | `authorized` | boolean | | | `purchasable` | boolean | | | `short_description` | string | | | `web_url` | string | URL for this game on the Catalog web storefront | | `purchase_url` | string | Path for this game's [`/games/:bundleId/purchase/`](#post-gamesbundleidpurchase) | ### Catalog Screenshot | Key | Type | Detail | |:----|:------|:------| | `url` | string | Path to .pdi image file | | `frame_timing` | number[] | | ## Auth Headers All routes require a basic authorization token sent via a HTTP header. | Header | Value | |:-|:-| | `Authorization` | `Token ` followed by your authorization token | ### Simulator Tokens If you have a developer account on [play.date](//play.date), you can generate a simulator-only access token by going to `https://play.date/players/account/` and clicking 'register simulator'. It looks as though you're allowed to register up to 5 simulators at one time. Note that this will be relatively limited, as the simulator cannot. ## Player ID Player IDs seem to use the `DCE 1.1, ISO/IEC 11578:1996` variant of UUID v4, with dashes separating each section, e.g `XXXXXXXX-XXXX-4XXX-8XXX-XXXXXXXXXXXX`. ================================================ FILE: tools/pdex2elf.py ================================================ import argparse import hashlib import zlib if __name__ == '__main__': parser = argparse.ArgumentParser( prog='pdex2elf.py', description='Converts a Playdate pdex.bin file to an ELF file.' ) parser.add_argument('pdex', help='the path to the input pdex.bin') parser.add_argument('elf', help='the path to the output ELF file') args = parser.parse_args() with open(args.pdex, 'rb') as pdex: pdex_magic = pdex.read(12) if pdex_magic in [b'Playdate PDX', b'Playdate BIN']: pdex_flags = int.from_bytes(pdex.read(4), byteorder='little') if pdex_flags & 0x40000000: raise ValueError('the specified pdex.bin is encrypted') pdex_version = '2.0' pdex_checksum = pdex.read(16) pdex_filesz = int.from_bytes(pdex.read(4), byteorder='little') pdex_memsz = int.from_bytes(pdex.read(4), byteorder='little') pdex_entry = int.from_bytes(pdex.read(4), byteorder='little') pdex_relnum = int.from_bytes(pdex.read(4), byteorder='little') pdex_data = zlib.decompress(pdex.read()) else: pdex_entry = int.from_bytes(pdex_magic[:4], byteorder='little') - 0x6000000c pdex_filesz = int.from_bytes(pdex_magic[4:8], byteorder='little') - 0x6000000c pdex_memsz = int.from_bytes(pdex_magic[8:], byteorder='little') - 0x6000000c if pdex_entry < 0 or pdex_filesz < 0 or pdex_memsz < 0: raise ValueError('the specified file is not a pdex.bin') pdex_version = '1.0' pdex_magic = None pdex_flags = 0 pdex_relnum = 0 pdex_checksum = None pdex_data = pdex.read() print('pdex.bin info:') print(' Version: {}'.format(pdex_version)) if pdex_magic is not None: print(' File signature: {}'.format(pdex_magic.decode())) print(' Flags: {}'.format(pdex_flags)) if pdex_checksum is not None: print(' Declared checksum: {}'.format(pdex_checksum.hex())) print(' Computed checksum: {}'.format(hashlib.md5(pdex_data[:pdex_filesz]).hexdigest())) print(' Entry point: {}'.format(pdex_entry)) print(' File size: {}'.format(pdex_filesz)) print(' Memory size: {}'.format(pdex_memsz)) print(' Relocations: {}'.format(pdex_relnum)) with open(args.elf, 'wb') as elf: text_index = 1 text_addr = 0 text_offset = 0x10000 text_size = pdex_filesz bss_index = 2 bss_addr = (pdex_filesz + 3) & ~3 bss_offset = text_offset + bss_addr bss_size = pdex_memsz - pdex_filesz rel_text_index = 3 rel_text_offset = bss_offset rel_text_size = pdex_relnum * 8 symtab_index = 4 symtab_offset = (rel_text_offset + rel_text_size + 3) & ~3 symtab_size = 2 * 16 strtab_index = 5 strtab_data = b'\0' strtab_offset = symtab_offset + symtab_size strtab_size = len(strtab_data) shstrtab_index = 6 shstrtab_data = b'\0.text\0.bss\0.rel.text\0.symtab\0.strtab\0.shstrtab\0' shstrtab_offset = strtab_offset + strtab_size shstrtab_size = len(shstrtab_data) sh_offset = (shstrtab_offset + shstrtab_size + 3) & ~3 # ==== ELF header ==== # e_ident[EI_MAG0..EI_MAG3] elf.write(b'\x7fELF') # e_ident[EI_CLASS] elf.write(b'\x01') # ELFCLASS32 # e_ident[EI_DATA] elf.write(b'\x01') # ELFDATA2LSB # e_ident[EI_VERSION] elf.write(b'\x01') # EV_CURRENT # e_ident[EI_OSABI] elf.write(b'\x00') # ELFOSABI_SYSV # e_ident[EI_ABIVERSION] elf.write(b'\x00') # e_ident[EI_PAD..(EI_NIDENT - 1)] elf.write(b'\x00\x00\x00\x00\x00\x00\x00') # e_type elf.write(b'\x02\x00') # ET_EXEC # e_machine elf.write(b'\x28\x00') # EM_ARM # e_version elf.write(b'\x01\x00\x00\x00') # EV_CURRENT # e_entry elf.write(pdex_entry.to_bytes(4, byteorder='little')) # e_phoff elf.write(b'\x34\x00\x00\x00') # e_shoff elf.write(sh_offset.to_bytes(4, byteorder='little')) # e_flags elf.write(b'\x00\x04\x00\x05') # EF_ARM_EABI_VER5 | EF_ARM_ABI_FLOAT_HARD # e_ehsize elf.write(b'\x34\x00') # e_phentsize elf.write(b'\x20\x00') # e_phnum elf.write(b'\x01\x00') # e_shentsize elf.write(b'\x28\x00') # e_shnum elf.write(b'\x07\x00') # e_shstrndx elf.write(shstrtab_index.to_bytes(2, byteorder='little')) # ==== Program header ==== # p_type elf.write(b'\x01\x00\x00\x00') # PT_LOAD # p_offset elf.write(text_offset.to_bytes(4, byteorder='little')) # p_vaddr elf.write(b'\x00\x00\x00\x00') # p_paddr elf.write(b'\x00\x00\x00\x00') # p_filesz elf.write(pdex_filesz.to_bytes(4, byteorder='little')) # p_memsz elf.write(pdex_memsz.to_bytes(4, byteorder='little')) # p_flags elf.write(b'\x07\x00\x00\x00') # PF_X | PF_W | PF_R # p_align elf.write(text_offset.to_bytes(4, byteorder='little')) # ==== .text section ==== elf.write(b'\x00' * (text_offset - elf.tell())) elf.write(pdex_data[:pdex_filesz]) # ==== .rel.text section ==== elf.write(b'\x00' * (rel_text_offset - elf.tell())) for i in range(pdex_filesz, pdex_filesz + 4 * pdex_relnum, 4): # r_offset elf.write(pdex_data[i:(i + 4)]) # r_info elf.write(b'\x02') elf.write(text_index.to_bytes(2, byteorder='little')) elf.write(b'\x00') # ==== .symtab section ==== elf.write(b'\x00' * (symtab_offset - elf.tell())) # NULL # st_name elf.write(b'\x00\x00\x00\x00') # st_value elf.write(b'\x00\x00\x00\x00') # st_size elf.write(b'\x00\x00\x00\x00') # st_info elf.write(b'\x00') # STB_LOCAL, STT_NOTYPE # st_other elf.write(b'\x00') # st_shndx elf.write(b'\x00\x00') # .text # st_name elf.write(text_addr.to_bytes(4, byteorder='little')) # st_value elf.write(b'\x00\x00\x00\x00') # st_size elf.write(b'\x00\x00\x00\x00') # st_info elf.write(b'\x03') # STB_LOCAL, STT_SECTION # st_other elf.write(b'\x00') # st_shndx elf.write(text_index.to_bytes(2, byteorder='little')) # ==== .strtab section ==== elf.write(strtab_data) # ==== .shstrtab section ==== elf.write(shstrtab_data) # ==== Section headers ==== elf.write(b'\x00' * (sh_offset - elf.tell())) # NULL # sh_name elf.write(b'\x00\x00\x00\x00') # sh_type elf.write(b'\x00\x00\x00\x00') # SHT_NULL # sh_flags elf.write(b'\x00\x00\x00\x00') # sh_addr elf.write(b'\x00\x00\x00\x00') # sh_offset elf.write(b'\x00\x00\x00\x00') # sh_size elf.write(b'\x00\x00\x00\x00') # sh_link elf.write(b'\x00\x00\x00\x00') # sh_info elf.write(b'\x00\x00\x00\x00') # sh_addralign elf.write(b'\x00\x00\x00\x00') # sh_entsize elf.write(b'\x00\x00\x00\x00') # .text # sh_name elf.write((shstrtab_data.index(b'\0.text\0') + 1).to_bytes(4, byteorder='little')) # sh_type elf.write(b'\x01\x00\x00\x00') # SHT_PROGBITS # sh_flags elf.write(b'\x37\x00\x00\x00') # SHF_WRITE | SHF_ALLOC | SHF_EXECINSTR | SHF_MERGE | SHF_STRINGS # sh_addr elf.write(text_addr.to_bytes(4, byteorder='little')) # sh_offset elf.write(text_offset.to_bytes(4, byteorder='little')) # sh_size elf.write(text_size.to_bytes(4, byteorder='little')) # sh_link elf.write(b'\x00\x00\x00\x00') # sh_info elf.write(b'\x00\x00\x00\x00') # sh_addralign elf.write(b'\x08\x00\x00\x00') # sh_entsize elf.write(b'\x00\x00\x00\x00') # .bss # sh_name elf.write((shstrtab_data.index(b'\0.bss\0') + 1).to_bytes(4, byteorder='little')) # sh_type elf.write(b'\x08\x00\x00\x00') # SHT_NOBITS # sh_flags elf.write(b'\x03\x00\x00\x00') # SHF_WRITE | SHF_ALLOC # sh_addr elf.write(bss_addr.to_bytes(4, byteorder='little')) # sh_offset elf.write(bss_offset.to_bytes(4, byteorder='little')) # sh_size elf.write(bss_size.to_bytes(4, byteorder='little')) # sh_link elf.write(b'\x00\x00\x00\x00') # sh_info elf.write(b'\x00\x00\x00\x00') # sh_addralign elf.write(b'\x04\x00\x00\x00') # sh_entsize elf.write(b'\x00\x00\x00\x00') # .rel.text # sh_name elf.write((shstrtab_data.index(b'\0.rel.text\0') + 1).to_bytes(4, byteorder='little')) # sh_type elf.write(b'\x09\x00\x00\x00') # SHT_REL # sh_flags elf.write(b'\x40\x00\x00\x00') # SHF_INFO_LINK # sh_addr elf.write(b'\x00\x00\x00\x00') # sh_offset elf.write(rel_text_offset.to_bytes(4, byteorder='little')) # sh_size elf.write(rel_text_size.to_bytes(4, byteorder='little')) # sh_link elf.write(symtab_index.to_bytes(4, byteorder='little')) # sh_info elf.write(text_index.to_bytes(4, byteorder='little')) # sh_addralign elf.write(b'\x04\x00\x00\x00') # sh_entsize elf.write(b'\x08\x00\x00\x00') # .symtab # sh_name elf.write((shstrtab_data.index(b'\0.symtab\0') + 1).to_bytes(4, byteorder='little')) # sh_type elf.write(b'\x02\x00\x00\x00') # SHT_SYMTAB # sh_flags elf.write(b'\x00\x00\x00\x00') # sh_addr elf.write(b'\x00\x00\x00\x00') # sh_offset elf.write(symtab_offset.to_bytes(4, byteorder='little')) # sh_size elf.write(symtab_size.to_bytes(4, byteorder='little')) # sh_link elf.write(strtab_index.to_bytes(4, byteorder='little')) # sh_info elf.write(b'\x02\x00\x00\x00') # sh_addralign elf.write(b'\x04\x00\x00\x00') # sh_entsize elf.write(b'\x10\x00\x00\x00') # .strtab # sh_name elf.write((shstrtab_data.index(b'\0.strtab\0') + 1).to_bytes(4, byteorder='little')) # sh_type elf.write(b'\x03\x00\x00\x00') # SHT_STRTAB # sh_flags elf.write(b'\x00\x00\x00\x00') # sh_addr elf.write(b'\x00\x00\x00\x00') # sh_offset elf.write(strtab_offset.to_bytes(4, byteorder='little')) # sh_size elf.write(strtab_size.to_bytes(4, byteorder='little')) # sh_link elf.write(b'\x00\x00\x00\x00') # sh_info elf.write(b'\x00\x00\x00\x00') # sh_addralign elf.write(b'\x01\x00\x00\x00') # sh_entsize elf.write(b'\x00\x00\x00\x00') # .shstrtab # sh_name elf.write((shstrtab_data.index(b'\0.shstrtab\0') + 1).to_bytes(4, byteorder='little')) # sh_type elf.write(b'\x03\x00\x00\x00') # SHT_STRTAB # sh_flags elf.write(b'\x00\x00\x00\x00') # sh_addr elf.write(b'\x00\x00\x00\x00') # sh_offset elf.write(shstrtab_offset.to_bytes(4, byteorder='little')) # sh_size elf.write(shstrtab_size.to_bytes(4, byteorder='little')) # sh_link elf.write(b'\x00\x00\x00\x00') # sh_info elf.write(b'\x00\x00\x00\x00') # sh_addralign elf.write(b'\x01\x00\x00\x00') # sh_entsize elf.write(b'\x00\x00\x00\x00') print('') print("ELF file successfully written to '{}'".format(args.elf)) ================================================ FILE: tools/pdi2png.py ================================================ #!/usr/bin/env python3 # PDI to PNG converter # PDI docs: https://github.com/jaames/playdate-reverse-engineering/blob/main/formats/pdi.md from struct import unpack, pack from zlib import compress, crc32, decompress from argparse import ArgumentParser PDI_IDENT = b'Playdate IMG' PNG_SIGNATURE = b'\x89PNG\r\n\x1a\n' def png_chunk(chunk_type, data): chunk = chunk_type + data return pack('>I', len(data)) + chunk + pack('>I', crc32(chunk) & 0xffffffff) def write_png(path, width, height, rows, has_alpha): # color type: 0 = grayscale, 4 = grayscale+alpha color_type = 4 if has_alpha else 0 ihdr = pack('>IIBBBBB', width, height, 8, color_type, 0, 0, 0) # build raw image data: filter byte (0=none) + row pixels raw = bytearray() for row in rows: raw.append(0) # filter: none raw.extend(row) with open(path, 'wb') as f: f.write(PNG_SIGNATURE) f.write(png_chunk(b'IHDR', ihdr)) f.write(png_chunk(b'IDAT', compress(bytes(raw)))) f.write(png_chunk(b'IEND', b'')) def read_cell(data, offset): clip_width, clip_height, stride, clip_left, clip_right, clip_top, clip_bottom, flags = \ unpack('<8H', data[offset:offset + 16]) offset += 16 has_alpha = (flags & 0x3) > 0 # read color bitmap (1-bit, 0=black 1=white) color_size = stride * clip_height color_data = data[offset:offset + color_size] offset += color_size # read alpha bitmap if present (1-bit, 0=transparent 1=opaque) alpha_data = None if has_alpha: alpha_data = data[offset:offset + color_size] offset += color_size # reconstruct full image dimensions full_width = clip_left + clip_width + clip_right full_height = clip_top + clip_height + clip_bottom # build pixel rows rows = [] for y in range(full_height): if has_alpha: row = bytearray(full_width * 2) # grayscale + alpha per pixel else: row = bytearray(b'\xff' * full_width) # default white cy = y - clip_top if 0 <= cy < clip_height: row_offset = cy * stride for x in range(clip_width): byte_index = row_offset + (x // 8) bit_index = 7 - (x % 8) color_bit = (color_data[byte_index] >> bit_index) & 1 color = 255 if color_bit else 0 px = clip_left + x if has_alpha: alpha_bit = (alpha_data[byte_index] >> bit_index) & 1 alpha = 255 if alpha_bit else 0 row[px * 2] = color row[px * 2 + 1] = alpha else: row[px] = color rows.append(row) return full_width, full_height, rows, has_alpha def convert_pdi(input_path, output_path): with open(input_path, 'rb') as f: data = f.read() # verify ident ident = data[0:12] if ident != PDI_IDENT: raise ValueError(f'Not a valid PDI file (got ident {ident!r})') flags = unpack(' 0 offset = 16 if is_compressed: decompressed_size, width, height, reserved = unpack('<4I', data[offset:offset + 16]) offset += 16 image_data = decompress(data[offset:]) else: image_data = data[offset:] full_width, full_height, rows, has_alpha = read_cell(image_data, 0) write_png(output_path, full_width, full_height, rows, has_alpha) print(f'Saved {output_path} ({full_width}x{full_height})') if __name__ == '__main__': parser = ArgumentParser(description='Convert Playdate .pdi images to .png') parser.add_argument('input', help='Input .pdi file path') parser.add_argument('-o', '--output', help='Output .png file path (default: input with .png extension)') args = parser.parse_args() output = args.output if not output: if args.input.lower().endswith('.pdi'): output = args.input[:-4] + '.png' else: output = args.input + '.png' convert_pdi(args.input, output) ================================================ FILE: tools/pdz.py ================================================ # based on https://gist.github.com/zhuowei/666c7e6d21d842dbb8b723e96164d9c3 # PDZ docs: https://github.com/jaames/playdate-reverse-engineering/blob/main/formats/pdz.md from sys import exit from os import path, makedirs from struct import pack, unpack from zlib import decompress from argparse import ArgumentParser PDZ_IDENT = b'Playdate PDZ' FILE_TYPES = { 1: 'luac', 2: 'pdi', 3: 'pdt', 4: 'pdv', 5: 'pda', 6: 'pds', 7: 'pft', } FILE_IDENTS = { 'pdi': b'Playdate IMG', 'pdt': b'Playdate IMT', 'pdv': b'Playdate VID', 'pda': b'Playdate AUD', 'pds': b'Playdate STR', 'pft': b'Playdate FNT' } class PlaydatePdz: @classmethod def open(cls, path): with open(path, "rb") as buffer: return cls(buffer) def __init__(self, buffer): self.buffer = buffer self.entries = {} self.num_entries = 0 self.read_header() self.read_entries() def read_header(self): self.buffer.seek(0) magic = self.buffer.read(16) magic = magic[:magic.index(b'\0')] # trim null bytes assert magic == PDZ_IDENT, 'Invalid PDZ file ident' self.buffer.seek(12) flags = unpack(' 0 assert not is_encrypted, 'PDZ file is encrypted' def read_string(self): res = b'' while True: char = self.buffer.read(1) if char == b'\0': break res += char return res.decode() def read_entries(self): self.buffer.seek(0, 2) ptr = 0x10 pdz_len = self.buffer.tell() self.buffer.seek(ptr) while ptr < pdz_len: head = unpack('> 8) & 0xFFFFFF # doesn't seem to be any other flags is_compressed = (flags >> 7) & 0x1 file_type = FILE_TYPES[flags & 0xF] # file name is a null terminated string file_name = self.read_string() # align offset to next nearest multiple of 4 self.buffer.seek((self.buffer.tell() + 3) & ~3) # .pda files have two more values after filename before data if file_type == 'pda': entry_len -= 4 audio_info = unpack('> 24) & 0xFF # if compression flag is set, there's another uint32 with the decompressed size if is_compressed: decompressed_size = unpack('> 7) & 0x1 innerlen = data[ptr + 1] | (data[ptr + 2] << 8) filename = data[ptr + 4 : data.find(b"\0", ptr + 4)] outerheadersize = 4 + len(filename) + 1 outerheadersize = ((ptr + outerheadersize + 3) & ~3) - ptr zlibdata = data[ptr + outerheadersize + 4: ptr + outerheadersize + innerlen] if filename.decode('utf-8') == entry: return decompress(zlibdata) ptr += outerheadersize + innerlen return None def usb_connect(): # find our playdate device device = usb.core.find(idVendor=PLAYDATE_VID, idProduct=PLAYDATE_PID) if device is None: raise ValueError('Device not found') # set the active configuration. With no arguments, the first # configuration will be the active one device.set_configuration() # get an endpoint instance cfg = device.get_active_configuration() intf = cfg[(1,0)] epOut = usb.util.find_descriptor( intf, # match the first OUT endpoint custom_match = \ lambda e: \ usb.util.endpoint_direction(e.bEndpointAddress) == \ usb.util.ENDPOINT_OUT) epIn = usb.util.find_descriptor( intf, # match the first IN endpoint custom_match = \ lambda e: \ usb.util.endpoint_direction(e.bEndpointAddress) == \ usb.util.ENDPOINT_IN) assert epOut is not None assert epIn is not None device.reset() return epOut, epIn def usb_read_bytes(endPoint): res = bytearray() has_started = False while True: try: b = bytearray(epIn.read(IN_SIZE)) res += b if b != b'': has_started = True if has_started and b == b'': break except usb.core.USBTimeoutError: break return res with tempfile.NamedTemporaryFile(prefix='main', suffix='.lua') as luafile, tempfile.TemporaryDirectory(suffix='.pdx') as pdxdir: # copy lua file print('reading input file') with open(argv[1], 'rb') as infile: luafile.write(infile.read()) luafile.seek(0) # compile lua with pdc print('compiling lua with pdc') subprocess.run(['pdc', luafile.name, pdxdir]) luastem = Path(luafile.name).stem # extract lua bytecode from pdz print('extracting lua bytecode') with open(Path(pdxdir, luastem + '.pdz'), 'rb') as pdzfile: pdz = pdzfile.read() bytecode = pdz_extract_entry(pdz, luastem) # connect to playdate over usb print('finding playdate connected to usb...') epOut, epIn = usb_connect() print('successfully connected to playdate!') # set usb echo mode to off print('setting usb echo to off') epOut.write('echo off\n') resp = usb_read_bytes(epIn) # get version info (to test things work, but also looks cool :^)) # print('playdate version info:') # epOut.write('version\n') # resp = usb_read_bytes(epIn) # print(resp.decode("utf-8").strip()) # consume printed console content until there's nothing new print('clearing current console data') epOut.write('eval\n') sleep(.2) usb_read_bytes(epIn) # send lua bytecode to the device print('sending payload for device to eval...') header = b'eval %d\n' % len(bytecode) payload = header + bytecode epOut.write(payload) sleep(.2) # payload seems to take a bit to execute, you may need to adjust this if you have a big payload resp = usb_read_bytes(epIn) print('===============') print('console output:') print(resp.decode("utf-8").strip()) # keep polling for new console output while True: try: sleep(.1) resp = usb_read_bytes(epIn) text = resp.decode("utf-8").strip() if text: print(text) except KeyboardInterrupt: break ================================================ FILE: usb/stream.md ================================================ # The `stream` protocol When `stream enable` is sent over serial, the Playdate enters streaming mode, used by Playdate Mirror. In this mode, the device continuously reports the state of its screen, audio engine, buttons, and crank over serial, using a special protocol to do so. (As with all Playdate formats, all numbers reported using the protocol are little-endian.) ### Entering, exiting, and maintaining streaming mode If the Playdate has been in streaming mode for over one second without a `stream poke` command sent over USB, it will stop reporting data. However, it won't completely exit streaming mode -- any future `stream enable` will *not* resend the entire screen afterward. To exit streaming mode, send `stream disable` via serial. ### Stream messages While the Playdate is streaming, it will report data in short messages. The general format of one of these messages is: | **Offset** | **Data type** | **Content** | |:-----|:-----|:-----| | 0 | `uint16` | Message type (see below) | | 2 | `uint16` | Payload length in bytes | The actual payload data follows this 4-byte header. ### Audio control To control how audio is reported, there are three options you can send during streaming mode. (The format the samples are parsed with isn't actually changed until the format switch is acknowledged with a stream message!) | **Command** | **Meaning** | |:-----|:-----| | `stream a+` | Switch to stereo signed 16-bit PCM audio | | `stream am` | Switch to mono signed 16-bit PCM audio | | `stream a-` | Don't send any audio (the default) | ## Message types ### `0x0001`: Input state Reports the state of the Playdate's buttons and crank every frame. | **Payload offset** | **Data type** | **Content** | |:-----|:-----|:-----| | 0 | `uint16` | Button flags (see below) | | 2 | `uint16` | Unknown: Seems to change erratically | | 4 | `float` | Crank angle in degrees | #### Button flags | **Bitmask** | **Meaning** | |:-----|:-----| | `flags & 0x0001` | If `> 0`, d-pad left button is pressed | | `flags & 0x0002` | If `> 0`, d-pad right button is pressed | | `flags & 0x0004` | If `> 0`, d-pad up button is pressed | | `flags & 0x0008` | If `> 0`, d-pad down button is pressed | | `flags & 0x0010` | If `> 0`, B button is pressed | | `flags & 0x0020` | If `> 0`, A button is pressed | | `flags & 0x0040` | If `> 0`, Menu button is pressed | ### `0x000A`: New frame (no delay) Starts a new frame as fast as possible, with no delay from the previous frame. ### `0x000B`: End frame Ends the current frame. In the official Mirror app, this flips the framebuffer to make it visible on-screen. ### `0x000C`: Update screen line Signals that a single horizontal line of the display was updated. Only the lines that have changed since the last frame are sent, unless the current frame is the first one. In that case, the entire screen is sent as 240 line update messages. | **Payload offset** | **Data type** | **Content** | |:-----|:-----|:-----| | 0 | `uint8` | Line number that was updated (starting at one and bit-reversed, so line 0 becomes `0b10000000 == 0x80`) | | 1 | `50 bytes` | Line data, left to right, with the least significant bit coming first in each byte | | 51 | `uint8` | Zero byte to pad the length to a multiple of 4 bytes | ### `0x000D`: New frame (with delay) Starts a new frame a certain amount of time since the previous frame started. | **Payload offset** | **Data type** | **Content** | |:-----|:-----|:-----| | 0 | `uint32` | Milliseconds since the start of the previous frame | ### `0x0014`: Audio frames (multiple) Sends one or multiple audio frames to buffer for playback. The data format is signed 16-bit PCM, with the left channel of each sample coming first. (If the audio format is mono, then the right channel will be zero.) ### `0x0015`: Audio format switch acknowledge Signals that a requested change in audio formats has taken place. | **Payload offset** | **Data type** | **Content** | |:-----|:-----|:-----| | 0 | `uint16` | Audio format flags (see below) | #### Audio format flags | **Bitmask** | **Meaning** | |:-----|:-----| | `flags & 0x0001` | If `> 0`, audio is enabled | | `flags & 0x0002` | If `> 0`, audio has two channels | ### `0x0016`: Audio frames (fill single) Sends one audio sample to continuously play until more data is received. This usually denotes silence. Like with type `0x0014`, the data format is signed 16-bit PCM, with the left channel of the sample coming first. ================================================ FILE: usb/usb.md ================================================ When connected to USB and unlocked, the Playdate provides a serial interface over USB. Commands are sent as ascii and must end in a newline character (`\n`). If the currently running game logs something to the console (e.g. via `print()` in Lua, or `playdate->system->logToConsole` in C) this will also be sent via serial output. Some commands (such as `button` or `stream enable`) will cause the Playdate to continually send data until another command is sent to cancel it, other commands (for example, `bitmap`) may require extra binary data to be sent after the newline character. Some of these commands are used by Playdate Simulator for features like "preview bitmap" or "run pdx", or to enable streaming in Playdate mirror. If you want to play around with these, and you use a browser that supports WebSerial, you should check out my [pd-usb](https://github.com/jaames/pd-usb) library. ## USB details ||| |:----------------|:------| | *USB Vendor ID* | `0x1331` | | *USB Product ID* | `0x5740` | | *Baud rate* | `115200` | ## Connecting to the USB You can use any terminal emulator to connect to the virtual serial port. For example, on macOS, you can connect using picocom by running: `picocom -b 115200 -p n -d 8 --omap crcrlf /dev/cu.usbmodemPD` When you're done, disconnect and quit picocom with `CTRL-a` `CTRL-x` ## USB commands Running `help` will return a very helpful list of available commands: ``` The following commands are available: Telnet commands: help Displays all available commands or individual help on each command CPU Control: serialread Print the device serial number trace trace_. (trace 10) stoptrace stoptrace bootdisk reboot into recovery segment USB disk datadisk reboot into data segment USB disk factoryreset factory reset formatdata format data disk settime sets the RTC. format is ISO8601 plus weekday (1=mon) e.g.: 2018-03-20T19:58:29Z 2 gettime reads the RTC vbat get battery voltage rawvbat get raw battery adc value batpct get battery percentage temp get estimated ambient temperature dcache dcache : turn dcache on or off icache icache : turn icache on or off Runtime control: echo echo (on|off): turn console echo on or off buttons Test buttons & crank tunebuttons tunebuttons btn btn : simulate a button press. +a/-a/a for down/up/both changecrank changecrank +- dockcrank simulates crank docking enablecrank Reenables crank updates disablecrank Disables crank updates accel simulate accelerometer change screen Dump framebuffer data (400x240 bits) bitmap Send bitmap to screen (followed by 400x240 bits) controller start or stop controller mode eval execute a compiled Lua function run run : Run the named program luatrace Get a Lua stack trace stats Display runtime stats autolock autolock version Display build target and SDK version memstats memstats hibernate hibernate Stream: stream stream ESP functions: espreset reset the ESP chip espoff turn off the ESP espbootlog get the ESP startup log espfile espfile
: add the given file to the upload list. If is added then the file is assumed to be compressed. espflash espflash [0|1] send the files listed with the espfile command to the ESP flash. espbaud espbaud [cts] esp esp : Forward a command to the ESP firmware, read until keypress Firmware Update: fwup fwup [bundle_path] ``` Secret commands: ``` formatboot unlock islocked ``` Most of these commands are self-explanatory, so I will just detail some of the interesting/different ones. Please note these were run on an older Playdate Developer Preview unit, so there may be some differences with the final units that get shipped to the public. ### `version` Example output: ``` ~version: target=DVT1 build=c4abdb37253e-1.7.0-release.127473-buildbot-20211215_200649 boot_build=c4abdb37253e-1.7.0-release.127473-buildbot SDK=1.7.0 pdxversion=10500 serial#= cc=9.2.1 20191025 (release) [ARM/arm-9-branch revision 277599] ``` ### `stats` Example output: ``` ~stats: frame count: 194503 frame time: 0.000977 gc time: 0.016602 disp time: 18 current time: 9855691 mem alloced: 403288 mem reserved: 460448 mem total: 16645684 kernel: 0.1% serial: 0.0% game: 2.5% GC: 35.2% wifi: 0.0% trace: 0.0% audio: 0.2% ``` ### `buttons` Begins button-testing mode causes the device to begin continually writing the current control state to USB bulk in, at approximately 50 times per second. This can be stopped by sending a newline to the device. Each new state will be written as a single line with the following structure: ``` buttons:XX XX XX crank:X.X docked:X ``` `button` gives three hex-formatted numbers containing the current button state. The first number indicates which buttons are currently pressed, the second indicates which buttons were pressed after the last update, and the third indicates which buttons were released after the last update. These should be treated as bitflags: | Button | Bitmask | |:-------|:--------| | a | `0x01` | | b | `0x02` | | up | `0x04` | | down | `0x08` | | left | `0x10` | | right | `0x20` | | menu | `0x40` | | lock | `0x80` | `crank` gives the crank angle as a floating point number, measured in degrees, with `0` being the 12 o'clock position. `docked` will be `0` if the crank is not docked, or `1` if docked. ### `screen` Gets the current screen buffer as a 1-bit array of pixels. The data returned from the Playdate will begin with an 11-byte string `\r\nscreen~:\n`, followed by 12000 bytes of bitmap data where each bit of every byte represents one pixel; `0` for black, `1` for white. ### `bitmap` Sends a 1-bit bitmap to be previewed on the Playdate screen. The command must begin with a 7-byte command string `bitmap\n`, followed by 12000 bytes of bitmap data where each bit of every byte represents one pixel; `0` for black, `1` for white. ### `run` Launches a .pdx rom from the Playdate's data partition. The game path must begin with a forward slash, e.g `run /System/Crayons.pdx`. ### `eval` Evaluates a compiled Lua function on the device. The command must begin with the string `eval %d\n` where `%d` is the length of the data to eval. This should then be followed by the data for a compiled Lua function. You can use the `usbeval.py` script in this repo's `tools` directory to play around with this. *Note: `eval` doesn't work in System apps (Launcher, Settings, etc) nor games purchased from the Catalog. You must remove `hash` from the purchased game in order to allow for eval.* This command will not work if the currently loaded game is from the System directory on the device, presumably for security reasons. ### `stream` Used for interacting with the Playdate's [video/audio streaming protocol](/usb/stream.md), as used by Playdate Mirror. ### `esp` Using the `esp ` command will forward an [ESP-AT](https://docs.espressif.com/projects/esp-at/en/latest/Get_Started/What_is_ESP-AT.html) command to the ESP32 firmware. Please note that these commands can potentially be very dangerous and may even damage your Playdate, so only mess with this stuff if you're stupid (me) or know what you're doing (not me). In the event that you do goof something up, you may be able to recover by holding down the Playdate's secret power/reset button, which is hidden in the cavity where the crank handle goes when it's docked. Have paperclips on standby! After sending an ESP-AT command, you need to continue reading from the device until you receive a line that contains `OK` or `ERROR` to get the full response. I can't profess to be very experienced here, so I didn't poke to deeply. However here's the results of some of the commands I tried: [**`AT+GMR`**](https://docs.espressif.com/projects/esp-at/en/latest/AT_Command_Set/Basic_AT_Commands.html#at-gmr-check-version-information): Version information: ``` AT version:2.0.0.0-dev(b6850a4 - Oct 24 2019 12:10:13) SDK version:v3.3-beta3-170-g91f29bef17 compile time(e9c8abb):Dec 15 2021 20:08:07 ``` [**`AT+CMD?`**](https://docs.espressif.com/projects/esp-at/en/latest/AT_Command_Set/Basic_AT_Commands.html#at-cmd-list-all-at-commands-and-types-supported-in-current-firmware): Querying supported commands doesn't seem to be supported. [**`AT+UART_CUR?`**](https://docs.espressif.com/projects/esp-at/en/latest/AT_Command_Set/Basic_AT_Commands.html#at-uart-cur-current-uart-configuration-not-saved-in-flash): Current UART configuration: ``` +UART_CUR:2534653,8,1,0,3 ``` [**`AT+SYSFLASH?`**](https://docs.espressif.com/projects/esp-at/en/latest/AT_Command_Set/Basic_AT_Commands.html#at-sysflash-query-set-user-partitions-in-flash): Querying user partitions in ESP flash: ``` AT+SYSFLASH? +SYSFLASH:"ble_data",64,1,0x21000,0x3000 +SYSFLASH:"server_cert",64,2,0x24000,0x2000 +SYSFLASH:"server_key",64,3,0x26000,0x2000 +SYSFLASH:"server_ca",64,4,0x28000,0x2000 +SYSFLASH:"client_cert",64,5,0x2a000,0x2000 +SYSFLASH:"client_key",64,6,0x2c000,0x2000 +SYSFLASH:"client_ca",64,7,0x2e000,0x2000 +SYSFLASH:"factory_param",64,8,0x30000,0x1000 +SYSFLASH:"wpa2_cert",64,9,0x31000,0x2000 +SYSFLASH:"wpa2_key",64,10,0x33000,0x2000 +SYSFLASH:"wpa2_ca",64,11,0x35000,0x2000 +SYSFLASH:"mqtt_cert",64,12,0x37000,0x2000 +SYSFLASH:"mqtt_key",64,13,0x39000,0x2000 +SYSFLASH:"mqtt_ca",64,14,0x3b000,0x2000 +SYSFLASH:"fatfs",1,129,0x70000,0x90000 ``` Most partitions don't seem to be readable, however you can for example dump the client CA cert by doing `AT+SYSFLASH=2,"client_ca",0,0x2000`. [**`AT_FS`**](https://docs.espressif.com/projects/esp-at/en/latest/AT_Command_Set/Basic_AT_Commands.html#esp32-only-at-fs-filesystem-operations): No file system commands seem to be supported. ### `btn` The `btn` command can also send key presses and releases, which call the associated callbacks for the current game. Most keys on the computer keyboard have a single-byte representation in the `btn` command. In the case of printable characters, this corresponds to their ASCII codepoint, but other keys like arrows and function keys also have a number used by the Simulator. You can find a full set of the key codes used by the Simulator [here](https://gist.github.com/scratchminer/dcbf3410a7e72151ea68273adce9c932). For a key press event, send `btn vK`, where `K` is the key code byte. For a key release event, send `btn ^K`, again where `K` is the key code byte. ### `unlock` Takes a 32 character unlock code and compares it to the device's unlock code. If it matches, [additional commands](usb_unlocked.md) are enabled. The unlock code is likely unique to each device and is located in different memory regions depending on the playdate hardware revision. - As of firmware 1.10, HW revision A contains the key at address `0x1FF0F040`. - HW revision B contains the key at address `0x08FFF040`, located in the OTP region of the flash memory. The following code snippet may be used to dump your key. Use the memory location that corresponds to the HW revision you have. Even though the memory region is protected in unprivileged mode, in firmware 2.4.2, the write succeeds and then the console crashes. You may then find the key in the data drive. ```c SDFile* file = pd->file->open("unlockkey.txt", kFileWrite); pd->file->write(file, (const char*)0x1FF0F040, 0x20); pd->file->close(file); ``` ### `islocked` Prints `1` if the serial console is locked, or `0` if the device successfully ran `unlock`. ## Changes ### 2.0 - Added `rawvbat` command ### 1.12.3 (these commands were observed in 1.12.3, but may have been introduced earlier) - Added `factoryreset` command - Added `tunebuttons` command - Changed `autolock` to remove never option. ### 1.7.0 - Added the `hibernate` command. - `version` output: ``` ~version: target=DVT1 build=c4abdb37253e-1.7.0-release.127473-buildbot-20211215_200649 boot_build=c4abdb37253e-1.7.0-release.127473-buildbot SDK=1.7.0 pdxversion=10500 serial#= cc=9.2.1 20191025 (release) [ARM/arm-9-branch revision 277599] ``` ### 1.4.0 Initial version tested ================================================ FILE: usb/usb_unlocked.md ================================================ ## USB commands in unlocked mode This is the output of running `help` on a Playdate with firmware 1.10 after running `unlock`: ``` The following commands are available: Telnet commands: help Displays all available commands or individual help on each command loop loop next command x times with delay y ms (eg loop 10 100 help) lock Lock the firmware command interface eMMC kvstore: kvget kvget [len] kvput kvput kvrm kvrm kvwipe kvwipe CPU Control: gpio gpio . (gpio A3 1 or gpio B2 ipu) i2cread i2cread
. (i2cread 0 0x47 0x38 3) i2cwrite i2cwrite
. (i2cwrite 3 0x47 0x38 0xff) tlv493read tlv493read
. (tlv493read 0x47 3) tlv493write tlv493write
. (tlv493write 0x47 0x00 0x37 0x48) serialwrite serialwrite serialread Print the device serial number pwwrite pwwrite pwread pwread reset reset system dfu reset system trace trace_. (trace 10) stoptrace stoptrace bootdisk reboot into recovery segment USB disk datadisk reboot into data segment USB disk sysdisk reboot into system segment USB disk factoryreset factory reset formatboot format recovery disk formatdata format data disk formatsys format system disk shutdown put device in stop mode cleartime clear time from device settime sets the RTC. format is ISO8601 plus weekday (1=mon) e.g.: 2018-03-20T19:58:29Z 2 gettime reads the RTC rtccalib rtccalib : enable rtc calibration output vbat get battery voltage batpct get battery percentage temp get estimated ambient temperature peek peek : Read a 32-bit value from memory poke poke : Write a 32-bit value int memory dump dump : Dump memory in range [start-end) as bytes dumpw dumpw : Dump memory in range [start-end) as words stop stop cpu led led . (led 0-255 0-255 0-255) leddemo leddemo charge charge : enables/disables battery charging readcharger readcharger burn waste cpu to run down the battery lsedrive get LSE drive level extv get 5V_ext voltage dcache dcache : turn dcache on or off icache icache : turn icache on or off rcccsr report bootinfo.rcccsr value rdp rdp suspendusb sets USB current limit to 500uA eMMC Control: emmctest emmctest emmcinfo emmcinfo emmcwipe emmcwipe emmcdump emmcdump Audio Control: audiotest Send test data to audio output audiosweep audiosweep : Send sweep signal to audio output. Frequencies are (integer) Hz, length is milliseconds stopaudio Stop audio output startaudio Start audio output audioout audioout mictest mictest : Record microphone to given file. length is in milliseconds blowtest blowtest micbiastest micbiastest Encryption: encrypt encrypt decrypt decrypt Runtime control: echo echo (on|off): turn console echo on or off buttons Test buttons & crank tunebuttons tunebuttons btn btn : simulate a button press. +a/-a/a for down/up/both changecrank changecrank +- dockcrank simulates crank docking enablecrank Reenables crank updates disablecrank Disables crank updates accel simulate accelerometer change pause Pause execution resume Resume execution restart Restart Lua runtime step Step one frame fps fps to return current frame rate, fps to set screen Dump framebuffer data (400x240 bits) bitmap Send bitmap to screen (followed by 400x240 bits) lcdtest Draw a marching stripes pattern on the screen controller start or stop controller mode eval execute a compiled Lua function run run : Run the named program whatsrunning Returns the path of the currently running program luatrace Get a Lua stack trace stats Display runtime stats autolock autolock version Display build target and SDK version station station : Configure device for running at the named station setvolume setvolume :, 0-255 getvolume Returns the volume level 0-255 wifi wifi [file1] [file2] wifitest wifitest memtest memtest memstats memstats woprset woprset [server] woprget woprget syncperiod syncperiod [period_s] hibernate hibernate Filesystem Stuff: listfiles listfiles path: list files at path getfile getfile : get file contents at path putfile putfile : upload file to path mkdir mkdir delete delete rmdir rmdir : Recursively delete directory unzip unzip zip_path out_path md5 md5 memio commands: memiomd5 memiomd5 Stream: stream stream ESP functions: espreset reset the ESP chip espoff turn off the ESP espbootlog get the ESP startup log espfile espfile
: add the given file to the upload list. If is added then the file is assumed to be compressed. espflash espflash [0|1] send the files listed with the espfile command to the ESP flash. espbaud espbaud [cts] esp esp : Forward a command to the ESP firmware, read until keypress esptest esptest espwipe espwipe espversion espversion wifiperf wifiperf wifistop wifistop Firmware Update: fw fw fwup fwup [bundle_path] recovery recovery unstage unstage unzip test: unzipmem unzipmem Memfault Tests: hardfault Trigger a hardfault assert Trigger an assert mflt mflt ``` ## Changes ### 1.12.x - Added `factoryreset` - Added `tunebuttons` - Added `never` option for `autolock`