Over the past few weeks, I revisited my old Embedded Systems notes to solve a problem that had been bothering me for a long time. I use SignalRGB as my unified lighting and peripheral controller, but NZXT CAM does not integrate cleanly with that workflow. Both applications attempt to control the same device and end up fighting for access.

The goal of this small project was to remove CAM out from the equation entirely and being able to control the LCD cooler without the need of the official software. This was accomplished through a small, open-source application made in Rust.

Ideally, I'd like to add more compatibility to this open source utility so that additional models (not necessarily from NZXT) could be supported.

See the project here: Cesarsk/kraken-unleashed: Deploy GIFs on Kraken LCDs with this simple utility!

None

I'm documenting the process here both to help others and to ensure I don't forget the details of the protocol and implementation, given my inexperience with Rust.

Project Scope

This article focuses exclusively on the Rust backend, which handles USB communication, protocol commands, and media upload. The Electron frontend is intentionally omitted because it is trivial compared to the backend's device‑level work.

The backend acts as a user‑space controller: a normal application that communicates with the device through driver‑exposed APIs. Instead of relying on CAM, the backend performs the entire media upload workflow:

enumerate the Kraken USB device
open the HID control path
open the USB bulk transfer path
prepare the GIF for the LCD resolution
delete previous media buckets
create a new bucket with the required size
start a GIF write transaction
send a fixed protocol header
stream the GIF bytes through the bulk endpoint
finalize the transaction
switch the LCD to display the uploaded bucket

This approach still uses the Windows driver stack as the hardware access layer. The objective is replace the proprietary control workflow with a smaller and more understandable implementation. Once the USB protocol is known, the application can perform the specific operations needed for the display without requiring the full vendor suite to stay installed or running (meaning, the drivers are still needed).

A small parenthesis about Drivers

A driver is the operating-system component that binds to hardware or to a device class and exposes controlled access to user-space programs. On Windows, an application cannot directly schedule USB transfers on the host controller. The kernel and driver stack handle enumeration, endpoint configuration, transfer submission, synchronization, access permissions, and device power state.

The backend therefore uses Windows APIs to reach the device through existing driver paths:

SetupDiGetClassDevsW        // enumerate device interface classes
SetupDiEnumDeviceInterfaces // list present device interfaces
CreateFileW                 // open a device path as a Windows handle
ReadFile / WriteFile        // read and write report-style data
HidD_GetAttributes          // inspect HID vendor/product information
HidD_SetOutputReport        // send an HID output report
WinUsb_Initialize           // initialize a WinUSB interface
WinUsb_QueryPipe            // inspect available USB pipes/endpoints
WinUsb_WritePipe            // write data to a USB endpoint

The full stack looks like:

Rust backend (this app)
↓
Windows user-space APIs
↓
HID / WinUSB / libusb-compatible access
↓
Windows kernel USB stack
↓
Kraken firmware

USB Interfaces and Endpoints

A USB device describes itself to the host through descriptors. These descriptors contain structured information such as vendor ID, product ID, configurations, interfaces, and endpoints. Windows reads these descriptors during enumeration and exposes device paths that applications can later open.

An interface is a logical function exposed by a USB device. One physical device may expose several interfaces. In this backend, the relevant distinction is between the interface used for small command packets and the path used for larger media transfer.

An endpoint is a directional communication channel associated with an interface. Endpoint addresses are one byte wide. The lower bits identify the endpoint number, while the highest bit indicates direction:

  • clear means OUT, from host to device;
  • set means IN, from device to host.

The backend defines the bulk payload endpoint as:

// u8 means "unsigned 8-bit integer".
// 0x02 is hexadecimal notation for decimal 2.
// In USB endpoint addressing, 0x02 means endpoint number 2, OUT direction,
// because the high direction bit is not set.
const BULK_OUT_ENDPOINT: u8 = 0x02;

The value was identified by inspecting the active USB configuration descriptor and by testing the candidate transfer paths. The libusb path enumerates interfaces, alternate settings, and endpoints, then selects the interface whose endpoint address is 0x02 and whose transfer type is bulk:

