The Capture Card That Wouldn't Capture
The card is a clone. Its PCI vendor ID is 0x8888 – not a registered vendor, just four eights, the fingerprint of hardware built to look like something it isn’t. lspci calls it a “Silicon Magic AVMatrix VC12 4K HDMI Capture.” It’s a cheap 4K HDMI capture card in the lineage of a Magewell Pro Capture, and it had sat dead in one of my machines since I bought it, because the only Linux driver I could find for it didn’t work.
I reinstalled that machine – Arch to Ubuntu 26.04, kernel 7.0 – and decided the card had earned one more try.
It turned into two bugs. One was a real kernel regression, now merged into GloriousEggroll’s driver fork – not the mainline kernel itself. The other looked exactly like a driver bug for about four hours and turned out to be a PCIe slot.
This is also a write-up of what AI-paired kernel debugging actually looks like, because that’s how I did it: I drove, Claude Code instrumented the driver and read the registers, and the work moved a lot faster than it would have with me alone in a hex dump. The conclusions are mine. The grunt work was shared.
The driver
The card isn’t supported in-tree, and Magewell’s own driver doesn’t claim the clone’s fake vendor ID. What exists is GloriousEggroll’s community fork
– a DKMS package built around an hws module (HwsUHDX1Capture). It’s a stripped-down reimplementation, not Magewell’s code; Magewell’s driver is a tree of mw-*.c files, and this is one big hws_video.c. The README says it was confirmed working on Fedora 37, kernel 6.2.
I’m on 7.0. There’s an open issue – #19 , “Device not usable in OBS on Kernel 7.0” – with no fix. So the card not working on a current kernel was a known, unsolved problem, not just my hardware.
The driver builds clean on 7.0. A prior commit
already handled the compile breakage. It loads, binds the card, and creates /dev/video0.
Then every open() fails with EINVAL.
Bug one: the device won’t open
v4l2-ctl --info returns Failed to open /dev/video0: Invalid argument, and dmesg has a kernel WARNING at videobuf2-core.c:2645, in vb2_core_queue_init, called from hws_open.
The cause is a clean kernel-API regression. On 7.0, vb2_ops.wait_prepare and wait_finish are gone, and vb2_core_queue_init() now requires the driver to set q->lock. The hws driver does neither:
q->lock = NULL; /* we use our own locks */
The compile-fix commit had already #if’d out the now-deleted wait_prepare/wait_finish callbacks so the thing would build – but it left q->lock = NULL. With the wait ops removed and no lock, vb2_core_queue_init bails with -EINVAL and the device can’t be opened at all. The port to 7.0 was half done: the half that makes it compile, not the half that makes it run.
The fix is small. Each open() already creates its own per-handle vb2_queue, so give each one its own mutex and point q->lock at it on new kernels:
#if (LINUX_VERSION_CODE < KERNEL_VERSION(7,0,0))
q->lock = NULL; /* pre-7.0: serialized via wait_prepare/wait_finish ops */
#else
q->lock = &ctx->qmutex;
#endif
Plus a struct mutex qmutex in the per-handle context and a mutex_init in hws_open. Old kernels keep their old behavior; 7.0 gets the lock vb2 now demands. The vb2 callbacks take spinlocks, not the file-handle’s ioctl mutex, so there’s no lock-ordering hazard in pointing q->lock at a per-handle mutex.
After that, open() succeeds and v4l2-ctl --info returns real capabilities. One gotcha on the way: the first load failed with Unknown symbol vb2_* because insmod doesn’t pull dependencies – videodev and the videobuf2-* modules have to be loaded first, which modprobe would have done. DKMS install, autoload via the PCI modalias, survives a reboot.
That’s issue #19. “Device not usable” meant “can’t open,” and now it opens.
So I was done.
I was not done.
Bug two: the device opens but captures black
open() works. Capture produces frames – a steady 3.91 frames per second of flat gray. The same 3.91 every time, whatever I point it at.
That fixed rate is a tell. It’s not a captured signal; it’s a fallback heartbeat. The hardware isn’t delivering frames, so the driver hands back placeholder buffers on a timer. The real capture path is dead.
Here is where the pairing earned its keep, by failing first. Claude called it fixed – frames were moving through the vb2 machinery, so it reported the pipeline working and was ready to write it up. I didn’t buy it. The strategy was “capture real video,” and “buffers are moving” isn’t that, so I sent it back to prove the frames had pixels in them.
They didn’t. Buffers moving is not the same as pixels arriving.
That exchange is the actual job of driving an AI. Not the typing – the agent types faster than I do and reads hex registers without getting bored. The job is holding the goal and checking the output against it, because the easiest move an agent makes, the instant the surface looks plausible, is to declare “done, it works” and reach for the next thing.
That’s not a machine failure. It’s the oldest one there is: the confident all-clear from someone who ran the happy path once. The model just does it faster and more fluently, which makes it easier to believe. I’ve shipped that bug myself, with no AI in the room.
The capture path in this driver is: hardware finishes a frame, raises an MSI interrupt, the ISR schedules a tasklet, the tasklet copies the frame into a vb2 buffer and marks it done. I instrumented each stage. StartVideoCapture runs. EnableVideoCapture sets the per-channel enable bit and the read-back confirms it. The engine is armed.
The interrupt handler never fires. /proc/interrupts shows the card’s MSI vector sitting at zero. Not once.
No interrupt means no frame-complete event means no real frames. So the question became: why doesn’t the hardware interrupt?
I ruled the usual suspects out, one at a time, by reading the actual setup code rather than guessing:
- DMA mask. Set correctly –
pci_set_dma_mask(pdev, DMA_BIT_MASK(32)), buffers fromdma_alloc_coherent, all under 4 GB. Not it. - Bus mastering.
pci_set_masteris called;lspciconfirmsBusMaster+. A PCIe device with bus mastering off can’t issue the memory write that is an MSI – but it’s on. Not it. - MSI itself.
Enable+, vector allocated, handler registered. Not it. - IOMMU. Off. No DMA-remapping faults. Not it.
Every kernel-level thing checked out. The engine was armed, interrupts were enabled (INT_EN_REG_BASE gets 0x3ffff written to it at init), the DMA addresses were programmed. And the hardware sat there producing nothing.
So I read the hardware’s own opinion. The driver detects an input signal by reading a register and checking a per-channel bit:
value = READ_REGISTER_ULONG(pdx, CVBS_IN_BASE + 5 * PCIE_BARADDROFSIZE);
active_video = ((value & 0xFF) >> ch) & 0x01;
active_video was 0. The decoder saw no live video.
That reframed everything. The card had HDMI link – a status light was on – but link is not active video, and I’d conflated the two. The machine was driving the capture card’s input from its own GPU as a loopback, and the GPU’s output connector was sitting in DPMS-off: connected, EDID exchanged, no pixels scanning out. The driver wasn’t broken. It was correctly waiting for a signal that nobody was sending.
I forced the GPU to actually scan out a test pattern with modetest. active_video flipped to 1.
Still no interrupt. Still black frames.
What actually fixed it
The smoking gun was three lines of lspci -vv I’d skimmed past an hour earlier:
LnkCap: Speed 5GT/s, Width x4
LnkSta: Speed 5GT/s, Width x1 (downgraded)
The port can do four lanes. The card had trained at one.
The board is an ASUS ROG Maximus XII Hero, three physical x16 slots, and the card was in the third. That third slot (PCIEX16_3) shares its four lanes with the second M.2 slot (M.2_2) off the Z490 chipset – a known quirk of the board, with a BIOS “bandwidth control” knob for the split. Put an NVMe drive in that M.2 and the slot drops to a single lane. The limiting hop even showed up in dmesg as the chipset root port (0000:00:1b.7). So the x1 wasn’t a fault. It was the board doing exactly what it’s designed to do.
Which makes the real question worse, not better. That x1 was clean – the board meant to hand the slot one lane, not four, and a clean lane has the bandwidth for 1080p capture, never mind a four-byte MSI write that no bandwidth limit would block. Yet at x1 the card produced zero frames and zero interrupts; at x4 it produced both. The likeliest read is that this clone’s DMA engine simply won’t run below its full width – but I didn’t prove that, and I stopped digging the moment the card worked.
I moved the card to a different x16 slot and powered back up.
LnkSta: Speed 5GT/s, Width x4
I drove the loopback, captured at the resolution the card auto-detected, and the interrupt counter went from zero to 127. The DMA buffer filled with real data. The frames were the machine’s own login screen – luma running from 16 to 231, an actual picture instead of a flat 16.
The card captures 4K-capable live video on kernel 7.0. The thing it had never done, in the years it sat in that slot, it now does.
The part that’s a kernel patch, and the part that isn’t
The driver fix is real. It’s one mutex and a version guard, it closes issue #19, and it’s merged upstream – fast, and with no changes requested. I know why I was up at 3am debugging a kernel driver. Why was he?
But the bug I spent the most time on – the one that looked like a driver bug, that had me instrumenting an ISR and decoding status registers – wasn’t in the code at all. It was a card seated one slot too far over, training a quarter of its lanes, silently dropping the writes that DMA and interrupts are made of.
I instrumented a kernel driver for an evening. The fix was three inches to the left.
When a PCIe device’s DMA and interrupts do nothing, and the driver setup all reads correct, check LnkSta before you blame the code. A device that comes up at fewer lanes than you expected isn’t just slow – it can quietly stop landing the writes that DMA and interrupts are made of. A shared-lane slot hands you that surprise without a word.