Skip to content
Merged
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
42 changes: 42 additions & 0 deletions src/gamepad.zig
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,35 @@ pub const TypeHint = enum(u8) {
generic,
};

/// Best-guess vendor family from a human-readable device name string.
///
/// Shared name→type classifier for any source that only has a name to go on
/// (Android `InputDevice.getName()`, raylib's `GetGamepadName`, the WebGamepad
/// `id` string). Backends with a stable USB vendor id (Linux evdev, iOS GC
/// profile) should classify from that instead — this is the name-only path.
///
/// Matching is case-insensitive substring. A non-empty name that matches no
/// known family is `.generic`; an empty name is `.unknown`.
pub fn typeHintFromName(name: []const u8) TypeHint {
if (containsIgnoreCase(name, "xbox") or
containsIgnoreCase(name, "microsoft")) return .xbox;
if (containsIgnoreCase(name, "playstation") or
containsIgnoreCase(name, "dualsense") or
containsIgnoreCase(name, "dualshock") or
containsIgnoreCase(name, "sony") or
containsIgnoreCase(name, "wireless controller")) return .playstation;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

X-Box wireless misclassified PlayStation

Medium Severity

In typeHintFromName, the Xbox branch only matches the contiguous substring xbox, so names like X-Box Wireless Controller skip Xbox and Microsoft checks. The PlayStation branch then matches the wireless controller substring and returns .playstation, so some Xbox pads get PlayStation glyphs instead of Xbox on Android.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 3a7300f. Configure here.

if (containsIgnoreCase(name, "nintendo") or
containsIgnoreCase(name, "switch") or
containsIgnoreCase(name, "joy-con") or
containsIgnoreCase(name, "pro controller")) return .nintendo;
if (name.len > 0) return .generic;
return .unknown;
}
Comment on lines +38 to +52

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

Because "wireless controller" is a very generic term that matches many controllers (such as a "Nintendo Switch Wireless Controller"), checking for it in the PlayStation block before checking for Nintendo terms will cause Nintendo wireless controllers to be incorrectly classified as .playstation.

Swapping the order of the Nintendo and PlayStation checks resolves this, ensuring that specific Nintendo keywords like "nintendo" or "switch" are matched first, while still allowing the generic "wireless controller" fallback (common for Sony DualShock controllers on macOS/iOS) to correctly map to .playstation.

pub fn typeHintFromName(name: []const u8) TypeHint {
    if (containsIgnoreCase(name, "xbox") or
        containsIgnoreCase(name, "microsoft")) return .xbox;
    if (containsIgnoreCase(name, "nintendo") or
        containsIgnoreCase(name, "switch") or
        containsIgnoreCase(name, "joy-con") or
        containsIgnoreCase(name, "pro controller")) return .nintendo;
    if (containsIgnoreCase(name, "playstation") or
        containsIgnoreCase(name, "dualsense") or
        containsIgnoreCase(name, "dualshock") or
        containsIgnoreCase(name, "sony") or
        containsIgnoreCase(name, "wireless controller")) return .playstation;
    if (name.len > 0) return .generic;
    return .unknown;
}


fn containsIgnoreCase(haystack: []const u8, needle: []const u8) bool {
return std.ascii.indexOfIgnoreCase(haystack, needle) != null;
}

/// What kind of physical device produced the event. Distinguishes a real
/// game controller from a TV / set-top "d-pad remote" (Android TV, tvOS),
/// which should usually be treated differently by menus.
Expand Down Expand Up @@ -161,6 +190,19 @@ test "setName truncates to NAME_CAPACITY and stays NUL-terminated" {
try std.testing.expectEqual(@as(u8, 0), ev.name[NAME_CAPACITY]); // sentinel intact
}

test "typeHintFromName classifies known vendor families (name-only path)" {
try std.testing.expectEqual(TypeHint.xbox, typeHintFromName("Xbox Wireless Controller"));
try std.testing.expectEqual(TypeHint.xbox, typeHintFromName("XBOX 360 For Windows"));
try std.testing.expectEqual(TypeHint.xbox, typeHintFromName("Microsoft X-Box pad"));
try std.testing.expectEqual(TypeHint.playstation, typeHintFromName("Sony DualSense Wireless Controller"));
try std.testing.expectEqual(TypeHint.playstation, typeHintFromName("PLAYSTATION(R)3 Controller"));
try std.testing.expectEqual(TypeHint.playstation, typeHintFromName("Wireless Controller"));
try std.testing.expectEqual(TypeHint.nintendo, typeHintFromName("Nintendo Switch Pro Controller"));
try std.testing.expectEqual(TypeHint.nintendo, typeHintFromName("Joy-Con (L)"));
try std.testing.expectEqual(TypeHint.generic, typeHintFromName("Generic USB Joystick"));
try std.testing.expectEqual(TypeHint.unknown, typeHintFromName(""));
}
Comment on lines +193 to +204

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