// bmAttributes & 0x03 extracts the USB transfer type.
// 0x02 means bulk transfer.
if endpoint.bEndpointAddress == BULK_OUT_ENDPOINT
    && (endpoint.bmAttributes & 0x03) == 0x02
{
    candidates.push((
        descriptor.bInterfaceNumber as i32,
        descriptor.bAlternateSetting as i32,
    ));
}

During development, this had to be verified empirically: many different interface numbers and access methods were attempted, HID-only transfer was unsuitable for the full GIF payload, and the backend eventually settled on HID for command packets plus bulk endpoint 0x02 for media bytes.

Why Rust Fits This Problem

Rust is a good fit for this backend because the code has to work close to the operating-system boundary while still remaining structured enough to maintain.

The protocol is binary. Commands are byte sequences, not JSON messages or high-level API calls:

// Start a GIF write transaction for the selected bucket.
self.write_packet(&[0x36, 0x01, bucket])?;

Rust also helps with native resources. The backend owns HID handles, WinUSB handles, libusb contexts, and transfer buffers. These resources must be opened, used, and released in the correct order. Rust's ownership model and Drop pattern make it possible to attach cleanup to the object that owns the resource.

There is still unsafe code, because Windows APIs and libusb are external interfaces.

What is a safe rust call? And an unsafe one?

A safe Rust call is one where the compiler can enforce the relevant memory and type rules. On the other hand, an unsafe Rust operation is one where the compiler cannot prove that all required invariants are valid. Calling a Windows API through FFI is unsafe because Rust cannot verify that a handle is valid, that a pointer points to valid memory, that the buffer lives long enough, or that the external function respects Rust's aliasing rules.

Note: In Rust, FFI stands for Foreign Function Interface. It is the mechanism that allows Rust code to interact with functions, types, and data from other programming languages, most commonly C. This is essential when integrating with existing native libraries or system APIs.

Preparing the GIF Payload

Before writing anything to the device, the backend prepares the GIF for the target LCD. This preparation step produces a payload with the correct resolution and a known byte length:

let prepared = prepare_gif_for_device(
    path,
    device.info.width,     // target LCD width, for example 640
    device.info.height,    // target LCD height, for example 640
    rotation,
    zoom,
    pan_x,
    pan_y,
    20 * 1024 * 1024,      // maximum accepted payload size
    emit_progress,
)?;

Knowing the final length is required because the firmware bucket must be allocated before the bulk transfer begins. The application cannot start streaming bytes and decide the allocation size later. The firmware needs to know how much media memory to reserve first.

For example, a 16:9 GIF uploaded to a square 640x640 LCD has to be transformed somehow. The backend treats the LCD as the target canvas, applies the selected zoom and pan values, and emits a device-sized GIF rather than relying on the firmware to scale it.

The backend also rejects oversized payloads:

if gif_data.len() > 20 * 1024 * 1024 {
    return Err("GIF is too large for device memory (20.0MiB)".to_string());
}

This size limit is intentionally checked before the upload sequence begins. If the prepared payload is too large, failing early is better than allocating a bucket, starting a write transaction, and leaving the device in a partially updated state.

At this point, the backend has a byte buffer ready for transfer.

Beginning the Write Transaction

The backend sends 0x36 0x03 and waits for 0x37 0x03. Since the protocol is undocumented, I describe this only as an observed synchronization step in the write sequence. It is kept because the upload path depends on this command and acknowledgement pair before bucket operations continue.

This is one of the recurring patterns in the implementation. Some commands have clear behavior because they produce a visible result. Other commands are only known because the firmware accepts the full workflow when they are present and becomes unreliable when they are missing (I am sure most Reverse Engineers can do so much better than me).

// Clear pending HID responses so old data does not affect the next command.
self.clear_hid();

// Switch the LCD to a known mode before changing bucket storage.
let _ = self.set_lcd_mode(DisplayMode::Liquid, 0);

// Allow the firmware state to settle after the mode change.
thread::sleep(Duration::from_millis(200));

self.clear_hid();

// Send the protocol command observed to be required before bucket writes.
let _ = self.write_packet(&[0x36, 0x03]);

// Wait for the expected acknowledgement.
let _ = self.read_until(&[[0x37, 0x03]], DEFAULT_TIMEOUT_MS);

What is a Bucket

A bucket is a numbered media storage allocation managed by the firmware. The host creates a bucket with a declared size, writes media bytes into it, and then selects that bucket as the active LCD source.

