Skip to content
RetrogamingDeep Dive Published Updated 8 min readViews unavailable

Libretro Disk Control: Multi-Disc Ejection, Indexing, and Safe Image Changes

Implement libretro multi-disc control with negotiated callbacks, tray-state rules, playlist indexing, initial-image validation, and safe swap tests.

Multi-image emulation needs more than a file picker. A frontend must know which images belong to one session, while the core must translate “eject,” “select image 2,” and “close tray” into the state of the emulated drive. Libretro exposes that boundary through disk-control callbacks. The API is intended for multi-disk games that require a manual swap, and it applies to optical disks, floppies, and other media that can change while the emulated machine is running.

This is an API implementation guide, not a disc-ripping article. Physical-media capture and verification are covered in the optical preservation guide; storage formats such as CHD are explained in the CHD format deep dive. The concern here is the runtime protocol between a core and its frontend.

Negotiate the interface before content loading

The original RETRO_ENVIRONMENT_SET_DISK_CONTROL_INTERFACE accepts a retro_disk_control_callback structure. The current canonical header deprecates this version in favor of RETRO_ENVIRONMENT_SET_DISK_CONTROL_EXT_INTERFACE, which provides additional information about disk images. A core can query RETRO_ENVIRONMENT_GET_DISK_CONTROL_INTERFACE_VERSION; when the frontend reports version 1 or later, the header says to use the extended interface. If the query is unavailable or reports no extended support, the core should use the legacy callback only as the compatibility path it is intended to be.

Register the extended interface in retro_init() where possible. The header specifically recommends this phase because set_initial_image() must be available before retro_load_game() for a frontend to select a playlist entry as the first disk. Registering too late can make a callback that the core implements unreachable for the initial-content path.

static struct retro_disk_control_ext_callback disk_control = {
    .set_eject_state = disk_set_eject_state,
    .get_eject_state = disk_get_eject_state,
    .get_image_index = disk_get_image_index,
    .set_image_index = disk_set_image_index,
    .get_num_images = disk_get_num_images,
    .replace_image_index = disk_replace_image_index,
    .add_image_index = disk_add_image_index,
    .set_initial_image = disk_set_initial_image,
    .get_image_path = disk_get_image_path,
    .get_image_label = disk_get_image_label
};

static void register_disk_control(void)
{
    unsigned version = 0;
    bool has_version_query = environ_cb(
        RETRO_ENVIRONMENT_GET_DISK_CONTROL_INTERFACE_VERSION, &version);

    if (has_version_query && version >= 1) {
        if (environ_cb(RETRO_ENVIRONMENT_SET_DISK_CONTROL_EXT_INTERFACE,
                       &disk_control))
            return;
    }

    register_legacy_disk_control_if_supported();
}

The callbacks shown are function names, not drop-in implementations. The environment callback receives a void * payload, while the extended structure contains required callbacks plus optional image metadata and initial-image hooks. A production core should fill each required function and only publish optional callbacks whose behavior it can honor. If the frontend refuses the extended command, do not behave as though registration succeeded.

Model the virtual tray as a state machine

The header documents the ordinary swap sequence: ask the core to open the emulated tray with set_eject_state(true), select a new index with set_image_index(index), and close the tray with set_eject_state(false). The frontend may only set the disk index while the tray is open. The core should report the actual ejected state through get_eject_state(), and an idempotent request to set the tray to its existing state should succeed without doing extra work.

Hardware behavior still governs the answer. Some emulated systems cannot open or close the tray at arbitrary times; the core can return false when it needs to wait for an operation such as a disc spin-down. Do not report success while continuing to read from the previous image. The point of the callback is to let the frontend request a state transition, not to bypass the emulated machine’s media protocol.

The current image index starts at zero for the first image. If no image is inserted, the API permits get_image_index() to return a value greater than or equal to get_num_images(). Selecting an index in that same out-of-range range represents removing the current disk while the tray is ejected. Validate ordinary image indices against the current count, because add/remove operations can change the available range during a session.

Keep playlist order and media identity consistent

The frontend determines the ordering of image indices, commonly from an M3U-formatted playlist. The core’s get_num_images() and get_image_index() must describe the same list that the frontend is displaying. If playlist entries are added or removed, update the count and current index atomically from the perspective of subsequent callbacks; a stale index can point at the wrong disk after a removal shifts later entries.

