Every modern iOS, macOS, watchOS, and tvOS application uses Asset Catalogs to manage images, colors, icons, and other resources. When you build an app with Xcode, your .xcassets folders are compiled into binary .car files that ship with your application. Despite being a fundamental part of every Apple app, there is little to none official documentation about this file format.
In this post, I’ll walk through the process of reverse engineering the .car file format, explain its internal structures, and show how to parse these files programmatically. This knowledge could be useful for security research and building developer tools that does not rely on Xcode or Apple’s proprietary tools.
As part of this research, I’ve built a custom parser and compiler for .car files that doesn’t depend on any of Apple’s private frameworks or proprietary tools. To make this research practical, I’ve compiled my parser to WebAssembly so it runs entirely in your browser, so no server uploads required. You can drop any .car file into the interactive demo below to explore its content. I’m considering open-sourcing these tools, but no promises yet!
When you call UIImage(named: "MyImage") in Swift (or [UIImage imageNamed:@"MyImage"] in Objective-C), the private CoreUI.framework opens your app’s Assets.car file and retrieves the appropriate image for the current device context (screen scale, appearance mode, size class, etc)
You can inspect car files using Apple’s closed-source tools:
actool: Compiles asset catalogs (usually invoked by Xcode)
assetutil: Processes and inspects car files
Running assetutil -I Assets.car produces a JSON dump of all assets:
Opening a .car file in a hex editor immediately reveals its foundation: the file begins with the ASCII magic BOMStore. BOM (Bill of Materials) is an old format still used today.
1
2
3
Offset 00 01 02 03 04 05 06 07 ASCII
0x0000 42 4F 4D 53 74 6F 72 65 BOMStore
0x0008 XX XX XX XX XX XX XX XX [version, info, ...]
Looking at CoreUI’s BOMStorageOpenWithSys function in a disassembler, we can see the magic validation:
1
2
3
4
5
6
7
8
9
// From BOMStorageOpenWithSys (0x18a69da5c)
if((unsignedint)BOMStreamReadUInt32(v8)==1112493395// 0x424F4D53 = "BOMS"
&&(unsignedint)BOMStreamReadUInt32(v9)==1953460837)// 0x746F7265 = "tore"
{UInt32=BOMStreamReadUInt32(v9);// Version, must be 1
if(UInt32==1){// ... allocate and parse storage
}}
A BOM file provides a hierarchical key-value store with two main primitives:
Blocks: Raw binary blobs accessed by numeric index
Trees: B+ tree structures for ordered key-value lookups
From the decompiled BOMStorageOpenWithSys function shown above, we can infer the overall layout of a BOM file. It begins with a fixed-size header, followed by a block index table and a variables table.
The BOM header structure (32 bytes, big-endian):
1
2
3
4
5
6
7
8
9
structBOMStoreHeader{charmagic[8];// "BOMStore"
uint32_tversion;// Usually 1
uint32_tnumberOfBlocks;uint32_tindexOffset;uint32_tindexLength;uint32_tvarsOffset;uint32_tvarsLength;};
The Block Index is an array of (offset, length) pairs providing random access to any block by its numeric ID. The Variables Table maps string names like "CARHEADER" and "RENDITIONS" to block indices.
CAR-Specific Blocks and Trees
While standard BOM files store installer package manifests, .car files repurpose the container for asset storage. Here are the named blocks and trees found in a typical car file:
Named Blocks
Block Name
Size
Purpose
CARHEADER
436 bytes
Version info, rendition count, UUID
EXTENDED_METADATA
1028 bytes
Build tool info, platform details
KEYFORMAT
Variable
Defines attribute ordering for keys
CARGLOBALS
Variable
Global settings (optional)
Named Trees (B+ Trees)
Tree Name
Purpose
RENDITIONS
Asset key → CSI data block mappings
FACETKEYS
Asset name → attribute token mappings
APPEARANCEKEYS
Appearance name → ID mappings (Dark Mode)
BITMAPKEYS
Bitmap-specific metadata
COLORS, FONTS, FONTSIZES
Type-specific indices
The CARHEADER Block
The CARHEADER block contains metadata about the entire asset catalog. It has a fixed size of 436 bytes. Unlike the BOM container (big-endian), CARHEADER fields are little-endian, with CoreUI calling _swapHeader when it detects the big-endian magic RATC:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
structcarheader{uint32_ttag;// 'RATC' (big-endian) or 'CTAR' (little-endian)
uint32_tcoreuiVersion;// CoreUI build version (e.g. 804)
uint32_tstorageVersion;// Format version (typically 15)
uint32_tstorageTimestamp;// Unix timestamp of compilation
uint32_trenditionCount;charmainVersionString[128];// "@(#)PROGRAM:CoreUI PROJECT:CoreUI-804.1"
charversionString[256];// Tool version string
uuid_tuuid;uint32_tassociatedChecksum;// CRC or hash
uint32_tschemaVersion;// Schema version (usually 2)
uint32_tcolorSpaceID;uint32_tkeySemantics;// Key interpretation mode
}
The KEYFORMAT Block
The KEYFORMAT block is crucial for understanding how asset keys are structured. The decompiler reveals that CoreUI validates this block and has a fallback when it’s malformed:
1
2
3
4
5
6
7
8
// From CUISystemThemeRenditionKeyFormat (0x18a6b20a8)
constchar*CUISystemThemeRenditionKeyFormat(){return"tmfk";// Magic identifier for system key format
}// From validatekeyformat (0x18a749094)
// Checks for KEYFORMATWORKAROUND block, validates attribute IDs < 0x18 (24)
The format begins with magic kfmt (or tmfk depending on byte order):
1
2
3
4
5
6
structrenditionkeyfmt{uint32_ttag;// 'kfmt' (0x6B666D74)
uint32_tversion;// Format version
uint32_tmaximumRenditionKeyTokenCount;// Number of attributes
uint32_trenditionKeyTokens[];// Array of attribute type IDs
}
The renditionKeyTokens array defines which attributes are stored in each rendition key and in what order. From the disassembler’s string references, here are the attribute types (the CUIThemeAttributeNameToString function maps indices 1-28 to these names):
Note: The CUIThemeAttributeNameToString function validates that attribute IDs must be in range 1-28 ((a1 - 1) > 0x1B returns “UNKNOWN”). The validatekeyformat function also checks that all attribute IDs are below 24 (0x18) for older format compatibility.
The idiom values (from kCoreThemeIdiom* strings in the binary):
1
2
3
4
5
6
7
8
9
10
11
enumThemeIdiom{kCoreThemeIdiomUniversal=0,// Any device
kCoreThemeIdiomPhone=1,// iPhone
kCoreThemeIdiomPad=2,// iPad
kCoreThemeIdiomTV=3,// Apple TV
kCoreThemeIdiomCar=4,// CarPlay
kCoreThemeIdiomWatch=5,// Apple Watch
kCoreThemeIdiomMarketing=6,// Marketing images
kCoreThemeIdiomMac=7,// macOS
kCoreThemeIdiomVision=8,// visionOS (Apple Vision Pro)
};
A typical KEYFORMAT might define 18 attributes, meaning each rendition key is 18 * 2 = 36 bytes (each attribute value is stored as a uint16_t).
The RENDITIONS Tree
The RENDITIONS tree is the heart of the .car file and as far as I can tell it’s a B+ tree where:
Keys: Rendition attribute values matching the KEYFORMAT layout
Values: Block indices pointing to CSI (Core Structured Image) data
B+ Tree Node Structure
The tree header begins with magic "tree" (0x74726565), validated in _BOMTreeOpen. Each tree node has a 12-byte header:
The forwardLink/backwardLink pointers are the key B+ tree characteristic: leaf nodes are linked together for efficient sequential traversal without re-traversing from the root.
Leaf nodes contain the actual key-value pairs. Each entry is 8 bytes:
1
2
3
4
5
structBOMTreeEntry{uint32_tkeyBlockID;// Block containing the key
uint32_tvalueBlockID;// Block containing CSI data (for leaves)
// or child node block ID (for branches)
};
Branch nodes use the same structure but valueBlockID points to child tree nodes.
Tree Traversal Algorithm
The decompiled BOMTreeIteratorNew shows how CoreUI creates tree iterators:
1
2
3
4
5
6
7
8
9
10
11
12
13
// From BOMTreeIteratorNew (0x18a79a454)
_QWORD*__fastcallBOMTreeIteratorNew(_QWORD*tree,...){v5=calloc(1,0x40);// 64-byte iterator structure
if(v5){v5[0]=tree;// Store tree reference
v7=tree[44];// tree->flags at offset 352+4=356
// Bit 0x04 in flags indicates inline keys
if((v7&4)!=0)v5[7]=v5+8;// Point to inline key buffer
}returnv5;}
The BOMTreeIteratorNext function, shown below, handles advancing through the tree. It increments the current index, checks if we’ve exhausted the current node’s entries, and follows the forwardLink pointer to move to the next leaf node. When no more nodes remain, it sets the end-of-tree flag.
The tree structure uses the flag at offset 356 to indicate whether keys are stored inline or in separate blocks.
To enumerate all assets:
Read the tree header block → get root node block ID
Descend to the leftmost leaf by following first child pointers
Iterate through leaf entries, extracting (key, value) pairs
Follow forwardLink to traverse to the next leaf
Repeat until forwardLink is 0 (end of tree)
CSI: Core Structured Image
Each asset’s data is stored in a CSI block. Decompiler analysis of initWithCSIData:forKey:version: reveals how CoreUI determines the rendition class:
1
2
3
4
5
6
7
// From -[CUIThemeRendition initWithCSIData:forKey:version:] (0x18a75a8cc)
v9=(unsigned__int16*)objc_msgSend(a3,"bytes");v10=objc_msgSend(objc_opt_class(a1),"renditionClassForRenditionType:andPixelFormat:",v9[18],// offset 36: layout/rendition type
*((unsignedint*)v9+6));// offset 24: pixel format
This tells us key offsets in the CSI header:
Offset 24 (bytes 24-27): pixelFormat as uint32
Offset 36 (bytes 36-37): layout as uint16
The full 184-byte CSI header structure (little-endian fields):
// From _initializeTypeIdentifiersWithLayout (simplified)
// Layout types are grouped into categories:
// 10-12: Type 0 (Basic images)
// 20-22: Type 1 (Multi-part images)
// 23-25: Type 2 (Three-part images)
// 30-34: Type 3 (Nine-part images)
// 40: Type 5 (Texture)
// 50: Type 8 (Special)
enumRenditionLayoutType{kLayoutType_Image_OnePartFixedSize=10,kLayoutType_Image_OnePartTile=11,kLayoutType_Image_OnePartScale=12,kLayoutType_Image_ThreePartHTile=20,kLayoutType_Image_ThreePartHScale=21,kLayoutType_Image_ThreePartHUniform=22,kLayoutType_Image_ThreePartVTile=23,kLayoutType_Image_ThreePartVScale=24,kLayoutType_Image_ThreePartVUniform=25,kLayoutType_Image_NinePartTile=30,kLayoutType_Image_NinePartScale=31,kLayoutType_Image_NinePartHT_VT=32,kLayoutType_Image_NinePartHT_VS=33,kLayoutType_Image_NinePartHS_VT=34,// High-value types (0x3E8+ range = 1000+)
// These are BOTH layout types AND rendition types (they pass through directly)
kLayoutType_Data=1000,// 0x3E8 - Raw data blob
kLayoutType_ExternalLink=1001,// 0x3E9 - Reference to external file
kLayoutType_LayerStack=1002,// 0x3EA - Multi-layer image
// NOTE: 1003 (0x3EB) does NOT exist in gRenditionTypes - jumps to 1004!
// Internal links use TLV type 1010 to store their reference, not a layout type.
kLayoutType_PackedImage=1004,// 0x3EC - Packed/compressed image
kLayoutType_NamedContent=1005,// 0x3ED - Named content reference
kLayoutType_ThinningPlaceholder=1006,// 0x3EE - Placeholder for app thinning
kLayoutType_Texture=1007,// 0x3EF - GPU texture
kLayoutType_TextureImage=1008,// 0x3F0 - Texture atlas
kLayoutType_Color=1009,// 0x3F1 - Named color
kLayoutType_MultisizeImageSet=1010,// 0x3F2 - App icon set
kLayoutType_LayerReference=1011,// 0x3F3 - Reference to layer in stack
kLayoutType_ContentRendition=1012,// 0x3F4 - Content rendition
kLayoutType_RecognitionObject=1013,// 0x3F5 - ML recognition data
};
TLV (Type-Length-Value) Metadata
Following the CSI header is a TLV section containing extended metadata. The decompiled _CUIThemePixelRendition _initWithCSIHeader:version: shows the parsing:
TLV type numbers are a separate namespace from layout types. For example, TLV type 1010 (InternalLink metadata) is different from layout type 1010 (MultisizeImageSet). Layout types identify the rendition class (from CSI header offset 36), while TLV types tag metadata entries within the TLV section.
Note: The TLV namespace has gaps—types 1002, 1004, 1005, and 1013-1019 don’t exist as immediate constants in the CoreUI binary. The table below only includes TLV types in the decompiled code. The “Name” column contains descriptive names I assigned based on observed behavior so they’re not from the CoreUI binary.
After the header and TLV section comes the actual asset data. The pixelFormat and layout fields determine how to interpret it.
CUIRawDataRendition (pixelFormat = ‘DATA’)
Used for arbitrary binary data (PDFs, text files, JSON, etc.). The decompiler shows the magic validation:
1
2
3
// From _CUIRawDataRendition _initWithCSIHeader:version: (0x18a756e9c)
if(a3[6]!=1145132097)// pixel format must be 'DATA' (0x44415441)
// Error: wrong pixel format
The raw data header starts at CSI offset 180 (after the 184-byte header minus 4):
1
2
3
4
5
6
structCUIRawDataHeader{uint32_ttag;// 'RAWD' (0x44574152) - may need byte-swap detection
uint32_tversion;// 0 = uncompressed, non-zero = LZFSE compressed
uint32_trawDataLength;// Size of following data (byte-swapped if tag == 'RAWD')
uint8_trawData[];// The actual file contents (12 bytes after tag)
}
The byte-order handling is interesting because CoreUI checks if tag == 0x44574152 (‘RAWD’) and if so, applies bswap32 to the length field:
1
2
3
4
5
6
7
// From decompilation at 0x18a756f04
v10=*(_DWORD*)(header+8);// Read rawDataLength
v11=bswap32(v10);// Compute swapped version
if(tag==0x44574152)// 'RAWD' (1146569042 decimal)
length=v11;// Use byte-swapped length
elselength=v10;// Use length as-is
If version field is non-zero, the data is LZFSE compressed. The decompilation shows:
1
2
3
4
5
6
7
8
9
if(v14){// version != 0 means compressed
*v15=v16|0x10;// Set renditionFlags bit 4 (compressed)
// Create subrange starting 12 bytes after header (tag + version + length)
v17=[_CUISubrangeDatainitWithData:range:srcData,offset+12,length];v6[27]=CUIUncompressDataWithLZFSE(v17);// Decompress
}else{*v15=v16;// Uncompressed
v6[27]=[_CUISubrangeDatainitWithData:range:srcData,offset+12,length];}
CUIRawPixelRendition (pixelFormat = ‘JPEG’ or ‘HEIF’)
Stores compressed images directly:
1
2
3
4
5
6
structCUIRawPixelRendition{uint32_ttag;// 'RAWD' (same as raw data)
uint32_tversion;uint32_trawDataLength;uint8_trawData[];// JPEG or HEIF bitstream
}
CUIThemeColorRendition (layout = 1009 / 0x3F1)
Named colors are stored with component values. The decompiled _CUIThemeColorRendition _initWithCSIHeader:version: shows the magic validation:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// From _CUIThemeColorRendition _initWithCSIHeader:version: (0x18a74ec24)
// Color data offset = csiHeader + bitmapCount*4 + tlvLength + 180
v6=a3+4*a3[43]+a3[42];// a3[43]=bitmapCount, a3[42]=tlvLength
v8=*(_DWORD*)(v6+180);// Read tag at computed offset
if(v8!=1129270354){// 0x434F4C52 = 'COLR' (bytes: 52 4C 4F 43 = "RLOC")
// handleFailureInMethod - invalid color magic
}// Color space flags at offset +8, bits 8-10 (& 0x700)
if((*(_DWORD*)(v9+8)&0x700)==0x100){// Extended color: read numberOfComponents at +12, then name string follows
v10=v7+8*numberOfComponents;// Skip past component doubles
if(v10[4]!='COLR')/* error */// Second COLR marker
if(v10[5]==1)// String encoding marker
// Read UTF-8 name string at v10+28, length at v10[6]
}
1
2
3
4
5
6
7
8
9
10
11
12
structcsicolor{uint32_ttag;// 'COLR' (0x434F4C52)
uint32_tversion;// Usually 0
uint32_tcolorSpaceID;// Low byte = space ID, bits 8-10 = extended flags
uint32_tnumberOfComponents;// Usually 4 (RGBA)
doublecomponents[];// Floating-point color values (8 bytes each)
// If extended (flags & 0x700 == 0x100):
// uint32_t marker; // 'COLR' again
// uint32_t encoding; // 1 = UTF-8
// uint32_t nameLength; // Length of name string
// char name[]; // Color name (e.g. "systemBlueColor")
}
The color space flags in bits 8-10:
0x000: Basic color, components follow immediately
0x100: Extended color with name string after components
The most complex type, used for PNG-derived images. The decompiler shows the bitmap header validation:
1
2
3
4
5
6
7
8
9
// From _CUIThemePixelRendition _initWithCSIHeader:version: (0x18a758dc4)
// Bitmap data at: csiHeader + bitmapOffsets[i] where offsets start at header+176
v10=(_DWORD*)(v7+aSize_8[v6]);// aSize_8 = &csiHeader[44] (bitmap offset array)
if(*v10!=1128614989&&*v10!=1296844099)// Must be CELM or MLEC
{// Error: invalid bitmap header
}v12=v10[1];// Read flags field
*renditionFlags=(*renditionFlags&0xFFFFFFFD)|(2*((v12>>1)&1));// Extract bit 1 → vector flag
structCUIThemePixelRendition{uint32_ttag;// 'CELM' (0x4D4C4543) or 'MLEC' (0x43454C4D)
uint32_tflags;// Bit 1 = vector rendition flag (SVG/PDF source)
uint32_tcompressionType;// Compression algorithm (0-12, see table below)
uint32_trawDataLength;// Size of (compressed) pixel data
uint8_trawData[];// Pixel data (format depends on compressionType)
}
The pixel format determines the color space handling. From the decompilation’s switch statement:
pixelFormat
Value
Bytes
Color Space Selection
ARGB
0x41524742
42 47 52 41
sRGB or from colorSpace field bits 0-3
RGBW
0x52474257
57 42 47 52
Extended/wide gamut, P3 variants
RGB5
0x52474235
35 42 47 52
16-bit RGB (5-5-5-1)
GA8
0x47413820
20 38 41 47
8-bit grayscale + alpha
GA16
0x47413136
36 31 41 47
16-bit grayscale + alpha
Compression types (from CUIConvertCompressionTypeToString at 0x18a6ddf4c, reading the string table at 0x1E6D7E0E0):
Value
Name (from binary)
Notes
0
uncompressed
Raw pixel bytes
1
rle
PackBits variant
2
zip
Uses zlib deflate
3
lzvn
Apple’s lightweight compressor
4
lzfse
Apple’s high-ratio compressor
5
jpeg-lzfse
JPEG-in-ARGB with LZFSE wrapper
6
blurred
Special blur processing
7
astc
Adaptive Scalable Texture Compression
8
palette-img
Indexed/quantized color (see below)
9
hevc
HEVC/H.265 encoded
10
deepmap-lzfse
Depth map with LZFSE compression
11
deepmap2
Alternative depth map format
12
dxtc
S3 Texture Compression (DXT)
Compression Architecture
CoreUI imports compression functions from Apple’s compression libraries:
Note: The compression type is stored in the CSI header’s compressionType field (types 0-12), and CoreUI explicitly passes the algorithm to libcompression.
The decompression routing in _BOMFileCompressionLibrary_Setup (0x18a6b64cc):
1
2
3
4
5
6
// Compression type -> algorithm mapping
switch(compressionType){case1:compression_stream_init(stream,op,COMPRESSION_ZLIB);break;case3:compression_stream_init(stream,op,0x900);break;// LZVN
case4:compression_stream_init(stream,op,COMPRESSION_LZFSE);break;}
And in BOMFileRead (0x18a6b65ac), streaming decompression handles each type:
1
2
3
4
5
6
7
if(compressionType==1){// Use zlib's inflate()
inflate(z_stream,Z_NO_FLUSH);}elseif(compressionType==3||compressionType==4){// Use libcompression's streaming API
compression_stream_process(stream,isFinished);}
The CELM/MLEC Wrapper
For RLE-compressed data (compression type 1), the CELM wrapper provides per-row offset tables. The expandCSIBitmapData function (0x18a755844) calls pk_decompressData (0x18a6c99dc):
1
2
3
4
5
6
// From expandCSIBitmapData at 0x18a7559f8:
if(compressionType==1){// v10 == 1
pk_decompressData(a2+4,destBuffer,srcSlice,srcHeight,dstWidth,dstHeight,rowBytes,*a2==1296844099);// Check if magic == "CELM" (0x4D4C4543)
}
The last parameter (*a2 == 0x4D4C4543) determines endianness handling. Inside pk_decompressData:
// From pk_decompressData (0x18a6c99dc):
// Row offset table starts at result[a4 + 3] (after header)
v18=(unsignedint*)&result[srcHeight+3];// Point to row offsets
// Select RLE decompressor based on pixel depth (*result field):
if(*result==3)v20=__decompressRLE16;// 16-bit pixels
elseif(*result>=3)v20=__decompressRLE32;// 32-bit pixels (default for >= 3)
elsev20=__decompressRLE8;// 8-bit pixels
// For each row, read offset and apply bswap32 if CELM magic:
do{v22=*v18++;// Read row offset
v23=bswap32(v22);// Compute swapped version
if(isCELM)// a8 parameter (magic == CELM)
v21=v23;// Use big-endian offset
elsev21=v22;// Use little-endian offset (MLEC)
// Decompress from (data + offset) to destination
v20((char*)data+v21,destRow,...);destRow+=rowBytes;}while(--rowCount);
The RLE format uses a run-length encoding where each entry has a 4-byte header:
High byte (0x80): Literal run - repeat single value N times
Other values: Copy N bytes from source
From __decompressRLE32 (0x18a6c9ad0):
1
2
3
4
5
6
7
8
9
10
v11=*entry;// Read 4-byte header
if(isCELM)v11=bswap32(v11);runLength=v11&0xFFFFFF;// Low 24 bits = run length
if(HIBYTE(v11)==0x80){// High byte == 0x80 means fill
CGBlt_fillBytes(4*runLength,1,entry[1],dest);// Repeat value
entry+=2;// Skip header + fill value
}else{CGBlt_copyBytes(4*runLength,1,&entry[1],dest);// Copy pixels
entry+=runLength+1;// Skip header + data
}
The KCBC Chunked Bitmap Format
For LZFSE-compressed bitmaps (compression types 3 and 4), large images are split into multiple independently-compressed chunks within the MLEC rawData payload. Each chunk represents a horizontal slice of the image. The chunks use a KCBC header:
1
2
3
4
5
6
7
8
structKCBCChunkHeader{uint32_tmagic;// 'KCBC' (0x4B434243)
uint8_treserved[8];// Reserved/unknown
uint32_tchunkHeight;// Number of pixel rows in this chunk
uint32_tcompressedSize;// Size of following LZFSE compressed data
};// Total header size: 20 bytes
// Immediately followed by compressedSize bytes of LZFSE (bvx2...) stream
Each chunk’s LZFSE stream decompresses to chunkHeight * rowBytes bytes of raw pixel data (possibly with row padding for alignment). The decompressed chunks need to be concatenated row-by-row to reconstruct the full image. The number of chunks is not stored explicitly so the iteration continues until all image rows have been decoded.
When the decompressed data size exceeds chunkHeight * width * bytesPerPixel, the source rows contain alignment padding that needs to be stripped when copying to the destination buffer.
The Paletted/Quantized Image Format
A particularly interesting format is the quantized image (compression type 8). The decompiled CUIUncompressQuantizedImageData (0x18a6c68e4) reveals the format details. The entire payload is LZFSE compressed, and CoreUI uses streaming decompression to read it:
// From CUIUncompressQuantizedImageData (0x18a6c68e4)
// First, validate pixel format (before decompression)
if(pixelFormat!=1095911234&&pixelFormat!=1380401751){// ARGB or RGBW
_CUILog(4,"CoreUI: %s got unsupported pixel format [%d]");return0;}// Initialize LZFSE streaming decompression
compression_stream_init(&stream,COMPRESSION_STREAM_DECODE,COMPRESSION_LZFSE);stream.src_ptr=compressedData;stream.src_size=compressedLength;// Decode first 8 bytes: magic (4) + version (4)
stream.dst_ptr=&v42;stream.dst_size=8;compression_stream_process(&stream,0);if((_DWORD)v42!=-889262067){// 0xCAFEF00D magic check
gotodecode_error;}if(HIDWORD(v42)>=2){// Version must be 0 or 1
_CUILog(4,"[%s] Decoded version is higher than supported");return0;}// Decode next 2 bytes: palette count
stream.dst_ptr=&paletteCount;stream.dst_size=2;compression_stream_process(&stream,0);if(paletteCount>0x1000u){// Max 4096 colors
_CUILog(4,"[%s] Decoded color count %u more than max number of colors allowed...");return0;}
The format structure (note: the entire payload is LZFSE compressed, these following fields are read from the decompressed stream):
1
2
3
4
5
6
7
8
9
structQuantizedImageData{// All fields below are INSIDE the LZFSE compressed stream
uint32_tmagic;// 0xCAFEF00D (check if offset 0 != -889262067)
uint32_tversion;// Must be < 2 otherwise error (Decoded version is higher than supported)
uint16_tpaletteCount;// Number of colors, max 4096 (check if v41 > 0x1000u)
// Followed by:
// - Palette: paletteCount * 4 bytes (ARGB) or * 8 bytes (RGBW)
// - Indices: bit-packed pixel indices (variable width based on palette size)
};
The compressed payload must be decompressed with LZFSE using streaming decompression:
This allows UIImage(named:) to find all renditions for an asset name by matching against the RENDITIONS tree keys.
Internal Link Renditions
Some renditions don’t contain actual image data, they reference other renditions. This enables asset reuse and deduplication.
The decompiled _CUIInternalLinkRendition _initWithCSIHeader:version: (0x18a7582bc) shows how these are parsed:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// From _CUIInternalLinkRendition _initWithCSIHeader:version: (0x18a7582bc)
// TLV type 1010 (0x3F2) contains the internal link
case1010:v23=*(_QWORD*)(v16+16);v24=*(_QWORD*)(v16+24);v27=*(_DWORD*)(v16+34);// Key length (max 88 bytes)
if(v27>=0x58)v28=88;elsev28=v27;__memcpy_chk(v79,v16+38,v28,92);// Copy key data from offset 38
// Initialize CUIRenditionKey with the target key tokens
*((_QWORD*)v5+116)=objc_msgSend(objc_alloc(CUIRenditionKey),"initWithKeyList:",v79);objc_msgSend(v5,"_initializeTypeIdentifiersWithLayout:",*(unsigned__int16*)(v16+32));
Internal links are identified by TLV type 1010 (0x3F2) and contain:
1
2
3
4
5
6
7
8
9
10
structInternalLinkTLV{uint32_ttype;// 0: 1010 (0x3F2)
uint32_tlength;// 4: Total TLV data length
uint64_treserved1;// 8: Unknown (not parsed)
uint64_tmetadata1;// 16: Metadata (converted to floats)
uint64_tmetadata2;// 24: Metadata (converted to floats)
uint16_tlayout;// 32: Layout type of the linked rendition
uint32_tkeyLength;// 34: Length of target key (max 88 bytes)
uint8_ttargetKey[];// 38: Rendition key tokens (4 bytes each, null-terminated)
};
When CoreUI encounters an internal link, it looks up the target key in RENDITIONS and loads that asset instead. This is commonly used for dark mode variants that share the same base image.
I’ve compiled my parser to WebAssembly so you can explore .car files directly in your browser. Drop any Assets.car file to see its structure:
All processing happens locally in your browser. No files are uploaded to any server. Loading a large .car file may take a while and use high memory, may even cause your browser to freeze or crash.
For those interested in doing similar research, here’s the approach I used:
Extract CoreUI from the dyld shared cache: On modern macOS/iOS, system frameworks are pre-linked into a shared cache. Use the ipsw tool to extract them:
actool --print-contents: Dump asset catalog info during builds
Conclusion
What started as a hex dump showing BOMStore turned into a journey through nested containers, B+ trees, TLV metadata, and bunch of compression schemes. I find this .car format is fairly complex, but understanding its internals opens up many possibilities.
For security researchers, this complexity might actually be a feature. Nested parsers and custom compression algorithms to create a rich attack surface. For tool builders, understanding these internals means you can build asset extractors, catalog editors, or cross-platform readers without depending on Apple’s closed-source tooling.
The WebAssembly demo above is just one example of what’s possible when we understand the format rather than relying on Apple’s tools.
Feel free to reach out with questions or corrections, as feedback is welcome. The structures documented here are based on macOS Version 26.2 (25C56) files and may evolve in future releases.