This concept is central to the upload process. The display workflow is storage-based: the GIF is first stored in a firmware-managed slot, then the firmware is instructed to render that slot.

The backend expresses this sequence directly:

// Remove previous media allocations from the device.
self.delete_all_buckets()?;

// Reserve bucket 0 with enough capacity for this GIF.
self.create_bucket(0, gif_data.len())?;

// Write the prepared GIF bytes into bucket 0.
self.write_gif_bucket(gif_data, 0)?;

// Ask the firmware to render bucket 0 on the LCD.
self.set_lcd_mode(DisplayMode::Bucket, 0)?;

Bucket Allocation

The bucket allocation command needs the payload size in KiB:

// Convert payload size from bytes to KiB.
// The extra +1 KiB gives the allocation a small margin.
let size_kib = ((size as f64 / 1024.0).ceil() as u16).saturating_add(1);
// Convert the u16 into two little-endian bytes.
let size_bytes = size_kib.to_le_bytes();

// Little-endian stores the least significant byte first. For example:
// 0x1234 becomes [0x34, 0x12]
// Big-endian stores the most significant byte first:
// 0x1234 becomes [0x12, 0x34]

The allocation packet is then sent over the command channel:

self.write_packet(&[
    0x32,                  // bucket-management command group
    0x01,                  // create/allocation operation
    bucket,                // bucket index to create
    bucket.saturating_add(1),
    0x00,
    0x00,
    size_bytes[0],         // low byte of size in KiB
    size_bytes[1],         // high byte of size in KiB
    0x01,
])?;

The important practical detail is that allocation happens before the media write. If the bucket is too small, the later bulk transfer cannot be interpreted as a valid media object.

Starting the Bucket Write

The next command tells the firmware that the upcoming bulk transfer belongs to the selected bucket:

// Tell the firmware that the next bulk transfer belongs to this bucket.
self.write_packet(&[0x36, 0x01, bucket])?;
// Wait for acknowledgement 0x37 0x01.
let start = self.read_until(&[[0x37, 0x01]], DEFAULT_TIMEOUT_MS)?;
// Interpret the returned status.
parse_standard_result(&start, "Could not start GIF write")?;

This step connects the command channel to the upcoming data transfer. The bulk endpoint moves bytes, while the HID command tells the firmware how to interpret those bytes.

Without this step, writing bytes to the bulk endpoint would only be a raw USB transfer. The device would not necessarily associate those bytes with a media bucket.

Fixed Write Header

Before the GIF bytes themselves, the backend sends a fixed write header:

// Fixed protocol preamble observed to be required by the firmware
// before the media payload.
let mut header = COMMON_WRITE_HEADER.to_vec();

// Additional marker/type field used by this write transaction.
header.extend_from_slice(&[0x01, 0x00, 0x00, 0x00]);

// Payload length as little-endian u32,
// so the firmware knows how many GIF bytes follow.
header.extend_from_slice(&(gif_data.len() as u32).to_le_bytes());

// Send the header before the actual GIF bytes.
self.bulk_write(&header)?;

const COMMON_WRITE_HEADER: [u8; 12] = [
    0x12, 0xFA, 0x01, 0xE8,
    0xAB, 0xCD, 0xEF, 0x98,
    0x76, 0x54, 0x32, 0x10,
];

This header is best described as a fixed protocol preamble. It gives the firmware a stable sequence before the variable-length media payload. The code does not assign a known semantic meaning to every byte in the fixed section, so the correct explanation is empirical: transfers using this preamble, followed by the marker and payload length, are accepted as valid media writes.

What is known is more practical:

Field             Size      Current understanding
-----------------------------------------------------------------------------
Common preamble   12 bytes  Required opaque sequence observed in valid writes
Marker/type field 4 bytes   Observed constant for this GIF write transaction
Payload length    4 bytes   Little-endian u32 containing the GIF byte length

Transfers using this preamble, followed by the marker and payload length, are accepted as valid media writes. Transfers without the same structure are not reliable.

This is also one of the areas where future compatibility work matters. Another cooler may use the same storage concept but require a different preamble, media marker, or payload descriptor.

Transferring the GIF Bytes

The prepared GIF is then transferred over the bulk endpoint:

