From 5a77d908a192796f7b6b9be38f8b29294e20fde0 Mon Sep 17 00:00:00 2001 From: BANANASJIM Date: Sat, 6 Jun 2026 22:34:37 -0700 Subject: [PATCH 1/2] feat(vader5): present Elite-2 paddles via UHID HID descriptor (045e:0b00) Steam unlocks the Xbox Elite Series 2 paddle config UI only when it detects a real Elite-2 HID device via hidapi. The Vader's virtual pad was presented through uinput (BUS_VIRTUAL evdev caps, no HID report descriptor), so Steam saw a descriptor-less pad at 045e:0b00 and applied a generic Xbox mapping with no paddles. Route the main pad to the UHID backend and present the [output] masquerade identity so Steam sees a real HID device whose descriptor declares the four back paddles (M1-M4 -> BTN_TRIGGER_HAPPY1-4) as Button usages. - config: new [output] backend ("uinput"|"uhid") routes the main pad to UHID independently of imu/ffb; new [output] present_output_id presents the [output] vid/pid instead of the daemon identity 0xFADE:0xC001. Both validated and default to current behavior, so existing UHID devices (IMU/PID, which rely on 0xFADE:0xC001 for SDL pairing) are unaffected. - device_instance: use_uhid honors [output] backend=uhid; identity precedence present_output_id > clone_vid_pid > daemon default. - rumble: a UHID gamepad descriptor has no FF output collection, so an FF-only uinput sidecar is spawned (EV_FF, masquerade vid/pid, no input caps) to keep rumble working when the main pad moves to UHID; FF polling routes to it. - vader5.toml: opt in with backend=uhid + present_output_id=true. - tests: routing-to-UHID, identity=045e:0b00 (not FADE:C001), descriptor declares paddle usages 17-20, plus config validation cases. UNVERIFIED in software: whether Steam's hidapi Elite-2 detection accepts a generic-gamepad UHID descriptor at 045e:0b00 requires real-hardware validation. --- .gitignore | 1 + devices/flydigi/vader5.toml | 11 ++ src/config/device.zig | 95 ++++++++++++++ src/device_instance.zig | 73 +++++++++-- src/event_loop.zig | 7 +- src/test/supervisor_uhid_routing_test.zig | 143 ++++++++++++++++++++++ 6 files changed, 322 insertions(+), 8 deletions(-) diff --git a/.gitignore b/.gitignore index ced4c481..04fd1e01 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ zig-pkg/ *.o *.a *.deb +core.* kcov-output/ .worktrees/ diff --git a/devices/flydigi/vader5.toml b/devices/flydigi/vader5.toml index 25c32255..e6ffb961 100644 --- a/devices/flydigi/vader5.toml +++ b/devices/flydigi/vader5.toml @@ -87,10 +87,21 @@ offset = 8 # --- Output device --- # Masquerade as Xbox Elite Series 2 (VID 045e PID 0b00) so that Steam and # most games apply Xbox button mappings without additional configuration. +# +# backend="uhid" + present_output_id=true: present the masquerade identity as a +# real HID device with a report descriptor that declares the four back paddles +# (M1-M4 -> BTN_TRIGGER_HAPPY1-4) as Button usages. Steam's hidapi-based +# Elite-2 detection unlocks the paddle config UI only for a real HID device; +# the prior uinput path exposed a descriptor-less evdev node and no paddle UI. +# Rumble keeps working via an automatic uinput EV_FF sidecar (see +# [output.force_feedback]) because a UHID gamepad descriptor carries no FF +# output collection. [output] name = "Xbox Elite Series 2" vid = 0x045e pid = 0x0b00 +backend = "uhid" +present_output_id = true [output.axes] left_x = { code = "ABS_X", min = -32768, max = 32767, fuzz = 16, flat = 128 } diff --git a/src/config/device.zig b/src/config/device.zig index ca65b24b..d435e353 100644 --- a/src/config/device.zig +++ b/src/config/device.zig @@ -176,6 +176,15 @@ pub const OutputConfig = struct { touchpad: ?TouchpadConfig = null, mapping: ?toml.HashMap(MappingEntry) = null, imu: ?ImuConfig = null, + // Route the main pad to the UHID backend independently of imu/ffb. A UHID + // primary presents a real HID report descriptor, which lets hidapi-based + // hosts (e.g. Steam's Elite-2 paddle detection) see a HID device rather + // than a descriptor-less uinput evdev node. "uinput" (default) | "uhid". + backend: []const u8 = "uinput", + // When true and the primary is on UHID, the card presents `vid`/`pid` + // (the `[output]` masquerade identity) instead of the daemon identity + // 0xFADE:0xC001. Opt-in so existing UHID devices keep their identity. + present_output_id: bool = false, }; pub const WasmOverridesConfig = struct { @@ -451,6 +460,20 @@ pub fn validate(cfg: *const DeviceConfig) !void { } } + // Output backend selector. Only "uinput" or "uhid" are legal; unknown + // strings fail closed. present_output_id requires non-zero vid/pid and is + // only meaningful on the UHID path. + if (cfg.output) |out| { + const is_uinput_out = std.mem.eql(u8, out.backend, "uinput"); + const is_uhid_out = std.mem.eql(u8, out.backend, "uhid"); + if (!is_uinput_out and !is_uhid_out) return error.InvalidConfig; + if (out.present_output_id) { + const vid = out.vid orelse 0; + const pid = out.pid orelse 0; + if (vid == 0 or pid == 0) return error.InvalidConfig; + } + } + // Force feedback backend/kind matrix. Absent force_feedback is always legal. if (cfg.output) |out| { if (out.force_feedback) |ffb| { @@ -1538,6 +1561,78 @@ test "device: fuzz parseString: no panic on arbitrary input" { }.run, .{}); } +// Output backend / present_output_id validate cases. + +test "validate: [output] backend=uhid + present_output_id=true is legal" { + const allocator = std.testing.allocator; + const toml_str = + \\[device] + \\name = "Pad" + \\vid = 0x37d7 + \\pid = 0x2401 + \\[[device.interface]] + \\id = 0 + \\class = "hid" + \\[[report]] + \\name = "r" + \\interface = 0 + \\size = 4 + \\[output] + \\name = "Elite" + \\vid = 0x045e + \\pid = 0x0b00 + \\backend = "uhid" + \\present_output_id = true + ; + const result = try parseString(allocator, toml_str); + defer result.deinit(); + try std.testing.expectEqualStrings("uhid", result.value.output.?.backend); + try std.testing.expect(result.value.output.?.present_output_id); +} + +test "validate: [output] backend=unknown is error.InvalidConfig" { + const allocator = std.testing.allocator; + const toml_str = + \\[device] + \\name = "Pad" + \\vid = 1 + \\pid = 2 + \\[[device.interface]] + \\id = 0 + \\class = "hid" + \\[[report]] + \\name = "r" + \\interface = 0 + \\size = 4 + \\[output] + \\name = "Pad" + \\backend = "xyz" + ; + try std.testing.expectError(error.InvalidConfig, parseString(allocator, toml_str)); +} + +test "validate: present_output_id=true requires non-zero output vid/pid" { + const allocator = std.testing.allocator; + const toml_str = + \\[device] + \\name = "Pad" + \\vid = 1 + \\pid = 2 + \\[[device.interface]] + \\id = 0 + \\class = "hid" + \\[[report]] + \\name = "r" + \\interface = 0 + \\size = 4 + \\[output] + \\name = "Pad" + \\backend = "uhid" + \\present_output_id = true + ; + try std.testing.expectError(error.InvalidConfig, parseString(allocator, toml_str)); +} + // ImuConfig validate cases. test "validate: ImuConfig default (absent) is legal" { diff --git a/src/device_instance.zig b/src/device_instance.zig index 5a4b1304..e50599fd 100644 --- a/src/device_instance.zig +++ b/src/device_instance.zig @@ -143,6 +143,10 @@ pub const InitOptions = struct { /// Test-only: substitute for the physical hidraw write-end used by FfbForwarder. /// When non-null, FfbForwarder.init receives this fd instead of the real device fd. test_physical_hidraw_fd: ?posix.fd_t = null, + /// Test-only: skip the uinput rumble FF sidecar (it would open /dev/uinput, + /// unavailable on CI). Routing/identity tests set this; sidecar wiring is + /// covered by event-loop rumble tests and real-hardware validation. + test_skip_ff_sidecar: bool = false, }; /// Build a `UhidDevice` — either against a caller-supplied test fd (pipe @@ -210,6 +214,12 @@ pub const DeviceInstance = struct { poll_timeout_ms: ?u32 = null, /// Active only when force_feedback.backend="uhid" + kind="pid". ffb_forwarder: ?FfbForwarder = null, + /// uinput FF sidecar. Created when the primary is on UHID but FFB is rumble + /// (uinput backend): a UHID gamepad descriptor declares no FF output + /// collection, so the kernel would synthesize no evdev FF device and game + /// rumble would never reach padctl. This sidecar exposes an EV_FF-capable + /// uinput node solely to receive rumble effects; it emits no input state. + ff_sidecar: ?*UinputDevice = null, /// PR-ε.1 wedge instrumentation. Bumped by hidraw + ffb_forwarder; read by /// Supervisor.handleStatus. Inert until consumers attach (see attachWedges). wedge: WedgeAtomics = .{}, @@ -305,6 +315,7 @@ pub const DeviceInstance = struct { var imu_output: ?OutputDevice = null; var imu_dev_ptr: ?*uhid_mod.UhidDevice = null; var ffb_fwd: ?FfbForwarder = null; + var ff_sidecar_ptr: ?*UinputDevice = null; var imu_name_ptr: ?[]const u8 = null; var aux_dev: ?AuxDevice = null; var touchpad_dev: ?TouchpadDevice = null; @@ -318,9 +329,12 @@ pub const DeviceInstance = struct { } } else if (cfg.output) |*out_cfg| { const imu_cfg_opt: ?device_cfg.ImuConfig = if (out_cfg.imu) |imu| imu else null; - // Enter UHID path when IMU backend=uhid (gamepad+IMU pair) OR - // when force_feedback.backend=uhid (racing wheel PID FFB). + // Enter UHID path when [output] backend=uhid (main pad as a real + // HID device, e.g. Steam Elite-2 paddle detection) OR IMU + // backend=uhid (gamepad+IMU pair) OR force_feedback.backend=uhid + // (racing wheel PID FFB). const use_uhid = blk: { + if (std.mem.eql(u8, out_cfg.backend, "uhid")) break :blk true; if (imu_cfg_opt) |imu_cfg| { if (std.mem.eql(u8, imu_cfg.backend, "uhid")) break :blk true; } @@ -352,19 +366,30 @@ pub const DeviceInstance = struct { defer allocator.free(primary_descriptor); const ffb_cfg = out_cfg.force_feedback orelse device_cfg.ForceFeedbackConfig{}; - // clone_vid_pid=true: wheel's real VID/PID (hid-universal-pidff modalias binding). - // clone_vid_pid=false (default): daemon identity so non-PID devices stay as FADE:C001. - const effective_vid: u16 = if (ffb_cfg.clone_vid_pid) + // Identity precedence: + // present_output_id=true → [output] masquerade vid/pid (e.g. + // 045e:0b00 so Steam sees a real Elite-2 HID device). + // clone_vid_pid=true → device's real VID/PID (hid-universal-pidff + // modalias binding for racing wheels). + // default → daemon identity FADE:C001 (SDL #81 IMU pairing). + const effective_vid: u16 = if (out_cfg.present_output_id) + @intCast(out_cfg.vid orelse 0) + else if (ffb_cfg.clone_vid_pid) @intCast(cfg.device.vid) else 0xFADE; - const effective_pid: u16 = if (ffb_cfg.clone_vid_pid) + const effective_pid: u16 = if (out_cfg.present_output_id) + @intCast(out_cfg.pid orelse 0) + else if (ffb_cfg.clone_vid_pid) @intCast(cfg.device.pid) else 0xC001; const primary_cfg = uhid_mod.Config{ - .name = cfg.device.name, + // When presenting the masquerade identity, also present its + // name: SDL/Steam derive the controller GUID from bus+vid+pid+ + // name, so a mismatched name defeats profile matching. + .name = if (out_cfg.present_output_id) (out_cfg.name orelse cfg.device.name) else cfg.device.name, .uniq = std.mem.sliceTo(uniq_z, 0), .vid = effective_vid, .pid = effective_pid, @@ -432,6 +457,34 @@ pub const DeviceInstance = struct { } } } + + // Rumble FF sidecar: the UHID gamepad descriptor has no FF + // output collection, so rumble cannot arrive on the UHID node. + // Spawn an FF-only uinput node so games' rumble effects still + // reach the event loop and get forwarded to the physical + // device. Only for rumble (uinput) FFB; PID FFB uses the + // UHID_OUTPUT path above. The sidecar config strips axes / + // buttons / dpad so the node carries EV_FF only — no phantom + // duplicate input pad next to the UHID primary. + if (out_cfg.force_feedback) |rumble_ffb| { + if (!opts.test_skip_ff_sidecar and + std.mem.eql(u8, rumble_ffb.backend, "uinput") and + std.mem.eql(u8, rumble_ffb.kind, "rumble")) + { + var ff_only_cfg = out_cfg.*; + ff_only_cfg.axes = null; + ff_only_cfg.buttons = null; + ff_only_cfg.dpad = null; + const sidecar = try UinputDevice.initBoxed(allocator, &ff_only_cfg); + errdefer { + sidecar.close(); + allocator.destroy(sidecar); + } + sidecar.log_tag = cfg.device.name; + ff_sidecar_ptr = sidecar; + try loop.addUinputFf(sidecar.pollFfFd()); + } + } } else { const uinput_ptr = try UinputDevice.initBoxed(allocator, out_cfg); errdefer { @@ -518,6 +571,7 @@ pub const DeviceInstance = struct { .pending_mapping = null, .stopped = false, .ffb_forwarder = ffb_fwd, + .ff_sidecar = ff_sidecar_ptr, }; } @@ -564,6 +618,10 @@ pub const DeviceInstance = struct { }, } if (self.ffb_forwarder) |*fwd| fwd.deinit(); + if (self.ff_sidecar) |p| { + p.close(); + self.allocator.destroy(p); + } if (self.aux_dev) |*a| a.close(); if (self.touchpad_dev) |*tp| tp.close(); if (self.generic_uinput) |*gu| gu.close(); @@ -632,6 +690,7 @@ pub const DeviceInstance = struct { .uhid => |p| p, else => null, }, + .ff_output = if (self.ff_sidecar) |p| p.outputDevice() else null, }) catch |err| { std.log.err("event loop failed: {}", .{err}); break; diff --git a/src/event_loop.zig b/src/event_loop.zig index 2add44b5..f065999c 100644 --- a/src/event_loop.zig +++ b/src/event_loop.zig @@ -290,6 +290,10 @@ pub const EventLoopContext = struct { /// Primary UHID device to drain for UHID_OUTPUT events. /// Set when `[output.force_feedback].backend = "uhid"` and `kind = "pid"`. uhid_primary: ?*UhidDevice = null, + /// Dedicated FF output. Set when the primary is on UHID but rumble must + /// arrive over a uinput EV_FF sidecar (UHID has no FF output collection). + /// When null, rumble polling falls back to `output`. + ff_output: ?OutputDevice = null, }; fn i64ToParamValue(v: ?i64) u16 { @@ -593,7 +597,8 @@ pub const EventLoop = struct { // Check uinput FF fd. if (self.uinput_ff_slot) |slot| { if (self.pollfds[slot].revents & posix.POLL.IN != 0) { - const ff_result = ctx.output.pollFf() catch |err| blk: { + const ff_dev = ctx.ff_output orelse ctx.output; + const ff_result = ff_dev.pollFf() catch |err| blk: { rumble_log.debug("[{s}] FF_ERROR: pollFf failed err={}", .{ ctx.device_tag, err }); break :blk null; }; diff --git a/src/test/supervisor_uhid_routing_test.zig b/src/test/supervisor_uhid_routing_test.zig index 46b58248..06e291eb 100644 --- a/src/test/supervisor_uhid_routing_test.zig +++ b/src/test/supervisor_uhid_routing_test.zig @@ -479,3 +479,146 @@ test "clone_vid_pid=true → primary UHID vid=device.vid pid=device.pid" { try testing.expectEqual(@as(u32, 0x11FF), primary_ev.payload.vendor); try testing.expectEqual(@as(u32, 0x1211), primary_ev.payload.product); } + +// --- [output] backend=uhid + present_output_id routing (Vader 5 paddles) ---- + +// Vader-representative: main pad on UHID, masquerade identity presented, no IMU +// card, rumble FFB (sidecar skipped in the test seam). M1-M4 -> the four +// BTN_TRIGGER_HAPPY paddle codes that buildFromOutput emits as Button usages. +const TEST_TOML_OUTPUT_UHID = + \\[device] + \\name = "Paddle Pad" + \\vid = 0x37d7 + \\pid = 0x2401 + \\[[device.interface]] + \\id = 0 + \\class = "hid" + \\[[report]] + \\name = "input" + \\interface = 0 + \\size = 8 + \\[report.match] + \\offset = 0 + \\expect = [0x01] + \\[report.fields] + \\left_x = { offset = 1, type = "i8" } + \\[output] + \\name = "Xbox Elite Series 2" + \\vid = 0x045e + \\pid = 0x0b00 + \\backend = "uhid" + \\present_output_id = true + \\axes = { left_x = { code = "ABS_X", min = -128, max = 127 } } + \\buttons = { A = "BTN_SOUTH", M1 = "BTN_TRIGGER_HAPPY1", M2 = "BTN_TRIGGER_HAPPY3", M3 = "BTN_TRIGGER_HAPPY2", M4 = "BTN_TRIGGER_HAPPY4" } + \\[output.force_feedback] + \\type = "rumble" +; + +fn initOutputUhid( + allocator: std.mem.Allocator, + parsed: anytype, + mock: *MockDeviceIO, + primary_fd: posix.fd_t, +) !DeviceInstance { + const devices = try allocator.alloc(DeviceIO, 1); + devices[0] = mock.deviceIO(); + var counter: u16 = 1; + return DeviceInstance.init(allocator, &parsed.value, null, null, &counter, .{ + .test_primary_uhid_fd = primary_fd, + .test_devices_override = devices, + .test_skip_ff_sidecar = true, + }); +} + +test "output backend=uhid routes main pad to UHID (Vader paddles)" { + if (builtin.os.tag != .linux) return error.SkipZigTest; + const allocator = testing.allocator; + + const primary_fds = try posix.pipe2(.{ .NONBLOCK = true }); + defer posix.close(primary_fds[0]); + + const parsed = try device_mod.parseString(allocator, TEST_TOML_OUTPUT_UHID); + defer parsed.deinit(); + + var mock = try MockDeviceIO.init(allocator, &.{}); + defer mock.deinit(); + + var inst = try initOutputUhid(allocator, parsed, &mock, primary_fds[1]); + defer inst.deinit(); + + // Reverse-verification: dropping backend="uhid" from the TOML flips owner + // to .uinput, failing this assertion. + switch (inst.owner) { + .uhid => {}, + else => { + std.debug.print("owner was {s}, expected .uhid\n", .{@tagName(inst.owner)}); + try testing.expect(false); + }, + } + // No [output.imu] -> no IMU companion card. + try testing.expect(inst.imu_dev == null); +} + +test "present_output_id=true presents [output] vid/pid not daemon FADE:C001" { + if (builtin.os.tag != .linux) return error.SkipZigTest; + const allocator = testing.allocator; + + const primary_fds = try posix.pipe2(.{ .NONBLOCK = true }); + defer posix.close(primary_fds[0]); + + const parsed = try device_mod.parseString(allocator, TEST_TOML_OUTPUT_UHID); + defer parsed.deinit(); + + var mock = try MockDeviceIO.init(allocator, &.{}); + defer mock.deinit(); + + var inst = try initOutputUhid(allocator, parsed, &mock, primary_fds[1]); + defer inst.deinit(); + + const scratch = try allocator.alloc(u8, uhid.UHID_EVENT_SIZE); + defer allocator.free(scratch); + + const primary_ev = try readCreate2(primary_fds[0], scratch); + // Masquerade identity, NOT the 0xFADE:0xC001 default and NOT device id. + try testing.expectEqual(@as(u32, 0x045e), primary_ev.payload.vendor); + try testing.expectEqual(@as(u32, 0x0b00), primary_ev.payload.product); + try testing.expect(primary_ev.payload.vendor != 0xFADE); + try testing.expect(primary_ev.payload.product != 0xC001); +} + +test "UHID primary descriptor declares the four paddle Button usages" { + if (builtin.os.tag != .linux) return error.SkipZigTest; + const allocator = testing.allocator; + + const primary_fds = try posix.pipe2(.{ .NONBLOCK = true }); + defer posix.close(primary_fds[0]); + + const parsed = try device_mod.parseString(allocator, TEST_TOML_OUTPUT_UHID); + defer parsed.deinit(); + + var mock = try MockDeviceIO.init(allocator, &.{}); + defer mock.deinit(); + + var inst = try initOutputUhid(allocator, parsed, &mock, primary_fds[1]); + defer inst.deinit(); + + const scratch = try allocator.alloc(u8, uhid.UHID_EVENT_SIZE); + defer allocator.free(scratch); + + const primary_ev = try readCreate2(primary_fds[0], scratch); + const rd = primary_ev.payload.rd_data[0..primary_ev.payload.rd_size]; + + // buildFromOutput maps BTN_TRIGGER_HAPPY1-4 to HID Button usages 17-20. + // Each appears as a Usage short item `0x09 `. Reverse-verification: + // removing the M1-M4 button mappings from the TOML drops these usages and + // fails the assertions. + const paddle_usages = [_][2]u8{ + .{ 0x09, 17 }, // BTN_TRIGGER_HAPPY1 (M1) + .{ 0x09, 18 }, // BTN_TRIGGER_HAPPY2 (M3) + .{ 0x09, 19 }, // BTN_TRIGGER_HAPPY3 (M2) + .{ 0x09, 20 }, // BTN_TRIGGER_HAPPY4 (M4) + }; + for (paddle_usages) |pu| { + try testing.expect(containsBytes(rd, &pu)); + } +} From bea98410ec36cac60245c1c85cd92fdd339251db Mon Sep 17 00:00:00 2001 From: BANANASJIM Date: Sat, 6 Jun 2026 23:53:26 -0700 Subject: [PATCH 2/2] fix(uhid): drain primary UHID fd for all main pads, answer GET/SET_REPORT The primary UHID fd was registered with the event loop only inside the PID-force-feedback block, so devices with plain rumble FFB (e.g. Flydigi Vader 5) never had /dev/uhid drained. After UHID_CREATE2 the kernel issues UHID_GET_REPORT / UHID_SET_REPORT during the HID probe; left unanswered they back-pressure the probe and the device never goes live, so zero input events reach the kernel (confirmed on real hardware). - Register primary_uhid.fd unconditionally for the UHID path; remove the now-duplicate registration in the PID block (FfbForwarder wiring stays). - Replace pollOutputReport in the loop drain with drainEvent, which: ignores lifecycle events (START/OPEN/CLOSE/STOP), answers GET_REPORT with UHID_GET_REPORT_REPLY and SET_REPORT with UHID_SET_REPORT_REPLY (empty, err=0, echoing the request id), forwards UHID_OUTPUT via output_cb, and loops to EAGAIN so the queue can't back up. - Tests: drainEvent GET_REPORT reply / lifecycle / OUTPUT; regression that a non-PID UHID main pad registers uhid_output_slot. --- src/device_instance.zig | 13 +- src/event_loop.zig | 19 +- src/io/uhid.zig | 220 +++++++++++++++++++++- src/test/supervisor_uhid_routing_test.zig | 8 + 4 files changed, 251 insertions(+), 9 deletions(-) diff --git a/src/device_instance.zig b/src/device_instance.zig index e50599fd..5c7745de 100644 --- a/src/device_instance.zig +++ b/src/device_instance.zig @@ -405,6 +405,15 @@ pub const DeviceInstance = struct { owner = .{ .uhid = primary_uhid }; primary_output = primary_uhid.outputDevice(); + // Register the primary UHID fd with the event loop for ALL + // UHID main pads, not just PID-FFB devices. The kernel issues + // UHID_GET_REPORT / UHID_SET_REPORT during HID probe; if the + // daemon never drains /dev/uhid those pile up and the device + // never goes live (zero input events — e.g. Vader 5 with plain + // rumble FFB). The PID-FFB path below only adds the output_cb + // wiring on top of this registration. + try loop.addUhidOutput(primary_uhid.fd); + if (imu_cfg_opt) |imu_cfg| { const imu_desc = try uhid_descriptor.UhidDescriptorBuilder.buildForImu(allocator, imu_cfg); defer allocator.free(imu_desc); @@ -452,8 +461,10 @@ pub const DeviceInstance = struct { const phys_fd = opts.test_physical_hidraw_fd orelse if (devices.len > 0) devices[0].pollfd().fd else -1; if (phys_fd >= 0) { + // primary_uhid.fd is already registered with the + // loop above; PID FFB only adds the forwarder that + // routes UHID_OUTPUT reports to the physical fd. ffb_fwd = FfbForwarder.init(phys_fd); - try loop.addUhidOutput(primary_uhid.fd); } } } diff --git a/src/event_loop.zig b/src/event_loop.zig index f065999c..59a89311 100644 --- a/src/event_loop.zig +++ b/src/event_loop.zig @@ -682,17 +682,24 @@ pub const EventLoop = struct { } } - // Drain UHID_OUTPUT events. Only active when uhid_output_slot is set - // (backend=uhid, kind=pid). + // Drain the primary UHID fd. Registered for ALL UHID main-pad + // devices, not just PID-FFB: the kernel issues GET/SET_REPORT + // requests during HID probe that must be answered or the device + // never goes live (zero input events). drainEvent answers those + // and forwards UHID_OUTPUT (rumble/FFB) via output_cb when wired. if (self.uhid_output_slot) |slot| { if (self.pollfds[slot].revents & posix.POLL.IN != 0) { if (ctx.uhid_primary) |uhid_dev| { var uhid_buf: [uhid_mod.UHID_EVENT_SIZE]u8 = undefined; while (true) { - const report = uhid_dev.pollOutputReport(&uhid_buf) catch break; - const r = report orelse break; - if (uhid_dev.output_cb) |cb| { - cb(uhid_dev.output_ctx.?, r); + const ev = uhid_dev.drainEvent(&uhid_buf) catch break; + switch (ev orelse break) { + .output => |r| { + if (uhid_dev.output_cb) |cb| { + cb(uhid_dev.output_ctx.?, r); + } + }, + .handled => {}, } } } diff --git a/src/io/uhid.zig b/src/io/uhid.zig index afe0db21..8e42860c 100644 --- a/src/io/uhid.zig +++ b/src/io/uhid.zig @@ -151,10 +151,48 @@ pub fn uhidDestroy(fd: posix.fd_t) void { write_exact.writeExact(fd, &buf) catch {}; } -// --- UHID_OUTPUT types ------------------------------------------------------- - +// --- Kernel→userspace event types ------------------------------------------ +// +// After `UHID_CREATE2` the kernel drives the device through a lifecycle and +// may issue report requests. A primary fd that is never drained back-pressures +// the kernel's HID probe, so the device never goes live and emits no input. +// These mirror `enum uhid_event_type` in the kernel UAPI. + +/// Device started — kernel is ready to receive input. Lifecycle, no reply. +pub const UHID_START: u32 = 2; +/// Device stopped. Lifecycle, no reply. +pub const UHID_STOP: u32 = 3; +/// A reader opened the hidraw/evdev node. Lifecycle, no reply. +pub const UHID_OPEN: u32 = 4; +/// The last reader closed the node. Lifecycle, no reply. +pub const UHID_CLOSE: u32 = 5; /// Kernel sends `UHID_OUTPUT` when the HID driver writes an output report. pub const UHID_OUTPUT: u32 = 6; +/// Kernel requests a feature/input report; userspace must answer with +/// `UHID_GET_REPORT_REPLY` echoing the same `id`, or the kernel blocks. +pub const UHID_GET_REPORT: u32 = 9; +/// Userspace answer to `UHID_GET_REPORT`. +pub const UHID_GET_REPORT_REPLY: u32 = 10; +/// Kernel forwards a SET_REPORT request; userspace must answer with +/// `UHID_SET_REPORT_REPLY` echoing the same `id`. +pub const UHID_SET_REPORT: u32 = 13; +/// Userspace answer to `UHID_SET_REPORT`. +pub const UHID_SET_REPORT_REPLY: u32 = 14; + +// `struct uhid_get_report_req` / `struct uhid_set_report_req` both begin with +// a `u32 id` at event offset 4 — the only field we need (read directly from the +// byte buffer in drainEvent, since the event buffer is only byte-aligned). + +/// Outcome of draining one event from the primary fd. +pub const DrainEvent = union(enum) { + /// A `UHID_OUTPUT` report (rumble / FFB effect data) for forwarding. + output: OutputReport, + /// A lifecycle or report-request event that was handled internally + /// (lifecycle ignored, GET/SET answered). No caller action needed. + handled, +}; + +// --- UHID_OUTPUT types ------------------------------------------------------- /// Mirror of `struct uhid_output_req` from the kernel UAPI. /// The kernel struct is __packed__ (4099 bytes); Zig extern struct is 4100 bytes @@ -471,6 +509,73 @@ pub const UhidDevice = struct { }; } + /// Read and handle one kernel→userspace event from the primary UHID fd. + /// + /// Returns null when no event is pending (`WouldBlock`). Otherwise: + /// - `UHID_OUTPUT` → returns `.output` for the caller to forward. + /// - `UHID_GET_REPORT` / `UHID_SET_REPORT` → answers the kernel with an + /// empty reply (err=0) echoing the request `id`, returns `.handled`. + /// - lifecycle events (START/OPEN/CLOSE/STOP) and any other type → + /// ignored, returns `.handled`. + /// + /// Unlike `pollOutputReport`, this never silently swallows GET/SET report + /// requests: leaving those unanswered back-pressures the kernel HID probe + /// so the device never goes live (zero input events). The caller should + /// loop until null to drain every pending event each wakeup. + pub fn drainEvent(self: *UhidDevice, buf: []u8) !?DrainEvent { + if (buf.len < UHID_EVENT_SIZE) return null; + const n = posix.read(self.fd, buf[0..UHID_EVENT_SIZE]) catch |err| switch (err) { + error.WouldBlock => return null, + else => return err, + }; + if (n < UHID_EVENT_SIZE) return error.IncompleteUhidEvent; + const ev_type = std.mem.readInt(u32, buf[0..4], .little); + switch (ev_type) { + UHID_OUTPUT => { + const req: *const UhidOutputReq = @ptrCast(@alignCast(&buf[4])); + const sz = @min(@as(usize, req.size), UHID_DATA_MAX); + return DrainEvent{ .output = .{ + .report_id = if (sz > 0) req.data[0] else 0, + .data = req.data[0..sz], + } }; + }, + UHID_GET_REPORT, UHID_SET_REPORT => { + // Both request structs begin with `u32 id` at offset 4. Read it + // straight from the byte buffer — the event buffer is only + // byte-aligned, so casting to an alignment-4 struct would trap. + const id = std.mem.readInt(u32, buf[4..8], .little); + if (ev_type == UHID_GET_REPORT) + self.replyGetReport(id) catch {} + else + self.replySetReport(id) catch {}; + return .handled; + }, + else => return .handled, // START/OPEN/CLOSE/STOP and unknowns + } + } + + /// Answer a `UHID_GET_REPORT` with an empty report (err=0), echoing `id`. + /// "I have nothing but don't block" — enough to let the kernel HID probe + /// finish so the device goes live. + fn replyGetReport(self: *UhidDevice, id: u32) !void { + // struct uhid_get_report_reply_req { u32 id; u16 err; u16 size; u8 data[]; } + var buf: [UHID_EVENT_SIZE]u8 = std.mem.zeroes([UHID_EVENT_SIZE]u8); + std.mem.writeInt(u32, buf[0..4], UHID_GET_REPORT_REPLY, .little); + std.mem.writeInt(u32, buf[4..8], id, .little); + // err (buf[8..10]) and size (buf[10..12]) stay 0. + try write_exact.writeExact(self.fd, &buf); + } + + /// Answer a `UHID_SET_REPORT` with err=0, echoing `id`. + fn replySetReport(self: *UhidDevice, id: u32) !void { + // struct uhid_set_report_reply_req { u32 id; u16 err; } + var buf: [UHID_EVENT_SIZE]u8 = std.mem.zeroes([UHID_EVENT_SIZE]u8); + std.mem.writeInt(u32, buf[0..4], UHID_SET_REPORT_REPLY, .little); + std.mem.writeInt(u32, buf[4..8], id, .little); + // err (buf[8..10]) stays 0. + try write_exact.writeExact(self.fd, &buf); + } + /// Always returns `null` — FF output reports from the kernel are consumed /// via `pollOutputReport` and routed through `output_cb`; the vtable /// `poll_ff` path is unused for UHID devices. @@ -1013,6 +1118,117 @@ test "uhid: pollOutputReport returns null on WouldBlock (empty pipe)" { try testing.expectEqual(@as(?OutputReport, null), report); } +test "uhid: drainEvent answers UHID_GET_REPORT with a reply echoing id" { + if (@import("builtin").os.tag != .linux) return error.SkipZigTest; + + const alloc = testing.allocator; + // socketpair so dev.fd is read+write: drainEvent reads the request and + // writes the reply back on the same fd; we read the reply from the peer. + var sv: [2]posix.fd_t = undefined; + { + const rc = std.os.linux.socketpair(posix.AF.UNIX, posix.SOCK.STREAM, 0, &sv); + if (rc != 0) return error.SkipZigTest; + } + defer posix.close(sv[0]); + defer posix.close(sv[1]); + + const cfg = Config{ + .vid = 0xFADE, + .pid = 0xCAFE, + .name = "padctl-getreport-test", + .descriptor = &[_]u8{ 0x05, 0x01, 0xC0 }, + }; + const dev = try UhidDevice.initWithFd(alloc, sv[0], cfg); + defer alloc.destroy(dev); + + // Hand-craft a UHID_GET_REPORT event: u32 type=9, then uhid_get_report_req + // { u32 id; u8 rnum; u8 rtype; } at offset 4. + var ev_buf: [UHID_EVENT_SIZE]u8 = std.mem.zeroes([UHID_EVENT_SIZE]u8); + std.mem.writeInt(u32, ev_buf[0..4], UHID_GET_REPORT, .little); + std.mem.writeInt(u32, ev_buf[4..8], 0xABCD, .little); // id + _ = try posix.write(sv[1], &ev_buf); + + var read_buf: [UHID_EVENT_SIZE]u8 = undefined; + const ev = try dev.drainEvent(&read_buf); + try testing.expect(ev != null); + try testing.expectEqual(DrainEvent.handled, ev.?); + + // The reply must have landed on the peer end: type + echoed id. + var reply: [UHID_EVENT_SIZE]u8 = undefined; + const n = try posix.read(sv[1], &reply); + try testing.expectEqual(@as(usize, UHID_EVENT_SIZE), n); + try testing.expectEqual(UHID_GET_REPORT_REPLY, std.mem.readInt(u32, reply[0..4], .little)); + try testing.expectEqual(@as(u32, 0xABCD), std.mem.readInt(u32, reply[4..8], .little)); +} + +test "uhid: drainEvent handles lifecycle events without error or reply" { + if (@import("builtin").os.tag != .linux) return error.SkipZigTest; + + const alloc = testing.allocator; + const fds = try posix.pipe2(.{ .NONBLOCK = true }); + defer posix.close(fds[0]); + defer posix.close(fds[1]); + + const cfg = Config{ + .vid = 0xFADE, + .pid = 0xCAFE, + .name = "padctl-lifecycle-test", + .descriptor = &[_]u8{ 0x05, 0x01, 0xC0 }, + }; + const dev = try UhidDevice.initWithFd(alloc, fds[0], cfg); + defer alloc.destroy(dev); + + for ([_]u32{ UHID_START, UHID_OPEN, UHID_CLOSE, UHID_STOP }) |ev_type| { + var ev_buf: [UHID_EVENT_SIZE]u8 = std.mem.zeroes([UHID_EVENT_SIZE]u8); + std.mem.writeInt(u32, ev_buf[0..4], ev_type, .little); + _ = try posix.write(fds[1], &ev_buf); + + var read_buf: [UHID_EVENT_SIZE]u8 = undefined; + const ev = try dev.drainEvent(&read_buf); + try testing.expectEqual(DrainEvent.handled, ev.?); + } + + // Empty pipe → null (drained). + var read_buf: [UHID_EVENT_SIZE]u8 = undefined; + try testing.expectEqual(@as(?DrainEvent, null), try dev.drainEvent(&read_buf)); +} + +test "uhid: drainEvent returns .output for UHID_OUTPUT" { + if (@import("builtin").os.tag != .linux) return error.SkipZigTest; + + const alloc = testing.allocator; + const fds = try posix.pipe2(.{ .NONBLOCK = true }); + defer posix.close(fds[0]); + defer posix.close(fds[1]); + + const cfg = Config{ + .vid = 0xFADE, + .pid = 0xCAFE, + .name = "padctl-drain-output-test", + .descriptor = &[_]u8{ 0x05, 0x01, 0xC0 }, + }; + const dev = try UhidDevice.initWithFd(alloc, fds[0], cfg); + defer alloc.destroy(dev); + + var ev_buf: [UHID_EVENT_SIZE]u8 = std.mem.zeroes([UHID_EVENT_SIZE]u8); + std.mem.writeInt(u32, ev_buf[0..4], UHID_OUTPUT, .little); + ev_buf[4] = 0x0A; // report_id + ev_buf[5] = 0x55; + std.mem.writeInt(u16, ev_buf[4100..4102], 2, .little); // size + _ = try posix.write(fds[1], &ev_buf); + + var read_buf: [UHID_EVENT_SIZE]u8 = undefined; + const ev = try dev.drainEvent(&read_buf); + try testing.expect(ev != null); + switch (ev.?) { + .output => |r| { + try testing.expectEqual(@as(u8, 0x0A), r.report_id); + try testing.expectEqualSlices(u8, &[_]u8{ 0x0A, 0x55 }, r.data); + }, + .handled => return error.TestUnexpectedResult, + } +} + test "uhid: openUhid sets O_NONBLOCK on the fd" { const fd = openUhid() catch |err| switch (err) { error.SkipZigTest => return error.SkipZigTest, diff --git a/src/test/supervisor_uhid_routing_test.zig b/src/test/supervisor_uhid_routing_test.zig index 06e291eb..6d6b486d 100644 --- a/src/test/supervisor_uhid_routing_test.zig +++ b/src/test/supervisor_uhid_routing_test.zig @@ -557,6 +557,14 @@ test "output backend=uhid routes main pad to UHID (Vader paddles)" { } // No [output.imu] -> no IMU companion card. try testing.expect(inst.imu_dev == null); + + // Regression: the primary UHID fd MUST be registered with the event loop + // for ALL UHID main pads, not only PID-FFB devices. This Vader config has + // plain rumble FFB (not PID); before the fix the fd was registered only + // inside the PID block, so /dev/uhid was never drained and the kernel HID + // probe stalled (zero input events). uhid_output_slot != null proves the + // unconditional registration. + try testing.expect(inst.loop.uhid_output_slot != null); } test "present_output_id=true presents [output] vid/pid not daemon FADE:C001" {