Skip to content
RetrogamingDeep Dive Published Updated 8 min readViews unavailable

The Libretro Content-Loading Contract: Paths, Memory, Patches, and Teardown

Implement libretro content loading without assuming paths: understand retro_game_info, need_fullpath, soft-patching, AV negotiation, and unload lifecycle.

A libretro core is a shared library loaded by a frontend, but content is not necessarily a normal file path handed to the core. The frontend may read a game into memory and pass bytes to retro_load_game(), or it may provide a path when the core declares that it needs full-path access. That choice affects soft-patching, archive handling, standard input, large files, multi-file content, and resource lookup. A core that assumes game->path is always present can work in one launch path and fail in another.

The libretro core-development documentation and libretro.h define the API contract. The exact frontend behavior can evolve, so compile against the API header you target and test with more than one frontend. The key rule is not “always read the path” or “always trust the memory buffer”; it is to implement the mode the core advertises and honor the fields that are valid for that mode.

retro_system_info declares the loading model

During core initialization, retro_get_system_info() reports system metadata including supported extensions and need_fullpath. If need_fullpath is false, the frontend can load content into memory and pass a data pointer and size. In this mode, retro_game_info.path is not guaranteed to be non-null; the content may come from a stream or another source without a meaningful filesystem path. The documentation recommends this mode when possible because it enables features such as soft-patching.

If need_fullpath is true, the frontend provides the path and may leave the in-memory data fields empty. The core is responsible for opening and reading content. The path can be relative or absolute, so code should resolve it using the relevant frontend/file-access facilities rather than assume a fixed current working directory. Full-path mode is useful when the engine must reopen a large file, access sidecar assets, or operate directly on a directory structure.

Do not set need_fullpath to true merely because file I/O is easier than parsing an in-memory buffer. That decision can disable or complicate frontend transformations. Conversely, do not set it false if the engine fundamentally requires later random access to a large container and has no safe way to own or map the supplied data for the required lifetime. Document the tradeoff and test the actual content formats.

retro_game_info is a tagged-by-contract structure

The content argument contains path, data, size, and optional metadata. The fields are not all valid in every load mode. In memory mode, copy or retain the supplied bytes only as permitted by the API lifetime and frontend contract; do not assume the pointer remains valid after unload. In full-path mode, open the provided path and report failure cleanly if it cannot be read.

The core should validate size before parsing. Check minimum header lengths, overflow when computing offsets, and format signatures. A content path or metadata field is untrusted input: do not use it to construct an unchecked path outside the expected content root. That is ordinary robust parsing, not a permission to access arbitrary host files.

A safe high-level loading pattern is:

bool retro_load_game(const struct retro_game_info *game)
{
    if (game == NULL)
        return load_contentless_mode_if_supported();

    if (system_info.need_fullpath)
        return load_from_frontend_path(game->path);

    if (game->data == NULL || game->size == 0)
        return false;

    return parse_or_copy_content(game->data, game->size, game->meta);
}

This is an architectural example, not a drop-in implementation: many cores do not support contentless startup, and a production core should decide that behavior deliberately. If it supports no content, advertise and handle that path consistently. Do not make NULL content silently launch a previous game’s state.

Patching and archives belong to the loading contract

When the frontend loads content into memory, it can apply supported soft patches before the core sees the bytes. If a core requires full-path access and reads the original file itself, it may bypass the frontend’s patched data. A title that launches but ignores a patch is therefore not automatically evidence of a bad patch file; it may be a loading-mode mismatch.

Archive support also depends on the frontend and core metadata. A frontend may extract or stream an archive entry, supply a path to a temporary file, or provide the decompressed bytes in memory. The core should not infer archive semantics from a suffix alone. Test ordinary files, compressed content where supported, patch files, and non-filesystem sources that exercise the missing-path case.

Multi-disc workflows are related but distinct. retro_load_game_special() and the disk-control interfaces have their own subsystem and media-change contracts. Do not overload retro_load_game() with a private comma-separated path convention if the frontend/core API already offers a defined multi-content path. At the same time, a multi-file application may legitimately use full-path mode to locate sibling assets; document whether the core consumes a descriptor file or a raw image.

Initialization, AV negotiation, and failure cleanup

After a successful content load, the frontend asks for system AV information. The core should report accurate geometry, aspect ratio, frame rate, and audio sample rate for the loaded system. These values can depend on the selected game or region, so they should not be frozen before content is known if the API permits querying them after load. Incorrect rates can cause recordings and audio to drift even if rendering appears fine for a short session.

If loading fails partway through, release allocated buffers, media handles, and hardware state before returning false. The frontend may then show an error or attempt another content. A half-initialized core can contaminate the next load with stale save RAM, event queues, or audio output. Make loading transactional: build temporary state, validate it, then publish it as the active machine only when the required components are ready.

retro_unload_game() is the matching teardown boundary. Stop audio/event production for the old machine, close content handles, release owned memory, clear pending callbacks/state, and preserve only data that the core’s save/persistence contract says to keep. Do not free frontend-owned buffers that the API did not transfer ownership of. Conversely, if the core copied bytes into its own storage, release that copy at unload.

Reset and unload are not interchangeable. Reset returns the emulated machine to a defined reset state while retaining loaded content; unload ends the content session. A frontend can load a second game without destroying the core library. Test repeated load → run → unload → load sequences in one process, not only one clean launch per application start.

Paths, VFS, and portability

When full-path access is required, use the path supplied by the frontend and its supported file-access interface where appropriate. Avoid hard-coding platform separators or assuming that user data, system BIOS, and game content share one directory. The libretro VFS interface exists to abstract file operations and can help cores work with frontend-managed locations; capability negotiation and fallback behavior need to follow the API version and environment support.

The core should distinguish content path from system directory and save directory. Firmware files are not necessarily siblings of the game. Save files should follow the frontend’s save-path contract rather than being placed next to content by default. Log which path class failed, but avoid exposing sensitive absolute paths unnecessarily in user-facing errors.

If the application uses sidecar files, define whether they are discovered relative to the content path, an extracted archive directory, or a frontend-provided asset/system directory. Validate the behavior under a relative path and an absolute path. Also test spaces, Unicode names, long paths, and a path that no longer exists at the time a later reopen is attempted.

Conformance matrix

For a core with memory mode, test a valid byte buffer with a null path, a buffer with metadata, a zero-size buffer, malformed headers, a frontend-applied patch, and a compressed entry if supported. Verify that the core copies or consumes bytes within the documented lifetime and does not keep a dangling pointer after return.

For a full-path core, test relative and absolute paths, a missing file, permissions failure, a large image requiring random access, and a content source that has no filesystem path. The last case should fail clearly if full-path semantics make it unsupported rather than crash or reuse stale content.

In both modes, test successful load, failed partial load, reset, unload, and a second load in the same process. Check retro_get_system_av_info() after content selection. Verify that retro_run() calls input polling at least once per frame and emits the required video callback behavior; content loading is only successful if the frontend can then run the machine consistently.

Acceptance criteria

A production core declares a loading mode based on real requirements, treats path/data/size according to that declaration, handles failure transactionally, and cleans up without confusing reset with unload. Soft-patches and archive workflows are either supported and tested or explicitly documented as limitations. AV metadata is accurate for the loaded content, and one frontend’s launch behavior is not mistaken for the whole libretro contract.

The frontend/core boundary is a protocol. Once the core treats content as either owned bytes or an external resource according to an explicit declaration, loading bugs become reproducible API mismatches rather than filesystem folklore.

Related:

Sources:

Comments