[{"data":1,"prerenderedAt":589},["ShallowReactive",2],{"page-/posts/th-linux-lcd":3},{"id":4,"title":5,"author":6,"body":7,"date":574,"description":575,"extension":576,"meta":577,"navigation":578,"path":579,"seo":580,"stem":581,"tags":582,"__hash__":588},"content/posts/th-linux-lcd.md","Reverse Engineering a Thermaltake LCD Using Claude: From USB Traces to a Native Linux App","Klatooine",{"type":8,"value":9,"toc":562},"minimark",[10,14,17,22,25,33,36,39,42,45,49,52,55,136,139,143,150,236,254,266,276,280,286,294,297,303,318,322,325,338,341,347,358,369,376,380,383,399,410,414,433,439,451,470,474,483,486,492,499,502,529,533,539,542,545,548,552,555,558],[11,12,13],"p",{},"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.",[11,15,16],{},"I wanted a clean, low-footprint native Linux app.",[18,19,21],"h2",{"id":20},"the-first-attempt","The first attempt",[11,23,24],{},"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.",[11,26,27,28,32],{},"The animation streaming was the obvious part — the traffic pattern is unmistakable once you see it. Bulk packets, 1024 bytes each, all starting with ",[29,30,31],"code",{},"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.",[11,34,35],{},"And then I hit the wall.",[11,37,38],{},"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.",[11,40,41],{},"I didn't have that time. I set it aside.",[11,43,44],{},"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.",[18,46,48],{"id":47},"capturing-usb-traffic-from-a-windows-vm","Capturing USB traffic from a Windows VM",[11,50,51],{},"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.",[11,53,54],{},"This gives you a clean capture of every USB packet the Windows app sends to the device, without needing a second physical machine.",[56,57,62],"pre",{"className":58,"code":59,"language":60,"meta":61,"style":61},"language-bash shiki shiki-themes github-light github-dark","tshark -r capture.pcapng \\\n  -Y \"usb.idVendor==0x264a\" \\\n  -T fields -e frame.number -e usb.endpoint_address \\\n  -e usb.transfer_type -e usb.data_len -e usb.capdata\n","bash","",[29,63,64,84,95,117],{"__ignoreMap":61},[65,66,69,73,77,81],"span",{"class":67,"line":68},"line",1,[65,70,72],{"class":71},"sScJk","tshark",[65,74,76],{"class":75},"sj4cs"," -r",[65,78,80],{"class":79},"sZZnC"," capture.pcapng",[65,82,83],{"class":75}," \\\n",[65,85,87,90,93],{"class":67,"line":86},2,[65,88,89],{"class":75},"  -Y",[65,91,92],{"class":79}," \"usb.idVendor==0x264a\"",[65,94,83],{"class":75},[65,96,98,101,104,107,110,112,115],{"class":67,"line":97},3,[65,99,100],{"class":75},"  -T",[65,102,103],{"class":79}," fields",[65,105,106],{"class":75}," -e",[65,108,109],{"class":79}," frame.number",[65,111,106],{"class":75},[65,113,114],{"class":79}," usb.endpoint_address",[65,116,83],{"class":75},[65,118,120,123,126,128,131,133],{"class":67,"line":119},4,[65,121,122],{"class":75},"  -e",[65,124,125],{"class":79}," usb.transfer_type",[65,127,106],{"class":75},[65,129,130],{"class":79}," usb.data_len",[65,132,106],{"class":75},[65,134,135],{"class":79}," usb.capdata\n",[11,137,138],{},"You get one row per packet. Staring at the patterns long enough, the protocol starts to emerge.",[18,140,142],{"id":141},"what-i-found","What I found",[11,144,145,146,149],{},"The device (",[29,147,148],{},"264a:233c",") has two interfaces with four relevant endpoints:",[151,152,153,172],"table",{},[154,155,156],"thead",{},[157,158,159,163,166,169],"tr",{},[160,161,162],"th",{},"Interface",[160,164,165],{},"Endpoint",[160,167,168],{},"Direction",[160,170,171],{},"Purpose",[173,174,175,192,207,222],"tbody",{},[157,176,177,181,186,189],{},[178,179,180],"td",{},"1",[178,182,183],{},[29,184,185],{},"0x03",[178,187,188],{},"OUT",[178,190,191],{},"Animation frame data",[157,193,194,196,201,204],{},[178,195,180],{},[178,197,198],{},[29,199,200],{},"0x84",[178,202,203],{},"IN",[178,205,206],{},"Drain after each frame (required)",[157,208,209,212,217,219],{},[178,210,211],{},"0",[178,213,214],{},[29,215,216],{},"0x01",[178,218,188],{},[178,220,221],{},"Control commands",[157,223,224,226,231,233],{},[178,225,211],{},[178,227,228],{},[29,229,230],{},"0x82",[178,232,203],{},[178,234,235],{},"ACK (440 bytes, echoes command byte)",[11,237,238,242,243,245,246,250,251,253],{},[239,240,241],"strong",{},"Animation streaming"," works by splitting each JPEG into 1024-byte packets. The first byte is always ",[29,244,31],{},". After all packets for a single frame are sent, you ",[247,248,249],"em",{},"must"," read from ",[29,252,200],{}," — even with a 1ms timeout — or the device locks up completely.",[11,255,256,259,260,262,263,265],{},[239,257,258],{},"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 (",[29,261,211],{}," for standby, ",[29,264,180],{}," for boot), then the data in 10240-byte chunks, each acknowledged before the next.",[11,267,268,269,272,273,275],{},"The boot animation has one extra step: after the last chunk, you send CMD ",[29,270,271],{},"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 ",[29,274,230],{}," FINALIZE closes the transaction.",[18,277,279],{"id":278},"a-real-trace","A real trace",[11,281,282,283,285],{},"Here's what actual USB packets look like for a boot animation upload. Each line is one 440-byte packet on EP ",[29,284,216],{},", shown as the first 16 bytes:",[56,287,292],{"className":288,"code":290,"language":291},[289],"language-text","; ── Pre-finalize (clears any in-progress transfer) ──────────────────────────\nEP 0x01 →  82 01 00 80 00 00 00 00 00 00 00 00 00 00 00 00  [+424 zeros]\nEP 0x82 ←  82 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  [+424 zeros]\n\n; ── INIT (kind=1 boot, total_size=82496 bytes) ───────────────────────────────\nEP 0x01 →  0a 01 00 80 01 00 00 00 40 42 01 00 00 00 00 00  [+424 zeros]\n;                    ^^^^ kind=1  ^^^^^^^^^^^^ 82496 LE\nEP 0x82 ←  0a 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  [+424 zeros]\n\n; ── Chunk 0 header (10240 bytes, 24 packets, offset=0, progress=12%) ─────────\nEP 0x01 →  0b 18 00 80 00 00 00 00 00 28 00 00 0c 00 00 00  [+424 bytes: container start]\n;          ^^^^ 24 pkts  ^offset=0  ^chunk=10240   ^12%\nEP 0x01 →  0b 02 00 00 \u003Cnext 436 bytes of container data ...>\nEP 0x01 →  0b 03 00 00 \u003C...>\n; ... 21 more data packets ...\nEP 0x82 ←  0b 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  [+424 zeros]  ; chunk ACK\n\n; (chunks 1–7 follow the same pattern with increasing offset and progress%)\n\n; ── Commit to flash (boot only — CMD 0x14, fire-and-forget) ─────────────────\nEP 0x01 →  14 01 00 80 62 00 00 00 00 00 00 00 00 00 00 00  [+424 zeros]\n; EP 0x82 returns nothing — no ACK\n\n; ── Finalize ─────────────────────────────────────────────────────────────────\nEP 0x01 →  82 01 00 80 00 00 00 00 00 00 00 00 00 00 00 00  [+424 zeros]\nEP 0x82 ←  82 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  [+424 zeros]\n","text",[29,293,290],{"__ignoreMap":61},[11,295,296],{},"And an animation frame — a 3-packet JPEG sent on Interface 1:",[56,298,301],{"className":299,"code":300,"language":291},[289],"; ── Frame packet 1 of 3 (first byte 0x08, third byte flags total count) ──────\nEP 0x03 →  08 03 00 80 ff d8 ff e0 00 10 4a 46 49 46 00 01\n;          ^^^^       JPEG SOI + JFIF APP0 marker ──────────\n           01 01 00 60 00 60 00 00 ff db 00 43 00 10 0b 0c\n           0e 0c 0a 10 0e 0d 0e 12 11 10 13 18 28 1a 18 16\n           ; ... 1004 more bytes of JPEG data ...\n\n; ── Frame packet 2 of 3 ───────────────────────────────────────────────────────\nEP 0x03 →  08 02 00 00 \u003Cnext 1020 bytes of JPEG>\n\n; ── Frame packet 3 of 3 ───────────────────────────────────────────────────────\nEP 0x03 →  08 03 00 00 \u003Cfinal 1020 bytes of JPEG, zero-padded if short>\n\n; ── Mandatory drain on EP 0x84 (1ms timeout — skip this and the device locks) ─\nEP 0x84 ←  00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00\n",[29,302,300],{"__ignoreMap":61},[11,304,305,306,309,310,313,314,317],{},"The JPEG header in packet 1 is genuine — bytes ",[29,307,308],{},"FF D8 FF E0"," are the JPEG SOI + APP0 marker, followed by ",[29,311,312],{},"4A 46 49 46"," (",[29,315,316],{},"JFIF","). That's exactly what you see when you extract a frame from the trace and open it in an image viewer.",[18,319,321],{"id":320},"the-boot-animation-container","The boot animation container",[11,323,324],{},"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.",[11,326,327,328,331,332,337],{},"At bytes ",[29,329,330],{},"[16:32]"," there's a null-padded ASCII string: ",[239,333,334],{},[29,335,336],{},"Update_Boot_GIF",". That's what the firmware calls this format — found verbatim in the binary, not something I named it.",[11,339,340],{},"The full header structure:",[56,342,345],{"className":343,"code":344,"language":291},[289],"[0:4]   checksum  = -(full_size + n_frames × 17) as int32 LE\n[4:8]   content_size = full_size - 16\n[8:12]  tbl_size = n_frames × 16\n[12]    0x10\n[13]    0x00\n[14]    n_frames\n[15]    timing = ms per frame  (80 = 12.5 fps)\n[16:32] \"Update_Boot_GIF\\0\"\n",[29,346,344],{"__ignoreMap":61},[11,348,349,350,353,354,357],{},"Followed by a frame table, the frame data (each prefixed with an 8-byte filename like ",[29,351,352],{},"000.jpg\\0","), and a 16-byte trailer (",[29,355,356],{},"0x00 × 15, 0x10",").",[11,359,360,361,364,365,368],{},"I verified the checksum formula by comparing multiple containers with different frame counts. The ",[29,362,363],{},"× 17"," factor turned out to encode ",[29,366,367],{},"1 (n_frames byte) + 16 (frame table entry size)"," — classic embedded firmware integrity check.",[11,370,371,372,375],{},"One thing I discovered the hard way: the firmware ",[239,373,374],{},"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.",[18,377,379],{"id":378},"the-jpeg-subsampling-quirk","The JPEG subsampling quirk",[11,381,382],{},"JPEG chroma subsampling tripped me up. The device is inconsistent about it:",[384,385,386,393],"ul",{},[387,388,389,392],"li",{},[239,390,391],{},"Streaming animations",": 4:4:4 subsampling. Use anything else and the bottom half of the display renders as corrupted garbage.",[387,394,395,398],{},[239,396,397],{},"Boot animation frames",": 4:2:0 subsampling. Opposite requirement, no obvious reason why.",[11,400,401,402,405,406,409],{},"In Python/Pillow: ",[29,403,404],{},"subsampling=0"," for 4:4:4, ",[29,407,408],{},"subsampling=2"," for 4:2:0. Getting this wrong is subtle — the upload completes successfully either way.",[18,411,413],{"id":412},"a-related-device","A related device",[11,415,416,417,426,427,430,431,357],{},"While digging into the USB VID, I found another project: ",[418,419,423],"a",{"href":420,"rel":421},"https://github.com/bekindpleaserewind/ttlcd",[422],"nofollow",[29,424,425],{},"ttlcd"," — a Linux controller for the Thermaltake LCD Panel Kit, a flat display for the Tower 200 Mini chassis. Its USB ID is ",[29,428,429],{},"264a:233d"," — one product ID away from this device (",[29,432,148],{},[11,434,435,436,438],{},"The protocol structure is the same family: 1024-byte frame packets with ",[29,437,31],{}," 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.",[11,440,441,442,444,445,447,448,450],{},"The differences are where things get interesting. Their device is a 480×128 flat panel; ours is a 480×480 circle. More importantly, ",[29,443,425],{}," has no boot animation support, no standby image upload, and no flash write operation — those features, along with the ",[29,446,336],{}," container format and CMD ",[29,449,271],{},", appear to be unique to the pump-head LCD variant.",[11,452,453,454,457,458,461,462,465,466,469],{},"The actual OEM manufacturer, by the way, is not Thermaltake. The USB descriptor's ",[29,455,456],{},"iManufacturer"," string reads ",[239,459,460],{},"HKC OVERSEAS LIMITED",", with ",[29,463,464],{},"iProduct"," set to ",[29,467,468],{},"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.",[18,471,473],{"id":472},"the-app","The app",[11,475,476,477,482],{},"For the UI, I went with ",[418,478,481],{"href":479,"rel":480},"https://github.com/emilk/egui",[422],"egui"," — an immediate-mode Rust GUI library. The binary is ~3MB, starts in ~150ms, and has zero system GUI library dependencies beyond OpenGL.",[11,484,485],{},"The architecture is a daemon + GUI split:",[56,487,490],{"className":488,"code":489,"language":291},[289],"GUI process (egui)\n  └─ Unix socket (JSON IPC)\n       └─ Daemon process\n            ├─ Socket thread (one per connection)\n            └─ USB thread (owns the rusb handle, streams frames)\n",[29,491,489],{"__ignoreMap":61},[11,493,494,495,498],{},"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 ",[29,496,497],{},"nc",".",[11,500,501],{},"Four features working:",[503,504,505,511,517,523],"ol",{},[387,506,507,510],{},[239,508,509],{},"GIF streaming"," — up to 60fps, rotation, live preview",[387,512,513,516],{},[239,514,515],{},"Standby image"," — JPEG/PNG/GIF upload to flash",[387,518,519,522],{},[239,520,521],{},"Boot animation"," — custom animation before OS connects",[387,524,525,528],{},[239,526,527],{},"Screen control"," — on/off",[18,530,532],{"id":531},"claude","Claude",[11,534,535,536,538],{},"My first solo pass got me the animation streaming — saw the 1024-byte packets, extracted a JPEG frame from the trace, confirmed the ",[29,537,31],{}," prefix pattern. That part was readable without much RE background.",[11,540,541],{},"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.",[11,543,544],{},"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.",[11,546,547],{},"The USB sniffing and empirical device testing were hands-on. The protocol reconstruction was collaborative.",[18,549,551],{"id":550},"fair-warning","Fair warning",[11,553,554],{},"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.",[11,556,557],{},"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.",[559,560,561],"style",{},"html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":61,"searchDepth":86,"depth":86,"links":563},[564,565,566,567,568,569,570,571,572,573],{"id":20,"depth":86,"text":21},{"id":47,"depth":86,"text":48},{"id":141,"depth":86,"text":142},{"id":278,"depth":86,"text":279},{"id":320,"depth":86,"text":321},{"id":378,"depth":86,"text":379},{"id":412,"depth":86,"text":413},{"id":472,"depth":86,"text":473},{"id":531,"depth":86,"text":532},{"id":550,"depth":86,"text":551},"2026-04-10","How I built a native Linux controller for the TH420V2 Ultra LCD by sniffing USB traffic from a Windows VM and reverse engineering the protocol from scratch, using Claude as a collaborator.","md",{},true,"/posts/th-linux-lcd",{"title":5,"description":575},"posts/th-linux-lcd",[583,584,585,586,481,587],"rust","reverse-engineering","linux","usb","hardware","ZrEk6UhKZ0UXYP6IStXLqxORJSgqSBA_Y10I388-mR0",1775981900720]