Every byte shown here comes from two files you can download and check yourself: mask-test.procreate (232 KB, one layer with a layer mask, saved in February 2026) and blend-modes.procreate (11.4 MB, one group per blend mode, saved in September 2022). Both are demo files from Silicate, an open-source Procreate viewer by An Tran, published under the MIT licence. Silicate’s parser is also the base of the one this site uses, so “Silicate does this” and “toPSD does this” are often the same code.
The container is a ZIP
$ unzip -l mask-test.procreate
Length Date Time Name
--------- ---------- ----- ----
1596 02-26-2026 09:21 5B28C772-E880-4A8A-A4FB-E9D5B46C7126/0~6.lz4
3118 02-26-2026 09:21 A925F0EE-1C42-48B2-8424-F7E1526D27D6/5~1.lz4
1608 02-26-2026 09:21 5B28C772-E880-4A8A-A4FB-E9D5B46C7126/4~4.lz4
1608 02-26-2026 09:19 1BDCB715-D4E4-4324-A581-2BF9F76D7C38/4~2.lz4
...
61253 02-26-2026 09:21 video/segments/segment-1.mp4
...
51702 02-26-2026 09:21 QuickLook/Thumbnail.png
...
5655 02-26-2026 09:21 Document.archive
...
--------- -------
396546 101 files
A .procreate file is an ordinary ZIP archive. It starts with the local file header signature 50 4b 03 04, and every entry in both samples is compressed with Deflate (method 8). The entries are:
Document.archive: the document itself, as a binary property list. 5,655 bytes in mask-test, 34,877 in blend-modes.QuickLook/Thumbnail.png: an RGBA preview, already turned the right way up. It is 1024 × 1024 in mask-test and 1024 × 888 in blend-modes. Some online “converters” return only this image.<UUID>/<column>~<row>.lz4or.chunk: one folder per layer and one file per 256-pixel tile.video/segments/segment-<n>.mp4: the time-lapse recording, in pieces.Signature/Signature.png: only in blend-modes. Probably the artist signature from Procreate’s settings; it is not a layer.
Entry order means nothing. Both samples start with a tile, and Document.archive is entry 92 of 101 in mask-test and entry 119 of 350 in blend-modes. Always look entries up by name.
The tiles are compressed once by Procreate and then again by the ZIP. The second pass still helps on flat areas: a plain white tile in mask-test is 262,144 bytes of pixels, 1,596 bytes of LZ4 and 65 bytes after Deflate. Across the 98 tiles in that file, Deflate takes 277,936 bytes down to 116,956.
toPSD also accepts a copy that was unzipped and zipped again by a Mac or a cloud drive, which moves everything one folder down (Name.procreate/Document.archive), as long as exactly one folder holds a Document.archive. A ZIP with no Document.archive but a Tracks/ folder or a .dreams entry is taken to be a Procreate Dreams project, which is a different format.
Document.archive is an NSKeyedArchiver plist
$ unzip -p mask-test.procreate Document.archive | xxd -l 32
00000000: 6270 6c69 7374 3030 d400 0100 0200 0300 bplist00........
00000010: 0400 0500 0600 0700 0a58 2476 6572 7369 .........X$versi
bplist00 is Apple’s binary property list. Inside it is an NSKeyedArchiver archive, the format Cocoa uses to save a graph of objects. The top level has four keys:
$archiver "NSKeyedArchiver"
$version 100000
$top {root: UID(1)}
$objects ["$null", {SilicaDocument}, "Mask Test File", "{2048, 2048}", ...]
All 55 objects in mask-test (392 in blend-modes) sit in the flat $objects array. A value that refers to another object is a UID, an index into that array, and UID(0) points at the string "$null", which means nil. Every object has a $class UID that leads to a {$classname, $classes} dictionary. Arrays are dictionaries with an NS.objects list of UIDs. The classes in both samples are SilicaDocument, SilicaLayer, SilicaGroup, ValkyrieColorProfile, ValkyrieDocumentAnimation, VideoSegmentInfo, NSArray, NSMutableArray and NSDictionary.
Python’s standard library reads it as it is:
import plistlib, zipfile
raw = zipfile.ZipFile("mask-test.procreate").read("Document.archive")
archive = plistlib.loads(raw)
objects = archive["$objects"]
def get(value):
return objects[value.data] if isinstance(value, plistlib.UID) else value
doc = get(archive["$top"]["root"])
print(get(doc["$class"])["$classname"], get(doc["size"]), doc["tileSize"], doc["orientation"])
# SilicaDocument {2048, 2048} 256 3
The root SilicaDocument has about 45 keys. These are the ones a reader needs:
| Key | In mask-test | Meaning |
|---|---|---|
size |
"{2048, 2048}" |
Canvas width and height, as a string, before rotation |
tileSize |
256 |
Tile edge in pixels |
orientation |
3 |
Canvas rotation, 1 to 4 (see below) |
flippedHorizontally, flippedVertically |
false |
Canvas flips |
backgroundColor |
16 bytes | RGBA as four little-endian float32 |
backgroundHidden |
false |
Background colour switched off |
colorProfile |
ValkyrieColorProfile |
ICC profile name and bytes |
SilicaDocumentArchiveDPIKey |
300.0 |
DPI |
unwrappedLayers |
NSArray |
The layer tree, top first |
layers |
NSMutableArray |
Every pixel layer in one flat list, no groups |
composite |
SilicaLayer, type 1 |
Procreate’s own flattened image |
mask |
SilicaLayer, type 2 |
A document-level mask, probably the selection |
SilicaDocumentVideoSegmentInfoKey |
VideoSegmentInfo |
Time-lapse settings |
The background colour in mask-test is 00 00 80 3f dd fb 7f 3f 00 00 80 3f 00 00 80 3f: 1.0, 0.99994, 1.0, 1.0, which is white with a rounding error in green. In blend-modes it is a teal (0, 0.671, 0.771) with backgroundHidden set, so that canvas is transparent. toPSD puts a visible background colour into the PSD as a bottom layer named “Background” and leaves it out when it is hidden.
To read the archive, toPSD uses silica, the parser crate from Silicate, copied in at commit 0e12deb with three changes: blend values it doesn’t know become Normal instead of failing the whole file (the raw number is kept), optional keys fall back to defaults, and group opacity is read.
Layers, groups and their order
A SilicaLayer has about 30 keys. The useful ones:
| Key | Meaning |
|---|---|
UUID |
Name of the folder that holds the tiles |
name |
Layer name, or nil |
type |
0 pixel layer, 1 composite, 2 document mask, 3 layer mask |
opacity, hidden |
Float 0 to 1, and a bool |
blend, extendedBlend |
Blend mode as an integer, stored twice (below) |
clipped |
Clipping mask onto the layer below |
mask |
Another SilicaLayer of type 3, or nil |
preserve, locked |
Alpha Lock and layer lock |
contentsRect |
The painted area as four float64 |
A SilicaGroup has name, children, isHidden, opacity, isClipped, isCollapsed, isLocked and isPrivate. It has no UUID, no blend key and no pixels of its own.
Index 0 is the top of the stack, in unwrappedLayers and inside every children array. In blend-modes, unwrappedLayers holds 27 items: a layer named “Layer 79”, then 26 groups named after blend modes, from “Divide” at the top down to “Normal”. Each group holds two layers, one of them set to that group’s mode. The flat layers list has all 53 pixel layers and no groups. Neither list contains the composite or the masks.
A PSD stores layer records bottom first, and a group is a run of records: a closing record named </Layer group> (section divider type 3), the children, then the folder record that carries the name and visibility (type 1, with the blend key pass, pass through, in its lsct block). toPSD walks Procreate’s tree from the end of each array to produce that order.
Tiles
Each layer is a grid of tileSize squares, with columns = ceil(width / 256) and rows = ceil(height / 256) taken from the stored size. A tile is named <column>~<row>, and row 0 is the bottom of the canvas. The rows of pixels inside each tile run bottom-up too, the way OpenGL and Core Graphics count.
Tiles on the right and top edges are stored at their real size, not padded to 256. blend-modes is 1836 × 2118, so its grid is 8 × 9, and tile 7~2.chunk of “Layer 79” decodes to 45,056 bytes, which is 44 × 256 × 4.
A tile with nothing painted on it has no file. “Layer 79” stores 45 of its 72 tiles; the single layer in mask-test stores 20 of 64.
toPSD flips each tile’s rows as it decodes it and places tile (column, row) at y = height − row × 256 − tile height, so everything after that step works top-down.
Two compressors, chosen per tile
The extension says how a tile is compressed: .chunk is LZO1X and .lz4 is LZ4 in Apple’s block framing. blend-modes, saved in 2022, has 325 tiles and all of them are LZO. mask-test, saved in 2026, has 98 and all of them are LZ4. Silicate’s demo folder also has two later saves of the blend-modes document: the 2023 one wrote only the composite as LZ4 and kept the other layers as LZO, and the 2026 one mixes both codecs, even inside one layer. Procreate seems to rewrite only the layers that changed, so a reader has to pick the decoder tile by tile.
.chunk: raw LZO1X
There is no header. The stream starts with the first LZO instruction and ends with the end-of-stream marker 11 00 00. Tile 7~0.chunk of one of the “Vivid Light” layers in blend-modes is 257 bytes and decodes to 45,056 (an edge tile, 44 px wide). Its first 99 bytes, and the last three:
02 00 00 00 00 00 literal run of 2 + 3 = 5 bytes, all zero
20 00 00 ... 00 96 match: length 31 + 2, plus 255 for each of the
89 zero bytes, plus 0x96 (150) = 22,878 bytes
01 00 distance 1: repeat the previous byte
...
11 00 00 end of stream
A match at distance 1 repeats the last byte, which is how empty areas compress. The first 22,883 decoded bytes are all zero: the bottom 130 rows of the tile, which are transparent, and three bytes of the next row. They take 99 bytes.
toPSD decodes LZO with the lzokay crate (MIT, pure Rust, decompression only). The licence matters here: the reference LZO library is GPL, as is the python-lzo package that two of the open-source Python readers depend on, and Silicate used a GPL binding until March 2026. The Linux kernel’s description of the LZO stream is enough to write a decoder from scratch. The one written to check this page is about 70 lines of Python, and it decodes all 325 LZO tiles in blend-modes to exactly the expected sizes.
.lz4: Apple’s LZ4 framing
Apple’s Compression framework wraps raw LZ4 blocks in small headers. This is the start of the white tile 0~6.lz4 from mask-test’s composite:
62 76 34 31 "bv41" a compressed block follows
00 00 01 00 65,536 decoded size, u32 little-endian
82 01 00 00 386 encoded size
1f ff 01 00 ... LZ4 data: one literal byte ff, then a long copy
from distance 1
... three more "bv41" blocks of 386 bytes
62 76 34 24 "bv4$" end of stream
That is 4 × (12 + 386) + 4 = 1,596 bytes for 262,144 bytes of ff. In mask-test every colour tile is four blocks of 64 KiB, and every mask tile is one.
The blocks are chained: a copy can reach back into the output of earlier blocks, so each block has to be decoded with everything before it as the dictionary. 84 of the 98 tiles in mask-test do this. toPSD’s framing loop is ported from Silicate and calls lz4_flex::block::decompress_into_with_dict with all of the tile decoded so far. Apple also defines bv4- for a stored, uncompressed block, but its two documentation pages disagree on the header (one size field or two), and no sample contains one, so that layout is still a guess.
Pixels are premultiplied RGBA
Colour tiles hold 8 bits per channel in R, G, B, A order, with premultiplied alpha. A semi-transparent pixel from the Screen layer in blend-modes (tile 0~3.chunk, byte offset 548) is stored as 7b 5a 2d b3: red 123, green 90, blue 45, alpha 179. Divided by alpha, that is the colour (175, 128, 64). In all 321 colour tiles of blend-modes, no colour byte is ever larger than its alpha, which is what premultiplied data looks like.
PSD stores straight alpha, so toPSD divides every pixel as it decodes it: (c × 255 + a / 2) / a, capped at 255. It does this on plain byte arrays in WebAssembly. An HTML canvas would store the pixels premultiplied, losing precision, and could convert Display P3 colours to sRGB, so the pixels never go through one.
Layer masks are one byte per pixel, 255 meaning visible. Mask tile 3~1.lz4 in mask-test is a single bv41 block of 538 bytes that decodes to 65,536 bytes: 65,269 of them are 255, 142 are 128 and the other 125 lie in between. Part of this mask was painted at half strength.
Orientation, and a worked example
orientation is 1 for no rotation, 2 for 180°, 3 for 270° clockwise and 4 for 90° clockwise. To get the picture the artist saw, decode the tiles bottom-up, flip the rows, then rotate. Width and height swap for 3 and 4: blend-modes is stored as 1836 × 2118 with orientation 4 and comes out as 2118 × 1836. toPSD applies the two flip flags before the rotation, as Silicate does. No sample has a flip set, so that order hasn’t been checked against Procreate.
The layer in mask-test makes a good test, because its contentsRect says exactly where the paint is:
00 00 00 00 00 90 83 40 x = 626
00 00 00 00 00 50 77 40 y = 373 (from the bottom)
00 00 00 00 00 18 8a 40 w = 835
00 00 00 00 00 30 8f 40 h = 998
Counted from the top, the box starts at y = 2048 − 373 − 998 = 677. Orientation 3 sends a point (x, y) to (y, 2047 − x), so the box lands at x = 677, y = 2047 − (626 + 835 − 1) = 587, now 998 × 835. The layer in the PSD that toPSD writes for this file has exactly those bounds.
toPSD doesn’t read contentsRect. There is a contentsRectValid flag beside it, and in these samples it is false on both composites and on mask-test’s layer mask, so toPSD takes the bounds of the stored tiles and then trims fully transparent rows and columns. For mask-test both ways give the same box. Cropping keeps each layer in the PSD, and in memory, in proportion to its painted area.
Blend modes are integers, stored twice
Each layer has blend and extendedBlend. Modes that Procreate added later store 0 in blend and their real value only in extendedBlend. In blend-modes, the layers set to Hard Mix through Divide (20 to 26) all have blend = 0, and the Darken layer has 19 in both. Silicate and toPSD read extendedBlend and fall back to blend. Files saved in 2026 add a third key, extendedBlend2, which equals extendedBlend in every sample; nothing reads it yet.
| Value | Procreate | PSD key toPSD writes |
|---|---|---|
| 0 | Normal | norm |
| 1 | Multiply | mul |
| 2 | Screen | scrn |
| 3 | Add | lddg (Linear Dodge) |
| 4 | Lighten | lite |
| 5 | Exclusion | smud |
| 6 | Difference | diff |
| 7 | Subtract | fsub |
| 8 | Linear Burn | lbrn |
| 9 | Color Dodge | div |
| 10 | Color Burn | idiv |
| 11 | Overlay | over |
| 12 | Hard Light | hLit |
| 13 | Color | colr |
| 14 | Luminosity | lum |
| 15 | Hue | hue |
| 16 | Saturation | sat |
| 17 | Soft Light | sLit |
| 18 | not used in any file or reader seen | |
| 19 | Darken; shown as Shade since Procreate 5.4 | dark |
| 20 | Hard Mix | hMix |
| 21 | Vivid Light | vLit |
| 22 | Linear Light | lLit |
| 23 | Pin Light | pLit |
| 24 | Lighter Color | lgCl |
| 25 | Darker Color | dkCl |
| 26 | Divide | fdiv |
The four-character keys come from Adobe’s Photoshop file format specification; five of them end in a space. Procreate 5.4 renamed the old Darken to Shade and added a new Darken that matches other apps. In the 2026 save of this document, the old “Darken” group and a new “Shade” group both hold layers set to 19, and nobody has published the number of the new Darken yet. toPSD writes 19 as Photoshop’s Darken, the closest mode it has. A number it doesn’t know is read as Normal.
Hue and Saturation don’t come out quite the same in Photoshop, because Procreate’s maths for them differs; toPSD says so in the export report when a file uses them. For its own flattened image, toPSD blends with the formulas from Adobe’s PDF blend mode addendum, ported from Silicate’s shader.
Masks and clipping
A layer mask is a separate SilicaLayer of type 3, hanging off the layer’s mask key, with its own UUID folder of one-channel tiles. mask-test’s mask stores 14 of its 64 tiles. Nobody knows yet whether Procreate treats a missing mask tile as hidden or shown. Silicate treats it as shown, and so does toPSD: it writes the mask cropped to the stored tiles, with a default colour of 255 so everything outside stays visible.
clipped clips a layer to the one below, and becomes the clipping flag on the PSD layer record. When several clipped layers sit in a row, toPSD clips each of them to the nearest unclipped layer below, the way Photoshop’s clipping groups work. Silicate clips each one to the layer directly below it. Neither sample on this site has a clipped layer.
Alpha Lock (preserve) and layer lock (locked) are in the file. toPSD doesn’t carry them into the PSD.
Colour
colorProfile is a ValkyrieColorProfile with two keys: SiColorProfileArchiveICCNameKey and SiColorProfileArchiveICCDataKey. Both samples embed the same 536-byte ICC profile, Apple’s Display P3. In mask-test the name key says “Display P3”; in blend-modes it says “ColorspaceDerived”, over the same bytes. So toPSD names the profile from the ICC bytes themselves, leaves the colour values in the file’s own colour space, and embeds the ICC bytes in the PSD as image resource 1039. A colour-managed editor then reads the pixels as Display P3, as Procreate does.
The composite and the thumbnail
composite is a type-1 layer holding Procreate’s own flattened render, with the background colour when it is visible, as ordinary tiles: all 64 in mask-test and 59 of 72 in blend-modes. It is not part of the layer tree. toPSD doesn’t use it for output. It flattens the layers itself, one 256-pixel tile at a time, and keeps Procreate’s composite as a reference for its tests. QuickLook/Thumbnail.png is 1024 px on the long side in both samples, which is why a converter that only unzips the thumbnail returns a small image.
The time-lapse
The recording is stored as video/segments/segment-1.mp4, segment-2.mp4 and so on. Sort them by number: the ZIP order is arbitrary, and as text segment-10 sorts before segment-2. blend-modes has 22 segments, all H.264 High profile, level 4.0, 1920 × 1080, tagged with Display P3 colour primaries. They run from a single frame (0.07 s) to 8.03 s. VideoSegmentInfo holds the settings: frameSize "{1920, 1080}", 30 fps, quality pro50.vqGood, resolution pro50.vr1080.
Each segment is a complete MP4, so joining them takes some care. Segments recorded by different app versions can carry different decoder configurations (avcC). The 2026 save of the blend-modes document has 67 segments with two configurations that differ only in max_num_ref_frames, 2 in the older segments and 4 in the newer ones. toPSD joins segments without re-encoding: it copies the coded frames as they are, rewrites the sample tables (stts, ctts, stss, stsc, stsz, stco) and the edit list, and keeps the configuration with the most reference frames. A segment whose configuration differs in anything other than the reference count or the level, or that doesn’t start on a keyframe, starts a new file, and the parts come out as a ZIP. For blend-modes, the result is one MP4 of 895 frames and 30.17 seconds: 7,744,827 bytes, made from 7,761,079 bytes of segments.
What is still unknown
- The number of the Darken mode added in Procreate 5.4, and what
extendedBlend2is for. - Text layers: the
text(aValkyrieTextobject) andtextPDFkeys are nil in every sample. - Whether a missing mask tile means hidden or shown.
- Whether flips apply before or after the rotation. No sample has a flip set, and no sample uses orientation 2.
- sRGB canvases: the profile name, and whether some files store no profile at all.
- The header of a stored
bv4-LZ4 block in a real file. - Groups below 100% opacity and hidden groups. Every group in the samples is visible at 100%. toPSD applies group opacity when it flattens the image but doesn’t yet write it on the PSD folder.
- What
privatelayers are.
If you have a file that settles one of these, hello@topsd.app would like to hear about it, or open an issue on topsd/procreate-format, where these notes and the .brushset and .swatches formats are kept under CC BY 4.0.
Sources
- Silicate by An Tran (MIT): its reader, its demo files (the two samples here) and its LZ4 framing and blend shader, which toPSD ports.
- Apple’s documentation for the LZ4 format in the Compression framework.
- The Linux kernel’s LZO stream format.
- Adobe’s Photoshop File Format Specification, for the layer records, section dividers and blend keys.
- The Procreate Handbook page on blend modes, for Shade.
toPSD, the converter on this site, does all of the above in the browser in Rust compiled to WebAssembly, and writes a layered PSD without uploading the file.