The Libretro VFS Contract: Portable File Access Without Path Assumptions
Implement the libretro Virtual File System interface with explicit version negotiation, handle ownership, partial I/O checks, and portable path handling.
Libretro cores often need to open game media, firmware, patches, databases, or save files. Calling the host C library directly can work for ordinary local paths, but it assumes that a path string has native filesystem meaning and that the core owns the relevant platform policy. A frontend may present content through archive paths, sandboxed locations, virtual filesystems, or platform-specific storage. The libretro Virtual File System (VFS) interface gives a core a frontend-provided file-operation table so it can access those resources without hard-coding every host’s path and permission model.
VFS is an API contract, not a guarantee that every frontend implements every interface version or that every path is readable. A core negotiates the interface, checks returned functions, and handles unavailable features explicitly. It must respect handle ownership, mode flags, partial reads/writes, and frontend-owned strings. A successful open is also not proof that content is the expected game or firmware; validate format, size, and integrity at the application layer.
Negotiate the interface during initialization
The core obtains the interface through the environment callback RETRO_ENVIRONMENT_GET_VFS_INTERFACE. The request includes a minimum interface version, and the frontend may return a compatible version and function table. The official header recommends requesting it as early as possible. Store the frontend-provided interface pointer only for the lifetime specified by the API and never free or modify the table.
A simplified C-style example shows the negotiation shape. Names and error handling are illustrative; use the exact definitions in the header bundled with the core:
static const struct retro_vfs_interface *vfs;
static bool acquire_vfs(retro_environment_t environ_cb)
{
struct retro_vfs_interface_info info = {0};
info.required_interface_version = 3;
if (!environ_cb(RETRO_ENVIRONMENT_GET_VFS_INTERFACE, &info))
return false;
if (!info.iface || !info.iface->open || !info.iface->close ||
!info.iface->read || !info.iface->size)
return false;
vfs = info.iface;
return true;
}
The required_interface_version should match the functions the core actually needs. Do not request a newer version simply because it exists; older frontends may decline the request. Conversely, do not call an optional function pointer without checking it. Feature negotiation should degrade cleanly: if a core cannot load a frontend-specific path through VFS and has no safe fallback, return a precise content-load error instead of treating the path as an ordinary disk path.
Use file access modes intentionally
VFS open modes are bit flags, including read, write, and read/write. The update-existing flag is important for a write open that must preserve an existing file rather than discard its contents. Access hints such as frequent-access or sequential-bulk are optimization hints; the header specifies that they do not change function behavior. Do not rely on a hint for correctness.
An opened stream is an opaque retro_vfs_file_handle. Close it with the VFS close function exactly once. Do not cast it to a FILE *, inspect its internals, or pass it to standard C fread(). A path returned by get_path() is owned by the frontend and may only be used as documented; copy the string if it must outlive the callback’s validity window. Avoid retaining borrowed pointers across unload/reload boundaries.
Reads and writes return the number of bytes actually transferred, which can be smaller than the requested length. A robust loop handles short reads, end-of-file, and errors separately:
uint64_t done = 0;
while (done < wanted) {
int64_t n = vfs->read(stream, buffer + done, wanted - done);
if (n < 0)
goto io_error;
if (n == 0)
break; /* end of file before wanted bytes */
done += (uint64_t)n;
}
This is a pattern, not a complete loader. Check integer conversions, enforce a maximum file size before allocating, and decide whether early EOF is valid for the specific format. Do not assume one read returns the entire ROM or save file. When writing a save, check every return value, call flush where the interface and durability contract require it, and report failure to the frontend or user.
Paths, archives, and authorized locations
Content paths can represent frontend-specific resources. The VFS API allows the frontend to expose content locations and, through separate environment calls, authorized filesystem locations. Returned strings are frontend-owned; copy them if retained. Only use a path with the VFS functions that are designed to consume it. Concatenating a path with native separators or resolving it through realpath() can break virtual paths and can escape the frontend’s intended sandbox.
Keep path roles distinct. The content path identifies the game or image being loaded; a system directory may hold firmware or shared data; a save directory may hold per-title writable files. The frontend has separate environment queries for many of these roles. Do not derive save paths by blindly appending .srm to arbitrary content strings when an archive path, playlist alias, or sandbox URI is possible. Use the frontend’s directory policy and sanitize any core-generated filename component.
The VFS interface is not a ROM-database or archive-format guarantee. Some frontends can expose files within archives or special URI-like paths, while others may not. A core should document required content formats and use the frontend’s negotiated capabilities. If it needs random access, directory enumeration, rename, or a later-version copy operation, require the corresponding function and version or implement a safe fallback.
Directory iteration and metadata
Directory functions also use opaque handles. Open a directory, inspect entries through the VFS directory functions, distinguish files from directories using returned flags, and close the handle on every exit path. Directory order is not necessarily stable, so sort entries when deterministic behavior matters. Do not recursively traverse untrusted or broad directories without depth and entry limits.
Stat APIs have version-specific structures and flags. A valid stat result can indicate a directory, special file, or read-only state depending on the interface version. A file size may require a 64-bit function for large images; do not truncate a 64-bit size into a 32-bit integer. Validate that a path exists and is the expected kind before reading, but handle a race between stat and open because metadata can change after inspection.
Mutating operations such as remove, rename, mkdir, truncate, and copy should be limited to directories intended for core data. Avoid deleting or overwriting user files because a title ID or path-derived name collided. Write to a temporary file and use a supported atomic rename pattern when preserving a previous save is important; check whether the frontend’s VFS implementation provides the guarantees your workflow requires.
Fallbacks, failure reporting, and validation
If the frontend does not provide VFS, the core must decide whether standard file operations are valid for the resource in question. A local file path may be usable on desktop, but a sandbox URI may not be. Do not silently convert “frontend VFS unavailable” into a generic “file not found”; report which capability is missing and which path role failed. Keep compatibility behavior explicit and test both VFS-present and VFS-absent frontends.
Test a matrix: plain uncompressed content; a frontend-provided archive or virtual content path where supported; read-only content; a large file requiring 64-bit size; partial reads; missing or malformed data; save creation, update, flush and rename; and unload/reload. On Android or other sandboxed platforms, test authorized directory access rather than assuming desktop paths. Capture frontend logs and core messages without exposing copyrighted data in bug reports.
An acceptance test should demonstrate that the core requests only the minimum VFS version it needs, checks required callbacks, closes every handle, handles short I/O, respects path ownership, and produces a clear error when a capability is absent. Run the same load and save tests on at least one frontend with VFS and one supported fallback environment if the core promises both.
Libretro VFS makes file operations portable by moving filesystem policy to the frontend. The core still owns format validation, memory limits, save correctness, and useful error messages. Treat the interface table, path strings, and file handles as borrowed API objects, and validate all I/O rather than assuming host paths behave uniformly.
Related:
- The Libretro Content-Loading Contract: Paths, Memory, Patches, and Teardown
- How to Organize a ROM Collection with Proper Metadata and Artwork
Sources: