Libretro Rumble: Optional Interfaces, Motor State, and Port Lifecycles
Negotiate libretro rumble at the right lifecycle point, distinguish interface availability from motor support, and handle per-port failures safely.
Rumble is an optional controller capability, not a guarantee that every frontend, input driver, port, or attached device can provide vibration. A libretro core should discover the interface through the environment callback, issue bounded requests for the intended controller port, and treat an unsupported motor as a normal feature-availability result. A successful interface query and a successful request to a particular motor answer different questions.
The maintained libretro.h contract defines two independently controlled effects: RETRO_RUMBLE_STRONG and RETRO_RUMBLE_WEAK. The strong motor does not override the weak motor. Each state request passes a port, effect, and unsigned 16-bit strength in the inclusive range 0 through 0xffff; the callback returns whether the requested state was honored. That return value is the core’s feedback about this request, not a promise that the user’s controller physically produced a measurable vibration.
Query during the documented lifecycle
The core receives the frontend’s environment callback through retro_set_environment. Store that callback for later use, but do not query RETRO_ENVIRONMENT_GET_RUMBLE_INTERFACE from inside retro_set_environment: the header says to make the rumble query from retro_init() or retro_load_game(). The query payload must point to a real retro_rumble_interface object; the header calls behavior undefined when the payload is null.
#include <stdbool.h>
#include <string.h>
#include "libretro.h"
static retro_environment_t g_environment;
static struct retro_rumble_interface g_rumble;
static bool g_rumble_query_available;
void retro_set_environment(retro_environment_t environment)
{
g_environment = environment;
}
/* Call only from retro_init() or retro_load_game(). */
static void query_rumble_interface(void)
{
memset(&g_rumble, 0, sizeof(g_rumble));
g_rumble_query_available = false;
if (g_environment != NULL) {
g_rumble_query_available = g_environment(
RETRO_ENVIRONMENT_GET_RUMBLE_INTERFACE, &g_rumble);
}
}
static bool request_rumble(unsigned port,
enum retro_rumble_effect effect, uint16_t strength)
{
if (!g_rumble_query_available || g_rumble.set_rumble_state == NULL)
return false;
return g_rumble.set_rumble_state(port, effect, strength);
}
The environment callback returning true means the frontend exposes the rumble interface, even if the currently selected device cannot vibrate. The core should separately honor the Boolean returned by set_rumble_state for the selected port and effect. Do not report “controller rumble supported” merely because the interface query succeeded, and do not turn a per-request false into a fatal core-load error. A game can continue with visual or audio feedback when haptics are absent.
Also avoid a null-payload probe borrowed from other environment commands. The rumble command explicitly says null behavior is undefined. Query with the correctly typed structure, clear its storage before reuse, and preserve the query result independently from a later motor request result.
Keep port, motor, and strength separate
The port argument is the libretro controller port being addressed; the effect selects one of the two motors; the strength is the requested intensity. Model all three in your own code rather than keeping a single “rumble on” bit. A title may request a short strong pulse for impact and a longer weak effect for engine vibration, and each request should modify only its own effect.
static void report_impact(unsigned player_port)
{
const uint16_t strong_pulse = 0xc000;
if (!request_rumble(player_port, RETRO_RUMBLE_STRONG, strong_pulse)) {
/* Optional feature unavailable or request not honored. */
}
}
static void stop_requested_effects(unsigned player_port)
{
(void)request_rumble(player_port, RETRO_RUMBLE_STRONG, 0);
(void)request_rumble(player_port, RETRO_RUMBLE_WEAK, 0);
}
The example strength is illustrative, not a calibrated physical amplitude. The contract defines the numeric range, but does not provide one universal mapping from values to perceived force or duration across hardware. The 0 calls express a stop request for both channels. They are best effort: if the callback returns false because support is absent or the port is unavailable, the core cannot claim that a disconnected or unsupported device received the stop command.
Do not call the callback every frame with unchanged values unless the application deliberately needs that behavior. Track desired state per port and effect, update it on meaningful transitions, and make any retry policy bounded. A frontend can reject a request before the first retro_run(). The header documents that an early call may return false; do not permanently disable rumble after that one result. Retry at a later point when the feature is actually needed, and continue to honor the returned Boolean.
Handle lifecycle as a best-effort boundary
The libretro header defines how the interface is queried and how a request is made; it does not guarantee that a controller remains attached between requests or define a successful stop operation for a device that has disappeared. Treat game unload, core deinitialization, controller remapping, and frontend changes as points to clear the core’s cached local state. If the frontend lifecycle still permits a request, sending zero to both effects for ports whose state the core believes it changed is a reasonable best-effort cleanup policy. Record request failure as unavailable cleanup, not as proof that the device stopped vibrating.
Re-query at the documented initialization/load boundary when the core is given a new lifecycle opportunity; do not cache an unsupported result forever across a different load or frontend session. Likewise, do not retain assumptions about a specific physical pad based solely on a numeric port. Port-to-device mapping is controlled by the frontend environment and can change independently of a core’s game state.
Test each layer without a real controller
First, build a core-side mock around a fake environment callback. Verify that the rumble query is made with command 23 and a non-null interface structure from the allowed lifecycle function, and is not made from retro_set_environment. Have the mock return false and verify that gameplay continues without dereferencing an uninitialized callback. Have it return true with a valid set_rumble_state pointer, then test separate strong and weak requests for the intended port and strengths 0, 1, and 0xffff.
Next, make the mock request callback reject one motor or port. Confirm that the result is handled as an optional feature failure and that a later request can succeed; this covers the documented possibility that a call before the first retro_run() returns false. Test a zero-strength stop independently for each effect, and ensure local state is cleared even when the frontend declines the request so the core does not spin in an unbounded retry loop. The final policy for such a decline is product-specific; the core cannot make an unavailable device honor it.
Finally, validate with the actual frontend and device combinations the release supports. A mock proves the core’s control flow, not frontend hardware integration or physical motor output. Record the core version, frontend, input driver, port mapping, device, and whether requests were accepted. A no-rumble device is a valid test outcome, not a reason to make the core unusable.
The useful contract is intentionally small: obtain the optional interface at the documented point, distinguish interface availability from request acceptance, address the right port and motor, and treat stop requests and early failures as best effort. Once those boundaries are explicit, rumble remains a polish feature instead of a hidden dependency in the core’s load or gameplay path.
Related:
- Libretro Input Polling: Ports, Device IDs, Analog Ranges, and Timing
- Libretro’s Environment Callback: How Cores Negotiate Features with Frontends
Sources: