🏗️ Internal Architecture

How input4j uses the Foreign Function & Memory API for native input handling

📅 Updated Mar 2026 📄 15 min read ⭐ Advanced

Architecture Overview

input4j follows a layered architecture that provides a unified API while leveraging platform-specific native APIs:

Application Layer
Your Java Game / Application
Unified API
InputDevices, InputDevice, InputComponent
Device Abstraction
AbstractInputDevicePlugin, VirtualComponentHandler
FFM Bridge
Native memory access, function calls
Native APIs
XInput, DirectInput, evdev, IOKit

FFM API Basics

The Foreign Function & Memory API (standard in Java 22+) provides two key capabilities:

🔌

Foreign Function Calls

Call native functions directly from Java without JNI overhead

💾

Foreign Memory Access

Read and write native memory directly with strong safety guarantees

Basic FFM Setup
import jdk.incubator.foreign.*;
import jdk.incubator.foreign.MemoryLayouts.*;

public class NativeBridge {
    // Load the native library
    private static final SymbolLookup LOOKUP = SymbolLookup.loaderLookup();
    
    // Define function descriptor
    private static final FunctionDescriptor XINPUT_GET_STATE = 
        FunctionDescriptor.of(
            ValueLayout.JAVA_INT,    // return: DWORD (int)
            ValueLayout.JAVA_INT,    // dwUserIndex
            ValueLayout.ADDRESS      // pState (pointer)
        );
    
    // Get function handle
    private static final MethodHandle xInputGetState = 
        LOOKUP.lookup("XInputGetState")
            .orElseThrow()
            .bindTo(XINPUT_GET_STATE);
}

Memory Management

FFM API provides safe memory access with automatic bounds checking:

Reading Native Memory
// Allocate native memory for XINPUT_STATE structure
MemorySegment stateBuffer = Arena.global().allocate(16);

// Call native function
int result = (int) xInputGetState.invokeExact(0, stateBuffer);

if (result == 0) { // ERROR_SUCCESS
    // Read values from native memory
    int packetNumber = stateBuffer.get(ValueLayout.JAVA_INT, 0);
    short buttons = stateBuffer.get(ValueLayout.JAVA_SHORT, 4);
    byte leftTrigger = stateBuffer.get(ValueLayout.JAVA_BYTE, 6);
    byte rightTrigger = stateBuffer.get(ValueLayout.JAVA_BYTE, 7);
    
    // Read gamepad struct at offset 8
    short leftX = stateBuffer.get(ValueLayout.JAVA_SHORT, 8);
    short leftY = stateBuffer.get(ValueLayout.JAVA_SHORT, 10);
    short rightX = stateBuffer.get(ValueLayout.JAVA_SHORT, 12);
    short rightY = stateBuffer.get(ValueLayout.JAVA_SHORT, 14);
}

// Memory is automatically freed when Arena is closed
// No manual free() calls needed!
Struct Layouts
// Define structured memory layout
public static final GroupLayout XINPUT_STATE = 
    StructLayout(
        ValueLayout.JAVA_INT.withName("dwPacketNumber"),
        StructLayout(
            ValueLayout.JAVA_SHORT.withName("sThumbLX"),
            ValueLayout.JAVA_SHORT.withName("sThumbLY"),
            ValueLayout.JAVA_SHORT.withName("sThumbRX"),
            ValueLayout.JAVA_SHORT.withName("sThumbRY"),
            ValueLayout.JAVA_BYTE.withName("bLeftTrigger"),
            ValueLayout.JAVA_BYTE.withName("bRightTrigger"),
            ValueLayout.JAVA_SHORT.withName("wButtons")
        ).withName("Gamepad"),
        MemoryLayout.paddingLayout(6)
    ).withName("XINPUT_STATE");

// Access structured data
int packetNumber = state.get(XINPUT_STATE, 0, "dwPacketNumber");
short leftX = state.get(XINPUT_STATE, 0, "Gamepad", "sThumbLX");

Platform Abstraction Layer

input4j uses a plugin-based architecture to support multiple platforms:

Device Plugin Interface
public interface InputDevicePlugin {
    String getName();
    Platform getPlatform();
    List<InputDevice> getDevices();
    void update(); // Poll for changes
    void close();
}

public abstract class AbstractInputDevicePlugin implements InputDevicePlugin {
    protected final List<InputDevice> devices = new ArrayList<>();
    protected final Map<String, InputComponent> components = new HashMap<>();
    
    @Override
    public List<InputDevice> getDevices() {
        return Collections.unmodifiableList(devices);
    }
    
    protected void registerComponent(String id, ComponentType type) {
        InputComponent component = new InputComponent(id, type);
        components.put(id, component);
    }
}
Device Discovery
public class InputDevices {
    private static final List<InputDevicePlugin> PLUGINS = 
        List.of(
            new XInputPlugin(),      // Windows Xbox
            new DirectInputPlugin(),  // Windows legacy
            new LinuxEventDevicePlugin(),  // Linux
            new IOKitPlugin()        // macOS
        );
    
    public static AutoCloseable init() {
        List<InputDevice> allDevices = new ArrayList<>();
        
        for (InputDevicePlugin plugin : PLUGINS) {
            try {
                if (plugin.isAvailable()) {
                    allDevices.addAll(plugin.getDevices());
                }
            } catch (Throwable e) {
                // Platform not supported, skip
                System.err.println("Skipping " + plugin.getName() + ": " + e);
            }
        }
        
        return () -> allDevices.forEach(InputDevice::close);
    }
}

Windows Implementation

Windows uses two APIs: XInput (Xbox controllers) and DirectInput (legacy):

🎯

XInput

Modern API for Xbox controllers. 12 buttons, 2 analog sticks, 2 triggers.

XInputGetState XInputSetState
📺

DirectInput

Legacy API for older gamepads. More flexible but deprecated.

IDirectInput8 EnumDevices

Linux Implementation

Linux uses the evdev interface for reading input events:

Reading evdev Events
public class LinuxEventDevice {
    private final MemorySegment eventFD;
    private static final int EV_REL = 0x02;  // Relative axis
    private static final int EV_ABS = 0x03;  // Absolute axis
    private static final int EV_KEY = 0x01;  // Button
    
    // Event structure: time(8) + type(2) + code(2) + value(4) = 16 bytes
    private static final GroupLayout INPUT_EVENT = 
        StructLayout(
            StructLayout(
                ValueLayout.JAVA_LONG.withName("tvSec"),
                ValueLayout.JAVA_LONG.withName("tvUsec")
            ).withName("time"),
            ValueLayout.JAVA_SHORT.withName("type"),
            ValueLayout.JAVA_SHORT.withName("code"),
            ValueLayout.JAVA_INT.withName("value")
        );
    
    public void readEvents() {
        MemorySegment event = Arena.global().allocate(INPUT_EVENT);
        
        // Read from /dev/input/eventX
        int bytesRead = read(eventFD, event, 16);
        
        if (bytesRead > 0) {
            short type = event.get(INPUT_EVENT, 0, "type");
            short code = event.get(INPUT_EVENT, 0, "code");
            int value = event.get(INPUT_EVENT, 0, "value");
            
            processEvent(type, code, value);
        }
    }
}

macOS Implementation

macOS uses the IOKit HID framework:

IOKit HID Access
public class IOKitPlugin extends AbstractInputDevicePlugin {
    
    private MemorySegment manager;
    
    @Override
    public void init() {
        // Create HID Manager
        manager = IOKit.HIDManager_Create(kCFAllocatorDefault, MemoryAddress.NULL);
        
        // Set device matching (gamepads only)
        MemorySegment matchingDict = createMatchingDictionary(
            IOHIDDeviceUsagePage.GENERIC_DESKTOP,
            IOHIDDeviceUsage.JOYSTICK
        );
        
        IOKit.HIDManager_SetDeviceMatching(manager, matchingDict);
        IOKit.HIDManager_ScheduleWithRunLoop(manager, CFRunLoopGetMain(), 0);
        IOKit.HIDManager_Open(manager, 0);
    }
    
    private MemorySegment createMatchingDictionary(int usagePage, int usage) {
        // Create CFMutableDictionaryRef
        MemorySegment dict = IOKit.CFDictionary_CreateMutable(
            kCFAllocatorDefault, 0, 
            CFDictionaryKeyCallBacks, CFDictionaryValueCallBacks
        );
        
        // Add matching criteria
        // ... (matching code)
        
        return dict;
    }
}