ADR-013: Save payload format version 1 is a fixed-length little-endian record with a CRC-32 check

Date: 2026-08-03

Status: Accepted

Context

v0.4.0 gives a world a way to leave the process and return unchanged, and SPEC-FR-039 requires persisted data to identify its own format so that compatibility can be handled explicitly. The byte layout is the durable artefact of the release. Every later reader on any platform reads what this decision fixes, and a reader on a microcontroller has to agree with a reader on a desktop without either of them negotiating.

The authoritative state is still small. One world holds one pet, a name, two enumerated regions, two dwell stamps, an elapsed clock, and an update count. ADR-012 added one wall-clock save stamp to that list. Deciding the encoding now, while the content is this size, is the reason the release exists in this order.

Decision

Format version 1 is a fixed 84-byte record. Every multi-byte field is little-endian and is written byte by byte from a value, never by copying structure storage.

OffsetSizeFieldEncoding
04magicthe bytes P, E, T, S
42format versionunsigned 16-bit, value 1
68elapsed timeunsigned 64-bit milliseconds
148update countunsigned 64-bit
228saved atunsigned 64-bit UTC milliseconds since the Unix epoch, 0 when unknown
301activityunsigned 8-bit PetActivity value
311expressionunsigned 8-bit PetExpression value
328activity sinceunsigned 64-bit milliseconds
408expression sinceunsigned 64-bit milliseconds
481name lengthunsigned 8-bit, 1 to PET_NAME_CAPACITY - 1
4931name bytesthe name, with every byte after the length set to zero
804integrity checkunsigned 32-bit CRC-32 over bytes 0 to 79

Four properties are decided together, because each one only makes sense with the others.

The length is fixed. Every payload of format version 1 is exactly 84 bytes. A reader knows its buffer size at compile time, a truncated file is rejected before it is parsed, and a device that keeps the payload in a fixed region needs no length record of its own.

The encoding is explicit. Field order, width, signedness, and byte order are properties of this table rather than of a compiler. Padding, alignment, enumeration width, and host endianness cannot reach the file, which is what lets the same bytes cross from a desktop to an ESP32.

The header comes first. Magic and version occupy the first six bytes, so a reader decides whether it understands a payload before it interprets anything else.

The integrity check is CRC-32 as used by IEEE 802.3, computed bitwise so the Core carries no lookup table. It detects damage. It is not a signature, and it offers no protection against a deliberately crafted payload.

Two fields in the world are not in the table. The initialised marker is derived by the loader, because it describes the storage rather than the pet, and a loader that copied it from a file would let a file assert that arbitrary bytes are a valid world. The name terminator is derived too, because the length field and the zero padding rule already determine it.

The compatibility promise is the one section 8.4 of the version architecture records. Version 1 is written and version 1 is the only version read. A payload whose version is not 1 is rejected rather than interpreted, and no migration, downgrade, or forward compatibility is promised.

Consequences

Adding a field is a format revision rather than an accident. That is the intended cost, and the version field exists to make it a normal event rather than a break.

The bitwise CRC-32 costs 8 iterations per byte over 80 bytes on each save and load. On the desktop this is invisible, and on the target class this is far cheaper than the 1 KiB table it avoids carrying in flash.

The name occupies 32 of the 84 bytes and is mostly zero for a typical pet. Compressing it would trade a fixed length for a variable one, and the fixed length is worth more than 20 bytes on any device the project cares about.

A field-by-field encoder must be reviewed against this table rather than trusted to match a structure definition. That review is a deliberate part of the encoder work.

Alternatives considered

A text format such as JSON, INI, or a key-value list. Rejected because it needs a parser on a microcontroller, has no fixed length, invites partial acceptance of a damaged file, and turns every field name into a compatibility commitment. The state is small enough that the readable-file argument does not pay for the parser.

A serialisation library or schema compiler. Rejected because it would become a portability and compatibility dependency for the life of the format, on targets where the whole payload is 84 bytes.

Copying the structure into the payload. Rejected because it makes padding, alignment, enumeration width, and endianness part of the file. The file would then be valid only for the compiler and architecture that wrote it, which contradicts SPEC-NFR-001 and defeats the purpose of writing a format down.

Big-endian encoding. Rejected in favour of little-endian, because every platform in scope, x86-64 and the Xtensa ESP32, is little-endian. The encoding stays explicit either way, so the choice is about which one costs nothing on the targets that exist.

A variable-length payload with a length prefix. Rejected because a fixed length is what makes a truncated file detectable before parsing and what lets a fixed flash region hold a payload without its own length record.

No integrity check, relying on the field validation. Rejected because field validation cannot see a flipped bit inside a valid range. An elapsed time that lost one bit is still a plausible elapsed time.

A cryptographic hash or a signature. Rejected because the threat is damage rather than forgery. A signature needs a key, and a key needs somewhere to live, which is a platform question this release does not open. The local-first ownership principle also says a file the user owns is the user’s to edit.

References