Reverse Engineering a Thermaltake LCD Using Claude: From USB Traces to a Native Linux App
I have a Thermaltake TH420V2 Ultra cooler. It has a 480×480 circular LCD on the pump head that can display animations and images. There is no Linux support. No community project, no open-source controller, nothing. The official software is Windows-only, bloated, and closed-source.
I wanted a clean, low-footprint native Linux app.
The first attempt
Before any AI was involved, I had a go at it myself. I set up a Windows VM, passed the USB device through with QEMU, fired up Wireshark on the Arch host, and started recording what the official software was sending.
The animation streaming was the obvious part — the traffic pattern is unmistakable once you see it. Bulk packets, 1024 bytes each, all starting with 0x08, cycling at frame rate. I wrote a quick Python script to intercept one frame and dump it to disk. The bytes starting at offset 4 decoded as a valid JPEG. That felt like a win.
And then I hit the wall.
The upload traffic for boot animations and standby images was a different story. 440-byte control packets with structured headers, multi-chunk protocols, a proprietary binary container embedded in the payload — with a header that didn't obviously decode to anything, a checksum I couldn't derive, and a magic string I wasn't sure was real or something I was misreading. Understanding it required sitting down and systematically working through multiple captures, cross-referencing offsets, deriving formulas, and tracking what was confirmed vs. hypothetical.
I didn't have that time. I set it aside.
A few months later I came back to it with Claude as a collaborator. Having an AI that could hold the full context of the capture data, keep track of what each byte range meant, validate checksum formulas, and work through the protocol incrementally — that changed the equation. What felt like a week of focused work compressed into a focused session.
Capturing USB traffic from a Windows VM
My setup: a Windows VM running the official Thermaltake software, with the USB device passed through via QEMU, and Wireshark running on the Arch host capturing the traffic.
This gives you a clean capture of every USB packet the Windows app sends to the device, without needing a second physical machine.
tshark -r capture.pcapng \
-Y "usb.idVendor==0x264a" \
-T fields -e frame.number -e usb.endpoint_address \
-e usb.transfer_type -e usb.data_len -e usb.capdata
You get one row per packet. Staring at the patterns long enough, the protocol starts to emerge.
What I found
The device (264a:233c) has two interfaces with four relevant endpoints:
| Interface | Endpoint | Direction | Purpose |
|---|---|---|---|
| 1 | 0x03 | OUT | Animation frame data |
| 1 | 0x84 | IN | Drain after each frame (required) |
| 0 | 0x01 | OUT | Control commands |
| 0 | 0x82 | IN | ACK (440 bytes, echoes command byte) |
Animation streaming works by splitting each JPEG into 1024-byte packets. The first byte is always 0x08. After all packets for a single frame are sent, you must read from 0x84 — even with a 1ms timeout — or the device locks up completely.
Standby and boot uploads use a chunked protocol over the control interface. The device receives an INIT command declaring the total size and a "kind" byte (0 for standby, 1 for boot), then the data in 10240-byte chunks, each acknowledged before the next.
The boot animation has one extra step: after the last chunk, you send CMD 0x14, which triggers the device to burn the data to flash. This command is fire-and-forget — the device returns nothing on any endpoint. I confirmed this by scanning all endpoints after sending it. Then a final 0x82 FINALIZE closes the transaction.
A real trace
Here's what actual USB packets look like for a boot animation upload. Each line is one 440-byte packet on EP 0x01, shown as the first 16 bytes:
; ── Pre-finalize (clears any in-progress transfer) ──────────────────────────
EP 0x01 → 82 01 00 80 00 00 00 00 00 00 00 00 00 00 00 00 [+424 zeros]
EP 0x82 ← 82 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 [+424 zeros]
; ── INIT (kind=1 boot, total_size=82496 bytes) ───────────────────────────────
EP 0x01 → 0a 01 00 80 01 00 00 00 40 42 01 00 00 00 00 00 [+424 zeros]
; ^^^^ kind=1 ^^^^^^^^^^^^ 82496 LE
EP 0x82 ← 0a 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 [+424 zeros]
; ── Chunk 0 header (10240 bytes, 24 packets, offset=0, progress=12%) ─────────
EP 0x01 → 0b 18 00 80 00 00 00 00 00 28 00 00 0c 00 00 00 [+424 bytes: container start]
; ^^^^ 24 pkts ^offset=0 ^chunk=10240 ^12%
EP 0x01 → 0b 02 00 00 <next 436 bytes of container data ...>
EP 0x01 → 0b 03 00 00 <...>
; ... 21 more data packets ...
EP 0x82 ← 0b 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 [+424 zeros] ; chunk ACK
; (chunks 1–7 follow the same pattern with increasing offset and progress%)
; ── Commit to flash (boot only — CMD 0x14, fire-and-forget) ─────────────────
EP 0x01 → 14 01 00 80 62 00 00 00 00 00 00 00 00 00 00 00 [+424 zeros]
; EP 0x82 returns nothing — no ACK
; ── Finalize ─────────────────────────────────────────────────────────────────
EP 0x01 → 82 01 00 80 00 00 00 00 00 00 00 00 00 00 00 00 [+424 zeros]
EP 0x82 ← 82 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 [+424 zeros]
And an animation frame — a 3-packet JPEG sent on Interface 1:
; ── Frame packet 1 of 3 (first byte 0x08, third byte flags total count) ──────
EP 0x03 → 08 03 00 80 ff d8 ff e0 00 10 4a 46 49 46 00 01
; ^^^^ JPEG SOI + JFIF APP0 marker ──────────
01 01 00 60 00 60 00 00 ff db 00 43 00 10 0b 0c
0e 0c 0a 10 0e 0d 0e 12 11 10 13 18 28 1a 18 16
; ... 1004 more bytes of JPEG data ...
; ── Frame packet 2 of 3 ───────────────────────────────────────────────────────
EP 0x03 → 08 02 00 00 <next 1020 bytes of JPEG>
; ── Frame packet 3 of 3 ───────────────────────────────────────────────────────
EP 0x03 → 08 03 00 00 <final 1020 bytes of JPEG, zero-padded if short>
; ── Mandatory drain on EP 0x84 (1ms timeout — skip this and the device locks) ─
EP 0x84 ← 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
The JPEG header in packet 1 is genuine — bytes FF D8 FF E0 are the JPEG SOI + APP0 marker, followed by 4A 46 49 46 (JFIF). That's exactly what you see when you extract a frame from the trace and open it in an image viewer.
The boot animation container
The trickiest part was the boot animation. It's not a raw GIF — it's a proprietary binary container. I extracted the upload payload from the pcap trace, reassembled the chunks, and started parsing manually.
At bytes [16:32] there's a null-padded ASCII string: Update_Boot_GIF. That's what the firmware calls this format — found verbatim in the binary, not something I named it.
The full header structure:
[0:4] checksum = -(full_size + n_frames × 17) as int32 LE
[4:8] content_size = full_size - 16
[8:12] tbl_size = n_frames × 16
[12] 0x10
[13] 0x00
[14] n_frames
[15] timing = ms per frame (80 = 12.5 fps)
[16:32] "Update_Boot_GIF\0"
Followed by a frame table, the frame data (each prefixed with an 8-byte filename like 000.jpg\0), and a 16-byte trailer (0x00 × 15, 0x10).
I verified the checksum formula by comparing multiple containers with different frame counts. The × 17 factor turned out to encode 1 (n_frames byte) + 16 (frame table entry size) — classic embedded firmware integrity check.
One thing I discovered the hard way: the firmware silently rejects containers with a timing byte below 80ms (more than 12.5 fps). The upload succeeds, all ACKs look fine, but after power cycle — no animation. Cap the fps at 12.5 or the device ignores everything.
The JPEG subsampling quirk
JPEG chroma subsampling tripped me up. The device is inconsistent about it:
- Streaming animations: 4:4:4 subsampling. Use anything else and the bottom half of the display renders as corrupted garbage.
- Boot animation frames: 4:2:0 subsampling. Opposite requirement, no obvious reason why.
In Python/Pillow: subsampling=0 for 4:4:4, subsampling=2 for 4:2:0. Getting this wrong is subtle — the upload completes successfully either way.
A related device
While digging into the USB VID, I found another project: ttlcd — a Linux controller for the Thermaltake LCD Panel Kit, a flat display for the Tower 200 Mini chassis. Its USB ID is 264a:233d — one product ID away from this device (264a:233c).
The protocol structure is the same family: 1024-byte frame packets with 0x08 prefix, 440-byte control packets, mandatory drain after each frame. The core packet format is consistent between the two devices, which is a useful cross-validation — it means the animation protocol I reverse-engineered wasn't idiosyncratic to my unit.
The differences are where things get interesting. Their device is a 480×128 flat panel; ours is a 480×480 circle. More importantly, ttlcd has no boot animation support, no standby image upload, and no flash write operation — those features, along with the Update_Boot_GIF container format and CMD 0x14, appear to be unique to the pump-head LCD variant.
The actual OEM manufacturer, by the way, is not Thermaltake. The USB descriptor's iManufacturer string reads HKC OVERSEAS LIMITED, with iProduct set to 2.1 inch Round TFT LCD Display Module-B. Thermaltake rebadges it. There are no public protocol docs from HKC for this module — which is why USB sniffing was the only path in.
The app
For the UI, I went with egui — an immediate-mode Rust GUI library. The binary is ~3MB, starts in ~150ms, and has zero system GUI library dependencies beyond OpenGL.
The architecture is a daemon + GUI split:
GUI process (egui)
└─ Unix socket (JSON IPC)
└─ Daemon process
├─ Socket thread (one per connection)
└─ USB thread (owns the rusb handle, streams frames)
The daemon runs as a systemd user service. The GUI auto-launches it if the socket isn't present. IPC is newline-delimited JSON — simple and debuggable with nc.
Four features working:
- GIF streaming — up to 60fps, rotation, live preview
- Standby image — JPEG/PNG/GIF upload to flash
- Boot animation — custom animation before OS connects
- Screen control — on/off
Claude
My first solo pass got me the animation streaming — saw the 1024-byte packets, extracted a JPEG frame from the trace, confirmed the 0x08 prefix pattern. That part was readable without much RE background.
The upload protocol was where it fell apart. The 440-byte command packets had structured headers I couldn't decode quickly. The boot container had a checksum I couldn't derive. I'd have needed a long, focused session with the hex dumps to work through it systematically, and I didn't have that. So I shelved it.
Coming back with Claude changed the dynamic. I'd feed in the raw hex, describe what I observed, and we'd work through it together — what each byte range likely meant, how to test a hypothesis, how to validate a formula across multiple captures. The AI kept the analysis structured across a long session and caught things I'd have missed staring at hex at midnight.
The USB sniffing and empirical device testing were hands-on. The protocol reconstruction was collaborative.
Fair warning
This is homemade, untested-on-anything-but-my-own-device software. It writes directly to flash memory. There is no warranty, no official support, and a non-zero chance it could brick your screen. Run it under your own responsibility.
If you have the same cooler and want to dig into the protocol — the full reverse engineering notes, container format spec, and USB command documentation are part of the project. I'll share the repo once I figure out where to host it publicly.