All skills
hyperb1iss avatar

/hal-driver-development

@05acaca

This skill should be used when writing, porting, or debugging device drivers in hypercolor-hal. Triggers on "add a driver", "port a driver", "implement protocol", "device not working", "wire format", "encode frame", "USB HID packet", "zerocopy struct", "CommandBuffer", "device database entry", "transport type", "frame encoding", "protocol implementation", "add device support", or any work in crates/hypercolor-hal/.

  • 3 files
  • 54.7 KB
  • Updated last month
  • GitHub

Use this Skill: https://skilld.dev/gh/hyperb1iss/hypercolor/hal-driver-development

This session only. Nothing lands on disk.

referenceszerocopy-wire-formats.md

≈2.6k tokens on demand. Your agent reads this file only when SKILL.md points to it.

Zerocopy Wire Format Patterns

Detailed patterns for defining protocol packet structs in Hypercolor. The source spec is docs/specs/62-zerocopy-protocol-structs.md; the workspace pins zerocopy 0.8.

Derive Combinations

Scenario Derives Notes
Write-only packet (frame encoding) FromZeros, IntoBytes, KnownLayout, Immutable Most common
Read+write packet (command + response) FromBytes, IntoBytes, KnownLayout, Immutable FromBytes implies FromZeros — never derive both
Nested struct inside packet Same as parent All fields must be zerocopy-compatible

Multi-Byte Wire Fields

Use zerocopy::byteorder types, not native integers:

use zerocopy::byteorder::{LittleEndian, BigEndian, U16, U32};

#[derive(FromZeros, IntoBytes, KnownLayout, Immutable)]
#[repr(C)]
struct MyPacket {
    length: U16<LittleEndian>,   // 2 bytes, LE on wire
    sequence: U32<BigEndian>,    // 4 bytes, BE on wire
    payload: [u8; 58],
}

Set values with .set():

packet.length.set(payload_len as u16);

Response Parsing: read_from_prefix vs read_from_bytes

Always use read_from_prefix() for parsing device responses:

let (response, _remainder) = MyResponse::read_from_prefix(data).map_err(|_| {
    ProtocolError::MalformedResponse {
        detail: format!("expected at least {} bytes, got {}", MY_RESPONSE_LEN, data.len()),
    }
})?;

Two things the compiler will hold you to. The workspace pins zerocopy 0.8, where read_from_prefix returns Result<(Self, &[u8]), _>, so it must be destructured as a tuple; binding a single value does not compile. And ProtocolError::MalformedResponse is a struct variant carrying detail: String, not a unit variant.

Why prefix and not bytes: HID transports often return buffers larger than the struct (extra report ID byte at index 0, padding at end). read_from_bytes() requires exact size match and will fail.

Report ID Handling

Platform behavior varies:

Platform Report ID Behavior
Linux hidraw Includes report ID as first byte on read
Linux hidapi Strips report ID on read
macOS hidapi Strips report ID on read
Windows hidapi Includes report ID as first byte

For writing, start by asking which transport the descriptor resolves to. Only two of the nine TransportType variants carry a report_mode at all, UsbHidApi and UsbHidRaw, and only those two have a prepend step: encode_hidapi_packet (transport/hidapi.rs) and encode_hidraw_packet (transport/hidraw.rs) push report_id in front of the payload unless the mode is one of the *WithReportId variants, in which case they send the payload untouched.

Under UsbHidApi or UsbHidRaw:

HidRawReportMode Transport behavior Packet struct
FeatureReport Prepends the report ID byte Must not carry a report-ID field
OutputReport Prepends the report ID byte Must not carry a report-ID field
FeatureReportWithReportId Sends the payload as-is Must carry the report ID as field 0
OutputReportWithReportId Sends the payload as-is Must carry the report ID as field 0

Get this backwards and the report ID ships twice, shifting every subsequent byte by one. Worked examples: ASUS declares OutputReport and AuraDirectPacket has no report-ID field (64-byte payload, 65 bytes on the wire). Lian Li TL declares OutputReportWithReportId and TlPacket starts with one (64 bytes total, report ID included).

Every other transport sends your payload verbatim, and where the report ID lives then depends on which one. UsbControl and UsbBulk carry it out of band, in the control-transfer wValue as (HID_REPORT_TYPE_FEATURE << 8) | report_id, so the struct stays clean: RazerReport is 90 bytes with no report-ID field. UsbHid, UsbMidi, UsbSerial, I2cSmBus, and UsbVendor write the buffer as-is with nothing added, so any leading byte the device expects has to be field 0 of your struct.

Lian Li ENE is the case to reason from. Its descriptors declare TransportType::UsbHid (lianli/devices.rs), which has no report_mode and no prepend step, so EnePacket11, EnePacket65, and the output packets all start with a report_id field that the encoder sets to ENE_REPORT_ID (lianli/common.rs, lianli/ene.rs). transport/hid.rs reads that first byte back out of the payload to address the feature report. Omit it and you have not just lost a byte, you have sent a wrong report ID and shifted every field after it.

