Pet embedded asset pack v1
| Document version | Date | Summary |
|---|---|---|
| v1 | 2026-08-07 | Define the version 1 pack format, its tables, its payload encoding, and the converter contract |
Status: Accepted contract for implementation
Product version: v0.5.0
Related documents: architecture for v0.5.0, ADR-019, ADR-007, the asset manifest contract, epics, and PBIs.
1. Purpose
This document is the normative engineering contract for the embedded asset pack introduced in
v0.5.0. It defines the byte layout of the pack, the compiled presentation structure it carries,
the pixel and transparency encoding, the acceptance rules a reader applies, and the contract the
converter that writes a pack must meet.
The pack is the compiled form of the canonical asset set for a runtime that has no filesystem, no
manifest parser, and no PNG decoder. The canonical manifest.petasset and the canonical PNG files
under assets/pet/ remain the single source of truth for what the pet looks like. Nothing about the
presentation is authored twice, and a pack is never edited by hand.
The document defines the contract rather than implementing it. PBI-118 writes the converter,
PBI-119 generates the pack from the build, and PBI-120 implements the reader.
2. Scope
The pack carries asset facts and only asset facts. Frame geometry, pixel encoding, transparency, and the presentation structure are the same on every board that ever reads the pack.
The pack carries no board offset, no rotation, and no panel colour order. Every board-specific fact belongs to the board profile, so a second panel changes a profile and reads the same pack.
The pack describes pet presentation assets only, on the same boundary the manifest contract draws in its section 2. Application chrome is not a pet presentation asset and does not enter a pet pack.
3. Format overview
A pack is one flat binary artefact. It opens with a fixed header, continues with four presentation tables and one entry table, holds the frame payloads, and ends with an integrity check.
header | images | animations | frames | roles | entries | payloads | checkEvery multi-byte integer field is unsigned and little-endian, and is written from a value rather than by copying structure storage, so no compiler layout decision reaches the artefact. Pixel bytes are the one exception and section 7.1 states their order and the reason for it.
Every table record and every payload begins at an offset that is a multiple of four bytes, and the total pack length is a multiple of four. The reader hands out pointers into the pack and the frontend reads pixels in place, so alignment is a property of the format rather than a courtesy.
The pack is not compressed. It is read in place from memory the firmware image already holds, so a compressed pack would have to be expanded into RAM that the device does not have to spare.
4. Header
The header is 28 bytes and occupies the start of the pack.
| Offset | Size | Field | Encoding |
|---|---|---|---|
| 0 | 4 | magic | the bytes P, E, T, P |
| 4 | 2 | format version | unsigned 16-bit, value 1 |
| 6 | 2 | header length | unsigned 16-bit, value 28 |
| 8 | 4 | pack length | unsigned 32-bit, total bytes including the header and the check |
| 12 | 1 | pixel encoding | unsigned 8-bit, value 1 for RGB565 high byte first |
| 13 | 1 | transparency | unsigned 8-bit, value 1 for a one-bit mask |
| 14 | 2 | image count | unsigned 16-bit |
| 16 | 2 | animation count | unsigned 16-bit |
| 18 | 2 | frame count | unsigned 16-bit |
| 20 | 2 | role count | unsigned 16-bit |
| 22 | 2 | fallback role | unsigned 16-bit index into the role table |
| 24 | 4 | payload offset | unsigned 32-bit offset of the first payload byte |
The header carries no reserved field and no space held back for a later revision. A change to the layout is a change of format version, which section 11 states.
The payload offset is derivable from the counts, and it is declared anyway. It is the one place where the writer states the arithmetic it used and the reader can compare that against its own, so a converter and a reader that disagree are caught at the header rather than inside a lookup.
5. Presentation tables
The four tables follow the header in the order given here, with no padding between them. Together
they are the canonical manifest with image indices in place of paths, which is what lets the device
resolve a role through pet_presentation_select_frame without a parser.
Every identifier field holds ASCII bytes terminated by a zero, with every byte after the terminator also zero. The identifiers are the ones the canonical manifest declared, and they follow the rules in section 6 of the manifest contract.
5.1 Image table
image count records of 32 bytes each, in the order the manifest declared them.
| Offset | Size | Field | Encoding |
|---|---|---|---|
| 0 | 32 | identifier | zero-terminated ASCII, zero padded |
The record carries no path, because a path names a file and the device has no filesystem. The record index is the image index used everywhere else in the pack, including as the index of the entry table record that carries the same image’s pixels.
The identifier is carried even though no device lookup needs it. It is what makes a pack materialise
into a PetManifest that is equal, field for field apart from the paths, to the one a desktop parse
of the same manifest produces, and that equality is the cheapest available test that the compiled
form did not drift from its source.
5.2 Animation table
animation count records of 36 bytes each, in the order the manifest declared them.
| Offset | Size | Field | Encoding |
|---|---|---|---|
| 0 | 32 | identifier | zero-terminated ASCII, zero padded |
| 32 | 2 | first frame | unsigned 16-bit index into the frame table |
| 34 | 2 | frame count | unsigned 16-bit, at least 1 |
The frames of one animation are consecutive records of the shared frame pool, exactly as
PetManifestAnimation describes them.
5.3 Frame table
frame count records of 4 bytes each. The table is one shared pool and an animation names a run
inside it, so a frame record belongs to whichever animation covers its index.
| Offset | Size | Field | Encoding |
|---|---|---|---|
| 0 | 2 | image | unsigned 16-bit index into the image table |
| 2 | 2 | duration | unsigned 16-bit milliseconds, 1 to 60000 |
The duration is 16 bits because the manifest contract already bounds a frame duration at 60000
milliseconds, which is inside the range. A reader widens it to the 32-bit field PetManifestFrame
carries.
5.4 Role table
role count records of 36 bytes each, in the order the manifest declared them.
| Offset | Size | Field | Encoding |
|---|---|---|---|
| 0 | 32 | identifier | zero-terminated ASCII, zero padded |
| 32 | 2 | animation | unsigned 16-bit index into the animation table |
| 34 | 2 | alignment padding | two zero bytes |
The padding exists so the record length is a multiple of four and every table after this one stays aligned. It is not reserved space for a later field, and a reader rejects a pack whose padding bytes are not zero, because a non-zero byte there is a writer that did not follow this table.
The header’s fallback role field indexes this table. The role it names must resolve through the
whole chain to a frame and an image, which is the chain section 9 of the manifest contract describes.
6. Entry table
image count records of 20 bytes each. Record i describes the pixels of image i, so the entry
table needs no identifier of its own.
| Offset | Size | Field | Encoding |
|---|---|---|---|
| 0 | 2 | width | unsigned 16-bit pixels, 1 to 1024 |
| 2 | 2 | height | unsigned 16-bit pixels, 1 to 1024 |
| 4 | 4 | colour offset | unsigned 32-bit offset from the start of the pack |
| 8 | 4 | colour length | unsigned 32-bit bytes |
| 12 | 4 | mask offset | unsigned 32-bit offset from the start of the pack |
| 16 | 4 | mask length | unsigned 32-bit bytes |
The colour and mask lengths are derivable from the width and the height, and they are declared for the same reason the payload offset is. They also let a reader bound every lookup with a single comparison rather than by recomputing geometry at every frame.
The dimension bound of 1024 keeps the product of a width, a height, and two bytes far inside a 32-bit value at every stage of a reader’s arithmetic. It is a format bound rather than a product one, and the canonical sprites are 64 by 64.
7. Payload region
The payloads follow the entry table, in image index order, with each image contributing its colour payload and then its mask payload. Each payload begins on a four-byte boundary and is followed by zero bytes where padding is needed to reach the next one.
7.1 Colour payload
The colour payload is width times height pixels in row-major order, top row first and leftmost
pixel first, with no row padding and no stride beyond the pixels themselves. Its length is therefore
width * height * 2 bytes.
One pixel is RGB565. The red channel occupies the five most significant bits of the 16-bit value, the green channel the middle six, and the blue channel the low five. The two bytes are written with the high byte first.
The high byte first order is the one an ST7789 consumes over SPI, so a classic backend transfers a composed buffer without touching a pixel. This is deliberately not the little-endian order the integer fields use. The disagreement is declared in the header rather than left to be discovered, and a future panel that wants the other order converts in its own backend.
A pixel the mask marks as transparent carries the colour value zero. The source colour of a fully transparent PNG pixel is not meaningful, so writing a fixed value keeps the pack deterministic and keeps a reader that ignores the mask from showing whatever the drawing tool happened to leave behind.
7.2 Mask payload
The mask payload is one bit per pixel in the same row-major order. A set bit means the pixel is opaque and a clear bit means it is transparent. Each row begins on a byte boundary and the most significant bit of a byte is the leftmost of the eight pixels it covers. Bits after the last pixel of a row are zero.
The length is ((width + 7) / 8) * height bytes, which is 512 bytes for a 64 by 64 sprite.
The mask is a separate payload rather than an alpha channel inside the colour data. It keeps the colour payload uniform, aligned, and directly transferable, and it costs one sixteenth of the colour payload.
8. Integrity check
The last four bytes of the pack are an unsigned 32-bit CRC-32 as used by IEEE 802.3, computed over every byte from offset 0 up to but excluding the check itself.
This is the same check, the same polynomial, and the same placement the save payload uses, which ADR-013 records. The project has one way of stating that a binary artefact is undamaged, and a second way would be a second thing to get right.
The check detects damage. It is not a signature and offers no protection against a deliberately crafted pack.
9. Limits
The pack inherits the limits of the manifest it compiles, because a pack that exceeded them could not
materialise into a PetManifest.
| Limit | Value |
|---|---|
| Images | 32 |
| Animations | 32 |
| Roles | 64 |
| Total frames | 128 |
| Frames per animation | 16 |
| Frame duration | 1 to 60000 milliseconds |
| Identifier length | 31 bytes plus a terminator |
| Image width and height | 1 to 1024 pixels |
| Pack length | 16 MiB |
The pack length bound is a sanity bound rather than a product one. A pack is linked into a firmware image, so the partition the image occupies is the real limit and it is far smaller.
10. Acceptance rules
A reader validates the whole pack before it answers any lookup, and it accepts a pack whole or not at all. The order matters, because each step makes the next one safe.
- the supplied length is at least the header length
- the magic bytes are
P,E,T,P - the format version is 1
- the header length is 28
- the declared pack length equals the supplied length and is a multiple of four
- the CRC-32 over the pack up to the check equals the stored check
- the pixel encoding is 1 and the transparency representation is 1
- every count is inside the limits in section 9
- the computed table offsets and the declared payload offset agree
- the fallback role index is inside the role table
- every animation names a run that lies inside the frame table and holds at least one frame
- every frame names an image inside the image table
- every role names an animation inside the animation table and every role padding field is zero
- every identifier holds a terminator inside its 32 bytes
- every entry width and height is inside the bounds, and each declared length matches the geometry
- every payload offset is a multiple of four and every payload lies inside the payload region
A rejection names the rule that was broken and yields no pointer, no partial structure, and no lookup. A rejected pack leaves the caller’s destination storage empty, in the same way a rejected manifest does.
The reader validates structure and does not re-validate the manifest grammar. Identifier spelling, duplicate names, and reserved words are the converter’s responsibility, because the converter reads the canonical manifest through a grammar module that already rejects them.
11. Compatibility promise
A pack is generated by the same build that produces the firmware image that reads it. The promise is therefore narrower than the save format’s promise, and deliberately so.
A reader accepts only the format version it was built for and rejects every other version rather than interpreting it. There is no migration, no second supported version, and no promise that a pack written by one firmware image can be read by another.
The format version exists so that a mismatch is detected and reported rather than misread. It does not exist so that a pack can outlive its image. The save payload is user data that must outlive the process that wrote it and the pack is a build output that never leaves the image, which is why the two artefacts make different promises about the same kind of version field.
12. Converter contract
The converter reads the canonical manifest and the canonical PNG files and writes one pack. It is deterministic, so equal inputs produce byte-identical output, and it depends on nothing outside the Python standard library.
The accepted PNG subset is bit depth 8, colour type 6 or colour type 2, and no interlacing. A palette, a grayscale image, a bit depth other than 8, and an interlaced image are each rejected with a named reason rather than converted approximately.
A channel is reduced to its RGB565 width by discarding its low bits, which is what a shift does. No dithering, no rounding to nearest, and no gamma adjustment is applied, because a deterministic and explainable reduction is worth more here than a marginally better gradient on a 64 by 64 sprite.
A pixel is opaque when its alpha is 128 or greater and transparent otherwise. A colour type 2 image has no alpha channel and every one of its pixels is opaque.
The converter fails loudly and leaves no output file behind. A pack that was half written is worse than no pack, because the build step after it would embed whatever exists.
13. Worked example
The canonical asset set has five images of 64 by 64, five animations, six frames, and six roles.
| Region | Bytes |
|---|---|
| 28 | header |
| 160 | image table, five records of 32 |
| 180 | animation table, five records of 36 |
| 24 | frame table, six records of 4 |
| 216 | role table, six records of 36 |
| 100 | entry table, five records of 20 |
| 43520 | payloads, five images of 8192 colour bytes and 512 mask bytes |
| 4 | integrity check |
The total is 44232 bytes, which is about 43 KiB. The firmware image at the close of E33 was 182 KiB
with 83 per cent of its partition free, so the pack costs a little under a quarter of the free space
and the release stays comfortably inside its partition.
14. Implementation status
PBI-118 implements the converter in tools/assets/pack.py and keeps its manifest grammar reader
in tools/assets/manifest.py. PBI-119 implements the shared pet_asset_pack build target, which
writes assets/pet.pack below each build directory and is used by both hosted and ESP-IDF builds.
PBI-120 adds the reader to components/assets behind its own build selection. PBI-121 is the
first consumer, when the TFT frontend composes from a pack frame instead of the procedural fallback.