How Lighthouse’s Custom Controller Mapping System Works: A Deep Dive into the Mapper Architecture
Lighthouse implements a user-editable controller mapping layer through the Mapper namespace, which wraps SDL's gamepad API with a state-machine-driven binding system that persists mappings in standard gamecontrollerdb.txt format.
The HarbourMasters/Lighthouse project provides flexible controller support for classic game engines by letting players create custom mappings without editing configuration files by hand. This article examines the Mapper namespace implementation in src/port/Controller/Mapper.h and Mapper.cpp, tracing how raw SDL joystick input becomes persistent, reloadable controller configurations.
Core Architecture: The Mapper Namespace
The custom controller mapping system centers on three collaborating components:
MappingSession– A state machine that guides users through a predefined binding sequenceMapperWindow– The ImGui-based UI that displays prompts and visual feedback- Persistence layer – Functions that read and write
gamecontrollerdb.txt-compatible strings
This separation allows the binding logic to remain testable and UI-agnostic while providing an intuitive "click-to-bind" experience for players.
Detecting Raw SDL Input
The mapping process begins when MapperWindow opens an SDL_Joystick* handle for the selected device. In MapperWindow::PollDeviceForSession (Mapper.cpp lines 151-166), the window polls the joystick each frame and forwards raw events to the active session:
void MapperWindow::PollDeviceForSession() {
if (!mSession.IsActive()) return;
// Poll buttons
for (int i = 0; i < SDL_JoystickNumButtons(mJoystick); i++) {
if (SDL_JoystickGetButton(mJoystick, i)) {
mSession.ProcessButtonDown(i);
}
}
// Poll axes and hats similarly...
}
This polling approach ensures compatibility with any SDL-supported controller, regardless of whether SDL recognizes it natively.
The MappingSession State Machine
MappingSession manages the binding sequence through an ordered vector of target bindings (mOrder) and tracks progress via mCurrentStep. As defined in Mapper.h lines 64-84, the session advances only when the user provides the explicitly requested input type.
Axis Binding Thresholds
For analog stick bindings, the session implements a two-threshold system to prevent accidental triggers:
| Threshold | Value | Purpose |
|---|---|---|
AXIS_COMMIT_DISTANCE |
16000 | Binding is locked in when stick moves this far from center |
AXIS_RETURN_DISTANCE |
10000 | Session can advance when stick returns within this range |
These constants (defined in Mapper.h lines 66-68) ensure the user deliberately moves the stick rather than brushing it accidentally. The state machine requires the stick to exceed the commit distance, then return near center, before marking the binding complete.
A typical axis capture sequence works like this:
// Simplified from MappingSession implementation
void ProcessAxisMotion(int axis, Sint16 value) {
if (mCurrentStepWantsAxis()) {
if (abs(value) > AXIS_COMMIT_DISTANCE && !mCommitReceived) {
// Record which direction (positive/negative) and wait for return
mPendingBinding.axis = axis;
mPendingBinding.axisPositive = (value > 0);
mCommitReceived = true;
}
else if (mCommitReceived && abs(value) < AXIS_RETURN_DISTANCE) {
// Stick returned to neutral, binding is complete
mBindings[mCurrentStep] = mPendingBinding;
Advance();
}
}
}
Generating Standard-Format Mapping Strings
Once all bindings are captured, GenerateMappingString (declared in Mapper.h lines 11-13) produces a gamecontrollerdb.txt-compatible string. This format—widely used across SDL-based games—ensures portability and tool compatibility:
030000005e0400008e02000011010000,Xbox Controller,a:b0,b:b1,back:b6,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b8,leftshoulder:b4,leftstick:b9,lefttrigger:a2,leftx:a0,lefty:a1,rightshoulder:b5,rightstick:b10,righttrigger:a5,rightx:a3,righty:a4,start:b7,x:b2,y:b3,platform:Linux,
The mapping includes:
- GUID – Unique hardware identifier derived from
SDL_JoystickGetGUID - Name – User-editable display name
- Button mappings – Format
logicalButton:physicalInput(e.g.,a:b0means "logical A button maps to physical button 0") - Axis mappings – Format
axisName:axisIndexwith sign for direction (e.g.,leftx:a0) - Hat mappings – Format
dpadDirection:h0.bitmask(e.g.,dpup:h0.1)
Persistence and Runtime Loading
User mappings survive between sessions through two key functions (Mapper.h lines 19-22):
| Function | Purpose |
|---|---|
SaveUserMapping |
Appends a new mapping string to usergamepadmappings.txt |
LoadUserMappings |
Reads the file at startup and registers each entry via SDL_GameControllerAddMapping |
The loading sequence occurs early in initialization, before any game code reads controller state:
// Early in main() or equivalent init
void InitializeControllers() {
SDL_Init(SDL_INIT_GAMECONTROLLER);
Mapper::LoadUserMappings(); // Registers all user overrides
// SDL now uses custom mappings when opening controllers
SDL_GameController* pad = SDL_GameControllerOpen(0);
}
Because SDL's mapping system is global, the rest of the codebase needs no awareness of custom mappings. Functions like controller_copyFaceButtons in src/core1/pfsmanager.c (lines 75-82) read standardized SDL controller state that already reflects the user's configuration.
UI Integration: MapperWindow
MapperWindow (defined in Mapper.h lines 29-71) inherits from Ship::GuiWindow to provide the binding interface. Its responsibilities include:
- Device selection – Lists available
SDL_Joystickdevices - Visual controller diagram – Renders a gamepad with highlighted prompts
- Step-by-step guidance – Displays the current target binding from
mOrder - Live feedback – Animates when the session detects valid input
The window maintains loose coupling with MappingSession through an explicit interface:
// Starting a new mapping session
void MapperWindow::StartMapping() {
std::vector<int32_t> order = {
// Analog sticks first (requires commit/return sequence)
Mapper::AXIS_LEFTX_NEGATIVE, Mapper::AXIS_LEFTX_POSITIVE,
Mapper::AXIS_LEFTY_NEGATIVE, Mapper::AXIS_LEFTY_POSITIVE,
Mapper::AXIS_RIGHTX_NEGATIVE, Mapper::AXIS_RIGHTX_POSITIVE,
Mapper::AXIS_RIGHTY_NEGATIVE, Mapper::AXIS_RIGHTY_POSITIVE,
// Then face buttons
SDL_CONTROLLER_BUTTON_A, SDL_CONTROLLER_BUTTON_B,
SDL_CONTROLLER_BUTTON_X, SDL_CONTROLLER_BUTTON_Y,
// D-pad, shoulders, triggers, stick clicks, menu buttons...
};
mSession.Start(mJoystick, order);
mState = MappingState::InProgress;
}
End-to-End Flow: From Physical Input to Game Action
The complete custom controller mapping lifecycle in Lighthouse follows these stages:
- Initialization –
LoadUserMappings()parsesusergamepadmappings.txtand registers entries with SDL - User activation – Player opens the controller mapping UI and selects a device
- Binding sequence –
MappingSessionsteps throughmOrder, applying axis thresholds and debouncing - String generation –
GenerateMappingString()produces the canonical format - Persistence –
SaveUserMapping()appends tousergamepadmappings.txt - Runtime consumption – Game code reads SDL controller state, unaware of mapping origins
This design isolates mapping complexity in the Mapper namespace while ensuring the game receives consistent, logical button names regardless of physical hardware.
Key Files and Their Roles
| File | Contribution to Custom Controller Mapping |
|---|---|
src/port/Controller/Mapper.h |
Declares MappingSession, binding structures, GenerateMappingString(), and MapperWindow |
src/port/Controller/Mapper.cpp |
Implements state machine logic, file I/O, and UI polling |
src/port/Controller/ControlSchemes.h/cpp |
Applies input shaping (e.g., right-stick-to-C-buttons) on top of mapped input |
src/core1/pfsmanager.c |
Consumes final SDL controller state via controller_copyFaceButtons and related helpers |
usergamepadmappings.txt (runtime) |
Persistent storage in gamecontrollerdb.txt format |
Summary
- The
Mappernamespace insrc/port/Controller/Mapper.handMapper.cppprovides Lighthouse's custom controller mapping capabilities MappingSessionimplements a state machine withAXIS_COMMIT_DISTANCE(16000) andAXIS_RETURN_DISTANCE(10000) thresholds for reliable analog capture- Standard
gamecontrollerdb.txtformat ensures tool compatibility and portable mapping strings LoadUserMappings()andSaveUserMapping()handle persistence without requiring manual file editing- SDL's global mapping registry allows higher-level code to remain agnostic about custom configurations
Frequently Asked Questions
How does Lighthouse prevent accidental axis bindings during mapping?
The MappingSession state machine requires analog sticks to exceed AXIS_COMMIT_DISTANCE (16000) to register a binding, then return within AXIS_RETURN_DISTANCE (10000) before advancing. This two-threshold system ensures deliberate stick movement rather than incidental contact.
Can I edit Lighthouse controller mappings with external tools?
Yes. Because GenerateMappingString() produces standard gamecontrollerdb.txt format, any compatible editor—including Steam's Big Picture controller configurator or standalone tools—can read and modify usergamepadmappings.txt. The format uses comma-separated key:value pairs like a:b0,leftx:a0.
Where are custom mappings stored on disk?
User mappings append to usergamepadmappings.txt in the application's configuration directory via SaveUserMapping(). The file is parsed at startup by LoadUserMappings() and registered with SDL through SDL_GameControllerAddMapping().
What happens if I map a controller that SDL already recognizes?
Lighthouse user mappings take precedence. When LoadUserMappings() calls SDL_GameControllerAddMapping(), SDL replaces any existing entry with the same GUID. The game uses the user's custom mapping without requiring code changes in pfsmanager.c or other consumers.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →