The Libretro Serialization Contract: State Size, Quirks, and Restore Tests
Define reliable libretro save states with size guarantees, serialization quirks, buffer contracts, deterministic restore tests, and realistic portability limits.
Libretro save states are not a file format that a frontend can interpret. They are an ABI contract: a core reports how many bytes it needs, writes an opaque representation of its emulated state into a caller-owned buffer, and later attempts to restore that representation. The frontend can store, copy, rewind, or transmit the bytes, but it cannot repair a core that omits a timer, includes a host pointer, or accepts a state that leaves the machine inconsistent.
This is narrower than a general explanation of what an emulator save state contains. The details of capturing an entire virtual machine are covered in the broader save-state article. Here the focus is the libretro boundary: the three calls, the size guarantee, newer serialization-quirk negotiation, deterministic testing, and the portability claims a core should or should not make.
The three calls form one indivisible capability
The API exposes retro_serialize_size(), retro_serialize(data, size), and retro_unserialize(data, size). A frontend asks for the required size, allocates a buffer, asks the core to serialize into it, and later passes the bytes back for restoration. The serializer and loader return Boolean success values. A nonzero size is a claim that serialization is implemented, not a hint that the frontend should try an experimental code path.
When a core does not support serialization, the documented signal is a zero size. The core should not return an arbitrary nonzero estimate, write a partial structure, and rely on a particular frontend to recover. If serialization is advertised, both saving and loading must be useful for the supported feature set. In the common case, that means the state contains enough information to continue the emulated machine without relying on transient host state that was never captured.
The frontend owns the buffer passed to the core. retro_serialize() must not retain its pointer after returning, and the loader must validate the supplied byte count before reading fields. A state buffer is opaque to the frontend and may be opaque to users as well. The API does not define a cross-core container, content hash, firmware manifest, or migration format. A frontend may wrap metadata around the payload, but the core must not assume every saved byte stream carries a portable identity envelope.
Size is a promise the frontend plans around
The core-development guide requires the frontend to ask retro_serialize_size() before serializing and to provide at least that much space. When the supplied buffer is larger than needed, the extra bytes should be ignored or zeroed. A core must never write past the caller’s size, and a frontend must not assume a buffer from another core, content, or session has the right capacity.
For the original compatibility contract, size can vary only downward between retro_load_game() and retro_unload_game(). This lets a frontend allocate once after loading content and know that later state writes still fit. The current canonical header also defines explicit variable-size serialization quirks. A core can request RETRO_SERIALIZATION_QUIRK_CORE_VARIABLE_SIZE; a frontend that supports the feature can acknowledge it with RETRO_SERIALIZATION_QUIRK_FRONT_VARIABLE_SIZE. Because the frontend clears unsupported flags during negotiation, a core must inspect the returned flags rather than assume its request was accepted. Without both sides agreeing to variable sizes, keep the non-increasing-size promise.
This distinction is important for systems whose state representation changes as content loads or as a dynamic subsystem is added. Do not silently increase the reported size midway through a session and hope that a frontend re-queries it before every frame. Either maintain a fixed-capacity serialization layout for the loaded game, or negotiate variable sizing through the defined quirk flags and handle a frontend that declines it.
Report special limits with serialization quirks
RETRO_ENVIRONMENT_SET_SERIALIZATION_QUIRKS lets a core describe limits that the frontend needs to know. The header says to set this from either retro_init() or retro_load_game(), but not both. The frontend clears flags it does not support, so the core should keep the returned value and make behavior consistent with what was accepted.
The flags communicate materially different limitations:
INCOMPLETEmeans states can be useful for ordinary save and load, but are not reliable for frame-sensitive features such as netplay or rerecording.MUST_INITIALIZEmeans serialization calls initially fail until the core reaches a defined ready state. The frontend must not interpret an early failure as a permanent lack of support.CORE_VARIABLE_SIZEandFRONT_VARIABLE_SIZEform the negotiation for size changes during a loaded-content session.SINGLE_SESSIONmeans a state cannot be restored outside the session that created it.ENDIAN_DEPENDENTandPLATFORM_DEPENDENTdisclose architecture constraints instead of allowing a byte stream to masquerade as portable.
These flags are not a substitute for accurate Boolean returns. If a particular capture fails because an asynchronous device has not reached a safe boundary, return failure. Do not write a plausible-looking partial state and mark it successful. A frontend that uses rewind or run-ahead may request state operations much more often than a player pressing “save”; the cost and failure mode therefore belong in the core’s normal run-time test plan.
Serialize machine state, not process implementation details
Libretro deliberately leaves the payload format to the core. A stable representation should encode values that determine future emulated behavior: CPU and coprocessor state, RAM, device registers, pending interrupts, scheduler deadlines, fractional clock accumulators, media position, and any deterministic random state. The exact set depends on the emulated machine. A cache can be omitted only if it is purely derived and can be invalidated and rebuilt without changing future output.
Raw host structures are a poor format unless their constraints are deliberate and disclosed. Structure padding, pointer values, compiler ABI, endianness, word size, and uninitialized bytes can make a state load only in the process that created it. Store stable values or explicit offsets, validate lengths and version fields, and reconstruct pointers and host resources after load. If a GPU context, audio device, file handle, thread, or operating-system callback cannot be serialized, rebuild or resynchronize it after restoring the emulated values.
The core should also define what happens when retro_unserialize() rejects data. Validate the whole payload before mutating live state when practical. If parsing can fail after partial mutation, restore the pre-load state or take the core back to a documented safe state. A failed load must not leave half the CPU from one snapshot and half the devices from another. Frontends cannot roll back hidden partial writes that the core never reported.
Frame-deterministic features need a stronger bar
A snapshot can be adequate for a user’s manual checkpoint and still be inadequate for rewind, tool-assisted recording, or rollback networking. The INCOMPLETE quirk exists precisely to distinguish common state save/load from features that require repeatable frame-by-frame restoration. These systems need the same input sequence and restored state to reproduce subsequent emulated behavior closely enough for their contract.
Host time, data races, callbacks arriving on worker threads, uninitialized bytes, and writes to files outside the emulated machine can all make two executions diverge. Synchronize workers around capture and restore. Keep libretro calls on the expected thread; the API does not guarantee thread safety. Define how audio history, video caches, asynchronous CD reads, and battery-backed memory are handled. A state that restores CPU registers but allows a worker to complete a stale read against the new timeline is not deterministic merely because its payload is complete.
Do not claim that a state from one core release is portable to another unless the core explicitly supports that path and tests it. The same is true across architectures when the payload depends on endianness or word size. If the core reports a single-session or platform-dependence quirk, a frontend should preserve that constraint in its UX; a file extension does not make the state portable.
A minimal caller-side pattern
The following helper shows the frontend-side ordering and returns an allocated payload to its caller, which becomes responsible for freeing it. Container metadata and persistence are deliberately outside this API fragment.
#include <stdlib.h>
static bool save_state_payload(void **out_data, size_t *out_size)
{
if (out_data == NULL || out_size == NULL)
return false;
*out_data = NULL;
*out_size = 0;
size_t size = retro_serialize_size();
if (size == 0)
return false;
void *buffer = malloc(size);
if (buffer == NULL)
return false;
if (!retro_serialize(buffer, size)) {
free(buffer);
return false;
}
*out_data = buffer;
*out_size = size;
return true;
}
The code must treat a false return as a failed capture and must not publish the buffer as a valid state. On load, validate the container and the exact payload length before calling retro_unserialize(), then honor its result. If size is negotiated as variable, query the size for the state operation that will be performed and ensure the frontend supports the relevant quirk; do not extrapolate the fixed-size path to that mode.
Test restoration as a deterministic experiment
Test the core with a fixed content image, firmware set, option set, and input sequence. Capture a state at a repeatable point, run a known number of frames, record output or a deterministic internal hash, restore the state, replay the same inputs, and compare again. Include transitions that stress hidden state: a timer boundary, DMA, a pending interrupt, audio-envelope changes, disc reads, controller serial shifts, and writes to persistent memory.
Test the size contract at multiple points after retro_load_game() and before retro_unload_game(). For the baseline path, assert that the value never increases. Test a frontend that clears unsupported serialization quirks as well as one that acknowledges them. Exercise MUST_INITIALIZE, false returns, undersized or malformed load buffers, repeated save/load cycles, unload followed by another content load, and failure in the middle of restore. Preserve regression fixtures only with their required core build, content, firmware, and platform metadata.
The acceptance record should state the core revision, frontend, platform, content identity, serialization quirks accepted, tests run, replay length, and comparison method. If only ordinary save/load is reliable, say so and set INCOMPLETE. A truthful boundary is more useful than advertising rewind or rollback based only on a successful call to retro_serialize().
Related:
- How Save States Work: Serializing an Entire Virtual Machine to Disk
- Inside Libretro: The Core/Frontend Architecture Behind RetroArch
Sources: