Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ zig-pkg/
*.o
*.a
*.deb
core.*
kcov-output/
.worktrees/

Expand Down
11 changes: 11 additions & 0 deletions devices/flydigi/vader5.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
95 changes: 95 additions & 0 deletions src/config/device.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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| {
Expand Down Expand Up @@ -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" {
Expand Down
86 changes: 78 additions & 8 deletions src/device_instance.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 = .{},
Expand Down Expand Up @@ -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;
Expand All @@ -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;
}
Expand Down Expand Up @@ -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,
Expand All @@ -380,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);
Expand Down Expand Up @@ -427,11 +461,41 @@ 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);
}
}
}

// 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 {
Expand Down Expand Up @@ -518,6 +582,7 @@ pub const DeviceInstance = struct {
.pending_mapping = null,
.stopped = false,
.ffb_forwarder = ffb_fwd,
.ff_sidecar = ff_sidecar_ptr,
};
}

Expand Down Expand Up @@ -564,6 +629,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();
Expand Down Expand Up @@ -632,6 +701,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;
Expand Down
26 changes: 19 additions & 7 deletions src/event_loop.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
};
Expand Down Expand Up @@ -677,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 => {},
}
}
}
Expand Down
Loading
Loading