Pet save format v1
| Document version | Date | Summary |
|---|---|---|
| v1 | 2026-08-07 | Record the version 1 save payload as a contract document beside the decision that made it |
Status: Accepted contract for implementation
Product version: v0.4.0
Related documents: architecture for v0.4.0, ADR-013, ADR-012, ADR-014, and the product specification.
1. Purpose
This document is the normative engineering contract for the save payload introduced in v0.4.0. It
defines the byte layout of a saved world, the rules a reader applies before it accepts one, what a
load does with the time that passed while the pet was away, and the compatibility promise the format
makes.
ADR-013 records why the format is shaped this way and which alternatives were rejected. This document records what the format is, so that a reader on another platform can be written from it without reading the encoder.
The payload existed before this document. It was written from the ADR, and this file was added in
v0.5.0 so the project’s two binary artefacts are each described by a contract of their own, beside
the pack contract and the manifest contract.
2. Scope
The payload carries the authoritative state of one world and nothing else. It carries no presentation state, no asset reference, no frontend preference, no window geometry, and no host setting, because none of those are Core state and a file that carried them would let a host assert them back into a world.
The payload is user data. It is written to a location an operator names, it may be copied, moved, backed up, and edited by the person who owns it, and it must outlive the process that wrote it. That is what separates its promise in section 8 from the pack’s.
Where the payload is stored, and how a write avoids destroying an existing file, are storage questions rather than format questions. ADR-014 answers them.
3. Format overview
A payload of format version 1 is exactly 84 bytes. There is no length prefix and no trailing data, so a truncated file is detected before anything is interpreted and a device that keeps a payload in a fixed region needs no length record of its own.
Every multi-byte field is unsigned and little-endian, and every field is written from a value rather than by copying structure storage. Padding, alignment, enumeration width, and host byte order cannot reach the file, which is what lets the same 84 bytes cross between a desktop and an ESP32.
4. Field table
| Offset | Size | Field | Encoding |
|---|---|---|---|
| 0 | 4 | magic | the bytes P, E, T, S |
| 4 | 2 | format version | unsigned 16-bit, value 1 |
| 6 | 8 | elapsed time | unsigned 64-bit milliseconds |
| 14 | 8 | update count | unsigned 64-bit |
| 22 | 8 | saved at | unsigned 64-bit UTC milliseconds since the Unix epoch, 0 when unknown |
| 30 | 1 | activity | unsigned 8-bit PetActivity value |
| 31 | 1 | expression | unsigned 8-bit PetExpression value |
| 32 | 8 | activity since | unsigned 64-bit milliseconds |
| 40 | 8 | expression since | unsigned 64-bit milliseconds |
| 48 | 1 | name length | unsigned 8-bit, 1 to 31 |
| 49 | 31 | name bytes | the name, with every byte after the length set to zero |
| 80 | 4 | integrity check | unsigned 32-bit CRC-32 over bytes 0 to 79 |
The magic and the version occupy the first six bytes, so a reader decides whether it understands a payload before it interprets anything else.
5. Field meanings
The elapsed time is the world clock, which is the total simulation time the world has been advanced by. It is not a wall clock and it never runs on its own, because ADR-002 makes every advance an explicit caller-supplied delta.
The update count is the number of accepted updates the world has performed. It is a record of how the world reached its state rather than an input to any rule.
The saved at field is the host’s wall clock at the moment of the save, in UTC milliseconds since the Unix epoch, and the value 0 means the host had no absolute time. ADR-012 added it, and section 6 states what a load does with it.
The activity and the expression are the two enumerated regions of pet state, each written as its numeric value in one byte. The two since fields are the world-clock stamps at which the pet entered its current activity and its current expression, so a load restores a pet mid-dwell rather than restarting its timers.
The name is stored as a length and 31 bytes. The length is at least 1 and at most 31, no byte inside the length may be zero, and every byte after the length must be zero. The terminator is not stored, because the length and the padding rule already determine it.
Two fields of the world are deliberately absent. 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 arbitrary bytes assert that they are a valid world. The name terminator is derived for the reason just given.
6. Absence on load
A load takes the host’s current wall clock as well as the payload. When both that value and the saved at field are known, and the current value is greater, the difference is the absence.
The absence is clamped to 604800000 milliseconds, which is seven days. It is added to the world clock without replaying any update, so a pet that returns after a month returns with a bounded amount of time added rather than with a month of simulation behind it.
The absence is zero when either side has no clock, when the clock did not move forwards, and on every host that has not been given an absolute time source. A load that would overflow the world clock is rejected rather than wrapped.
What an absence should feel like to a returning user is a product question this format does not
answer, and LIM-014 records that boundary.
7. Acceptance rules
A reader validates a payload in this order, and a rejection at any step leaves the destination world byte for byte unchanged.
- the supplied length is at least 6 bytes, so the magic and the version can be read
- the magic bytes are
P,E,T,S - the format version is 1, and any other version is rejected as an unsupported version rather than as a malformed payload
- the supplied length is exactly 84
- the CRC-32 over bytes 0 to 79 equals the stored check
- the activity and the expression are values this Core knows
- the name length, its bytes, and its zero padding follow section 5
- adding the absence to the elapsed time does not overflow
- the assembled world passes the Core’s own state validation
The world is assembled in Core-owned storage and copied to the caller only after the last step passes, so no partly restored world is ever visible.
The integrity check is CRC-32 as used by IEEE 802.3, computed bitwise with the reversed polynomial
0xEDB88320 so that no lookup table is carried. It detects damage. It is not a signature and offers
no protection against a payload somebody crafted deliberately, which is consistent with the
local-first principle that a file the user owns is the user’s to edit.
8. Compatibility promise
Version 1 is written and version 1 is the only version read. A payload naming another version is rejected rather than interpreted, and no migration, downgrade, or forward compatibility is promised.
The promise is wider than the pack’s in one way that matters. A save payload is user data that must survive the process, the application version, and in principle the machine that wrote it, so the format is fixed, explicit, and platform neutral by design. The pack is a build output that never leaves the firmware image that reads it, so its identical-looking version field exists only to catch a mismatch.
Adding a field is a format revision rather than an accident. The version field exists to make that a normal event rather than a break.
9. Implementation
pet_world_save and pet_world_load in include/pet/save.h are the whole of the surface, and
PET_SAVE_CAPACITY is the 84-byte length. The Core allocates nothing, touches no file, and retains
no caller pointer, so the encoding is a pure function of its arguments and the same world always
produces the same bytes.
Reaching a file is the storage component’s work rather than the Core’s.
components/storage carries the contract and its desktop implementation writes through a temporary
file, which ADR-014 decided. No embedded
implementation exists, and LIM-015 records that a device cannot yet keep a pet across a power
cycle.