HidAccessMode::Direct resolves to exactly this case on Linux (transport.rs), so an intent-based descriptor can land on UsbHid even though the intent named a report_mode. Check what your target resolves to before deciding the struct layout.

For reading: use read_from_prefix() which tolerates the extra byte.

Packet Size Validation

Every packet struct must have its size pinned. The usual form is a compile-time assertion next to the struct, as in lianli/common.rs:

const _: () = assert!(
    std::mem::size_of::<EnePacket65>() == 65,
    "EnePacket65 must match the 65-byte ENE feature report size"
);

Corsair, QMK, ASUS, Nollie, PrismRGB, and Push 2 all pin their structs the same way. Razer is the exception: razer/crc.rs declares RAZER_REPORT_LEN = 90 and the size check lives in tests/razer_protocol_tests.rs as a runtime assert_eq! against that constant. Either form satisfies the rule; pick the const assertion for a new struct, because it fails at build time rather than at cargo test.

Common packet sizes across Hypercolor drivers:

Device Family Packet Size Why
Razer RazerReport = 90 bytes HID feature report, RAZER_REPORT_LEN
Lian Li ENE EnePacket11 = 11, EnePacket65 = 65 (cmd) HID feature report; SL/SL Redragon use the 11-byte form
Lian Li ENE EneOutputPacket146 / EneOutputPacket353 Output report; AL uses 146, SL V2 / AL V2 / SL Infinity use 353
Lian Li TL TlPacket = 64 bytes TL_PACKET_LEN, report ID included in the struct
Corsair LN LnDirectPacket = 65 bytes LN_WRITE_BUF_SIZE
Corsair LINK 513 bytes LINK_WRITE_BUF_SIZE; two zero bytes, 0x01, then command
Corsair LCD LcdDisplayPacket = 1024 bytes LCD_PACKET_SIZE, bulk display chunk
ASUS USB AuraDirectPacket = 64 bytes AURA_REPORT_PAYLOAD_LEN; the transport prepends the report ID, so 65 on the wire
QMK QmkPacket = 65 bytes PACKET_SIZE, fixed; report ID is field 0

Corsair LINK has two layers and they are easy to conflate. build_link_packet (corsair/framing.rs) allocates the 513-byte buffer, leaves bytes 0 and 1 zero, writes 0x01 at byte 2, and starts the command at byte 3. There is no length prefix at that layer. The 16-bit little-endian length belongs to the inner payload built by build_link_write_buffer, whose layout is len_le16 | 00 00 | data_type[2] | payload, and that buffer is then handed to build_link_packet as data.

Not every payload is a fixed struct. Lian Li SL and SL Redragon color data is built with CommandBuffer::push_fill at 2 + leds * 3 bytes, so the on-wire length tracks fan count (98 bytes for two 16-LED fans). Use push_fill when the length is data-dependent and a zerocopy struct when it is not.

Color Array Fields

For packets carrying LED color data, size the array to fit the maximum chunk:

#[derive(FromZeros, IntoBytes, KnownLayout, Immutable)]
#[repr(C)]
struct ColorPacket {
    header: [u8; 5],
    colors: [u8; 60],    // 20 LEDs × 3 bytes (RGB)
}

Fill partially if fewer LEDs — zerocopy's FromZeros initializes everything to 0x00, so unused slots are black (LEDs off). No explicit zeroing needed.

Enum Fields on Wire

Wire protocol enums are just u8 values. Keep the packet struct field a plain u8 and put the naming in an ordinary Rust enum with a const fn byte() accessor. Corsair does this in drivers/corsair/types.rs:

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LightingNodePacketId {
    Firmware,
    Direct,
    Commit,
    Reset,
    PortState,
    Brightness,
}

impl LightingNodePacketId {
    #[must_use]
    pub const fn byte(self) -> u8 {
        match self {
            Self::Firmware => 0x02,
            Self::Direct => 0x32,
            Self::Commit => 0x33,
            Self::Reset => 0x37,
            Self::PortState => 0x38,
            Self::Brightness => 0x39,
        }
    }
}

#[derive(FromZeros, IntoBytes, KnownLayout, Immutable)]
#[repr(C)]
struct LnDirectPacket {
    padding: u8,
    packet_id: u8,  // set from LightingNodePacketId::Direct.byte()
    // ...
}

Bare const CMD_*: u8 constants are fine too where there is no natural grouping. What matters is that the struct field stays u8.

Zerocopy requires all fields to be valid for any bit pattern. Rust enums with #[repr(u8)] are NOT valid for arbitrary bytes — they'll fail FromBytes if the wire sends an unknown variant. Use plain u8 fields in wire structs.

Source: SKILL.md on GitHub

No third-party reports yet.

Signed by skilld at 05acaca. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub yesterday.

Activeupdated last month
version
1.0.0

README badge

README badge for hyperb1iss/hypercolor/hal-driver-development