// Send the actual prepared GIF bytes.
// Progress values are only for reporting; they do not affect the protocol.
self.bulk_write_with_progress(gif_data, 94, 97)?;

Each USB write submits at most 64 KiB at a time:

const BULK_WRITE_CHUNK_SIZE: usize = 64 * 1024;
for chunk in bytes.chunks(BULK_WRITE_CHUNK_SIZE) {
    let mut written = 0u32;
    unsafe {
        WinUsb_WritePipe(
            interface,
            BULK_OUT_ENDPOINT,   // endpoint 0x02, host-to-device bulk pipe
            chunk,               // current slice of GIF/header bytes
            Some(&mut written),  // number of bytes actually written
            None,
        )
    }?;
    // A partial write would corrupt the object stored in the bucket.
    if written as usize != chunk.len() {
        return Err("Bulk write incomplete".to_string());
    }
}

The firmware receives one continuous object made of the fixed header followed by the GIF bytes. The application splits the transfer only because large buffers should be submitted to the USB stack in manageable pieces.

The partial-write check is important. If a chunk reports fewer bytes written than expected, continuing would corrupt the object stored in the bucket. In that case the backend stops and reports the failure instead of pretending that the upload succeeded.

Finalization and Display Selection

Once the payload has been transferred, the backend finalizes the write:

// End the GIF write transaction.
self.write_packet(&[0x36, 0x02])?;

// Wait for the final acknowledgement when available.
match self.read_until(&[[0x37, 0x02]], DEFAULT_TIMEOUT_MS) {
    Ok(end) => parse_standard_result(&end, "Could not finalize GIF write"),
    Err(_) => Ok(()),
}

enum DisplayMode {
    Liquid = 2,
    Bucket = 4,
}

// Select bucket display mode and point it at bucket 0.
self.set_lcd_mode(DisplayMode::Bucket, 0)?;

// 0x38 0x01 is the mode-setting command.
// mode is Liquid or Bucket.
// bucket selects which media slot to display when mode is Bucket.
self.write_packet(&[0x38, 0x01, mode as u8, bucket])?;

The final acknowledgement is less reliable than the start acknowledgement. Some transfers complete successfully even when the final read times out, so the backend currently treats a missing final acknowledgement as non-fatal.

This is not ideal protocol design, but it matches the observed behavior. It is also one of the areas that should be validated across more devices before the protocol is considered stable.

The display mode is then changed to render the uploaded bucket:

enum DisplayMode {
    Liquid = 2,
    Bucket = 4,
}
// Select bucket display mode and point it at bucket 0.
self.set_lcd_mode(DisplayMode::Bucket, 0)?;
// 0x38 0x01 is the mode-setting command.
// mode is Liquid or Bucket.
// bucket selects which media slot to display when mode is Bucket.
self.write_packet(&[0x38, 0x01, mode as u8, bucket])?;

At this point, the upload has moved through all required stages: the media was prepared, storage was allocated, bytes were transferred, the transaction was finalized, and the LCD was pointed at the uploaded bucket.

How Contributions Can Work

Contributions should focus on adding compatibility for new cooler models. For a new model, the required information is usually:

cooler model name
vendor ID
product ID
LCD resolution
HID report sizes
bulk or interrupt endpoint addresses
required initialization sequence
media upload sequence
payload header format
maximum accepted payload size
display activation command
recovery command, if available
driver path used on Windows

A good compatibility contribution should answer one practical question:

what does this specific cooler require so that the backend can discover it, prepare media for its screen, write the payload, and activate it on the display?

If another cooler uses the same protocol as the Kraken models already supported, adding compatibility may be as simple as adding its product ID and resolution. If it uses a different endpoint, payload header, storage model, or display activation command, the contribution should document those differences and add the required model-specific path.

Conclusions

With this project I was able to upload prepared media to supported LCD coolers without keeping the vendor control application running. The most fragile part is not the Rust code itself, but the undocumented protocol surface. Every additional tested model helps turn hard-coded observations into proper device profiles.

For now, the application is focused on Windows and GIF upload for supported Kraken LCD coolers. The next useful step is to grow the hardware compatibility list through real tested devices. If you own a cooler with a similar LCD and are willing to collect USB descriptors, test upload sequences, or compare protocol behavior, contributions are welcome.

If you wish to stay in contact: