feat(gamepad): Linux evdev state API + uinput verification harness (core#33)#34
Conversation
…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.
PR SummaryMedium Risk Overview Adds a core#33 runtime harness: Reviewed by Cursor Bugbot for commit ed8dcb7. Bugbot is set up for automated code reviews on this repo. Configure here. |
There was a problem hiding this comment.
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.
| 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; | ||
| } |
There was a problem hiding this comment.
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;
}
| 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; | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
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;
}
}
}
| 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; | ||
| } |
There was a problem hiding this comment.
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;
}
| 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; | ||
| } |
There was a problem hiding this comment.
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;
}
| OUT=$(mktemp) | ||
| ./zig-out/bin/evdev-probe >"$OUT" 2>&1 & | ||
| PROBE=$! | ||
| sleep 1 # let the probe arm the udev monitor before the first hotplug |
There was a problem hiding this comment.
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.
| 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 |
…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.
Implements core#33 scope item 1:
gamepad_source/linux.ziggains the full gamepad state surface, mirroringbackends/sdl_gamepad'sSourceexactly —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
update()does oneEVIOCGKEYbitmap ioctl + oneEVIOCGABSper axis per pad. Measured (WSL2, two pads connected): avg 11µs, max 36µs per call — ~0.07% of a 60fps frame.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.pollEventsstill pumps immediately.permission_deniedpads stay detected but read idle.ABS_HAT0X/Y) OR into the dpad buttons alongsideBTN_DPAD_*; geographic face-button mapping per the Linux gamepad spec.Verification (the uinput harness, first commit of this branch)
tools/run_detection_check.shdrives the probe against two kernel-level virtual pads — all green on WSL2 (stock 6.18 kernel):describe()after teardownzig build test; thecheck-platformsstep cross-compiles the full Linux body.Out of scope (stays on core#33)
/dev/inputpermission UX in a real desktop session.