Skip to content

feat(gamepad): Linux evdev state API + uinput verification harness (core#33)#34

Merged
apotema merged 2 commits into
mainfrom
feat/33-linux-gamepad-state
Jun 12, 2026
Merged

feat(gamepad): Linux evdev state API + uinput verification harness (core#33)#34
apotema merged 2 commits into
mainfrom
feat/33-linux-gamepad-state

Conversation

@apotema

@apotema apotema commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Implements core#33 scope item 1: gamepad_source/linux.zig gains the full gamepad state surface, mirroring backends/sdl_gamepad's Source exactly — update / isAvailable / isButtonDown / isButtonPressed / axisValue, canonical raylib-compatible numbering, sticks [-1,1], triggers [0,1], synthesized analog-trigger buttons at the same 0.5 threshold, per-slot snapshot-and-edge model.

Design

  • Per-frame, same thread, no threads anywhere — wasm-class targets have none and input belongs on the game thread. update() does one EVIOCGKEY bitmap ioctl + one EVIOCGABS per axis per pad. Measured (WSL2, two pads connected): avg 11µs, max 36µs per call — ~0.07% of a 60fps frame.
  • Hotplug throttled inside update() to ~1/s (HOTPLUG_PUMP_INTERVAL_NS) — a controller appearing a beat late is invisible, so the steady-state cost is the state ioctls alone. pollEvents still pumps immediately.
  • Each tracked pad keeps its evdev fd open for its lifetime; permission_denied pads stay detected but read idle.
  • Dense slot ids with lowest-free reuse (table index) — fixes a contract divergence where slots grew monotonically and would walk out of the engine's 0..MAX-1 query range.
  • Hat-form d-pads (ABS_HAT0X/Y) OR into the dpad buttons alongside BTN_DPAD_*; geographic face-button mapping per the Linux gamepad spec.

Verification (the uinput harness, first commit of this branch)

tools/run_detection_check.sh drives the probe against two kernel-level virtual pads — all green on WSL2 (stock 6.18 kernel):

  • detection: 2 simultaneous connects with distinct slots + GUIDs, targeted disconnect, replug with a stable GUID and slot-0 reuse, empty describe() after teardown
  • state: press edges per pad with correct canonical mapping, axis normalization (+1.0 / 1.0 with exact absinfo ranges), hat d-pad, trigger-button synthesis, and simultaneous input on both pads with explicit no-cross-slot-bleed assertions
  • pure mapping/normalization/bitmap tests run on any host via zig build test; the check-platforms step cross-compiles the full Linux body.

Out of scope (stays on core#33)

  • assembler routing (scope item 2): pointing raylib/sokol Linux desktop at this source and dropping the SDL2 link there — separate PR once this bakes.
  • bare-metal checklist: real-pad label quirks (xpad NORTH/WEST), /dev/input permission UX in a real desktop session.

apotema added 2 commits June 12, 2026 11:44
…of the Linux source (core#33)

Adds the hardware-free verification rig core#33 needs before its state
API can land:

- src/evdev_probe_main.zig — tiny exe around gamepad_source that prints
  one greppable line per hotplug event / describe entry. Linked with
  libc so std.DynLib reaches libudev through real dlopen (the bare ELF
  loader can't load it — matches real game binaries, which link libc).
- tools/uinput_feeder.py — creates TWO kernel-level virtual gamepads
  (python3-evdev/uinput) with distinct identities, kills one, recreates
  it with the same ids, then exits. Multi-pad coverage is deliberate:
  distinct slots/GUIDs and independent lifecycles are asserted, not
  assumed.
- tools/run_detection_check.sh — orchestrates probe + feeder and
  asserts the no-hardware acceptance subset: 2 simultaneous connects
  (distinct slots + GUIDs), targeted disconnect, replug with a STABLE
  GUID, and empty describe() after teardown (slots freed).

Verified on WSL2 (stock 6.18 kernel, uinput/evdev modules) — all
checks pass, giving gamepad_source/linux.zig its first runtime
exercise: udev monitor hotplug, ID_INPUT_JOYSTICK filtering, evdev
identity ioctls, SDL-layout GUID derivation, dedup and slot lifecycle
all behave as written. This replaces the "needs a real Linux box"
blocker for everything except real-pad quirks and permission UX.
…mepad (core#33)

gamepad_source/linux.zig grows the state surface the issue scopes,
mirroring backends/sdl_gamepad exactly: update / isAvailable /
isButtonDown / isButtonPressed / axisValue, canonical raylib-compatible
numbering, sticks [-1,1], triggers [0,1], synthesized analog-trigger
buttons at the same 0.5 threshold, per-slot snapshot-and-edge model.

Design points:
- Each tracked pad keeps its evdev fd open for its lifetime (closed on
  disconnect/deinit/dedup/table-full). permission_denied pads stay
  detected but read idle.
- update() samples per frame with ONE EVIOCGKEY bitmap ioctl plus one
  EVIOCGABS per axis; EVIOCGABS returns value+range together so
  normalization needs no cached absinfo. Measured on WSL2 with two pads
  connected: avg 11us, max 36us per call — ~0.07% of a 60fps frame.
- Hotplug detection inside update() is throttled to ~1/s (a pad
  appearing a beat late is invisible); pollEvents still pumps
  immediately. No background threads anywhere — wasm-class targets
  have none and input belongs on the game thread.
- Dense slot ids with lowest-free reuse (table index), fixing a
  contract divergence: slots were monotonically increasing, which
  walks out of the engine's 0..MAX-1 query range.
- Hat-form d-pads (ABS_HAT0X/Y) OR into the dpad buttons alongside
  BTN_DPAD_*; geographic (physical-position) face-button mapping per
  the Linux gamepad spec. Real-pad label quirks remain on the
  bare-metal checklist.
- FallbackSource gets matching no-op state methods so the file stays
  contract-conformant on every host.

Harness extended to cover it end-to-end on uinput pads (WSL2, all
green): press/axis edges per pad, hat d-pad, trigger synthesis, slot-0
reuse after replug, and SIMULTANEOUS input on two pads with explicit
no-cross-slot-bleed assertions. Pure mapping/normalization/bitmap tests
run on any host via zig build test.
@cursor

cursor Bot commented Jun 12, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Large changes to the Linux input path (open fds, ioctl sampling, slot semantics) affect future desktop routing, but behavior is isolated behind the Linux source and backed by unit plus uinput integration checks.

Overview
Extends gamepad_source/linux.zig from udev/evdev detection-only to a full per-frame state API aligned with backends/sdl_gamepad: update, isAvailable, isButtonDown, isButtonPressed, and axisValue, with raylib-style button/axis numbering, stick/trigger normalization, hat d-pad support, and analog-trigger button synthesis. Tracked pads keep evdev fds open for EVIOCGKEY / EVIOCGABS sampling; update() throttles udev hotplug to ~1s while pollEvents still pumps immediately. Slot IDs switch from a monotonic counter to dense lowest-free reuse (table index).

Adds a core#33 runtime harness: evdev-probe (installed via build.zig, libc-linked for dlopen), tools/uinput_feeder.py, and tools/run_detection_check.sh to assert multi-pad connect/disconnect/GUID stability, state edges, axis values, and no cross-slot bleed on Linux/WSL2. Unit tests cover mapping, normalization, and bitmap parsing; non-Linux FallbackSource gains matching state no-ops.

Reviewed by Cursor Bugbot for commit ed8dcb7. Bugbot is set up for automated code reviews on this repo. Configure here.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements Linux evdev/udev gamepad state sampling and integration testing (core#33), adding a state surface (update, isAvailable, isButtonDown, isButtonPressed, axisValue) to the Linux gamepad source that mirrors the SDL backend. It also introduces an evdev-probe harness and automated testing tools (run_detection_check.sh and uinput_feeder.py) to verify detection and state logic using virtual uinput devices. The review feedback suggests several robustness improvements: clamping normalized axis values and handling invalid ranges in normalizeAxis, initializing the bits array in sampleButtons to avoid reading uninitialized memory, initializing the timespec struct in nowNs functions to prevent undefined behavior if clock_gettime fails, and adding an EXIT trap in the shell script to ensure proper cleanup of background processes and temporary files.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +345 to +351
fn normalizeAxis(ai: input_absinfo, is_trigger: bool) f32 {
if (ai.maximum == ai.minimum) return 0;
const range: f32 = @floatFromInt(ai.maximum - ai.minimum);
const v: f32 = @floatFromInt(ai.value - ai.minimum);
const unit = v / range; // 0..1
return if (is_trigger) unit else unit * 2.0 - 1.0;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The normalizeAxis function does not handle cases where ai.maximum <= ai.minimum robustly (which can happen with buggy or corrupted virtual/physical device drivers). Additionally, clamping the final normalized value to [0.0, 1.0] for triggers and [-1.0, 1.0] for sticks ensures strict adherence to the expected range contract.

fn normalizeAxis(ai: input_absinfo, is_trigger: bool) f32 {
    if (ai.maximum <= ai.minimum) return 0;
    const range: f32 = @floatFromInt(ai.maximum - ai.minimum);
    const v: f32 = @floatFromInt(ai.value - ai.minimum);
    const unit = std.math.clamp(v / range, 0.0, 1.0);
    return if (is_trigger) unit else unit * 2.0 - 1.0;
}

Comment on lines +590 to +600
fn sampleButtons(t: *Tracked) void {
@memset(&t.cur_buttons, false);
var bits: [KEY_BITMAP_BYTES]u8 = undefined;
const rc = std.os.linux.ioctl(t.fd, EVIOCGKEY_IOCTL, @intFromPtr(&bits));
if (std.os.linux.errno(rc) != .SUCCESS) return;
for (tracked_key_codes) |code| {
if (bitmapHas(&bits, code)) {
if (keyCodeToCanonical(code)) |canonical| t.cur_buttons[canonical] = true;
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

In sampleButtons, the bits array is left uninitialized (undefined). If the kernel's EVIOCGKEY ioctl writes fewer bytes than KEY_BITMAP_BYTES (e.g., due to driver/kernel version differences or partial success), the uninitialized bytes will contain garbage stack memory. Reading them in bitmapHas leads to undefined behavior or phantom button presses. Initializing bits to zero prevents this.

    fn sampleButtons(t: *Tracked) void {
        @memset(&t.cur_buttons, false);
        var bits: [KEY_BITMAP_BYTES]u8 = [_]u8{0} ** KEY_BITMAP_BYTES;
        const rc = std.os.linux.ioctl(t.fd, EVIOCGKEY_IOCTL, @intFromPtr(&bits));
        if (std.os.linux.errno(rc) != .SUCCESS) return;
        for (tracked_key_codes) |code| {
            if (bitmapHas(&bits, code)) {
                if (keyCodeToCanonical(code)) |canonical| t.cur_buttons[canonical] = true;
            }
        }
    }

Comment on lines +623 to +627
fn nowNs() i128 {
var ts: std.os.linux.timespec = undefined;
_ = std.os.linux.clock_gettime(.MONOTONIC, &ts);
return @as(i128, ts.sec) * std.time.ns_per_s + ts.nsec;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

In nowNs, the ts timespec struct is left uninitialized. If the clock_gettime syscall fails for any reason (e.g., in a sandboxed or mocked environment), reading from ts will result in undefined behavior. Initializing ts to zero is a safer defensive programming practice.

    fn nowNs() i128 {
        var ts: std.os.linux.timespec = .{ .sec = 0, .nsec = 0 };
        _ = std.os.linux.clock_gettime(.MONOTONIC, &ts);
        return @as(i128, ts.sec) * std.time.ns_per_s + ts.nsec;
    }

Comment thread src/evdev_probe_main.zig
Comment on lines +43 to +50
fn nowNs() u64 {
if (comptime builtin.target.os.tag == .linux) {
var ts: std.os.linux.timespec = undefined;
_ = std.os.linux.clock_gettime(.MONOTONIC, &ts);
return @as(u64, @intCast(ts.sec)) * std.time.ns_per_s + @as(u64, @intCast(ts.nsec));
}
return 0;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

In nowNs, the ts timespec struct is left uninitialized. If the clock_gettime syscall fails for any reason, reading from ts will result in undefined behavior. Initializing ts to zero is a safer defensive programming practice.

fn nowNs() u64 {
    if (comptime builtin.target.os.tag == .linux) {
        var ts: std.os.linux.timespec = .{ .sec = 0, .nsec = 0 };
        _ = std.os.linux.clock_gettime(.MONOTONIC, &ts);
        return @as(u64, @intCast(ts.sec)) * std.time.ns_per_s + @as(u64, @intCast(ts.nsec));
    }
    return 0;
}

Comment on lines +18 to +21
OUT=$(mktemp)
./zig-out/bin/evdev-probe >"$OUT" 2>&1 &
PROBE=$!
sleep 1 # let the probe arm the udev monitor before the first hotplug

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If the script exits early (e.g., due to set -e when python3 tools/uinput_feeder.py fails), the background evdev-probe process will be left running, and the temporary file OUT will not be cleaned up. Adding an EXIT trap ensures robust cleanup of both the background process and the temporary file under all exit conditions.

Suggested change
OUT=$(mktemp)
./zig-out/bin/evdev-probe >"$OUT" 2>&1 &
PROBE=$!
sleep 1 # let the probe arm the udev monitor before the first hotplug
OUT=$(mktemp)
./zig-out/bin/evdev-probe >"$OUT" 2>&1 &
PROBE=$!
trap 'kill "$PROBE" 2>/dev/null || true; rm -f "$OUT"' EXIT
sleep 1 # let the probe arm the udev monitor before the first hotplug

@apotema apotema merged commit 6f2e2b5 into main Jun 12, 2026
2 checks passed
@apotema apotema deleted the feat/33-linux-gamepad-state branch June 12, 2026 15:20
apotema added a commit that referenced this pull request Jun 12, 2026
…follow-up) (#37)

Address Gemini review on #34 (merged into v1.16.0):
- sampleButtons: zero-init the EVIOCGKEY bitmap so a kernel partial-write
  can't leak uninitialised stack bytes as phantom button presses.
- normalizeAxis: guard maximum <= minimum (not just ==) so a corrupt
  descriptor yields neutral 0 instead of inverted/garbage.
- nowNs (linux.zig + evdev_probe_main.zig): zero-init the timespec so a
  clock_gettime failure can't read uninitialised memory.
- run_detection_check.sh: trap EXIT to kill the background probe + remove
  the temp file, so an early 'set -e' abort doesn't leak a running probe.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant