From f512391ee43bf61a7754df18f02a1a42bdf88e86 Mon Sep 17 00:00:00 2001 From: flamingcow Date: Fri, 31 Jul 2026 16:46:11 -0700 Subject: [PATCH] Capture the panel by reading the displayed drm buffer instead of fbdev --- harness/grab.go | 250 ++++++++++++++++++++++++++++++++++++++++++++++++ harness/main.go | 78 +-------------- 2 files changed, 254 insertions(+), 74 deletions(-) create mode 100644 harness/grab.go diff --git a/harness/grab.go b/harness/grab.go new file mode 100644 index 0000000..5d148e4 --- /dev/null +++ b/harness/grab.go @@ -0,0 +1,250 @@ +package main + +import ( + "bytes" + "fmt" + "image" + "image/color" + "path/filepath" + "unsafe" + + "golang.org/x/sys/unix" +) + +// cabletest drives the panel through drm and page flips between two scanout +// buffers of its own, so /dev/fb0 holds the kernel console and there is nothing +// in it worth reading. The buffer being displayed is reachable from out here +// though: GETCRTC names it, and root is allowed a handle to it without being +// drm master, so it can be mapped and read without the program that owns it +// having to cooperate or even notice. +const ( + drmCardGlob = "/dev/dri/card*" + + drmVblankRelative = 0x1 + drmVblankHighCrtcMask = 0x3e + drmVblankHighCrtcShft = 1 + + xrgbRedShift = 16 + xrgbGreenShift = 8 + xrgbBlueShift = 0 +) + +func drmIOWR(nr, size uintptr) uintptr { return 3<<30 | size<<16 | 0x64<<8 | nr } + +type drmModeInfo struct { + clock uint32 + hdisplay, hsyncStart, hsyncEnd, htotal, hskew uint16 + vdisplay, vsyncStart, vsyncEnd, vtotal, vscan uint16 + vrefresh, flags, typ uint32 + name [32]byte +} + +type drmModeCardRes struct { + fbIDPtr, crtcIDPtr, connIDPtr, encIDPtr uint64 + countFBs, countCRTCs, countConns, countEncs uint32 + minWidth, maxWidth, minHeight, maxHeight uint32 +} + +type drmModeCrtc struct { + setConnectorsPtr uint64 + countConnectors uint32 + crtcID uint32 + fbID uint32 + x, y uint32 + gammaSize uint32 + modeValid uint32 + mode drmModeInfo +} + +type drmModeFBCmd struct { + fbID, width, height, pitch, bpp, depth uint32 + handle uint32 +} + +type drmModeMapDumb struct { + handle, pad uint32 + offset uint64 +} + +type drmWaitVblank struct { + typ uint32 + sequence uint32 + signal uint64 + tvSec int64 + tvUsec int64 +} + +var ( + drmGetResources = drmIOWR(0xa0, unsafe.Sizeof(drmModeCardRes{})) + drmGetCrtc = drmIOWR(0xa1, unsafe.Sizeof(drmModeCrtc{})) + drmGetFB = drmIOWR(0xad, unsafe.Sizeof(drmModeFBCmd{})) + drmMapDumb = drmIOWR(0xb3, unsafe.Sizeof(drmModeMapDumb{})) + drmWaitVblankIO = drmIOWR(0x3a, 24) +) + +func drmIoctl(fd int, req uintptr, arg unsafe.Pointer) error { + if _, _, errno := unix.Syscall(unix.SYS_IOCTL, uintptr(fd), req, uintptr(arg)); errno != 0 { + return errno + } + return nil +} + +type grabber struct { + fd int + crtc uint32 + index int +} + +// The card and crtc actually putting something on a display, which is whichever +// one cabletest chose when it set its mode. +func openGrabber() (*grabber, error) { + paths, err := filepath.Glob(drmCardGlob) + if err != nil { + return nil, err + } + for _, p := range paths { + fd, err := unix.Open(p, unix.O_RDWR|unix.O_CLOEXEC, 0) + if err != nil { + continue + } + crtc, index, err := activeCrtc(fd) + if err == nil { + return &grabber{fd: fd, crtc: crtc, index: index}, nil + } + unix.Close(fd) + } + return nil, fmt.Errorf("no drm crtc is scanning out a framebuffer") +} + +func activeCrtc(fd int) (uint32, int, error) { + var res drmModeCardRes + if err := drmIoctl(fd, drmGetResources, unsafe.Pointer(&res)); err != nil { + return 0, 0, err + } + if res.countCRTCs == 0 { + return 0, 0, fmt.Errorf("card has no crtcs") + } + crtcs := make([]uint32, res.countCRTCs) + res.countFBs, res.countConns, res.countEncs = 0, 0, 0 + res.fbIDPtr, res.connIDPtr, res.encIDPtr = 0, 0, 0 + res.crtcIDPtr = uint64(uintptr(unsafe.Pointer(&crtcs[0]))) + if err := drmIoctl(fd, drmGetResources, unsafe.Pointer(&res)); err != nil { + return 0, 0, err + } + for i, id := range crtcs { + c := drmModeCrtc{crtcID: id} + if err := drmIoctl(fd, drmGetCrtc, unsafe.Pointer(&c)); err != nil { + continue + } + if c.fbID != 0 && c.modeValid != 0 { + return id, i, nil + } + } + return 0, 0, fmt.Errorf("no crtc is scanning out a framebuffer") +} + +func (g *grabber) close() { unix.Close(g.fd) } + +// Reading the buffer takes about as long as a frame, so where the read starts +// in the flip cycle is what decides whether it stays ahead of the writer. Woken +// at a blank, the buffer named next has just gone on screen, which leaves a +// whole frame plus however long cabletest spends drawing before anything +// touches it again. +func (g *grabber) waitVblank() error { + v := drmWaitVblank{ + typ: drmVblankRelative | uint32(g.index<> xrgbRedShift), + G: uint8(v >> xrgbGreenShift), + B: uint8(v >> xrgbBlueShift), + A: 255, + }) + } + } + return img +} diff --git a/harness/main.go b/harness/main.go index 48922fe..321757c 100644 --- a/harness/main.go +++ b/harness/main.go @@ -1,5 +1,5 @@ // Runs cabletest for a fixed time and dumps the panel to png, since the machine -// with the framebuffer on it is not the machine looking at it. +// with the display on it is not the machine looking at it. // // sudo go run ./harness -for 20s -at 3s,15s -- -a enp1s0f0np0 -b enp1s0f1np1 package main @@ -7,41 +7,21 @@ package main import ( "flag" "fmt" - "image" - "image/color" "image/png" "os" "os/exec" "path/filepath" "slices" - "strconv" "strings" "syscall" "time" - "unsafe" - - "golang.org/x/sys/unix" ) const ( - fbPath = "/dev/fb0" shotsDir = "shots" grace = 3 * time.Second ) -// Matching fb.go, which reads the variable screen info as a flat run of u32s -// rather than a struct to sidestep any question of padding. -const ( - fbioGetVScreenInfo = 0x4600 - viXres = 0 - viYres = 1 - viBitsPerPixel = 6 - viRedOffset = 8 - viGreenOffset = 11 - viBlueOffset = 14 - viScreenInfoLen = 40 -) - func main() { runFor := flag.Duration("for", 15*time.Second, "how long to let cabletest run") at := flag.String("at", "", "offsets to capture the panel at, comma separated, e.g. 3s,10s") @@ -165,55 +145,17 @@ func groupGone(pgid int) bool { } func shoot(name string) error { - f, err := os.Open(fbPath) + g, err := openGrabber() if err != nil { return err } - defer f.Close() + defer g.close() - var vi [viScreenInfoLen]uint32 - if _, _, errno := unix.Syscall(unix.SYS_IOCTL, f.Fd(), - fbioGetVScreenInfo, uintptr(unsafe.Pointer(&vi[0]))); errno != 0 { - return fmt.Errorf("get screen info: %w", errno) - } - if vi[viBitsPerPixel] != 32 { - return fmt.Errorf("only 32bpp supported, got %d", vi[viBitsPerPixel]) - } - stride, err := readUint("/sys/class/graphics/fb0/stride") + img, err := g.frame() if err != nil { return err } - pw, ph := int(vi[viXres]), int(vi[viYres]) - mem, err := unix.Mmap(int(f.Fd()), 0, int(stride)*ph, unix.PROT_READ, unix.MAP_SHARED) - if err != nil { - return fmt.Errorf("mmap: %w", err) - } - defer unix.Munmap(mem) - - // cabletest flushes a whole frame into this mapping every 16ms, so the pixel - // loop below is far too slow to read it directly: consecutive scanlines come - // from different frames and every changing digit ends up drawn twice. One - // memmove out first narrows the window to about the length of its own copy. - buf := make([]byte, len(mem)) - copy(buf, mem) - - rs, gs, bs := vi[viRedOffset], vi[viGreenOffset], vi[viBlueOffset] - // The panel is landscape and every draw is turned a quarter turn on its way - // into it, so the turn is undone here to get back what a person standing in - // front of it sees. - img := image.NewNRGBA(image.Rect(0, 0, ph, pw)) - for x := 0; x < ph; x++ { - for y := 0; y < pw; y++ { - o := x*int(stride) + (pw-1-y)*4 - v := uint32(buf[o]) | uint32(buf[o+1])<<8 | - uint32(buf[o+2])<<16 | uint32(buf[o+3])<<24 - img.SetNRGBA(x, y, color.NRGBA{ - R: uint8(v >> rs), G: uint8(v >> gs), B: uint8(v >> bs), A: 255, - }) - } - } - out, err := os.Create(name) if err != nil { return err @@ -224,15 +166,3 @@ func shoot(name string) error { } return out.Close() } - -func readUint(path string) (uint64, error) { - b, err := os.ReadFile(path) - if err != nil { - return 0, err - } - v, err := strconv.ParseUint(strings.TrimSpace(string(b)), 10, 64) - if err != nil { - return 0, fmt.Errorf("%s: %w", path, err) - } - return v, nil -}