Add a test case for "Nintendo Switch Wireless Controller" to ensure that controllers containing both a specific brand keyword (like "Switch") and the generic "Wireless Controller" phrase are correctly classified under their specific brand rather than falling back to PlayStation.

test "typeHintFromName classifies known vendor families (name-only path)" {
    try std.testing.expectEqual(TypeHint.xbox, typeHintFromName("Xbox Wireless Controller"));
    try std.testing.expectEqual(TypeHint.xbox, typeHintFromName("XBOX 360 For Windows"));
    try std.testing.expectEqual(TypeHint.xbox, typeHintFromName("Microsoft X-Box pad"));
    try std.testing.expectEqual(TypeHint.playstation, typeHintFromName("Sony DualSense Wireless Controller"));
    try std.testing.expectEqual(TypeHint.playstation, typeHintFromName("PLAYSTATION(R)3 Controller"));
    try std.testing.expectEqual(TypeHint.playstation, typeHintFromName("Wireless Controller"));
    try std.testing.expectEqual(TypeHint.nintendo, typeHintFromName("Nintendo Switch Pro Controller"));
    try std.testing.expectEqual(TypeHint.nintendo, typeHintFromName("Nintendo Switch Wireless Controller"));
    try std.testing.expectEqual(TypeHint.nintendo, typeHintFromName("Joy-Con (L)"));
    try std.testing.expectEqual(TypeHint.generic, typeHintFromName("Generic USB Joystick"));
    try std.testing.expectEqual(TypeHint.unknown, typeHintFromName(""));
}


test "disconnected constructor" {
const ev = GamepadEvent.disconnected(3);
try std.testing.expectEqual(GamepadEvent.Kind.disconnected, ev.kind);
Expand Down
17 changes: 17 additions & 0 deletions src/gamepad_source/android.zig
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,11 @@ export fn labelle_android_on_device_added(
ev.setName(name);
ev.guid = descriptorGuid(descriptor);
ev.source_class = classifySources(sources);
// Derive the vendor family from the name (Android exposes no stable USB
// vendor id here), matching the desktop/web name-only path so the HUD shows
// e.g. `Pad 9: Xbox Wireless Controller [xbox]` instead of `[unknown]`
// (labelle-assembler#270). Uses the buffered name so truncation is honored.
ev.type_hint = source.typeHintFromName(ev.nameSlice());
ring.push(ev);
}

Expand Down Expand Up @@ -343,6 +348,18 @@ test "EventRing overflow drops oldest" {
try std.testing.expectEqual(@as(u32, 5), out[0].slot);
}

test "device-added uses the shared name classifier for type_hint (#270)" {
// The `labelle_android_on_device_added` body early-returns off Android, so we
// exercise the same name→type_hint derivation it performs (`setName` then
// `typeHintFromName(nameSlice())`) to lock in the wiring that drives the HUD.
var ev = GamepadEvent{ .kind = .connected, .slot = 9 };
ev.setName("Xbox Wireless Controller");
ev.type_hint = source.typeHintFromName(ev.nameSlice());
try std.testing.expectEqual(source.GamepadEvent.Kind.connected, ev.kind);
try std.testing.expectEqualStrings("Xbox Wireless Controller", ev.nameSlice());
try std.testing.expectEqual(source.TypeHint.xbox, ev.type_hint);
}

test "pollEvents returns 0 on host (no Android activity)" {
var buf: [8]GamepadEvent = undefined;
try std.testing.expectEqual(@as(usize, 0), Source.pollEvents(&buf));
Expand Down
8 changes: 8 additions & 0 deletions src/gamepad_source/root.zig
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,14 @@ pub const GamepadDescription = gamepad.GamepadDescription;
// into `../gamepad.zig`. Without it `android.zig` fails to compile for the
// Android target (labelle-core#23).
pub const SourceClass = gamepad.SourceClass;
/// Re-exported alongside the name classifier so per-OS source files can name
/// the result type through `@import("root.zig")`.
pub const TypeHint = gamepad.TypeHint;
/// Re-exported so per-OS source files can derive a `TypeHint` from a device
/// name (the name-only classification path) through `@import("root.zig")`
/// without reaching back into `../gamepad.zig`. Shared so Android, raylib and
/// the web source classify identical name strings the same way (#270).
pub const typeHintFromName = gamepad.typeHintFromName;

/// Comptime OS/abi dispatch. Each branch maps to exactly one owned file so
/// parallel Wave-1 work never collides. Android is detected via abi (there
Expand Down
Loading