Pet embedded asset pack v1

Document versionDateSummary
v12026-08-07Define 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 | check

Every 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.

OffsetSizeFieldEncoding
04magicthe bytes P, E, T, P
42format versionunsigned 16-bit, value 1
62header lengthunsigned 16-bit, value 28
84pack lengthunsigned 32-bit, total bytes including the header and the check
121pixel encodingunsigned 8-bit, value 1 for RGB565 high byte first
131transparencyunsigned 8-bit, value 1 for a one-bit mask
142image countunsigned 16-bit
162animation countunsigned 16-bit
182frame countunsigned 16-bit
202role countunsigned 16-bit
222fallback roleunsigned 16-bit index into the role table
244payload offsetunsigned 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.

OffsetSizeFieldEncoding
032identifierzero-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.

OffsetSizeFieldEncoding
032identifierzero-terminated ASCII, zero padded
322first frameunsigned 16-bit index into the frame table
342frame countunsigned 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.

OffsetSizeFieldEncoding
02imageunsigned 16-bit index into the image table
22durationunsigned 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.

OffsetSizeFieldEncoding
032identifierzero-terminated ASCII, zero padded
322animationunsigned 16-bit index into the animation table
342alignment paddingtwo 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.

OffsetSizeFieldEncoding
02widthunsigned 16-bit pixels, 1 to 1024
22heightunsigned 16-bit pixels, 1 to 1024
44colour offsetunsigned 32-bit offset from the start of the pack
84colour lengthunsigned 32-bit bytes
124mask offsetunsigned 32-bit offset from the start of the pack
164mask lengthunsigned 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.

LimitValue
Images32
Animations32
Roles64
Total frames128
Frames per animation16
Frame duration1 to 60000 milliseconds
Identifier length31 bytes plus a terminator
Image width and height1 to 1024 pixels
Pack length16 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.

  1. the supplied length is at least the header length
  2. the magic bytes are P, E, T, P
  3. the format version is 1
  4. the header length is 28
  5. the declared pack length equals the supplied length and is a multiple of four
  6. the CRC-32 over the pack up to the check equals the stored check
  7. the pixel encoding is 1 and the transparency representation is 1
  8. every count is inside the limits in section 9
  9. the computed table offsets and the declared payload offset agree
  10. the fallback role index is inside the role table
  11. every animation names a run that lies inside the frame table and holds at least one frame
  12. every frame names an image inside the image table
  13. every role names an animation inside the animation table and every role padding field is zero
  14. every identifier holds a terminator inside its 32 bytes
  15. every entry width and height is inside the bounds, and each declared length matches the geometry
  16. 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.

RegionBytes
28header
160image table, five records of 32
180animation table, five records of 36
24frame table, six records of 4
216role table, six records of 36
100entry table, five records of 20
43520payloads, five images of 8192 colour bytes and 512 mask bytes
4integrity 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.