Input Bindings & Keybinding System
How to read, write, and capture keybindings — data structure, Lua and UI APIs, input capture flow, and critical cleanup for mod deactivation.
Reference for the GE-level keybinding system: how bindings are stored, read, written, and captured. This covers core_input_bindings (Lua) and the controls.js Pinia store (UI).
See Also
- Cross-VM Comms - How Lua and UI data flows
- UI Bridge - Lua to UI communication
- Anti-Patterns - Common mistakes to avoid
Data Structure
Bindings are organized per device. The canonical data lives in core_input_bindings.bindings (Lua) and is pushed to the UI via InputBindingsChanged event.
core_input_bindings.bindings = [
{
devname: "keyboard0",
contents: {
guid: "...",
devicetype: "...",
name: "...",
vidpid: "...",
displayName: "...",
bindings: [
{ action: "openPhone", control: "p", player: 0, ... },
{ action: "toggleParkingBrake", control: "x", player: 0, ... },
...
]
}
},
{
devname: "mouse0",
contents: { ... }
},
...
]Binding Entry Fields
| Field | Type | Description |
|---|---|---|
action | string | Action name (e.g. "openPhone") |
control | string | Raw control name - "p", NOT "keyboard_p" |
player | number | Player index (usually 0) |
lockType | number | Default 1 |
linearity | number | Default 1 |
isInverted | boolean | Default false |
deadzoneResting | number | Default 0 |
deadzoneEnd | number | Default 0 |
ffb | object | Force feedback config |
⚠️ Critical: Control strings are raw device names - "p", "button0", "x" - never prefixed with device type like "keyboard_p".
Reading Bindings
Iterate core_input_bindings.bindings → each device's contents.bindings[] looking for action match.
Lua Side
local function findBinding(actionName)
if not core_input_bindings or not core_input_bindings.bindings then return nil end
for _, device in ipairs(core_input_bindings.bindings) do
if device.contents and device.contents.bindings then
for _, b in ipairs(device.contents.bindings) do
if b.action == actionName and b.control and b.control ~= "" then
return b, device
end
end
end
end
return nil
endUI Side (Vue/controls.js)
The controls.js Pinia store caches all bindings. Use:
Controls.findBindingForAction(actionName, devName?, useLastDeviceOrder?)This reads from the in-memory store - it does not call Lua.
Writing / Saving Bindings
Pattern: modify device.contents.bindings array in memory, then save.
Adding a Binding (Lua)
local device = nil
for _, d in ipairs(core_input_bindings.bindings) do
if d.devname == deviceName then device = d; break end
end
table.insert(device.contents.bindings, {
action = "openPhone",
control = "p", -- raw control, no prefix
player = 0,
})
core_input_bindings.saveBindingsToDisk(device.contents)Adding a Binding (JS)
const device = bindings.value.find(b => b.devname === devname)
device.contents.bindings.push({
...bindingTemplate.value,
control: rawControl,
action: actionName,
})
lua.extensions.core_input_bindings.saveBindingsToDisk(device.contents)⚠️ Key Gotchas
-
Pass
device.contents, NOT the device wrapper.saveBindingsToDiskexpects the inner contents object withguid,devicetype,name,bindings[], etc.-- WRONG: saves garbage / wipes bindings core_input_bindings.saveBindingsToDisk(targetDevice) -- CORRECT: core_input_bindings.saveBindingsToDisk(targetDevice.contents) -
Saves ALL bindings for one device, not just one binding. It replaces the entire binding file for that device. Other devices are untouched.
-
saveBindingsToDiskhandles engine rebinding automatically. Vanilla never callsam:bind()directly - the save function triggers the engine to pick up the new bindings. -
Call
core_input_bindings.notifyUI()after saving to push fresh data to the UI:core_input_bindings.notifyUI("binding changed")
Capturing Input (Recording a Key Press)
To let the user press a key and capture what they pressed, follow this exact sequence:
Start Capture
// 1. Prevent bound actions from firing during capture
lua.ActionMap.enableBindingCapturing(true)
// 2. Prevent CEF from eating keystrokes
lua.setCEFTyping(true)
// 3. Forward raw input events to JS
lua.WinInput.setForwardRawEvents(true)
// 4. Listen for input
events.on("RawInputChanged", listener)Handle RawInputChanged
The event data contains:
{
devName: "keyboard0", // device identifier
control: "p", // raw control name
controlType: "...",
value: 1, // 1 = pressed, 0 = released
direction: "..."
}Validate by waiting for a press+release cycle on the same key.
Stop Capture (reverse order)
lua.ActionMap.enableBindingCapturing(false)
lua.WinInput.setForwardRawEvents(false)
lua.setCEFTyping(false)
events.off("RawInputChanged", listener)Refreshing UI Data
core_input_bindings.notifyUI("reason string")This triggers the InputBindingsChanged event on the UI side, which delivers the full bindings data to the controls store's _processBindingsData().
Custom Actions & Mod Lifecycle
Registering Custom Actions
Custom actions defined via JSON files (e.g. phone.json in your mod's input_actions/ folder) are NOT automatically rescanned when mods load. You must force a rescan:
extensions.reload("core_input_actions")Call this on mod startup (e.g. in onExtensionLoaded) to ensure your custom action is registered before any code tries to look it up.
⚠️ bindingsLegend.lua Fatal Crash
The vanilla bindingsLegend.lua iterates ALL saved bindings and looks up action metadata via the action registry. If it encounters a binding for an action that doesn't exist (e.g. "openPhone" after your mod is deactivated), it crashes with a nil actionInfo error.
This is a fatal crash on game startup - the user cannot recover without manually editing their binding files.
Prevention requires TWO things:
- Force rescan on startup: Call
extensions.reload("core_input_actions")in your mod's init so the action JSON is loaded beforebindingsLegendruns. - Clean up bindings on mod deactivation: Remove any custom action bindings from saved data when your mod unloads (see below).
Mod Deactivation Cleanup (CRITICAL)
If your mod adds custom bindings, you MUST remove them when the mod deactivates. Otherwise the game crashes on next boot because bindingsLegend.lua finds bindings for actions that no longer exist.
local function removeCustomBinding(actionName)
if not core_input_bindings or not core_input_bindings.bindings then return end
for _, device in ipairs(core_input_bindings.bindings) do
if device.contents and device.contents.bindings then
local newBindings = {}
local changed = false
for _, b in ipairs(device.contents.bindings) do
if b.action == actionName then
changed = true
else
table.insert(newBindings, b)
end
end
if changed then
device.contents.bindings = newBindings
core_input_bindings.saveBindingsToDisk(device.contents)
end
end
end
end
-- In your mod's unload/deactivation hook:
local function onExtensionUnloaded()
removeCustomBinding("openPhone")
-- Also reload core_input_actions to drop the now-unmounted action JSON
extensions.reload("core_input_actions")
endCustom Action JSON Loading Order
core_input_actions may not have your custom action registered when your extension code first runs. If you need to call actionToCommands() or any function that looks up action metadata, always check first and force reload if needed:
local cmds = core_input_actions.actionToCommands("myAction")
if not cmds then
extensions.reload("core_input_actions")
cmds = core_input_actions.actionToCommands("myAction")
endAnti-Patterns
| ❌ Don't | ✅ Do Instead |
|---|---|
Prefix control strings: "keyboard_p" | Use raw control: "p" |
Pass device wrapper to saveBindingsToDisk | Pass device.contents |
Call am:bind() manually | Let saveBindingsToDisk handle engine rebinding |
| Read bindings via custom Lua functions | Iterate core_input_bindings.bindings structure |
Forget to call notifyUI() after save | Always notify so UI stays in sync |
Complete Example: Add a Custom Keybinding
local function setCustomBinding(actionName, controlString, deviceName)
deviceName = deviceName or "keyboard0"
if not core_input_bindings or not core_input_bindings.bindings then
log("E", "bindings", "core_input_bindings not available")
return false
end
-- Find device
local device = nil
for _, d in ipairs(core_input_bindings.bindings) do
if d.devname == deviceName then device = d; break end
end
if not device then return false end
-- Remove existing binding for this action on this device
local newBindings = {}
for _, b in ipairs(device.contents.bindings) do
if b.action ~= actionName then
table.insert(newBindings, b)
end
end
-- Add new binding
table.insert(newBindings, {
action = actionName,
control = controlString, -- raw: "p", not "keyboard_p"
player = 0,
})
device.contents.bindings = newBindings
core_input_bindings.saveBindingsToDisk(device.contents)
core_input_bindings.notifyUI("custom binding updated")
return true
end