The extended interface’s optional set_initial_image(index, path) solves a specific startup problem. retro_load_game() alone does not tell the core which image in a playlist the frontend intends to insert first. The frontend may call set_initial_image() immediately before loading content. The core should retain that request and verify during retro_load_game() that the index exists and the image at that index matches the supplied path. If the playlist changed and the pair no longer matches, the header instructs the core to ignore the request and insert image zero rather than opening an unintended disk.

The initial-image callback should not itself load the path. Its purpose is to communicate selection metadata to the core before content loading. The core remains responsible for validating the path against its own content list when it initializes the emulated machine. This avoids a race in which a user edits a playlist after the frontend has selected an index but before the core resolves its media.

The optional get_image_path() and get_image_label() callbacks let a frontend report or display more useful disk information. The header notes that recording or restoring the last-used disk index requires both set_initial_image() and get_image_path() to be implemented. A friendly label can include information such as “installation disk” or “bonus disc,” but it should describe the selected image, not silently change its identity.

Distinguish changing a loaded image from editing the list

set_image_index() changes which existing image is in the virtual drive. replace_image_index() substitutes a new retro_game_info at an existing playlist index, and add_image_index() reserves a new index that is not usable until it is filled through replace_image_index(). These are list-management operations, not synonyms for a routine disc swap.

The header explicitly requires the virtual tray to be ejected for replace_image_index(). add_image_index() reserves an empty slot; that new index is not usable until the core populates it with replace_image_index(), which has the same ejected-tray precondition. Passing NULL to replace_image_index(index, info) removes that image from the frontend’s internal list and shifts subsequent indices down. That means removing index one changes the numeric identity of what had been index two. The core should update any current selection, displayed label, cached file descriptor, and playlist metadata before returning success. If the content source cannot be replaced cleanly, return false and preserve the previous valid list rather than claiming a partial update.

This distinction matters to games that allow a player to append a disc during a session or to swap between disc revisions. Changing the selected image means “the current virtual drive now contains another item in the existing list.” Replacing an image means “the list itself changed.” A frontend UI should make the two actions distinct enough that the player does not accidentally remove a playlist entry while trying to perform an ordinary in-game swap.

Coordinate media state with reads and saved state

Before closing an old image, finish or cancel outstanding reads and release handles owned by that media. If the emulated platform writes to the image or associated memory card, flush only the state that belongs to the old medium and respect the emulated device’s own eject sequence. Do not destroy a persistent save merely because the frontend has selected a different disc.

Disk position and drive state can also matter to a save state. If the core serializes a machine while the tray is open, the selected index and any pending media transition need a consistent representation. A state restored with a different playlist order must not silently load another image at the same numeric index. The extended interface’s initial-image and path hooks help the frontend preserve identity, but the API does not guarantee that every core, frontend, or state container stores a complete playlist manifest. Keep the content and playlist identity as part of the user’s reproducibility record.

The older interface remains useful for compatibility with frontends and cores that predate the extended callbacks, but it has fewer ways to expose path and label details. A core should not register both interfaces without a deliberate policy for which one is authoritative. Query once at a lifecycle point where the result is valid, choose the best supported contract, and keep the internal disk-list state behind one implementation layer.

Test both normal swaps and invalid transitions

Validate a small but adversarial matrix before shipping a core’s disk support:

  1. Load one image and verify count, index zero, initial tray state, and path/label reporting.
  2. Load a multi-image M3U playlist and confirm the displayed ordering equals the core’s count and index mapping.
  3. Request ejection, select the next valid disk, close the tray, and verify the emulated machine reads from the new image.
  4. Repeat an already-satisfied eject or close request and verify it is safely idempotent.
  5. Attempt an index change with the tray closed and confirm the core rejects it without changing media.
  6. Eject with no disk inserted and verify the API’s out-of-range empty-drive index is reported consistently.
  7. Test add, replace, and removal while ejected, including index shifts and a failed replacement that must leave the prior list usable.
  8. Request a nonzero initial image, then change or reorder the M3U file before content loading; verify path/index validation chooses the documented fallback.
  9. Save and restore around tray-open and tray-closed states, and test the expected behavior if the playlist identity differs.

Record frontend and core revisions, playlist hash and ordering, image hashes, selected initial index, and the return values of each transition. A successful callback alone is not enough; the emulated drive must behave as if the user performed the intended hardware action. With those contracts explicit, multi-disc control becomes a tested state machine rather than an unexplained sequence of menu clicks.

Related:

Sources:

Comments