Libretro Memory Maps: Exposing Console Addresses Without Leaking Host Pointers
How libretro memory IDs and experimental address maps differ, how to describe mirrors and banks safely, and how frontends can validate mappings.
A frontend may need to inspect a game’s save memory, show a memory viewer, or implement a generic cheat search. The emulator, meanwhile, stores bytes in host allocations and presents a virtual console address space that can contain banks, mirrors, holes, and memory-mapped devices. These are related views, but they are not the same thing. Libretro has both a small set of memory-region identifiers and an experimental interface for describing address maps. A core should expose only the contract it can keep correct.
Memory IDs identify regions, not the console bus
The retro_get_memory_data() and retro_get_memory_size() functions use IDs such as RETRO_MEMORY_SAVE_RAM, RETRO_MEMORY_RTC, RETRO_MEMORY_SYSTEM_RAM, RETRO_MEMORY_VIDEO_RAM, and RETRO_MEMORY_ROM. These names let a frontend request a broad region without understanding a console’s bus decoding. They do not say where the bytes appear in a CPU address space, which bank is active, or whether two visible addresses alias the same physical byte.
The API explicitly allows a core to return NULL or size zero when a region does not apply. A cartridge may not have battery-backed save memory; an implementation may not expose a separate video RAM allocation; or a core may represent complex persistent state through files rather than one contiguous buffer. The header says that complex save data can use the save directory, preferably, or system directory callback. Returning a made-up zero-filled block to satisfy a frontend is worse than reporting no applicable region because the frontend may treat it as real writable state.
Memory IDs are useful for broad features such as a save-RAM manager or an inspector that labels “system RAM.” They are not a substitute for a CPU bus map. A Game Boy RAM allocation can be contiguous in the emulator while visible at several banked addresses; a console can mirror a small RAM device across a much larger decoded range. The frontend cannot reconstruct those rules from a pointer and a byte count.
Address descriptors add a separate, experimental view
RETRO_ENVIRONMENT_SET_MEMORY_MAPS accepts a retro_memory_map containing an ordered array of retro_memory_descriptor entries. The API marks this environment command experimental and describes it as a way to map addresses in emulated console space to host memory. This is primarily useful to frontends that can use a generic map for features such as cheats. It is optional: a core must continue working when a frontend rejects the command, and a frontend must not assume every core or frontend supports it.
The descriptor fields express more than a base pointer and length:
addrspacenames a bus or logical address space, for exampleCPUorWRAM. Names are case-sensitive, restricted to a short character set, and limited to eight characters plus the terminator.startis an address in the emulated system, not a host pointer.ptrpoints to the corresponding host allocation. The header requires that it remain valid for the duration of the session and not be moved by the core.offsetselects an offset into the host allocation for the mapping.selectmarks address bits that participate in selecting a descriptor. With zero,startandlendescribe a complete power-of-two mapping.disconnectmarks address bits not connected to the emulated memory chip’s address pins. This can represent mirror behavior when used correctly with the other fields.lenbounds the backing region. The header describes how the high bits are cleared when the resulting address exceeds the region.
Array order matters when regions overlap: the first descriptor that claims a byte wins. That is significant for cartridges where a banked ROM range overlaps a special hardware aperture, or when a frontend is told about both RAM and a broader ROM mapping. A null pointer can describe a known unmapped range; such a descriptor should not carry memory-use flags.
Keep storage stable for as long as it is advertised
A minimal core may have a fixed 2 KiB RAM array at CPU address zero. The following sketch shows a single contiguous address range; it is illustrative C using the public structure names, not a complete core:
static uint8_t work_ram[2048];
static struct retro_memory_descriptor cpu_regions[] = {
{
.ptr = work_ram,
.start = 0x0000,
.len = sizeof(work_ram),
.addrspace = "CPU"
}
};
static bool publish_cpu_map(retro_environment_t environ_cb)
{
struct retro_memory_map map = {
.descriptors = cpu_regions,
.num_descriptors = sizeof(cpu_regions) / sizeof(cpu_regions[0])
};
return environ_cb(RETRO_ENVIRONMENT_SET_MEMORY_MAPS, &map);
}
The backing array is static so a map does not point into a temporary stack allocation or a vector that may reallocate. A real core should choose publication timing that matches its content lifecycle and frontend expectations, update the map when loaded content changes the address layout, and ensure the memory remains valid until it is no longer advertised. Do not publish a pointer into a bank object that is destroyed on retro_unload_game() while a consumer can still use it. If the frontend returns false, keep ordinary emulation and other memory IDs functional.
The example deliberately describes one exact region and omits select and disconnect, leaving both zero. Real banked hardware needs a descriptor set that models the visible address ranges and selected host offsets. A bank switch changes which physical bytes answer a console address; it does not necessarily relocate the core’s RAM. Where the API’s descriptor fields cannot express a hardware behavior faithfully, exposing less is safer than presenting a false linear map. The core can still offer the broad stable memory ID or a core-specific debugger interface for richer behavior.
Common mapping mistakes
Treating a host pointer as an emulated address. A pointer such as 0x7f... has meaning only in the host process. Set start to the console address and ptr to the host buffer. Never derive one from the other.
Advertising every cartridge byte as ROM. ROM can occupy different banks or address windows, and some addresses decode to registers or open bus. ROM IDs may expose a byte array for inspection, while descriptors should express only the mapping the core actually emulates.
Forgetting mirrors. If a 2 KiB RAM device is visible in multiple CPU ranges, a map that lists only one range is incomplete for address-based tools. Conversely, describing a mirror incorrectly can cause writes through one address to appear disconnected from another. Verify aliases against the emulator’s actual bus decoder.
Using descriptor order accidentally. Overlapping descriptors are resolved in array order, not by an implicit “most specific wins” rule. Put the intended higher-priority mapping first and test an address that could match both descriptors.
Moving or freeing memory. A std::vector resize, bank replacement, save-state restore that swaps the allocation, or core unload can invalidate an advertised pointer. Keep allocations stable or republish a valid map according to the lifecycle contract.
Confusing persistence with inspection. A frontend that can peek at Save RAM does not automatically know how to commit it atomically or interpret a console-specific checksum. Serialization state, battery-backed memory, RTC bytes, and a memory map have different semantics.
Test the map independently from the game
Use a small deterministic test core with a documented map. Populate different known patterns in each region, place a mirror at two addresses, and switch a bank while recording the mapping before and after. A frontend-side test harness should check each descriptor’s address namespace, visible bounds, offset, alias behavior, holes, and overlapping priority. If the frontend implements generic cheat search, verify both reading and writing through the address it reports.
Then test lifecycle boundaries: load content, unload it, load a different mapper, restore a save state, and shut down. The map must not expose freed memory or continue reporting a previous game’s bank layout. Run once with a frontend that accepts memory maps and once with one that rejects the experimental command. The unsupported case is a normal compatibility path, not an emulator failure.
For a core review, record the frontend and core versions, the memory API feature result, loaded content hash, and the address ranges tested. Keep the generic IDs useful even when maps are unavailable. Treat a map as a precise debugger interface: if an address is undocumented, bank state is stale, or a target pointer is unstable, the frontend can turn a small metadata error into a visible data corruption bug.
The distinction is the key design rule. Memory IDs expose named regions; descriptors explain how selected emulated addresses reach backing memory. Both can be valuable, but neither means “here is an unrestricted pointer to the whole machine.”
Related:
- Libretro’s Environment Callback: How Cores Negotiate Features with Frontends
- Battery-Backed Save RAM and Real-Time Clocks: Persistent State Beyond Save States
Sources: