diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3c8b272..3108a6e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -26,6 +26,9 @@ jobs: # Run tests. - run: nim c tests/test.nim - run: nim c tests/manual_inputs.nim + - name: Check 32-bit fbdev without DRM + if: matrix.os == 'ubuntu-latest' + run: WINDY_TEST_BACKENDS=fbdev WINDY_TEST_TARGET=arm nim check tests/test_linux_display.nim - run: nim r tests/test_openurl.nim - run: nim r tests/test_macos.nim if: matrix.os == 'macos-latest' diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..da1818a --- /dev/null +++ b/Makefile @@ -0,0 +1,11 @@ +.PHONY: test-native test-linux-cpu test-linux-combined test-x11-pixels +test-x11-pixels: + nim r tests/test_x11_pixels.nim +test-native: + nim r tests/test_native_input.nim + nim r tests/test_linux_framebuffer.nim + nim r tests/test_linux_drm.nim +test-linux-cpu: + WINDY_TEST_BACKENDS=x11 nim r tests/test_linux_display.nim +test-linux-combined: + WINDY_TEST_BACKENDS=x11,fbdev,drm nim r tests/test_linux_display.nim diff --git a/README.md b/README.md index f7c4d72..6ab5e45 100644 --- a/README.md +++ b/README.md @@ -54,10 +54,13 @@ while not window.closeRequested: ## CPU Pixels -On Windows, compile with `-d:useCpu` to create a plain window without an +On Windows and X11 Linux, compile with `-d:useCpu` to create a plain window without an OpenGL context. CPU renderers can update the client area by passing a Pixie image to `window.presentPixels(image)`. +Linux also has experimental fbdev, DRM and evdev touch backends +for applications running without a window server. + ### Why not just use GLFW or SDL? diff --git a/src/windy/common.nim b/src/windy/common.nim index b92bcfd..30e8fde 100644 --- a/src/windy/common.nim +++ b/src/windy/common.nim @@ -3,6 +3,16 @@ import pixie, std/strutils, std/unicode type WindyError* = object of ValueError + TouchPhase* = enum + TouchBegin, TouchMove, TouchEnd, TouchCancel + TouchContact* = object + id*: int32 + position*: Vec2 + TouchEvent* = object + contact*: TouchContact + phase*: TouchPhase + TouchCallback* = proc(event: TouchEvent) + OpenGLVersion* = enum OpenGL3Dot0 OpenGL3Dot1 diff --git a/src/windy/platforms/emscripten/platform.nim b/src/windy/platforms/emscripten/platform.nim index ecbb568..8c7e171 100644 --- a/src/windy/platforms/emscripten/platform.nim +++ b/src/windy/platforms/emscripten/platform.nim @@ -21,6 +21,7 @@ type Window* = ref object onCloseRequest*: Callback onFrame*: Callback + onTouch*: TouchCallback onMove*: Callback onResize*: Callback onFocusChange*: Callback @@ -1311,3 +1312,6 @@ proc saveFileDialog*( discard defaultName warn("saveFileDialog is not supported on emscripten") result = "" + +proc touches*(window: Window): seq[TouchContact] = + @[] diff --git a/src/windy/platforms/linux/backends.nim b/src/windy/platforms/linux/backends.nim new file mode 100644 index 0000000..1869c29 --- /dev/null +++ b/src/windy/platforms/linux/backends.nim @@ -0,0 +1,21 @@ +import std/strutils + +const + windyBackends* {.strdefine.} = "x11" + HasX11* = "x11" in windyBackends.split(',') + HasFbdev* = "fbdev" in windyBackends.split(',') + HasDrm* = "drm" in windyBackends.split(',') + HasDirect* = HasFbdev or HasDrm + +type + LinuxBackend* = enum + X11Backend, FbdevBackend, DrmBackend + LinuxWindowOptions* = object + device*, output*, touchDevice*: string + rotation*, touchRotation*: int + gpuPixels*: bool + ## Opt into a double-buffered X11 OpenGL drawable in CPU pixel builds. + +static: + for backend in windyBackends.split(','): + doAssert backend in ["x11", "fbdev", "drm"], "Unknown Windy backend" diff --git a/src/windy/platforms/linux/direct.nim b/src/windy/platforms/linux/direct.nim new file mode 100644 index 0000000..7f0064b --- /dev/null +++ b/src/windy/platforms/linux/direct.nim @@ -0,0 +1,193 @@ +import + pixie, + ../../common, + ./backends, + ./native/[state, touch] + +when HasFbdev: + import ./fbdev +when HasDrm: + import ./drm + +when HasX11: + import ./x11 + type Window* = x11.Window +else: + type Window* = ref object + onCloseRequest*, onFrame*, onMove*, onResize*, onFocusChange*: Callback + onMouseMove*, onScroll*, onImeChange*: Callback + onButtonPress*, onButtonRelease*: ButtonCallback + onRune*: RuneCallback + onTextInput*: TextInputCallback + onTextEdit*: TextInputEditCallback + onFileDrop*: FileDropCallback + onTouch*: TouchCallback + direct*: DirectWindowState + +var windows: seq[Window] + +proc close*(window: Window) = + ## Release input and display resources before removing the window. + if window.direct == nil or window.direct.closed: + return + let data = window.direct + when HasDrm: + if data.drm != nil and data.drm.gpuOwned: + raise newException(WindyError, "Close the GPU pixel presenter before its windows") + data.closed = true + window.onTextInput = nil + window.onTextEdit = nil + try: + if data.touch != nil: + try: + for event in data.touch.cancel(): + data.contacts.setLen(0) + if window.onTouch != nil: window.onTouch(event) + finally: + data.touch.close() + finally: + try: + when HasFbdev: + if data.fb != nil: data.fb.close() + when HasDrm: + if data.drm != nil and data.screen != nil: + data.drm.closeScreen(data.screen) + finally: + when HasDrm: + if data.drm != nil: + for index, screen in data.drm.screens: + if screen == data.screen: + data.drm.screens.delete(index) + break + if data.drm.screens.len == 0: + data.drm.close() + for index, current in windows: + if current == window: + windows.delete(index) + break + +proc size*(window: Window): IVec2 = + ## Return the native dimensions of the selected display. + let data = window.direct + when HasFbdev: + if data.backend == FbdevBackend: + return ivec2(data.fb.width.int32, data.fb.height.int32) + when HasDrm: + return ivec2(data.screen.width.int32, data.screen.height.int32) + +proc newDirectWindow*(title: string, backend: LinuxBackend, + options: LinuxWindowOptions, vsync = false): Window = + ## Open one compiled direct display backend and its optional touch device. + let path = if options.device.len > 0: options.device + elif backend == FbdevBackend: "/dev/fb0" else: "/dev/dri/card0" + result = Window(direct: DirectWindowState(title: title, device: path, backend: backend)) + let data = result.direct + try: + case backend + of FbdevBackend: + when HasFbdev: + for window in windows: + if window.direct.device == path: + raise newException(WindyError, "Framebuffer is already owned: " & path) + data.fb = openFramebuffer(path, options.rotation) + else: + raise newException(WindyError, "fbdev backend was not compiled") + of DrmBackend: + when HasDrm: + if options.output.len == 0: + raise newException(WindyError, "A DRM connector must be selected") + for window in windows: + if window.direct.backend == DrmBackend and window.direct.device == path: + data.drm = window.direct.drm + break + if data.drm == nil: + data.drm = openDrm(path) + data.drm.openOutput(@[options.output], options.rotation, vsync) + data.screen = data.drm.screens[^1] + else: + raise newException(WindyError, "DRM backend was not compiled") + of X11Backend: + raise newException(WindyError, "X11 is not a direct display backend") + if options.touchDevice.len > 0: + for window in windows: + if window.direct.touch != nil and window.direct.touch.path == options.touchDevice: + raise newException(WindyError, "Touchscreen is already assigned") + let dimensions = result.size + data.touch = openTouch(options.touchDevice, dimensions.x.int, dimensions.y.int, + options.touchRotation) + windows.add result + except: + result.close() + raise + +proc framebufferSize*(window: Window): IVec2 = window.size +proc closed*(window: Window): bool = window.direct.closed +proc closeRequested*(window: Window): bool = window.direct.closeRequested +proc `closeRequested=`*(window: Window, value: bool) = window.direct.closeRequested = value +proc title*(window: Window): string = window.direct.title +proc `title=`*(window: Window, value: string) = window.direct.title = value +proc contentScale*(window: Window): float32 = 1 +proc fullscreen*(window: Window): bool = true +proc visible*(window: Window): bool = not window.closed +proc focused*(window: Window): bool = not window.closed +proc pos*(window: Window): IVec2 = ivec2(0, 0) +proc touches*(window: Window): seq[TouchContact] = window.direct.contacts +proc buttonDown*(window: Window): ButtonView = ButtonView({}) +proc buttonPressed*(window: Window): ButtonView = ButtonView({}) +proc buttonReleased*(window: Window): ButtonView = ButtonView({}) + +proc presentPixels*(window: Window, image: Image) = + if window.closed: + raise newException(WindyError, "Cannot present to a closed window") + let data = window.direct + when HasFbdev: + if data.fb != nil: + data.fb.present(image) + return + when HasDrm: + for index, screen in data.drm.screens: + if screen == data.screen: + data.drm.present(index, image) + return + +proc pollEvents*() = + let current = windows + for window in current: + if window.closed: continue + let data = window.direct + if data.touch != nil: + for event in data.touch.poll(): + var found = -1 + for index, contact in data.contacts: + if contact.id == event.contact.id: found = index + if event.phase in {TouchEnd, TouchCancel}: + if found >= 0: data.contacts.delete(found) + elif found >= 0: + data.contacts[found] = event.contact + else: + data.contacts.add event.contact + if window.onTouch != nil: window.onTouch(event) + if window.closed: break + if not window.closed and window.onFrame != nil: + window.onFrame() + +proc hasWindows*(): bool = windows.len > 0 + +proc unsupported*() = + raise newException(WindyError, "Operation is unavailable on a direct display") + +proc minimized*(window: Window): bool = unsupported() +proc maximized*(window: Window): bool = unsupported() +proc style*(window: Window): WindowStyle = unsupported() +proc runeInputEnabled*(window: Window): bool = unsupported() +proc cursor*(window: Window): Cursor = unsupported() +proc mousePos*(window: Window): IVec2 = unsupported() +proc mousePrevPos*(window: Window): IVec2 = unsupported() +proc mouseDelta*(window: Window): IVec2 = unsupported() +proc mouseInside*(window: Window): bool = unsupported() +proc mouseCaptured*(window: Window): bool = unsupported() +proc scrollDelta*(window: Window): Vec2 = unsupported() +proc buttonToggle*(window: Window): ButtonView = unsupported() +proc imeCursorIndex*(window: Window): int = unsupported() +proc imeCompositionString*(window: Window): string = unsupported() +proc url*(window: Window): string = unsupported() diff --git a/src/windy/platforms/linux/drm.nim b/src/windy/platforms/linux/drm.nim new file mode 100644 index 0000000..413abae --- /dev/null +++ b/src/windy/platforms/linux/drm.nim @@ -0,0 +1,2 @@ +include drm/display +include drm/pixels diff --git a/src/windy/platforms/linux/drm/display.nim b/src/windy/platforms/linux/drm/display.nim new file mode 100644 index 0000000..aeb0187 --- /dev/null +++ b/src/windy/platforms/linux/drm/display.nim @@ -0,0 +1,544 @@ +import + std/[dynlib, monotimes, os, posix, strformat, strutils, times], + pixie, + ../native/pixels + +type + ModeInfo = object + clock: uint32 + hdisplay, hsyncStart, hsyncEnd, htotal, hskew: uint16 + vdisplay, vsyncStart, vsyncEnd, vtotal, vscan: uint16 + refresh, flags, modeType: uint32 + name: array[32, char] + Resources = object + countFbs: cint + fbs: ptr UncheckedArray[uint32] + countCrtcs: cint + crtcs: ptr UncheckedArray[uint32] + countConnectors: cint + connectors: ptr UncheckedArray[uint32] + countEncoders: cint + encoders: ptr UncheckedArray[uint32] + minWidth, maxWidth, minHeight, maxHeight: uint32 + Connector = object + id, encoderId, connectorType, typeId: uint32 + connection: cint + mmWidth, mmHeight: uint32 + subpixel, countModes: cint + modes: ptr UncheckedArray[ModeInfo] + countProperties: cint + properties: ptr UncheckedArray[uint32] + values: ptr UncheckedArray[uint64] + countEncoders: cint + encoders: ptr UncheckedArray[uint32] + Encoder = object + id, encoderType, crtcId, possibleCrtcs, possibleClones: uint32 + Crtc = object + id, bufferId, x, y, width, height: uint32 + modeValid: cint + mode: ModeInfo + gammaSize: cint + DumbBuffer = object + height, width, bpp, flags, handle, pitch: uint32 + size: uint64 + MapBuffer = object + handle, padding: uint32 + offset: uint64 + FramebufferInfo = object + id, width, height, pitch, bpp, depth, handle: uint32 + GemClose = object + handle, padding: uint32 + PlaneResources = object + count: uint32 + planes: ptr UncheckedArray[uint32] + Plane = object + countFormats: uint32 + formats: ptr UncheckedArray[uint32] + id, crtc, framebuffer, crtcX, crtcY, x, y, possibleCrtcs, gammaSize: uint32 + ObjectProperties = object + count: uint32 + ids: ptr UncheckedArray[uint32] + values: ptr UncheckedArray[uint64] + Property = object + id, flags: uint32 + name: array[32, char] + countValues: cint + values: pointer + countEnums: cint + enums: pointer + countBlobs: cint + blobs: pointer + FlipHandler = proc(fd: cint, sequence, seconds, microseconds: cuint, + data: pointer) {.cdecl.} + EventContext = object + # Version 2 uses only this prefix of libdrm's extensible event context. + version: cint + vblankHandler, pageFlipHandler: FlipHandler + Scanout = object + buffer: DumbBuffer + memory: pointer + framebuffer: uint32 + DrmApi = object + library: LibHandle + getResources: proc(fd: cint): ptr Resources {.cdecl.} + freeResources: proc(value: ptr Resources) {.cdecl.} + getConnector: proc(fd: cint, id: uint32): ptr Connector {.cdecl.} + freeConnector: proc(value: ptr Connector) {.cdecl.} + getEncoder: proc(fd: cint, id: uint32): ptr Encoder {.cdecl.} + freeEncoder: proc(value: ptr Encoder) {.cdecl.} + getCrtc: proc(fd: cint, id: uint32): ptr Crtc {.cdecl.} + freeCrtc: proc(value: ptr Crtc) {.cdecl.} + setCrtc: proc(fd: cint, id, buffer, x, y: uint32, connectors: ptr uint32, + count: cint, mode: ptr ModeInfo): cint {.cdecl.} + pageFlip: proc(fd: cint, crtc, framebuffer, flags: uint32, + data: pointer): cint {.cdecl.} + handleEvent: proc(fd: cint, context: ptr EventContext): cint {.cdecl.} + addFb: proc(fd: cint, width, height: uint32, depth, bpp: uint8, + pitch, handle: uint32, id: ptr uint32): cint {.cdecl.} + removeFb: proc(fd: cint, id: uint32): cint {.cdecl.} + getFb: proc(fd: cint, id: uint32): ptr FramebufferInfo {.cdecl.} + freeFb: proc(value: ptr FramebufferInfo) {.cdecl.} + setMaster, dropMaster: proc(fd: cint): cint {.cdecl.} + setClientCap: proc(fd: cint, capability, value: uint64): cint {.cdecl.} + getPlaneResources: proc(fd: cint): ptr PlaneResources {.cdecl.} + freePlaneResources: proc(value: ptr PlaneResources) {.cdecl.} + getPlane: proc(fd: cint, id: uint32): ptr Plane {.cdecl.} + freePlane: proc(value: ptr Plane) {.cdecl.} + getProperties: proc(fd: cint, id, kind: uint32): ptr ObjectProperties {.cdecl.} + freeProperties: proc(value: ptr ObjectProperties) {.cdecl.} + getProperty: proc(fd: cint, id: uint32): ptr Property {.cdecl.} + freeProperty: proc(value: ptr Property) {.cdecl.} + addFb2: proc(fd: cint, width, height, format: uint32, + handles, pitches, offsets: ptr uint32, id: ptr uint32, flags: uint32): cint {.cdecl.} + DrmScreen* = ref object + name*: string + width*, height*: int + rotation: int + connector, crtc: uint32 + mode: ModeInfo + original: Crtc + originalConnectors: seq[uint32] + buffers: seq[Scanout] + front, draw: int + vsync, pending: bool + pixels: seq[uint32] + rgba: bool + activated: bool + DrmDisplay* = ref object + api: DrmApi + fd: cint + master: bool + gpuOwned*: bool + screens*: seq[DrmScreen] + retiredScreens: seq[DrmScreen] + +const + FlipEvent = 1'u32 + FlipTimeoutMs = 1000 + CreateDumb = 0xc02064b2.culong + MapDumb = 0xc01064b3.culong + DestroyDumb = 0xc00464b4.culong + CloseGem = 0x40086409.culong + Connected = 1 + PreferredMode = 1'u32 shl 3 + Xbgr8888 = 0x34324258'u32 + Abgr8888 = 0x34324241'u32 + MaximumBufferBytes = 512 * 1024 * 1024 + ConnectorNames = ["Unknown", "VGA", "DVI-I", "DVI-D", "DVI-A", "Composite", + "SVIDEO", "LVDS", "Component", "DIN", "DP", "HDMI-A", "HDMI-B", "TV", + "eDP", "Virtual", "DSI", "DPI", "Writeback", "SPI", "USB"] + +static: + doAssert sizeof(pointer) == 8 + doAssert sizeof(ModeInfo) == 68 + doAssert sizeof(Resources) == 80 + doAssert sizeof(Connector) == 88 + doAssert sizeof(Crtc) == 100 + doAssert sizeof(Encoder) == 20 + doAssert sizeof(DumbBuffer) == 32 + doAssert sizeof(MapBuffer) == 16 + doAssert sizeof(FramebufferInfo) == 28 + doAssert sizeof(GemClose) == 8 + doAssert sizeof(EventContext) == 24 + +proc loadDrm(): DrmApi = + ## Load DRM only when a DRM display is requested. + result.library = loadLib("libdrm.so.2") + if result.library == nil: + raise newException(IOError, "Cannot load libdrm.so.2; install libdrm for DRM displays") + template bindSymbol(field: untyped, name: string) = + result.field = cast[type(result.field)](symAddr(result.library, name)) + if result.field == nil: + unloadLib(result.library) + raise newException(IOError, "Missing DRM symbol: " & name) + bindSymbol(getResources, "drmModeGetResources") + bindSymbol(freeResources, "drmModeFreeResources") + bindSymbol(getConnector, "drmModeGetConnector") + bindSymbol(freeConnector, "drmModeFreeConnector") + bindSymbol(getEncoder, "drmModeGetEncoder") + bindSymbol(freeEncoder, "drmModeFreeEncoder") + bindSymbol(getCrtc, "drmModeGetCrtc") + bindSymbol(freeCrtc, "drmModeFreeCrtc") + bindSymbol(setCrtc, "drmModeSetCrtc") + bindSymbol(pageFlip, "drmModePageFlip") + bindSymbol(handleEvent, "drmHandleEvent") + bindSymbol(addFb, "drmModeAddFB") + bindSymbol(removeFb, "drmModeRmFB") + bindSymbol(getFb, "drmModeGetFB") + bindSymbol(freeFb, "drmModeFreeFB") + bindSymbol(setMaster, "drmSetMaster") + bindSymbol(dropMaster, "drmDropMaster") + bindSymbol(setClientCap, "drmSetClientCap") + bindSymbol(getPlaneResources, "drmModeGetPlaneResources") + bindSymbol(freePlaneResources, "drmModeFreePlaneResources") + bindSymbol(getPlane, "drmModeGetPlane") + bindSymbol(freePlane, "drmModeFreePlane") + bindSymbol(getProperties, "drmModeObjectGetProperties") + bindSymbol(freeProperties, "drmModeFreeObjectProperties") + bindSymbol(getProperty, "drmModeGetProperty") + bindSymbol(freeProperty, "drmModeFreeProperty") + bindSymbol(addFb2, "drmModeAddFB2") + +proc rgbaFormat(display: DrmDisplay, crtcIndex: int): uint32 = + let api = display.api + if api.setClientCap(display.fd, 2, 1) < 0: + if osLastError() == OSErrorCode(EINVAL): return 0 + raiseOSError(osLastError(), "Enabling universal DRM planes") + let resources = api.getPlaneResources(display.fd) + if resources == nil: + raiseOSError(osLastError(), "Reading DRM planes") + defer: api.freePlaneResources(resources) + for index in 0 ..< int(resources.count): + let plane = api.getPlane(display.fd, resources.planes[index]) + if plane == nil: raiseOSError(osLastError(), "Reading DRM plane") + defer: api.freePlane(plane) + if (plane.possibleCrtcs and (1'u32 shl crtcIndex)) == 0: continue + let properties = api.getProperties(display.fd, plane.id, 0xeeeeeeee'u32) + if properties == nil: raiseOSError(osLastError(), "Reading DRM plane properties") + defer: api.freeProperties(properties) + var primary = false + for propertyIndex in 0 ..< int(properties.count): + let property = api.getProperty(display.fd, properties.ids[propertyIndex]) + if property == nil: raiseOSError(osLastError(), "Reading DRM property") + if $cast[cstring](addr property.name[0]) == "type": + primary = properties.values[propertyIndex] == 1 + api.freeProperty(property) + if primary: + for format in [Xbgr8888, Abgr8888]: + for formatIndex in 0 ..< int(plane.countFormats): + if plane.formats[formatIndex] == format: return format + +proc flipComplete(fd: cint, sequence, seconds, microseconds: cuint, + data: pointer) {.cdecl.} = + ## Mark a screen's queued buffer as scanned out after its vblank event. + let screen = cast[DrmScreen](data) + screen.front = screen.draw + screen.pending = false + +proc waitForFlip(display: DrmDisplay, screen: DrmScreen) = + ## Wait before reusing scanout storage, dispatching events for every output. + if not screen.pending: return + let deadline = getMonoTime() + initDuration(milliseconds = FlipTimeoutMs) + var context = EventContext(version: 2, pageFlipHandler: flipComplete) + while screen.pending: + let remaining = (deadline - getMonoTime()).inMilliseconds + if remaining <= 0: + raise newException(IOError, "Timed out waiting for DRM page flip on " & screen.name) + var descriptor = TPollfd(fd: display.fd, events: POLLIN) + let count = poll(addr descriptor, 1, cint(remaining)) + if count < 0: + if osLastError() == OSErrorCode(EINTR): continue + raiseOSError(osLastError(), "Waiting for DRM page flip") + if count == 0: continue + if (descriptor.revents and (POLLERR or POLLHUP or POLLNVAL)) != 0: + raise newException(IOError, "DRM event descriptor became unavailable") + if display.api.handleEvent(display.fd, addr context) < 0: + if osLastError() == OSErrorCode(EINTR): continue + raiseOSError(osLastError(), "Reading DRM page flip events") + +proc closeScreen*(display: DrmDisplay, screen: DrmScreen) = + if display.gpuOwned: + raise newException(IOError, "Close the GPU pixel presenter before its windows") + var errors: seq[string] + try: + display.waitForFlip(screen) + except CatchableError as error: + errors.add error.msg + # A late event still contains this screen's address until the DRM fd closes. + display.retiredScreens.add screen + if screen.activated: + var + original = screen.original + connectors = screen.originalConnectors + enabled = original.modeValid != 0 and original.bufferId != 0 + if enabled: + let framebuffer = display.api.getFb(display.fd, original.bufferId) + if framebuffer == nil: + let error = osLastError() + enabled = false + if error == OSErrorCode(ENOENT): + echo "Original framebuffer retired by kernel; disabling ", screen.name, " on exit" + else: + errors.add "Querying original framebuffer for " & screen.name & ": " & $error + else: + if framebuffer.handle != 0: + var handle = GemClose(handle: framebuffer.handle) + if posix.ioctl(display.fd, CloseGem, addr handle) < 0: + errors.add "Closing original GEM handle for " & screen.name + display.api.freeFb(framebuffer) + let + connectorPointer = if enabled and connectors.len > 0: addr connectors[0] else: nil + modePointer = if enabled: addr original.mode else: nil + count = if enabled: cint(connectors.len) else: 0.cint + buffer = if enabled: original.bufferId else: 0'u32 + if display.api.setCrtc(display.fd, screen.crtc, buffer, original.x, + original.y, connectorPointer, count, modePointer) < 0: + errors.add "Restoring " & screen.name + screen.activated = false + for scanout in screen.buffers.mitems: + if scanout.memory != nil: + if munmap(scanout.memory, int(scanout.buffer.size)) < 0: + errors.add "Unmapping " & screen.name + scanout.memory = nil + if scanout.framebuffer != 0: + if display.api.removeFb(display.fd, scanout.framebuffer) < 0: + errors.add "Removing framebuffer for " & screen.name + scanout.framebuffer = 0 + if scanout.buffer.handle != 0: + if posix.ioctl(display.fd, DestroyDumb, addr scanout.buffer.handle) < 0: + errors.add "Destroying buffer for " & screen.name + scanout.buffer.handle = 0 + + if errors.len > 0: + raise newException(IOError, errors.join("; ")) + +proc close*(display: DrmDisplay) = + if display.gpuOwned: + raise newException(IOError, "Close the GPU pixel presenter before its display") + var errors: seq[string] + for screen in display.screens: + try: + display.closeScreen(screen) + except IOError as error: + errors.add error.msg + if display.fd >= 0: + if display.master and display.api.dropMaster(display.fd) < 0: + errors.add "Releasing DRM master" + if posix.close(display.fd) < 0: + errors.add "Closing DRM device" + display.fd = -1 + display.retiredScreens.setLen(0) + if display.api.library != nil: + unloadLib(display.api.library) + display.api.library = nil + if errors.len > 0: + raise newException(IOError, errors.join("; ")) + +proc connectorName(connector: ptr Connector): string = + ## Format the kernel connector type and instance as a stable output name. + let prefix = if connector.connectorType < uint32(ConnectorNames.len): + ConnectorNames[connector.connectorType] else: "Connector" & $connector.connectorType + prefix & "-" & $connector.typeId + +proc chooseCrtc*(ids: openArray[uint32], current, possible: uint32, + unavailable: openArray[uint32]): uint32 = + ## Prefer the current compatible controller without taking another output's controller. + for index, id in ids: + if index < 32 and (possible and (1'u32 shl index)) != 0 and + id notin unavailable and id == current: + return id + for index, id in ids: + if index < 32 and (possible and (1'u32 shl index)) != 0 and id notin unavailable: + return id + raise newException(IOError, "No independent display controller is available") + +proc openOutput*(result: DrmDisplay, outputs: seq[string], rotation: int, vsync = false) = + ## Prepare independent CPU scanout buffers without changing the active display. + if rotation notin [0, 90, 180, 270]: + raise newException(ValueError, "Rotation must be 0, 90, 180 or 270") + let originalCount = result.screens.len + try: + let + api = result.api + resources = api.getResources(result.fd) + if resources == nil: + raiseOSError(osLastError(), "Reading DRM resources") + defer: api.freeResources(resources) + var + connectors: seq[ptr Connector] + names: seq[string] + crtcs: seq[uint32] + used: seq[uint32] + requested = outputs + defer: + for connector in connectors: + api.freeConnector(connector) + for screen in result.screens: + used.add screen.crtc + for index in 0 ..< int(resources.countCrtcs): + crtcs.add resources.crtcs[index] + for index in 0 ..< int(resources.countConnectors): + let connector = api.getConnector(result.fd, resources.connectors[index]) + if connector == nil: + raiseOSError(osLastError(), "Reading DRM connector") + connectors.add connector + names.add connector.connectorName() + if outputs.len == 0 and connector.connection == Connected and connector.countModes > 0: + requested.add names[^1] + if requested.len == 0: + raise newException(IOError, "No connected DRM displays") + for name in requested: + for screen in result.screens: + if screen.name == name: + raise newException(IOError, "DRM output is already owned: " & name) + let index = names.find(name) + if index < 0: + raise newException(IOError, "DRM output not found: " & name) + let connector = connectors[index] + if connector.connection != Connected or connector.countModes == 0: + raise newException(IOError, "DRM output has no connected mode: " & name) + var + possible = 0'u32 + current = 0'u32 + for index in 0 ..< int(connector.countEncoders): + let encoder = api.getEncoder(result.fd, connector.encoders[index]) + if encoder == nil: + raiseOSError(osLastError(), "Reading DRM encoder") + possible = possible or encoder.possibleCrtcs + if encoder.id == connector.encoderId: + current = encoder.crtcId + api.freeEncoder(encoder) + var unavailable = used + for other in connectors: + if other.connectorName() notin requested and other.encoderId != 0: + let encoder = api.getEncoder(result.fd, other.encoderId) + if encoder == nil: + raiseOSError(osLastError(), "Reading existing DRM route") + if encoder.crtcId != 0: + unavailable.add encoder.crtcId + api.freeEncoder(encoder) + let + id = chooseCrtc(crtcs, current, possible, unavailable) + original = api.getCrtc(result.fd, id) + if original == nil: + raiseOSError(osLastError(), "Saving DRM scanout") + let screen = DrmScreen(name: name, connector: connector.id, crtc: id, + original: original[], mode: connector.modes[0], rotation: rotation, vsync: vsync) + api.freeCrtc(original) + result.screens.add screen + used.add id + for modeIndex in 0 ..< int(connector.countModes): + if (connector.modes[modeIndex].modeType and PreferredMode) != 0: + screen.mode = connector.modes[modeIndex] + break + if screen.original.modeValid != 0: + screen.mode = screen.original.mode + for other in connectors: + if other.encoderId != 0: + let encoder = api.getEncoder(result.fd, other.encoderId) + if encoder == nil: + raiseOSError(osLastError(), "Saving DRM connector routing") + if encoder.crtcId == id: + screen.originalConnectors.add other.id + api.freeEncoder(encoder) + screen.width = int(if rotation in [90, 270]: screen.mode.vdisplay else: screen.mode.hdisplay) + screen.height = int(if rotation in [90, 270]: screen.mode.hdisplay else: screen.mode.vdisplay) + let format = result.rgbaFormat(crtcs.find(id)) + screen.rgba = format != 0 + screen.buffers.setLen(if vsync: 2 else: 1) + for scanout in screen.buffers.mitems: + scanout.buffer = DumbBuffer(width: uint32(screen.mode.hdisplay), + height: uint32(screen.mode.vdisplay), bpp: 32) + if posix.ioctl(result.fd, CreateDumb, addr scanout.buffer) < 0: + raiseOSError(osLastError(), "Allocating DRM scanout buffer") + if scanout.buffer.pitch < scanout.buffer.width * 4 or + scanout.buffer.size < uint64(scanout.buffer.pitch) * scanout.buffer.height or + scanout.buffer.size > MaximumBufferBytes: + raise newException(IOError, "Unsupported DRM buffer geometry") + if screen.rgba: + var + handles = [scanout.buffer.handle, 0'u32, 0, 0] + pitches = [scanout.buffer.pitch, 0'u32, 0, 0] + offsets: array[4, uint32] + if api.addFb2(result.fd, scanout.buffer.width, scanout.buffer.height, format, + addr handles[0], addr pitches[0], addr offsets[0], addr scanout.framebuffer, 0) < 0: + raiseOSError(osLastError(), "Registering RGBA DRM framebuffer") + elif api.addFb(result.fd, scanout.buffer.width, scanout.buffer.height, 24, 32, + scanout.buffer.pitch, scanout.buffer.handle, addr scanout.framebuffer) < 0: + raiseOSError(osLastError(), "Registering DRM framebuffer") + var mapping = MapBuffer(handle: scanout.buffer.handle) + if posix.ioctl(result.fd, MapDumb, addr mapping) < 0: + raiseOSError(osLastError(), "Mapping DRM buffer") + scanout.memory = mmap(nil, int(scanout.buffer.size), PROT_READ or PROT_WRITE, + MAP_SHARED, result.fd, Off(mapping.offset)) + if scanout.memory == MAP_FAILED: + scanout.memory = nil + raiseOSError(osLastError(), "Mapping DRM scanout memory") + except: + while result.screens.len > originalCount: + let screen = result.screens.pop() + result.closeScreen(screen) + raise + +proc upload*(display: DrmDisplay, index: int, image: Image) = + ## Upload a frame, waiting before reusing storage still owned by scanout. + let screen = display.screens[index] + if image.width != screen.width or image.height != screen.height: + raise newException(ValueError, "Canvas does not match DRM screen") + display.waitForFlip(screen) + screen.draw = if screen.vsync and screen.activated: 1 - screen.front else: 0 + let scanout = screen.buffers[screen.draw] + let destination = cast[ptr UncheckedArray[uint32]](scanout.memory) + if screen.rgba and screen.rotation == 0: + for row in 0 ..< image.height: + copyMem(addr destination[row * int(scanout.buffer.pitch) div 4], + unsafeAddr image.data[row * image.width], image.width * 4) + return + screen.pixels.setLen(image.data.len) + if screen.rotation == 0: + for index, pixel in image.data: + screen.pixels[index] = rgb(pixel) + else: + for y in 0 ..< image.height: + for x in 0 ..< image.width: + let point = rotatedPoint(x, y, image.width, image.height, screen.rotation) + screen.pixels[point.y * int(scanout.buffer.width) + point.x] = + (if screen.rgba: cast[uint32](image.data[y * image.width + x]) + else: rgb(image.data[y * image.width + x])) + for row in 0 ..< int(scanout.buffer.height): + copyMem(addr destination[row * int(scanout.buffer.pitch) div 4], + addr screen.pixels[row * int(scanout.buffer.width)], int(scanout.buffer.width) * 4) + +proc present*(display: DrmDisplay, index: int, image: Image) = + if display.gpuOwned or display.screens[index].buffers.len == 0: + raise newException(IOError, "Reopen windows before switching from GPU to CPU pixels") + display.upload(index, image) + let screen = display.screens[index] + if not screen.activated: + if not display.master: + if display.api.setMaster(display.fd) < 0: + raiseOSError(osLastError(), "Acquiring DRM master; another display application may be running") + display.master = true + if display.api.setCrtc(display.fd, screen.crtc, screen.buffers[screen.draw].framebuffer, 0, 0, + addr screen.connector, 1, addr screen.mode) < 0: + raiseOSError(osLastError(), "Displaying " & screen.name) + screen.activated = true + screen.front = screen.draw + elif screen.vsync: + if display.api.pageFlip(display.fd, screen.crtc, + screen.buffers[screen.draw].framebuffer, FlipEvent, cast[pointer](screen)) < 0: + raiseOSError(osLastError(), "Queuing DRM page flip on " & screen.name) + screen.pending = true + +proc describe*(screen: DrmScreen): string = + ## Describe the selected connector, logical geometry and controller. + &"{screen.name}: {screen.width}x{screen.height}, CRTC {screen.crtc}, rotation {screen.rotation}" +proc openDrm*(path: string): DrmDisplay = + result = DrmDisplay(fd: -1, api: loadDrm()) + result.fd = posix.open(path.cstring, O_RDWR or O_CLOEXEC) + if result.fd < 0: + let error = osLastError() + result.close() + raiseOSError(error, path) + +include gpu diff --git a/src/windy/platforms/linux/drm/gpu.nim b/src/windy/platforms/linux/drm/gpu.nim new file mode 100644 index 0000000..19061d7 --- /dev/null +++ b/src/windy/platforms/linux/drm/gpu.nim @@ -0,0 +1,687 @@ +# Included by the DRM backend to share its private native bindings. +type + GpuFlipPair = object + crtcs: array[2, uint32] + completed: set[range[0..1]] + timestamps: array[2, uint64] + GpuGbmHandle {.union.} = object + pointerValue: pointer + u32: uint32 + u64: uint64 + GpuAtomicRequest = pointer + GpuAtomicApi = object + alloc: proc(): GpuAtomicRequest {.cdecl.} + free: proc(request: GpuAtomicRequest) {.cdecl.} + add: proc(request: GpuAtomicRequest, id, gpuProperty: uint32, value: uint64): cint {.cdecl.} + gpuCommit: proc(fd: cint, request: GpuAtomicRequest, flags: uint32, data: pointer): cint {.cdecl.} + createBlob: proc(fd: cint, data: pointer, size: csize_t, id: ptr uint32): cint {.cdecl.} + destroyBlob: proc(fd: cint, id: uint32): cint {.cdecl.} + GpuAtomicValue = object + id, gpuProperty: uint32 + value: uint64 + GpuPairEvents = object + version: cint + vblank, flip: FlipHandler + flip2: proc(fd: cint, sequence, seconds, microseconds, crtc: cuint, + data: pointer) {.cdecl.} + GpuApi = ref object + gbmLibrary, eglLibrary, glesLibrary: LibHandle + gbm_create_device: proc(fd: cint): pointer {.cdecl.} + gbm_device_destroy: proc(device: pointer) {.cdecl.} + gbm_surface_create_with_modifiers2: proc(device: pointer, width, height, format: uint32, + modifiers: ptr uint64, count, flags: uint32): pointer {.cdecl.} + gbm_surface_destroy: proc(surface: pointer) {.cdecl.} + gbm_surface_lock_front_buffer: proc(surface: pointer): pointer {.cdecl.} + gbm_surface_release_buffer: proc(surface, bo: pointer) {.cdecl.} + gbm_bo_get_handle: proc(bo: pointer): GpuGbmHandle {.cdecl.} + gbm_bo_get_stride: proc(bo: pointer): uint32 {.cdecl.} + gbm_bo_get_modifier: proc(bo: pointer): uint64 {.cdecl.} + eglGetPlatformDisplay: proc(platform: cuint, native: pointer, attributes: pointer): pointer {.cdecl.} + eglInitialize: proc(display: pointer, major, minor: ptr cint): cuint {.cdecl.} + eglBindAPI: proc(api: cuint): cuint {.cdecl.} + eglChooseConfig: proc(display: pointer, attributes: ptr cint, configs: ptr pointer, + count: cint, found: ptr cint): cuint {.cdecl.} + eglGetConfigAttrib: proc(display, config: pointer, attribute: cint, value: ptr cint): cuint {.cdecl.} + eglCreateContext: proc(display, config, share: pointer, attributes: ptr cint): pointer {.cdecl.} + eglCreateWindowSurface: proc(display, config, window: pointer, attributes: pointer): pointer {.cdecl.} + eglMakeCurrent: proc(display, gpuDraw, read, context: pointer): cuint {.cdecl.} + eglSwapInterval: proc(display: pointer, interval: cint): cuint {.cdecl.} + eglSwapBuffers: proc(display, surface: pointer): cuint {.cdecl.} + eglDestroyContext: proc(display, context: pointer): cuint {.cdecl.} + eglDestroySurface: proc(display, surface: pointer): cuint {.cdecl.} + eglTerminate: proc(display: pointer): cuint {.cdecl.} + eglGetError: proc(): cuint {.cdecl.} + glGetString: proc(name: cuint): cstring {.cdecl.} + glCreateShader: proc(kind: cuint): cuint {.cdecl.} + glShaderSource: proc(gpuShader: cuint, count: cint, source: ptr cstring, lengths: pointer) {.cdecl.} + glCompileShader: proc(gpuShader: cuint) {.cdecl.} + glGetShaderiv: proc(gpuShader, name: cuint, value: ptr cint) {.cdecl.} + glGetShaderInfoLog: proc(gpuShader: cuint, size: cint, length: ptr cint, log: cstring) {.cdecl.} + glDeleteShader: proc(gpuShader: cuint) {.cdecl.} + glCreateProgram: proc(): cuint {.cdecl.} + glAttachShader: proc(program, gpuShader: cuint) {.cdecl.} + glBindAttribLocation: proc(program, index: cuint, name: cstring) {.cdecl.} + glLinkProgram: proc(program: cuint) {.cdecl.} + glGetProgramiv: proc(program, name: cuint, value: ptr cint) {.cdecl.} + glGetProgramInfoLog: proc(program: cuint, size: cint, length: ptr cint, log: cstring) {.cdecl.} + glDeleteProgram: proc(program: cuint) {.cdecl.} + glUseProgram: proc(program: cuint) {.cdecl.} + glGetUniformLocation: proc(program: cuint, name: cstring): cint {.cdecl.} + glUniform1i: proc(location, value: cint) {.cdecl.} + glGenTextures: proc(count: cint, textures: ptr cuint) {.cdecl.} + glDeleteTextures: proc(count: cint, textures: ptr cuint) {.cdecl.} + glGenBuffers: proc(count: cint, buffers: ptr cuint) {.cdecl.} + glBindBuffer: proc(target, buffer: cuint) {.cdecl.} + glBufferData: proc(target: cuint, size: int, data: pointer, usage: cuint) {.cdecl.} + glDeleteBuffers: proc(count: cint, buffers: ptr cuint) {.cdecl.} + glBindTexture: proc(target, texture: cuint) {.cdecl.} + glTexParameteri: proc(target, name: cuint, value: cint) {.cdecl.} + glTexImage2D: proc(target: cuint, level, internal, width, height, border: cint, + format, kind: cuint, data: pointer) {.cdecl.} + glTexSubImage2D: proc(target: cuint, level, x, y, width, height: cint, + format, kind: cuint, data: pointer) {.cdecl.} + glViewport: proc(x, y, width, height: cint) {.cdecl.} + glScissor: proc(x, y, width, height: cint) {.cdecl.} + glEnable: proc(capability: cuint) {.cdecl.} + glDisable: proc(capability: cuint) {.cdecl.} + glGetIntegerv: proc(name: cuint, value: ptr cint) {.cdecl.} + glClearColor: proc(red, green, blue, alpha: cfloat) {.cdecl.} + glClear: proc(mask: cuint) {.cdecl.} + glEnableVertexAttribArray: proc(index: cuint) {.cdecl.} + glVertexAttribPointer: proc(index: cuint, size: cint, kind: cuint, normalized: uint8, + stride: cint, data: pointer) {.cdecl.} + glDrawArrays: proc(mode: cuint, first, count: cint) {.cdecl.} + glFinish: proc() {.cdecl.} + glReadPixels: proc(x, y, width, height: cint, format, kind: cuint, data: pointer) {.cdecl.} + glGetError: proc(): cuint {.cdecl.} + GpuDisplay* = ref object + gpu: GpuApi + display: DrmDisplay + atomic: GpuAtomicApi + restore, properties: seq[GpuAtomicValue] + blobs: seq[uint32] + planes: array[2, uint32] + fbProperties: array[2, uint32] + pair: GpuFlipPair + armed, pending: bool + callbackError: string + gbm, surface, egl, context, eglSurface: pointer + program, texture, vertices: uint32 + gbmFrames: seq[tuple[bo: pointer, fb: uint32]] + locked: seq[pointer] + front: pointer + outputs: array[2, DrmScreen] + regions: array[2, PixelRegion] + panelWidth, panelHeight, textureWidth, textureHeight: int + maximumTextureSize: cint + +const + GpuPlaneObject = 0xeeeeeeee'u32 + GpuCrtcObject = 0xcccccccc'u32 + GpuConnectorObject = 0xc0c0c0c0'u32 + GpuAtomicTest = 0x100'u32 + GpuAtomicNonblock = 0x200'u32 + GpuAllowModeset = 0x400'u32 + GpuXrgb8888 = 0x34325258'u32 + GpuGlTexture = 0x0de1'u32 + GpuGlRgba = 0x1908'u32 + GpuGlByte = 0x1401'u32 + GpuGlFloat = 0x1406'u32 + GpuGlTriangles = 4'u32 + GpuGlColourBuffer = 0x4000'u32 + GpuGlScissor = 0x0c11'u32 + GpuGlMaxTextureSize = 0x0d33'u32 + GpuGlRenderer = 0x1f01'u32 + GpuGlVersion = 0x1f02'u32 + GpuGlVertexShader = 0x8b31'u32 + GpuGlFragmentShader = 0x8b30'u32 + GpuGlCompileStatus = 0x8b81'u32 + GpuGlLinkStatus = 0x8b82'u32 + GpuGlMagFilter = 0x2800'u32 + GpuGlMinFilter = 0x2801'u32 + GpuGlWrapS = 0x2802'u32 + GpuGlWrapT = 0x2803'u32 + GpuGlNearest = 0x2600.cint + GpuGlClampEdge = 0x812f.cint + GpuEglGbm = 0x31d7'u32 + GpuEglEsApi = 0x30a0'u32 + GpuEglNativeVisual = 0x302e.cint + GpuEglClientVersion = 0x3098.cint + GpuEglDepthSize = 0x3025.cint + GpuEglStencilSize = 0x3026.cint + GpuEglSamples = 0x3031.cint + GpuEglSampleBuffers = 0x3032.cint + GpuAtomicCapability = 3'u64 + GpuPrimaryPlane = 1'u64 + GpuGbmScanoutRendering = 5'u32 + GpuGlArrayBuffer = 0x8892'u32 + GpuGlStaticDraw = 0x88e4'u32 + GpuVerticesPerPanel = 6 + GpuVertexStride = 4 * sizeof(cfloat) + GpuEglNone = 0x3038.cint + GpuEglConfigAttributes = [0x3033.cint, 4, 0x3040, 4, 0x3024, 8, + 0x3023, 8, 0x3022, 8, 0x3025, 0, 0x3026, 0, GpuEglNone] + GpuVertexSource = "attribute vec2 position; attribute vec2 uv; varying vec2 tex; void main(){tex=uv;gl_Position=vec4(position,0.0,1.0);}" + GpuFragmentSource = "precision highp float; varying vec2 tex; uniform sampler2D image; void main(){gl_FragColor=vec4(texture2D(image,tex).rgb,1.0);}" + +static: + doAssert cpuEndian == littleEndian + doAssert sizeof(GpuGbmHandle) == 8 + doAssert sizeof(GpuPairEvents) == 32 + +proc close(api: GpuApi) = + ## Release only libraries opened for this GPU session. + if api == nil: return + for library in [api.glesLibrary, api.eglLibrary, api.gbmLibrary]: + if library != nil: unloadLib(library) + +proc gpuLoadApi(): GpuApi = + ## Resolve GPU entry points lazily so CPU-only applications need no Mesa. + result = GpuApi() + try: + result.gbmLibrary = loadLib("libgbm.so.1") + result.eglLibrary = loadLib("libEGL.so.1") + result.glesLibrary = loadLib("libGLESv2.so.2") + if result.gbmLibrary == nil or result.eglLibrary == nil or result.glesLibrary == nil: + raise newException(IOError, "GPU pixel presentation requires GBM, EGL and GLESv2") + template bindSymbol(field, library: untyped, name: string) = + result.field = cast[type(result.field)](symAddr(result.library, name)) + if result.field == nil: raise newException(IOError, "Missing GPU entry point: " & name) + bindSymbol(gbm_create_device, gbmLibrary, "gbm_create_device") + bindSymbol(gbm_device_destroy, gbmLibrary, "gbm_device_destroy") + bindSymbol(gbm_surface_create_with_modifiers2, gbmLibrary, "gbm_surface_create_with_modifiers2") + bindSymbol(gbm_surface_destroy, gbmLibrary, "gbm_surface_destroy") + bindSymbol(gbm_surface_lock_front_buffer, gbmLibrary, "gbm_surface_lock_front_buffer") + bindSymbol(gbm_surface_release_buffer, gbmLibrary, "gbm_surface_release_buffer") + bindSymbol(gbm_bo_get_handle, gbmLibrary, "gbm_bo_get_handle") + bindSymbol(gbm_bo_get_stride, gbmLibrary, "gbm_bo_get_stride") + bindSymbol(gbm_bo_get_modifier, gbmLibrary, "gbm_bo_get_modifier") + bindSymbol(eglGetPlatformDisplay, eglLibrary, "eglGetPlatformDisplay") + bindSymbol(eglInitialize, eglLibrary, "eglInitialize") + bindSymbol(eglBindAPI, eglLibrary, "eglBindAPI") + bindSymbol(eglChooseConfig, eglLibrary, "eglChooseConfig") + bindSymbol(eglGetConfigAttrib, eglLibrary, "eglGetConfigAttrib") + bindSymbol(eglCreateContext, eglLibrary, "eglCreateContext") + bindSymbol(eglCreateWindowSurface, eglLibrary, "eglCreateWindowSurface") + bindSymbol(eglMakeCurrent, eglLibrary, "eglMakeCurrent") + bindSymbol(eglSwapInterval, eglLibrary, "eglSwapInterval") + bindSymbol(eglSwapBuffers, eglLibrary, "eglSwapBuffers") + bindSymbol(eglDestroyContext, eglLibrary, "eglDestroyContext") + bindSymbol(eglDestroySurface, eglLibrary, "eglDestroySurface") + bindSymbol(eglTerminate, eglLibrary, "eglTerminate") + bindSymbol(eglGetError, eglLibrary, "eglGetError") + bindSymbol(glGetString, glesLibrary, "glGetString") + bindSymbol(glCreateShader, glesLibrary, "glCreateShader") + bindSymbol(glShaderSource, glesLibrary, "glShaderSource") + bindSymbol(glCompileShader, glesLibrary, "glCompileShader") + bindSymbol(glGetShaderiv, glesLibrary, "glGetShaderiv") + bindSymbol(glGetShaderInfoLog, glesLibrary, "glGetShaderInfoLog") + bindSymbol(glDeleteShader, glesLibrary, "glDeleteShader") + bindSymbol(glCreateProgram, glesLibrary, "glCreateProgram") + bindSymbol(glAttachShader, glesLibrary, "glAttachShader") + bindSymbol(glBindAttribLocation, glesLibrary, "glBindAttribLocation") + bindSymbol(glLinkProgram, glesLibrary, "glLinkProgram") + bindSymbol(glGetProgramiv, glesLibrary, "glGetProgramiv") + bindSymbol(glGetProgramInfoLog, glesLibrary, "glGetProgramInfoLog") + bindSymbol(glDeleteProgram, glesLibrary, "glDeleteProgram") + bindSymbol(glUseProgram, glesLibrary, "glUseProgram") + bindSymbol(glGetUniformLocation, glesLibrary, "glGetUniformLocation") + bindSymbol(glUniform1i, glesLibrary, "glUniform1i") + bindSymbol(glGenTextures, glesLibrary, "glGenTextures") + bindSymbol(glDeleteTextures, glesLibrary, "glDeleteTextures") + bindSymbol(glGenBuffers, glesLibrary, "glGenBuffers") + bindSymbol(glBindBuffer, glesLibrary, "glBindBuffer") + bindSymbol(glBufferData, glesLibrary, "glBufferData") + bindSymbol(glDeleteBuffers, glesLibrary, "glDeleteBuffers") + bindSymbol(glBindTexture, glesLibrary, "glBindTexture") + bindSymbol(glTexParameteri, glesLibrary, "glTexParameteri") + bindSymbol(glTexImage2D, glesLibrary, "glTexImage2D") + bindSymbol(glTexSubImage2D, glesLibrary, "glTexSubImage2D") + bindSymbol(glViewport, glesLibrary, "glViewport") + bindSymbol(glScissor, glesLibrary, "glScissor") + bindSymbol(glEnable, glesLibrary, "glEnable") + bindSymbol(glDisable, glesLibrary, "glDisable") + bindSymbol(glGetIntegerv, glesLibrary, "glGetIntegerv") + bindSymbol(glClearColor, glesLibrary, "glClearColor") + bindSymbol(glClear, glesLibrary, "glClear") + bindSymbol(glEnableVertexAttribArray, glesLibrary, "glEnableVertexAttribArray") + bindSymbol(glVertexAttribPointer, glesLibrary, "glVertexAttribPointer") + bindSymbol(glDrawArrays, glesLibrary, "glDrawArrays") + bindSymbol(glFinish, glesLibrary, "glFinish") + bindSymbol(glReadPixels, glesLibrary, "glReadPixels") + bindSymbol(glGetError, glesLibrary, "glGetError") + except: + result.close() + raise + +proc gpuChecked(value: bool, operation: string) = + ## Fail with the operation and current system error instead of continuing. + if not value: raise newException(IOError, operation & ": " & $osLastError()) + +proc gpuCheckedEgl(probe: GpuDisplay, value: cuint, operation: string) = + ## Include EGL's own error code when context or buffer operations fail. + if value == 0: raise newException(IOError, operation & ": EGL " & $probe.gpu.eglGetError()) + +proc gpuLoadAtomic(display: DrmDisplay): GpuAtomicApi = + ## Load experimental entry points without changing the normal DRM backend. + template bindField(field: untyped, name: string) = + result.field = cast[type(result.field)](symAddr(display.api.library, name)) + gpuChecked(result.field != nil, name) + bindField(alloc, "drmModeAtomicAlloc") + bindField(free, "drmModeAtomicFree") + bindField(add, "drmModeAtomicAddProperty") + bindField(gpuCommit, "drmModeAtomicCommit") + bindField(createBlob, "drmModeCreatePropertyBlob") + bindField(destroyBlob, "drmModeDestroyPropertyBlob") + +proc gpuProperty(probe: GpuDisplay, id, kind: uint32, name: string): GpuAtomicValue = + ## Find a gpuProperty by name and retain its original value for restoration. + let + api = probe.display.api + values = api.getProperties(probe.display.fd, id, kind) + gpuChecked(values != nil, "Reading properties") + defer: api.freeProperties(values) + for index in 0 ..< int(values.count): + let item = api.getProperty(probe.display.fd, values.ids[index]) + gpuChecked(item != nil, "Reading property") + let matches = $cast[cstring](addr item.name[0]) == name + api.freeProperty(item) + if matches: + return GpuAtomicValue(id: id, gpuProperty: values.ids[index], value: values.values[index]) + raise newException(IOError, "Missing DRM property " & name & " on " & $id) + +proc gpuSet(probe: GpuDisplay, id, kind: uint32, name: string, value: uint64) = + ## Append an atomic update and the matching original state exactly once. + let previous = probe.gpuProperty(id, kind, name) + probe.restore.add previous + probe.properties.add GpuAtomicValue(id: id, gpuProperty: previous.gpuProperty, value: value) + +proc gpuBlob(probe: GpuDisplay, mode: var ModeInfo): uint32 = + ## Retain a mode gpuBlob until after the display has been restored. + gpuChecked(probe.atomic.createBlob(probe.display.fd, addr mode, csize_t(sizeof(mode)), addr result) == 0, + "Creating mode blob") + probe.blobs.add result + +proc gpuConfigure(probe: GpuDisplay) = + ## Discover primary planes and construct one transaction for two active outputs. + let + display = probe.display + api = display.api + planes = api.getPlaneResources(display.fd) + resources = api.getResources(display.fd) + gpuChecked(planes != nil and resources != nil, "Enumerating planes") + defer: + api.freePlaneResources(planes) + api.freeResources(resources) + for index, screen in probe.outputs: + var crtcIndex = -1 + for candidate in 0 ..< int(resources.countCrtcs): + if resources.crtcs[candidate] == screen.crtc: crtcIndex = candidate + gpuChecked(crtcIndex >= 0, "Finding CRTC index") + for candidate in 0 ..< int(planes.count): + let plane = api.getPlane(display.fd, planes.planes[candidate]) + gpuChecked(plane != nil, "Reading plane") + defer: api.freePlane(plane) + if (plane.possibleCrtcs and (1'u32 shl crtcIndex)) == 0: continue + if probe.gpuProperty(plane.id, GpuPlaneObject, "type").value != GpuPrimaryPlane: continue + if plane.crtc notin [0'u32, screen.crtc]: continue + if plane.id in probe.planes: continue + var compatible = false + for format in 0 ..< int(plane.countFormats): + if plane.formats[format] == GpuXrgb8888: compatible = true + if compatible: + probe.planes[index] = plane.id + break + gpuChecked(probe.planes[index] != 0, "Finding compatible primary plane") + let plane = probe.planes[index] + probe.pair.crtcs[index] = screen.crtc + probe.gpuSet(screen.connector, GpuConnectorObject, "CRTC_ID", screen.crtc) + probe.gpuSet(screen.crtc, GpuCrtcObject, "ACTIVE", 1) + probe.gpuSet(screen.crtc, GpuCrtcObject, "MODE_ID", probe.gpuBlob(screen.mode)) + # Copy an active original mode into our own gpuBlob instead of retaining its ID. + if screen.original.modeValid != 0: + probe.restore[^1].value = probe.gpuBlob(screen.original.mode) + probe.gpuSet(plane, GpuPlaneObject, "CRTC_ID", screen.crtc) + probe.gpuSet(plane, GpuPlaneObject, "FB_ID", 0) + probe.fbProperties[index] = probe.properties[^1].gpuProperty + probe.gpuSet(plane, GpuPlaneObject, "SRC_X", 0) + probe.gpuSet(plane, GpuPlaneObject, "SRC_Y", uint64(index * probe.panelHeight) shl 16) + probe.gpuSet(plane, GpuPlaneObject, "SRC_W", uint64(probe.panelWidth) shl 16) + probe.gpuSet(plane, GpuPlaneObject, "SRC_H", uint64(probe.panelHeight) shl 16) + probe.gpuSet(plane, GpuPlaneObject, "CRTC_X", 0) + probe.gpuSet(plane, GpuPlaneObject, "CRTC_Y", 0) + probe.gpuSet(plane, GpuPlaneObject, "CRTC_W", uint64(probe.panelWidth)) + probe.gpuSet(plane, GpuPlaneObject, "CRTC_H", uint64(probe.panelHeight)) + echo screen.name, ": CRTC ", screen.crtc, ", plane ", plane + +proc gpuCommit(probe: GpuDisplay, values: seq[GpuAtomicValue], flags: uint32, data: pointer = nil) = + ## Submit a complete transaction or leave the current state unchanged on error. + let request = probe.atomic.alloc() + gpuChecked(request != nil, "Allocating atomic request") + defer: probe.atomic.free(request) + for item in values: + gpuChecked(probe.atomic.add(request, item.id, item.gpuProperty, item.value) >= 0, "Adding atomic property") + gpuChecked(probe.atomic.gpuCommit(probe.display.fd, request, flags, data) == 0, "Committing paired display update") + +proc complete(pair: var GpuFlipPair, crtc: uint32, timestamp: uint64) = + ## Record one event per controller before either old scanout can be retired. + for index in 0..1: + if pair.crtcs[index] == crtc: + if index in pair.completed: raise newException(ValueError, "Duplicate flip completion") + pair.completed.incl index + pair.timestamps[index] = timestamp + return + raise newException(ValueError, "Completion from an unexpected CRTC") + +proc ready(pair: GpuFlipPair): bool = + ## Return whether both controllers have completed the generation. + pair.completed == {0, 1} + +proc gpuFlip2(fd: cint, sequence, seconds, microseconds, crtc: cuint, data: pointer) {.cdecl.} = + ## Handle both events without allowing a Nim exception across a C callback. + let probe = cast[GpuDisplay](data) + try: + probe.pair.complete(crtc, uint64(seconds) * 1_000_000 + uint64(microseconds)) + except ValueError as error: + probe.callbackError = error.msg + +proc gpuWaitPair(probe: GpuDisplay) = + ## Retire a generation only after both display controllers have completed it. + if not probe.pending: return + let started = getMonoTime() + var context = GpuPairEvents(version: 3, flip2: gpuFlip2) + while not probe.pair.ready: + let remaining = FlipTimeoutMs - int((getMonoTime() - started).inMilliseconds) + gpuChecked(remaining > 0, "Timed out waiting for paired flip") + var descriptor = TPollfd(fd: probe.display.fd, events: POLLIN) + let polled = posix.poll(addr descriptor, 1, cint(remaining)) + if polled < 0 and osLastError() == OSErrorCode(EINTR): continue + gpuChecked(polled > 0 and (descriptor.revents and POLLIN) != 0, "Polling paired flip") + gpuChecked(probe.display.api.handleEvent(probe.display.fd, cast[ptr EventContext](addr context)) == 0, + "Reading paired flip") + if probe.callbackError.len > 0: raise newException(IOError, probe.callbackError) + probe.pending = false + +proc gpuShader(probe: GpuDisplay, kind: uint32, source: string): uint32 = + ## Compile a shader and report the driver's compiler log on failure. + result = probe.gpu.glCreateShader(kind) + var + text = source.cstring + compiled: cint + probe.gpu.glShaderSource(result, 1, addr text, nil) + probe.gpu.glCompileShader(result) + probe.gpu.glGetShaderiv(result, GpuGlCompileStatus, addr compiled) + if compiled == 0: + var log = newString(4096) + probe.gpu.glGetShaderInfoLog(result, cint(log.len), nil, log.cstring) + probe.gpu.glDeleteShader(result) + raise newException(IOError, "Compiling shader: " & log) + +proc gpuInitialize(probe: GpuDisplay) = + ## Create one EGL context and one linear GBM render target for both panels. + probe.gbm = probe.gpu.gbm_create_device(probe.display.fd) + gpuChecked(probe.gbm != nil, "Creating GBM device") + var linear = 0'u64 + probe.surface = probe.gpu.gbm_surface_create_with_modifiers2(probe.gbm, uint32(probe.panelWidth), + uint32(probe.panelHeight * 2), GpuXrgb8888, addr linear, 1, GpuGbmScanoutRendering) + gpuChecked(probe.surface != nil, "Creating linear combined GBM surface") + probe.egl = probe.gpu.eglGetPlatformDisplay(GpuEglGbm, probe.gbm, nil) + gpuChecked(probe.egl != nil, "Opening GBM EGL display") + probe.gpuCheckedEgl(probe.gpu.eglInitialize(probe.egl, nil, nil), "Initializing EGL") + probe.gpuCheckedEgl(probe.gpu.eglBindAPI(GpuEglEsApi), "Selecting OpenGL ES") + var + attributes = GpuEglConfigAttributes + count: cint + selected: pointer + probe.gpuCheckedEgl(probe.gpu.eglChooseConfig(probe.egl, addr attributes[0], nil, 0, addr count), "Counting EGL configs") + gpuChecked(count > 0, "Expected a window-compatible EGL config") + var + configs = newSeq[pointer](count) + sizes: array[4, cint] + probe.gpuCheckedEgl(probe.gpu.eglChooseConfig(probe.egl, addr attributes[0], addr configs[0], count, addr count), "Choosing EGL config") + for index in 0 ..< int(count): + var visual: cint + probe.gpuCheckedEgl(probe.gpu.eglGetConfigAttrib(probe.egl, configs[index], GpuEglNativeVisual, addr visual), "Reading EGL visual") + if uint32(visual) != GpuXrgb8888: continue + for field, name in [GpuEglDepthSize, GpuEglStencilSize, GpuEglSamples, GpuEglSampleBuffers]: + probe.gpuCheckedEgl(probe.gpu.eglGetConfigAttrib(probe.egl, configs[index], name, addr sizes[field]), "Reading EGL buffers") + # EGL's zero attributes specify minimum sizes, not the absence of MSAA/depth. + if sizes == [0.cint, 0, 0, 0]: + selected = configs[index] + break + gpuChecked(selected != nil, "Finding XRGB EGL config without depth, stencil or MSAA") + echo "EGL depth/stencil/samples/sample buffers: ", sizes + var contextAttributes = [GpuEglClientVersion, 2.cint, GpuEglNone] + probe.context = probe.gpu.eglCreateContext(probe.egl, selected, nil, addr contextAttributes[0]) + gpuChecked(probe.context != nil, "Creating GLES2 context") + probe.eglSurface = probe.gpu.eglCreateWindowSurface(probe.egl, selected, probe.surface, nil) + gpuChecked(probe.eglSurface != nil, "Creating EGL window surface") + probe.gpuCheckedEgl(probe.gpu.eglMakeCurrent(probe.egl, probe.eglSurface, probe.eglSurface, probe.context), "Making context current") + probe.gpuCheckedEgl(probe.gpu.eglSwapInterval(probe.egl, 0), "Disabling EGL's separate refresh wait") + let + renderer = $probe.gpu.glGetString(GpuGlRenderer) + version = $probe.gpu.glGetString(GpuGlVersion) + echo "GPU: ", renderer, "; GLES: ", version + gpuChecked(renderer.len > 0, "Expected a hardware renderer") + for software in ["llvmpipe", "softpipe", "swrast", "swiftshader"]: + gpuChecked(software notin renderer.toLowerAscii, "Software rendering is not a GPU proof: " & renderer) + let + vertex = probe.gpuShader(GpuGlVertexShader, GpuVertexSource) + fragment = probe.gpuShader(GpuGlFragmentShader, GpuFragmentSource) + defer: + probe.gpu.glDeleteShader(vertex) + probe.gpu.glDeleteShader(fragment) + probe.program = probe.gpu.glCreateProgram() + probe.gpu.glAttachShader(probe.program, vertex) + probe.gpu.glAttachShader(probe.program, fragment) + probe.gpu.glBindAttribLocation(probe.program, 0, "position") + probe.gpu.glBindAttribLocation(probe.program, 1, "uv") + probe.gpu.glLinkProgram(probe.program) + var linked: cint + probe.gpu.glGetProgramiv(probe.program, GpuGlLinkStatus, addr linked) + if linked == 0: + var log = newString(4096) + probe.gpu.glGetProgramInfoLog(probe.program, cint(log.len), nil, log.cstring) + raise newException(IOError, "Linking program: " & log) + probe.gpu.glUseProgram(probe.program) + probe.gpu.glUniform1i(probe.gpu.glGetUniformLocation(probe.program, "image"), 0) + probe.gpu.glGenTextures(1, addr probe.texture) + probe.gpu.glBindTexture(GpuGlTexture, probe.texture) + for name in [GpuGlMagFilter, GpuGlMinFilter]: probe.gpu.glTexParameteri(GpuGlTexture, name, GpuGlNearest) + for name in [GpuGlWrapS, GpuGlWrapT]: probe.gpu.glTexParameteri(GpuGlTexture, name, GpuGlClampEdge) + probe.gpu.glGetIntegerv(GpuGlMaxTextureSize, addr probe.maximumTextureSize) + gpuChecked(probe.maximumTextureSize > 0, "Reading GPU texture capacity") + probe.gpu.glEnableVertexAttribArray(0) + probe.gpu.glEnableVertexAttribArray(1) + probe.gpu.glGenBuffers(1, addr probe.vertices) + probe.gpu.glBindBuffer(GpuGlArrayBuffer, probe.vertices) + +proc gpuUpload(probe: GpuDisplay, image: Image, regions: array[2, PixelRegion]) = + ## Upload one native-size image and retain geometry until its crops change. + let + width = if image == nil: 0 else: image.width + height = if image == nil: 0 else: image.height + validateRegions(width, height, regions) + if image == nil: + probe.regions = regions + return + if width <= 0 or height <= 0 or width > int(probe.maximumTextureSize) or + height > int(probe.maximumTextureSize) or image.data.len != width * height: + raise newException(ValueError, "Invalid GPU source image dimensions") + let resized = width != probe.textureWidth or height != probe.textureHeight + if resized or regions != probe.regions: + var vertices = pixelVertices(width, height, regions) + probe.gpu.glBufferData(GpuGlArrayBuffer, sizeof(vertices), addr vertices[0], GpuGlStaticDraw) + if resized: + probe.gpu.glTexImage2D(GpuGlTexture, 0, cint(GpuGlRgba), cint(width), cint(height), + 0, GpuGlRgba, GpuGlByte, unsafeAddr image.data[0]) + else: + probe.gpu.glTexSubImage2D(GpuGlTexture, 0, 0, 0, cint(width), cint(height), + GpuGlRgba, GpuGlByte, unsafeAddr image.data[0]) + probe.textureWidth = width + probe.textureHeight = height + probe.regions = regions + gpuChecked(probe.gpu.glGetError() == 0, "Uploading GPU pixels") + +proc gpuDraw(probe: GpuDisplay) = + ## Enlarge two cropped texture regions with nearest-neighbour sampling. + probe.gpu.glDisable(GpuGlScissor) + probe.gpu.glClearColor(0, 0, 0, 1) + probe.gpu.glClear(GpuGlColourBuffer) + probe.gpu.glEnable(GpuGlScissor) + for index in 0..1: + if probe.regions[index] == PixelRegion(): continue + let + offset = index * GpuVerticesPerPanel * GpuVertexStride + view = pixelViewport(probe.regions[index], probe.panelWidth, probe.panelHeight) + probe.gpu.glScissor(0, cint((1 - index) * probe.panelHeight), + cint(probe.panelWidth), cint(probe.panelHeight)) + probe.gpu.glViewport(cint(view.x), cint((2 - index) * probe.panelHeight - view.y - view.height), + cint(view.width), cint(view.height)) + probe.gpu.glVertexAttribPointer(0, 2, GpuGlFloat, 0, GpuVertexStride, cast[pointer](offset)) + probe.gpu.glVertexAttribPointer(1, 2, GpuGlFloat, 0, GpuVertexStride, cast[pointer](offset + 2 * sizeof(cfloat))) + probe.gpu.glDrawArrays(GpuGlTriangles, 0, GpuVerticesPerPanel) + gpuChecked(probe.gpu.glGetError() == 0, "Drawing GPU pixels") + +proc gpuFramebuffer(probe: GpuDisplay, bo: pointer): uint32 = + ## Register each GBM allocation once and reject non-linear storage explicitly. + for frame in probe.gbmFrames: + if frame.bo == bo: return frame.fb + gpuChecked(probe.gpu.gbm_bo_get_modifier(bo) == 0, "Expected a linear GBM buffer") + var + handles = [probe.gpu.gbm_bo_get_handle(bo).u32, 0'u32, 0, 0] + pitches = [probe.gpu.gbm_bo_get_stride(bo), 0'u32, 0, 0] + offsets: array[4, uint32] + gpuChecked(probe.display.api.addFb2(probe.display.fd, uint32(probe.panelWidth), + uint32(probe.panelHeight * 2), GpuXrgb8888, addr handles[0], addr pitches[0], + addr offsets[0], addr result, 0) == 0, "Registering combined GBM framebuffer") + probe.gbmFrames.add (bo, result) + +proc gpuSubmit(probe: GpuDisplay, fb: uint32) = + ## Test the initial pair and then submit one vblank-synchronized transaction. + var values: seq[GpuAtomicValue] + for item in probe.properties: + if probe.armed and item.gpuProperty notin probe.fbProperties: continue + var value = item + if item.id in probe.planes: + for index in 0..1: + if item.id == probe.planes[index] and item.gpuProperty == probe.fbProperties[index]: + value.value = fb + values.add value + if not probe.armed: + probe.gpuCommit(values, GpuAtomicTest or GpuAllowModeset) + echo "Atomic TEST_ONLY: combined framebuffer accepted by both outputs" + probe.gpuCommit(values, GpuAllowModeset) + probe.armed = true + else: + probe.pair.completed = {} + probe.gpuCommit(values, GpuAtomicNonblock or FlipEvent, cast[pointer](probe)) + probe.pending = true + +proc close*(probe: GpuDisplay) = + ## Restore the saved transaction before freeing anything still used for scanout. + if probe == nil or probe.display == nil: return + var errors: seq[string] + template attempt(body: untyped) = + try: body + except CatchableError as error: errors.add error.msg + if probe.armed: + attempt: probe.gpuWaitPair() + attempt: + probe.gpuCommit(probe.restore, GpuAllowModeset) + probe.armed = false + if probe.armed: + # A failed restore must not leave scanout pointing at freed GPU storage. + var disabled = true + for screen in probe.display.screens: + if probe.display.api.setCrtc(probe.display.fd, screen.crtc, 0, 0, 0, nil, 0, nil) < 0: + disabled = false + errors.add "Disabling output after failed restore: " & screen.name + if not disabled: + attempt: gpuChecked(posix.close(probe.display.fd) == 0, "Closing failed DRM session") + probe.display.fd = -1 + for screen in probe.display.screens: + for scanout in screen.buffers: + attempt: gpuChecked(munmap(scanout.memory, int(scanout.buffer.size)) == 0, "Unmapping abandoned CPU buffer") + screen.buffers.setLen(0) + for bo in probe.locked: probe.gpu.gbm_surface_release_buffer(probe.surface, bo) + probe.locked.setLen(0) + for frame in probe.gbmFrames: + if probe.display.fd >= 0: + attempt: gpuChecked(probe.display.api.removeFb(probe.display.fd, frame.fb) == 0, "Removing GPU framebuffer") + if probe.egl != nil: + if probe.context != nil: + attempt: + probe.gpuCheckedEgl(probe.gpu.eglMakeCurrent(probe.egl, probe.eglSurface, + probe.eglSurface, probe.context), "Activating context for GPU cleanup") + if probe.texture != 0: probe.gpu.glDeleteTextures(1, addr probe.texture) + if probe.vertices != 0: probe.gpu.glDeleteBuffers(1, addr probe.vertices) + if probe.program != 0: probe.gpu.glDeleteProgram(probe.program) + attempt: probe.gpuCheckedEgl(probe.gpu.eglMakeCurrent(probe.egl, nil, nil, nil), "Releasing EGL context") + if probe.eglSurface != nil: + attempt: probe.gpuCheckedEgl(probe.gpu.eglDestroySurface(probe.egl, probe.eglSurface), "Destroying EGL surface") + if probe.context != nil: + attempt: probe.gpuCheckedEgl(probe.gpu.eglDestroyContext(probe.egl, probe.context), "Destroying EGL context") + attempt: probe.gpuCheckedEgl(probe.gpu.eglTerminate(probe.egl), "Terminating EGL") + if probe.surface != nil: probe.gpu.gbm_surface_destroy(probe.surface) + if probe.gbm != nil: probe.gpu.gbm_device_destroy(probe.gbm) + if probe.display.fd >= 0: + for gpuBlob in probe.blobs: + attempt: gpuChecked(probe.atomic.destroyBlob(probe.display.fd, gpuBlob) == 0, "Destroying mode blob") + probe.display.gpuOwned = false + probe.display = nil + probe.gpu.close() + probe.gpu = nil + if errors.len > 0: raise newException(IOError, errors.join("; ")) + +proc newGpuDisplay*(display: DrmDisplay, outputs: array[2, DrmScreen]): GpuDisplay = + ## Claim two idle unrotated outputs; close this presenter before its windows. + if display == nil or display.fd < 0 or display.gpuOwned or + display.screens.len != 2 or outputs[0] == outputs[1]: + raise newException(ValueError, "GPU pixels require two exclusively owned DRM outputs") + for screen in outputs: + if screen notin display.screens or screen.rotation != 0 or screen.activated or screen.pending: + raise newException(ValueError, "GPU pixels require idle, unrotated DRM windows") + if outputs[0].crtc == outputs[1].crtc: + raise newException(ValueError, "GPU pixels require independent display controllers") + if outputs[0].width != outputs[1].width or outputs[0].height != outputs[1].height: + raise newException(ValueError, "GPU pixels require equally sized outputs") + result = GpuDisplay(display: display, outputs: outputs, + panelWidth: outputs[0].width, panelHeight: outputs[0].height) + try: + result.gpu = gpuLoadApi() + result.atomic = gpuLoadAtomic(display) + gpuChecked(display.api.setClientCap(display.fd, GpuAtomicCapability, 1) == 0, "Enabling atomic KMS") + result.gpuConfigure() + for screen in outputs: + display.closeScreen(screen) + screen.buffers.setLen(0) + result.gpuInitialize() + gpuChecked(display.api.setMaster(display.fd) == 0, "Acquiring DRM master") + display.master = true + display.gpuOwned = true + except: + result.close() + raise + +proc presentGpuPixels*(probe: GpuDisplay, image: Image, regions: array[2, PixelRegion]) = + ## Upload once, scale on the GPU, and retire storage after both vblank events. + if probe == nil or probe.display == nil: + raise newException(ValueError, "GPU pixel presenter is closed") + probe.gpuCheckedEgl(probe.gpu.eglMakeCurrent(probe.egl, probe.eglSurface, + probe.eglSurface, probe.context), "Activating GPU pixel context") + probe.gpuUpload(image, regions) + probe.gpuDraw() + probe.gpuCheckedEgl(probe.gpu.eglSwapBuffers(probe.egl, probe.eglSurface), "Swapping EGL render buffer") + probe.gpu.glFinish() + gpuChecked(probe.gpu.glGetError() == 0, "Finishing GPU pixels") + let bo = probe.gpu.gbm_surface_lock_front_buffer(probe.surface) + gpuChecked(bo != nil, "Locking completed GBM buffer") + probe.locked.add bo + probe.gpuSubmit(probe.gpuFramebuffer(bo)) + probe.gpuWaitPair() + if probe.front != nil: + probe.gpu.gbm_surface_release_buffer(probe.surface, probe.front) + probe.locked.delete(probe.locked.find(probe.front)) + probe.front = bo + diff --git a/src/windy/platforms/linux/drm/pixels.nim b/src/windy/platforms/linux/drm/pixels.nim new file mode 100644 index 0000000..341bee9 --- /dev/null +++ b/src/windy/platforms/linux/drm/pixels.nim @@ -0,0 +1,35 @@ +import + ../../../common, + ../backends + +export PixelRegion + +type DrmPixelPair* = ref object + display: GpuDisplay + +proc newDrmPixelPair*[T](first, second: T): DrmPixelPair = + ## Claim two equal native DRM windows for GPU pixels; close them after this pair. + when T is typeof(nil): + raise newException(WindyError, "GPU pixel pairs require native DRM windows") + else: + if first == nil or second == nil: + raise newException(WindyError, "GPU pixel pairs require native DRM windows") + let + a = first.direct + b = second.direct + if a == nil or b == nil: + raise newException(WindyError, "GPU pixel pairs require native DRM windows") + if a.closed or b.closed or a.backend != DrmBackend or b.backend != DrmBackend or + a.drm == nil or b.drm == nil or a.screen == nil or b.screen == nil or a.drm != b.drm: + raise newException(WindyError, "GPU pixel pairs require open windows on one DRM device") + result = DrmPixelPair(display: newGpuDisplay(a.drm, [a.screen, b.screen])) + +proc presentPixels*(pair: DrmPixelPair, image: Image, regions: array[2, PixelRegion]) = + ## Center integer-scaled source crops, using black for an empty crop or nil image. + if pair == nil: raise newException(WindyError, "GPU pixel pair is nil") + pair.display.presentGpuPixels(image, regions) + +proc close*(pair: DrmPixelPair) = + ## Restore both outputs and release GPU resources before closing the windows. + if pair == nil: return + pair.display.close() diff --git a/src/windy/platforms/linux/fbdev.nim b/src/windy/platforms/linux/fbdev.nim new file mode 100644 index 0000000..5382e66 --- /dev/null +++ b/src/windy/platforms/linux/fbdev.nim @@ -0,0 +1,161 @@ +import + std/[os, posix, strformat], + pixie, + ./native/pixels + +type + Bitfield* {.importc: "struct fb_bitfield", header: "", bycopy.} = object + offset*, length*, msb_right*: uint32 + VariableInfo {.importc: "struct fb_var_screeninfo", header: "", bycopy.} = object + xres, yres, xres_virtual, yres_virtual, xoffset, yoffset: uint32 + bits_per_pixel, grayscale: uint32 + red, green, blue, transp: Bitfield + nonstd, activate, height, width, accel_flags: uint32 + pixclock, left_margin, right_margin, upper_margin, lower_margin: uint32 + hsync_len, vsync_len, sync, vmode, rotate, colorspace: uint32 + reserved: array[4, uint32] + FixedInfo {.importc: "struct fb_fix_screeninfo", header: "", bycopy.} = object + id: array[16, char] + smem_start: culong + smem_len, `type`, type_aux, visual: uint32 + xpanstep, ypanstep, ywrapstep: uint16 + line_length: uint32 + mmio_start: culong + mmio_len, accel: uint32 + capabilities: uint16 + reserved: array[2, uint16] + Framebuffer* = ref object + fd: cint + variable, original: VariableInfo + fixed: FixedInfo + memory: pointer + saved: seq[byte] + length: int + activated, touched: bool + rotation*: int + width*, height*: int + +const + GetVariable = 0x4600.culong + PutVariable = 0x4601.culong + GetFixed = 0x4602.culong + PanDisplay = 0x4606.culong + Blank = 0x4611.culong + ActivateForce = 128'u32 + +proc packPixel*(color: uint32, red, green, blue, alpha: Bitfield): uint32 = + for (value, channel) in [(color shr 16 and 255, red), + (color shr 8 and 255, green), (color and 255, blue), (255'u32, alpha)]: + if channel.length > 0: + if channel.length > 8 or channel.offset + channel.length > 32 or channel.msb_right != 0: + raise newException(ValueError, "Unsupported framebuffer channel layout") + let maximum = (1'u32 shl channel.length) - 1 + result = result or ((value * maximum + 127) div 255 shl channel.offset) + +proc checkedIoctl(fd: cint, request: culong, argument: pointer) = + if posix.ioctl(fd, request, argument) < 0: + raiseOSError(osLastError()) + +proc openFramebuffer*(path: string, rotation = 0): Framebuffer = + if rotation notin [0, 90, 180, 270]: + raise newException(ValueError, "Rotation must be 0, 90, 180 or 270") + result = Framebuffer(fd: -1, rotation: rotation) + result.fd = posix.open(path.cstring, O_RDWR or O_CLOEXEC) + if result.fd < 0: + raiseOSError(osLastError(), path) + try: + checkedIoctl(result.fd, GetVariable, addr result.variable) + checkedIoctl(result.fd, GetFixed, addr result.fixed) + result.original = result.variable + if result.variable.bits_per_pixel notin [16'u32, 24, 32] or + result.variable.grayscale != 0 or result.fixed.`type` != 0 or result.fixed.visual != 2: + raise newException(ValueError, "Expected a packed truecolor framebuffer") + let + width = int(result.variable.xres) + height = int(result.variable.yres) + stride = int(result.fixed.line_length) + pixelBytes = int(result.variable.bits_per_pixel div 8) + if width notin 1..8192 or height notin 1..8192 or + stride < width * pixelBytes or stride > 131072: + raise newException(ValueError, "Invalid framebuffer geometry") + result.length = int(result.fixed.smem_len) + if result.length < stride * height or result.length > 256 * 1024 * 1024: + raise newException(ValueError, "Invalid framebuffer memory size") + discard packPixel(0xffffff'u32, result.variable.red, result.variable.green, + result.variable.blue, result.variable.transp) + result.width = if rotation in [90, 270]: height else: width + result.height = if rotation in [90, 270]: width else: height + result.memory = mmap(nil, result.length, PROT_READ or PROT_WRITE, MAP_SHARED, result.fd, 0) + if result.memory == MAP_FAILED: + result.memory = nil + raiseOSError(osLastError(), "Mapping framebuffer") + result.saved = newSeq[byte](result.length) + copyMem(addr result.saved[0], result.memory, result.length) + except: + if result.memory != nil: + discard munmap(result.memory, result.length) + discard posix.close(result.fd) + raise + +proc describe*(fb: Framebuffer): string = + let v = fb.variable + &"{v.xres}x{v.yres} visible, {v.xres_virtual}x{v.yres_virtual} virtual, " & + &"{v.bits_per_pixel}bpp, stride={fb.fixed.line_length}, offsets={v.xoffset},{v.yoffset}, " & + &"RGB={v.red.offset}:{v.red.length}/{v.green.offset}:{v.green.length}/{v.blue.offset}:{v.blue.length}" + +proc present*(fb: Framebuffer, image: Image) = + if image.width != fb.width or image.height != fb.height: + raise newException(ValueError, "Canvas dimensions differ from framebuffer") + let + destination = cast[ptr UncheckedArray[byte]](fb.memory) + bytesPerPixel = int(fb.variable.bits_per_pixel div 8) + stride = int(fb.fixed.line_length) + fb.touched = true + if cpuEndian == littleEndian and bytesPerPixel == 4 and stride mod 4 == 0 and fb.rotation == 0 and + fb.variable.red.length == 8 and fb.variable.green.length == 8 and + fb.variable.blue.length == 8: + let alpha = packPixel(0, fb.variable.red, fb.variable.green, + fb.variable.blue, fb.variable.transp) + for y in 0 ..< image.height: + let + source = cast[ptr UncheckedArray[ColorRGBX]](unsafeAddr image.data[y * image.width]) + row = cast[ptr UncheckedArray[uint32]](addr destination[y * stride]) + for x in 0 ..< image.width: + let pixel = source[x] + row[x] = uint32(pixel.r) shl fb.variable.red.offset or + uint32(pixel.g) shl fb.variable.green.offset or + uint32(pixel.b) shl fb.variable.blue.offset or alpha + else: + for y in 0 ..< image.height: + for x in 0 ..< image.width: + let + point = rotatedPoint(x, y, image.width, image.height, fb.rotation) + offset = point.y * stride + point.x * bytesPerPixel + pixel = packPixel(rgb(image.data[y * image.width + x]), fb.variable.red, + fb.variable.green, fb.variable.blue, fb.variable.transp) + for index in 0 ..< bytesPerPixel: + destination[offset + index] = byte((pixel shr (index * 8)) and 255) + if not fb.activated: + fb.variable.xoffset = 0 + fb.variable.yoffset = 0 + if posix.ioctl(fb.fd, PanDisplay, addr fb.variable) < 0: + fb.variable.activate = ActivateForce + checkedIoctl(fb.fd, PutVariable, addr fb.variable) + checkedIoctl(fb.fd, Blank, nil) + fb.activated = true + +proc close*(fb: Framebuffer) = + if fb.memory != nil: + var restoreResult = 0 + if fb.touched: + copyMem(fb.memory, addr fb.saved[0], fb.length) + if fb.activated: + restoreResult = posix.ioctl(fb.fd, PanDisplay, addr fb.original) + if restoreResult < 0: + restoreResult = posix.ioctl(fb.fd, PutVariable, addr fb.original) + let unmapResult = munmap(fb.memory, fb.length) + fb.memory = nil + let closeResult = posix.close(fb.fd) + fb.fd = -1 + if restoreResult < 0 or unmapResult < 0 or closeResult < 0: + raise newException(IOError, "Unable to completely restore/close framebuffer") diff --git a/src/windy/platforms/linux/native/pixels.nim b/src/windy/platforms/linux/native/pixels.nim new file mode 100644 index 0000000..27c6117 --- /dev/null +++ b/src/windy/platforms/linux/native/pixels.nim @@ -0,0 +1,49 @@ +import pixie + +type PixelRegion* = object + x*, y*, width*, height*: int + +proc rgb*(pixel: ColorRGBX): uint32 {.inline.} = + uint32(pixel.r) shl 16 or uint32(pixel.g) shl 8 or uint32(pixel.b) + +proc rotatedPoint*(x, y, width, height, rotation: int): tuple[x, y: int] {.inline.} = + case rotation + of 0: (x, y) + of 90: (height - 1 - y, x) + of 180: (width - 1 - x, height - 1 - y) + of 270: (y, width - 1 - x) + else: raise newException(ValueError, "Rotation must be 0, 90, 180 or 270") + +proc validateRegions*(width, height: int, regions: array[2, PixelRegion]) = + ## Validate source crops; an empty region leaves its output black. + for region in regions: + if region == PixelRegion(): continue + if width <= 0 or height <= 0 or region.x < 0 or region.y < 0 or + region.width <= 0 or region.height <= 0 or region.x > width or + region.y > height or region.width > width - region.x or + region.height > height - region.y: + raise newException(ValueError, "Invalid pixel source region") + +proc pixelViewport*(region: PixelRegion, width, height: int): PixelRegion = + ## Center whole-number scaling, clipping at native size when necessary. + if min(region.width, region.height) <= 0 or min(width, height) <= 0: + raise newException(ValueError, "Invalid pixel viewport dimensions") + let scale = max(1, min(width div region.width, height div region.height)) + result.width = region.width * scale + result.height = region.height * scale + result.x = (width - result.width) div 2 + result.y = (height - result.height) div 2 + +proc pixelVertices*(width, height: int, regions: array[2, PixelRegion]): array[48, cfloat] = + ## Address top-left source crops with OpenGL's bottom-left vertex convention. + validateRegions(width, height, regions) + if width <= 0 or height <= 0: return + for index, region in regions: + let + left = cfloat(region.x) / cfloat(width) + right = cfloat(region.x + region.width) / cfloat(width) + top = cfloat(region.y) / cfloat(height) + bottom = cfloat(region.y + region.height) / cfloat(height) + vertices = [-1.cfloat, -1, left, bottom, 1, -1, right, bottom, + -1, 1, left, top, -1, 1, left, top, 1, -1, right, bottom, 1, 1, right, top] + for offset, value in vertices: result[index * vertices.len + offset] = value diff --git a/src/windy/platforms/linux/native/state.nim b/src/windy/platforms/linux/native/state.nim new file mode 100644 index 0000000..8ab3b9f --- /dev/null +++ b/src/windy/platforms/linux/native/state.nim @@ -0,0 +1,22 @@ +import + ../../../common, + ../backends, + ./touch + +when HasFbdev: + import ../fbdev +when HasDrm: + import ../drm + +type + DirectWindowState* = ref object + when HasFbdev: + fb*: Framebuffer + when HasDrm: + drm*: DrmDisplay + screen*: DrmScreen + touch*: TouchReader + device*, title*: string + backend*: LinuxBackend + closed*, closeRequested*: bool + contacts*: seq[TouchContact] diff --git a/src/windy/platforms/linux/native/touch.nim b/src/windy/platforms/linux/native/touch.nim new file mode 100644 index 0000000..b6b3f14 --- /dev/null +++ b/src/windy/platforms/linux/native/touch.nim @@ -0,0 +1,190 @@ +import + std/[os, posix], + pixie, + ../../../common, + ./pixels + +type + Event {.importc: "struct input_event", header: "", bycopy.} = object + time: Timeval + kind {.importc: "type".}: uint16 + code: uint16 + value: int32 + Axis {.importc: "struct input_absinfo", header: "", bycopy.} = object + value, minimum, maximum, fuzz, flat, resolution: int32 + Slot* = object + id*, x*, y*: int32 + TouchReader* = ref object + fd: cint + path*: string + minimumX*, maximumX*, minimumY*, maximumY*: int32 + slotMinimum, slot: int + multitouch, dropped: bool + pending*, previous*: seq[Slot] + width*, height*, rotation*: int + +proc getAxis(code: cint): culong {.importc: "EVIOCGABS", header: "".} +proc getSlots(length: cint): culong {.importc: "EVIOCGMTSLOTS", header: "".} +proc getKeys(length: cint): culong {.importc: "EVIOCGKEY", header: "".} +proc getBits(kind, length: cint): culong {.importc: "EVIOCGBIT", header: "".} + +proc contact(reader: TouchReader, slot: Slot): TouchContact = + let + rawWidth = if reader.rotation in [90, 270]: reader.height else: reader.width + rawHeight = if reader.rotation in [90, 270]: reader.width else: reader.height + x = int((int64(clamp(slot.x, reader.minimumX, reader.maximumX)) - reader.minimumX) * + (rawWidth - 1) div (int64(reader.maximumX) - reader.minimumX)) + y = int((int64(clamp(slot.y, reader.minimumY, reader.maximumY)) - reader.minimumY) * + (rawHeight - 1) div (int64(reader.maximumY) - reader.minimumY)) + point = rotatedPoint(x, y, rawWidth, rawHeight, reader.rotation) + TouchContact(id: slot.id, position: vec2(point.x.float32, point.y.float32)) + +proc report*(reader: TouchReader): seq[TouchEvent] = + ## Publish a coherent report, ending replaced contacts before beginning new ones. + for index, old in reader.previous: + let current = reader.pending[index] + if old.id >= 0 and old.id != current.id: + result.add TouchEvent(contact: reader.contact(old), phase: TouchEnd) + for index, current in reader.pending: + let old = reader.previous[index] + if current.id >= 0: + if old.id != current.id: + result.add TouchEvent(contact: reader.contact(current), phase: TouchBegin) + elif old.x != current.x or old.y != current.y: + result.add TouchEvent(contact: reader.contact(current), phase: TouchMove) + reader.previous = reader.pending + +proc cancel*(reader: TouchReader): seq[TouchEvent] = + for old in reader.previous: + if old.id >= 0: + result.add TouchEvent(contact: reader.contact(old), phase: TouchCancel) + for slot in reader.pending.mitems: + slot.id = -1 + reader.previous = reader.pending + +proc resync(reader: TouchReader) = + if reader.multitouch: + var axis: Axis + if posix.ioctl(reader.fd, getAxis(47), addr axis) < 0: + raiseOSError(osLastError(), reader.path) + reader.slot = int(axis.value) - reader.slotMinimum + for code in [57'i32, 53, 54]: + var values = newSeq[int32](reader.pending.len + 1) + values[0] = code + if posix.ioctl(reader.fd, getSlots(cint(values.len * sizeof(int32))), addr values[0]) < 0: + raiseOSError(osLastError(), reader.path) + for index in 0 ..< reader.pending.len: + case code + of 57: reader.pending[index].id = values[index + 1] + of 53: reader.pending[index].x = values[index + 1] + of 54: reader.pending[index].y = values[index + 1] + else: discard + else: + var + keys: array[96, byte] + x, y: Axis + if posix.ioctl(reader.fd, getKeys(cint(keys.len)), addr keys[0]) < 0 or + posix.ioctl(reader.fd, getAxis(0), addr x) < 0 or + posix.ioctl(reader.fd, getAxis(1), addr y) < 0: + raiseOSError(osLastError(), reader.path) + reader.pending[0] = Slot(id: (if (keys[330 div 8] and (1'u8 shl (330 mod 8))) != 0: 0'i32 else: -1'i32), + x: x.value, y: y.value) + +proc close*(reader: TouchReader) = + if reader.fd >= 0: + let fd = reader.fd + reader.fd = -1 + if posix.close(fd) < 0: + raiseOSError(osLastError(), reader.path) + +proc openTouch*(path: string, width, height, rotation: int): TouchReader = + discard rotatedPoint(0, 0, width, height, rotation) + result = TouchReader(fd: -1, path: path, width: width, height: height, rotation: rotation) + result.fd = posix.open(path.cstring, O_RDONLY or O_NONBLOCK or O_CLOEXEC) + if result.fd < 0: + raiseOSError(osLastError(), path) + try: + var + bits: array[8, byte] + slots, x, y: Axis + if posix.ioctl(result.fd, getBits(3, cint(bits.len)), addr bits[0]) < 0: + raiseOSError(osLastError(), path) + result.multitouch = (bits[47 div 8] and (1'u8 shl (47 mod 8))) != 0 + if result.multitouch: + if posix.ioctl(result.fd, getAxis(47), addr slots) < 0: + raiseOSError(osLastError(), path) + result.slotMinimum = int(slots.minimum) + let + count = if result.multitouch: int(slots.maximum - slots.minimum + 1) else: 1 + xCode = if result.multitouch: 53 else: 0 + yCode = if result.multitouch: 54 else: 1 + if count notin 1 .. 32: + raise newException(WindyError, "Unsupported touch slot count") + if posix.ioctl(result.fd, getAxis(cint(xCode)), addr x) < 0 or + posix.ioctl(result.fd, getAxis(cint(yCode)), addr y) < 0: + raiseOSError(osLastError(), path) + if x.maximum <= x.minimum or y.maximum <= y.minimum: + raise newException(WindyError, "Invalid touchscreen coordinate ranges") + result.minimumX = x.minimum + result.maximumX = x.maximum + result.minimumY = y.minimum + result.maximumY = y.maximum + result.pending = newSeq[Slot](count) + for slot in result.pending.mitems: + slot.id = -1 + result.previous = result.pending + result.resync() + except: + result.close() + raise + +proc poll*(reader: TouchReader): seq[TouchEvent] = + if reader.fd < 0: + return + result = reader.report() + while true: + var event: Event + let count = posix.read(reader.fd, addr event, sizeof(event)) + if count < 0: + let error = osLastError() + if error == OSErrorCode(EAGAIN): break + if error == OSErrorCode(EINTR): continue + if error == OSErrorCode(ENODEV): + result.add reader.cancel() + reader.close() + break + raiseOSError(error, reader.path) + if count == 0: + result.add reader.cancel() + reader.close() + break + if count != sizeof(event): + raise newException(WindyError, "Incomplete touchscreen event") + if event.kind == 0 and event.code == 3: + result.add reader.cancel() + reader.dropped = true + elif reader.dropped: + if event.kind == 0 and event.code == 0: + reader.resync() + reader.dropped = false + result.add reader.report() + elif event.kind == 0 and event.code == 0: + result.add reader.report() + elif event.kind == 3: + if event.code == 47 and reader.multitouch: + let slot = int(event.value) - reader.slotMinimum + if slot notin 0 ..< reader.pending.len: + raise newException(WindyError, "Invalid touchscreen slot") + reader.slot = slot + elif reader.slot in 0 ..< reader.pending.len: + case event.code + of 57: reader.pending[reader.slot].id = event.value + of 53: reader.pending[reader.slot].x = event.value + of 54: reader.pending[reader.slot].y = event.value + of 0: + if not reader.multitouch: reader.pending[0].x = event.value + of 1: + if not reader.multitouch: reader.pending[0].y = event.value + else: discard + elif event.kind == 1 and event.code == 330 and not reader.multitouch: + reader.pending[0].id = if event.value == 0: -1 else: 0 diff --git a/src/windy/platforms/linux/platform.nim b/src/windy/platforms/linux/platform.nim index d581b16..9ae5f62 100644 --- a/src/windy/platforms/linux/platform.nim +++ b/src/windy/platforms/linux/platform.nim @@ -1,5 +1,434 @@ -# when defined(wayland): -# include wayland -# else: -include x11 -# include x11_2 +import ../../common, ./backends +export backends + +when not HasDirect: + import ./x11 + export x11 + + proc setLinuxBackend*(backend: LinuxBackend) = + if backend != X11Backend: + raise newException(WindyError, "Backend was not compiled into Windy") +else: + import pixie, ./direct + import ../../http + export http + when HasX11: + import ./x11 + export x11.quitRequested, x11.onQuitRequest, x11.multiClickInterval, x11.multiClickRadius + export x11.X11PixelPresenter, x11.newX11PixelPresenter, x11.drawPixels, + x11.swap, x11.PixelRegion + + proc close*(presenter: x11.X11PixelPresenter) = + ## Release the X11 pixel presenter's GPU resources before its window. + x11.close(presenter) + else: + var + quitRequested*: bool + onQuitRequest*: Callback + type Window* = direct.Window + var selected = when HasX11: X11Backend elif HasFbdev: FbdevBackend else: DrmBackend + + proc setLinuxBackend*(backend: LinuxBackend) = + if backend == selected: return + if direct.hasWindows(): + raise newException(WindyError, "Close windows before changing backend") + when HasX11: + if x11.hasWindows(): + raise newException(WindyError, "Close windows before changing backend") + case backend + of X11Backend: + when not HasX11: raise newException(WindyError, "X11 backend was not compiled") + of FbdevBackend: + when not HasFbdev: raise newException(WindyError, "fbdev backend was not compiled") + of DrmBackend: + when not HasDrm: raise newException(WindyError, "DRM backend was not compiled") + selected = backend + + proc newWindow*(title: string, size: IVec2, style = DecoratedResizable, + visible = true, vsync = true, openglVersion = OpenGL4Dot1, + msaa = msaaDisabled, depthBits = 24, stencilBits = 8, + linuxOptions = LinuxWindowOptions()): Window = + if selected == X11Backend: + when HasX11: + return x11.newWindow(title, size, style, visible, vsync, openglVersion, + msaa, depthBits, stencilBits, linuxOptions) + else: raise newException(WindyError, "X11 backend was not compiled") + if vsync and selected == FbdevBackend: + raise newException(WindyError, "Framebuffer pixel presentation requires vsync=false") + direct.newDirectWindow(title, selected, linuxOptions, vsync) + + proc pollEvents*() = + if selected == X11Backend: + when HasX11: x11.pollEvents() + else: + direct.pollEvents() + pollHttp() + + proc closed*(window: Window): bool = + when HasX11: + if window.direct == nil: return x11.closed(window) + direct.closed(window) + + proc size*(window: Window): IVec2 = + when HasX11: + if window.direct == nil: return x11.size(window) + direct.size(window) + + proc framebufferSize*(window: Window): IVec2 = + when HasX11: + if window.direct == nil: return x11.framebufferSize(window) + direct.framebufferSize(window) + + proc title*(window: Window): string = + when HasX11: + if window.direct == nil: return x11.title(window) + direct.title(window) + + proc contentScale*(window: Window): float32 = + when HasX11: + if window.direct == nil: return x11.contentScale(window) + direct.contentScale(window) + + proc fullscreen*(window: Window): bool = + when HasX11: + if window.direct == nil: return x11.fullscreen(window) + direct.fullscreen(window) + + proc visible*(window: Window): bool = + when HasX11: + if window.direct == nil: return x11.visible(window) + direct.visible(window) + + proc focused*(window: Window): bool = + when HasX11: + if window.direct == nil: return x11.focused(window) + direct.focused(window) + + proc pos*(window: Window): IVec2 = + when HasX11: + if window.direct == nil: return x11.pos(window) + direct.pos(window) + + proc touches*(window: Window): seq[TouchContact] = + when HasX11: + if window.direct == nil: return x11.touches(window) + direct.touches(window) + + proc buttonDown*(window: Window): ButtonView = + when HasX11: + if window.direct == nil: return x11.buttonDown(window) + direct.buttonDown(window) + + proc buttonPressed*(window: Window): ButtonView = + when HasX11: + if window.direct == nil: return x11.buttonPressed(window) + direct.buttonPressed(window) + + proc buttonReleased*(window: Window): ButtonView = + when HasX11: + if window.direct == nil: return x11.buttonReleased(window) + direct.buttonReleased(window) + + proc closeRequested*(window: Window): bool = + when HasX11: + if window.direct == nil: return x11.closeRequested(window) + direct.closeRequested(window) + + proc close*(window: Window) = + when HasX11: + if window.direct == nil: + x11.close(window) + return + direct.close(window) + + proc `title=`*(window: Window, value: string) = + when HasX11: + if window.direct == nil: + x11.`title=`(window, value) + return + direct.`title=`(window, value) + + proc `closeRequested=`*(window: Window, value: bool) = + when HasX11: + if window.direct == nil: + x11.`closeRequested=`(window, value) + return + direct.`closeRequested=`(window, value) + + when defined(useCpu): + proc presentPixels*(window: Window, image: Image) = + when HasX11: + if window.direct == nil: + x11.presentPixels(window, image) + return + direct.presentPixels(window, image) + + proc minimized*(window: Window): bool = + when HasX11: + if window.direct == nil: return x11.minimized(window) + direct.unsupported() + + proc maximized*(window: Window): bool = + when HasX11: + if window.direct == nil: return x11.maximized(window) + direct.unsupported() + + proc style*(window: Window): WindowStyle = + when HasX11: + if window.direct == nil: return x11.style(window) + direct.unsupported() + + proc runeInputEnabled*(window: Window): bool = + when HasX11: + if window.direct == nil: return x11.runeInputEnabled(window) + direct.unsupported() + + proc cursor*(window: Window): Cursor = + when HasX11: + if window.direct == nil: return x11.cursor(window) + direct.unsupported() + + proc mousePos*(window: Window): IVec2 = + when HasX11: + if window.direct == nil: return x11.mousePos(window) + direct.unsupported() + + proc mousePrevPos*(window: Window): IVec2 = + when HasX11: + if window.direct == nil: return x11.mousePrevPos(window) + direct.unsupported() + + proc mouseDelta*(window: Window): IVec2 = + when HasX11: + if window.direct == nil: return x11.mouseDelta(window) + direct.unsupported() + + proc mouseInside*(window: Window): bool = + when HasX11: + if window.direct == nil: return x11.mouseInside(window) + direct.unsupported() + + proc mouseCaptured*(window: Window): bool = + when HasX11: + if window.direct == nil: return x11.mouseCaptured(window) + direct.unsupported() + + proc scrollDelta*(window: Window): Vec2 = + when HasX11: + if window.direct == nil: return x11.scrollDelta(window) + direct.unsupported() + + proc buttonToggle*(window: Window): ButtonView = + when HasX11: + if window.direct == nil: return x11.buttonToggle(window) + direct.unsupported() + + proc imeCursorIndex*(window: Window): int = + when HasX11: + if window.direct == nil: return x11.imeCursorIndex(window) + direct.unsupported() + + proc imeCompositionString*(window: Window): string = + when HasX11: + if window.direct == nil: return x11.imeCompositionString(window) + direct.unsupported() + + proc url*(window: Window): string = + when HasX11: + if window.direct == nil: return x11.url(window) + direct.unsupported() + + proc makeContextCurrent*(window: Window) = + when HasX11: + if window.direct == nil: + x11.makeContextCurrent(window) + return + direct.unsupported() + + proc hasGpuPixelContext*(window: Window): bool = + ## Report an open opt-in X11 GPU drawable without selecting a native backend. + when HasX11: + if window != nil and window.direct == nil: return x11.hasGpuPixelContext(window) + + proc gpuPixelVsync*(window: Window): bool = + ## Report whether X11 swap control enabled vsync for this pixel window. + when HasX11: + if window != nil and window.direct == nil: return x11.gpuPixelVsync(window) + + proc swapBuffers*(window: Window) = + when HasX11: + if window.direct == nil: + x11.swapBuffers(window) + return + direct.unsupported() + + proc centerWindow*(window: Window) = + when HasX11: + if window.direct == nil: + x11.centerWindow(window) + return + direct.unsupported() + + proc focus*(window: Window) = + when HasX11: + if window.direct == nil: + x11.focus(window) + return + direct.unsupported() + + proc captureMouse*(window: Window) = + when HasX11: + if window.direct == nil: + x11.captureMouse(window) + return + direct.unsupported() + + proc releaseMouse*(window: Window) = + when HasX11: + if window.direct == nil: + x11.releaseMouse(window) + return + direct.unsupported() + + proc closeIme*(window: Window) = + when HasX11: + if window.direct == nil: + x11.closeIme(window) + return + direct.unsupported() + + proc `visible=`*(window: Window, value: bool) = + when HasX11: + if window.direct == nil: + x11.`visible=`(window, value) + return + direct.unsupported() + + proc `pos=`*(window: Window, value: IVec2) = + when HasX11: + if window.direct == nil: + x11.`pos=`(window, value) + return + direct.unsupported() + + proc `size=`*(window: Window, value: IVec2) = + when HasX11: + if window.direct == nil: + x11.`size=`(window, value) + return + direct.unsupported() + + proc `maximized=`*(window: Window, value: bool) = + when HasX11: + if window.direct == nil: + x11.`maximized=`(window, value) + return + direct.unsupported() + + proc `minimized=`*(window: Window, value: bool) = + when HasX11: + if window.direct == nil: + x11.`minimized=`(window, value) + return + direct.unsupported() + + proc `fullscreen=`*(window: Window, value: bool) = + when HasX11: + if window.direct == nil: + x11.`fullscreen=`(window, value) + return + direct.unsupported() + + proc `style=`*(window: Window, value: WindowStyle) = + when HasX11: + if window.direct == nil: + x11.`style=`(window, value) + return + direct.unsupported() + + proc `runeInputEnabled=`*(window: Window, value: bool) = + when HasX11: + if window.direct == nil: + x11.`runeInputEnabled=`(window, value) + return + direct.unsupported() + + proc `cursor=`*(window: Window, value: Cursor) = + when HasX11: + if window.direct == nil: + x11.`cursor=`(window, value) + return + direct.unsupported() + + proc `icon=`*(window: Window, value: Image) = + when HasX11: + if window.direct == nil: + x11.`icon=`(window, value) + return + direct.unsupported() + + proc getClipboardString*(): string = + if selected != X11Backend: direct.unsupported() + when HasX11: + return x11.getClipboardString() + else: direct.unsupported() + + proc setClipboardString*(value: string) = + if selected != X11Backend: direct.unsupported() + when HasX11: + x11.setClipboardString(value) + else: direct.unsupported() + + proc getClipboardContentKinds*(): set[ClipboardContentKind] = + if selected != X11Backend: direct.unsupported() + when HasX11: + return x11.getClipboardContentKinds() + else: direct.unsupported() + + proc getClipboardImage*(): Image = + if selected != X11Backend: direct.unsupported() + when HasX11: + return x11.getClipboardImage() + else: direct.unsupported() + + proc getConfigHome*(appName: string): string = + if selected != X11Backend: direct.unsupported() + when HasX11: + return x11.getConfigHome(appName) + else: direct.unsupported() + + proc getConfig*(appName, fileName: string): string = + if selected != X11Backend: direct.unsupported() + when HasX11: + return x11.getConfig(appName, fileName) + else: direct.unsupported() + + proc setConfig*(appName, fileName, content: string) = + if selected != X11Backend: direct.unsupported() + when HasX11: + x11.setConfig(appName, fileName, content) + else: direct.unsupported() + + proc openUrl*(url: string) = + if selected != X11Backend: direct.unsupported() + when HasX11: + x11.openUrl(url) + else: direct.unsupported() + + proc openTempTextFile*(title, text: string) = + if selected != X11Backend: direct.unsupported() + when HasX11: + x11.openTempTextFile(title, text) + else: direct.unsupported() + + proc openFileDialog*(title = "Open File", filters: seq[FileDialogFilter] = @[], defaultPath = ""): string = + if selected != X11Backend: direct.unsupported() + when HasX11: + return x11.openFileDialog(title, filters, defaultPath) + else: direct.unsupported() + + proc saveFileDialog*(title = "Save File", filters: seq[FileDialogFilter] = @[], defaultPath = "", defaultName = ""): string = + if selected != X11Backend: direct.unsupported() + when HasX11: + return x11.saveFileDialog(title, filters, defaultPath, defaultName) + else: direct.unsupported() diff --git a/src/windy/platforms/linux/x11.nim b/src/windy/platforms/linux/x11.nim index 5da0fbb..867dede 100644 --- a/src/windy/platforms/linux/x11.nim +++ b/src/windy/platforms/linux/x11.nim @@ -1,18 +1,26 @@ import - std/[os, osproc, sequtils, sets, strformat, strutils, times, unicode, uri, pathnorm], + std/[math, os, osproc, sequtils, sets, strformat, strutils, times, unicode, uri, pathnorm], ../../[common, internal], - vmath, pixie, - x11/[glx, keysym, x, xevent, xlib, xcursor] + opengl, vmath, pixie, + x11/[glx, keysym, x, xevent, xlib, xcursor], + ./native/pixels import ../../http export http +import ./backends +when HasDirect: + import ./native/state + type XWindow = x.Window Window* = ref object + when HasDirect: + direct*: DirectWindowState onCloseRequest*: Callback onFrame*: Callback + onTouch*: TouchCallback onMove*: Callback onResize*: Callback onFocusChange*: Callback @@ -40,6 +48,8 @@ type handle: XWindow colormap: Colormap ctx: GlxContext + gpuPixelOptIn: bool + gpuPixelVsyncState: bool gc: GC ic: XIC im: XIM @@ -419,11 +429,14 @@ proc releaseMouse*(window: Window) = proc destroy(window: Window) = ## Releases native resources and any active pointer grab. + if window.closed: + return window.releaseMouse() if window.ctx != nil: if glXGetCurrentContext() == window.ctx: - display.glXMakeCurrent(0, nil) + display.glXMakeCurrent(0.Drawable, nil) display.glXDestroyContext(window.ctx) + window.ctx = nil if window.ic != nil: XDestroyIC(window.ic) if window.im != nil: @@ -446,6 +459,14 @@ proc close*(window: Window) = window.onTextEdit = nil destroy window +proc hasGpuPixelContext*(window: Window): bool = + ## True when GPU pixels were opted in, a GLX context is present, and the window is open. + window != nil and window.gpuPixelOptIn and window.ctx != nil and not window.closed + +proc gpuPixelVsync*(window: Window): bool = + ## True only if opt-in GPU vsync was successfully configured. + window != nil and not window.closed and window.gpuPixelVsyncState + proc makeContextCurrent*(window: Window) = display.glXMakeCurrent(window.handle, window.ctx) @@ -772,6 +793,15 @@ proc `cursor=`*(window: Window, v: common.Cursor) = window.state.cursor = v window.applyCursor() +proc hasGlxExtension(name: string): bool = + ## True when the GLX server advertises this exact extension name. + let raw = display.glXQueryExtensionsString(display.defaultScreen) + if raw == nil: + return false + for part in split($raw): + if part == name: + return true + proc createContext( style: WindowStyle, openglVersion: OpenGLVersion, @@ -877,121 +907,171 @@ proc newWindow*( openglVersion = OpenGL4Dot1, msaa = msaaDisabled, depthBits = 24, - stencilBits = 8 + stencilBits = 8, + linuxOptions = LinuxWindowOptions() ): Window = ## Creates a new window and initializes Windy if needed. init() result = Window() result.innerDecorated = true result.state.runeInputEnabled = true - - let - root = display.defaultRootWindow - graphics = createContext( - style, - openglVersion, - msaa, - depthBits, - stencilBits + result.gpuPixelOptIn = linuxOptions.gpuPixels + try: + let root = display.defaultRootWindow + + var vi: XVisualInfo + if linuxOptions.gpuPixels: + let vis = + if style == Transparent: + var attribList = [ + GlxRgba, GlxDoublebuffer, GlxAlphaSize, 8'i32, + GlxRedSize, 8'i32, GlxGreenSize, 8'i32, GlxBlueSize, 8'i32, + GlxDepthSize, 0'i32, GlxStencilSize, 0'i32, + GlxSampleBuffers, 0'i32, 0'i32 + ] + display.glXChooseVisual(display.defaultScreen, attribList[0].addr) + else: + var attribList = [ + GlxRgba, GlxDoublebuffer, + GlxRedSize, 8'i32, GlxGreenSize, 8'i32, GlxBlueSize, 8'i32, + GlxDepthSize, 0'i32, GlxStencilSize, 0'i32, + GlxSampleBuffers, 0'i32, 0'i32 + ] + display.glXChooseVisual(display.defaultScreen, attribList[0].addr) + if vis == nil: + if style == Transparent: + raise WindyError.newException("Transparent GPU pixel windows are not supported") + raise WindyError.newException("Error choosing OpenGL visual") + vi = vis[] + XFree(vis) + if style == Transparent and vi.depth < 32: + raise WindyError.newException("Transparent GPU pixel windows are not supported") + elif not defined(useCpu): + let graphics = createContext(style, openglVersion, msaa, depthBits, stencilBits) + vi = graphics.visual + result.ctx = graphics.context + elif style == Transparent: + display.XMatchVisualInfo(display.defaultScreen, 32, TrueColor, vi.addr) + else: + display.XMatchVisualInfo(display.defaultScreen, 24, TrueColor, vi.addr) + # strangely, glx returns nil when -d:danger + # var attribList = [GlxRgba, GlxDepthSize, 24, GlxDoublebuffer] + # vi = display.glXChooseVisual(display.defaultScreen, attribList[0].addr)[] + + result.colormap = display.XCreateColormap(root, vi.visual, AllocNone) + var swa = XSetWindowAttributes(colormap: result.colormap) + + result.handle = display.XCreateWindow( + root, + 0, 0, + size.x.cuint, size.y.cuint, + 0, + vi.depth.cuint, + InputOutput, + vi.visual, + CwColormap or CwEventMask or CwBorderPixel, + swa.addr ) - vi = graphics.visual - result.ctx = graphics.context - var created = false - defer: - if not created: - result.destroy() - - result.colormap = display.XCreateColormap(root, vi.visual, AllocNone) - var swa = XSetWindowAttributes(colormap: result.colormap) - - result.handle = display.XCreateWindow( - root, - 0, 0, - size.x.cuint, size.y.cuint, - 0, - vi.depth.cuint, - InputOutput, - vi.visual, - CwColormap or CwEventMask or CwBorderPixel, - swa.addr - ) - - display.XSelectInput( - result.handle, - ExposureMask or - KeyPressMask or - KeyReleaseMask or - PointerMotionMask or - ButtonPressMask or - ButtonReleaseMask or - StructureNotifyMask or - EnterWindowMask or - LeaveWindowMask or - FocusChangeMask or - PropertyChangeMask - ) - - var wmProtocols = [xaWMDeleteWindow, xaNetWMSyncRequest] - display.XSetWMProtocols( - result.handle, wmProtocols[0].addr, cint wmProtocols.len - ) - - result.im = display.XOpenIM - result.ic = result.im.XCreateIC( - "clientWindow", - result.handle, - "focusWindow", - result.handle, - "inputStyle", - XimPreeditNothing or XimStatusNothing, - nil - ) - - var gcv: XGCValues - result.gc = display.XCreateGC(result.handle, GCForeground or GCBackground, gcv.addr) - - result.title = title - # Set a default cursor - result.state.cursor = common.Cursor(kind: ArrowCursor) - result.applyCursor() + display.XSelectInput( + result.handle, + ExposureMask or + KeyPressMask or + KeyReleaseMask or + PointerMotionMask or + ButtonPressMask or + ButtonReleaseMask or + StructureNotifyMask or + EnterWindowMask or + LeaveWindowMask or + FocusChangeMask or + PropertyChangeMask + ) - # Enable XDnD awareness (version 5) - const xdndVersion = 5'u32 - result.handle.setProperty(xaXdndAware, xaCardinal, 32, @[xdndVersion].asString) + var wmProtocols = [xaWMDeleteWindow, xaNetWMSyncRequest] + display.XSetWMProtocols( + result.handle, wmProtocols[0].addr, cint wmProtocols.len + ) - makeContextCurrent result + result.im = display.XOpenIM + result.ic = result.im.XCreateIC( + "clientWindow", + result.handle, + "focusWindow", + result.handle, + "inputStyle", + XimPreeditNothing or XimStatusNothing, + nil + ) - if vsync: - if glXSwapIntervalEXT != nil: - display.glXSwapIntervalEXT(result.handle, 1) - elif glXSwapIntervalMESA != nil: - glXSwapIntervalMESA(1) - elif glXSwapIntervalSGI != nil: - glXSwapIntervalSGI(1) - else: - raise WindyError.newException("VSync is not supported") - - if visible: - result.visible = true - - block xsync: - var vEv, vEr: cint - if display.XSyncQueryExtension(vEv.addr, vEr.addr): - var vMaj, vMin: cint - display.XSyncInitialize(vMaj.addr, vMin.addr) - result.xSyncCounter = display.XSyncCreateCounter(XSyncValue()) - result.handle.setProperty( - xaNetWMSyncRequestCounter, - xaCardinal, - 32, - @[result.xSyncCounter].asString - ) + var gcv: XGCValues + result.gc = display.XCreateGC(result.handle, GCForeground or GCBackground, gcv.addr) + + if linuxOptions.gpuPixels: + # The pixel shaders use a compatibility context. + result.ctx = display.glXCreateContext(vi.addr, nil, 1) + if result.ctx == nil: + raise WindyError.newException("Error creating OpenGL context") + + result.title = title + + # Set a default cursor + result.state.cursor = common.Cursor(kind: ArrowCursor) + result.applyCursor() + + # Enable XDnD awareness (version 5) + const xdndVersion = 5'u32 + result.handle.setProperty(xaXdndAware, xaCardinal, 32, @[xdndVersion].asString) + + if linuxOptions.gpuPixels: + makeContextCurrent result + let interval = if vsync: 1'i32 else: 0'i32 + var configured = false + if hasGlxExtension("GLX_EXT_swap_control"): + display.glXSwapIntervalEXT(result.handle, interval) + configured = true + elif hasGlxExtension("GLX_MESA_swap_control"): + if glXSwapIntervalMESA(interval.cuint) == 0: + configured = true + elif vsync and hasGlxExtension("GLX_SGI_swap_control"): + if glXSwapIntervalSGI(interval) == 0: + configured = true + if vsync and not configured: + raise WindyError.newException("VSync is not supported") + result.gpuPixelVsyncState = vsync and configured + elif not defined(useCpu): + makeContextCurrent result + if vsync: + if glXSwapIntervalEXT != nil: + display.glXSwapIntervalEXT(result.handle, 1) + elif glXSwapIntervalMESA != nil: + discard glXSwapIntervalMESA(1) + elif glXSwapIntervalSGI != nil: + discard glXSwapIntervalSGI(1) + else: + raise WindyError.newException("VSync is not supported") + if visible: + result.visible = true + + block xsync: + var vEv, vEr: cint + if display.XSyncQueryExtension(vEv.addr, vEr.addr): + var vMaj, vMin: cint + display.XSyncInitialize(vMaj.addr, vMin.addr) + result.xSyncCounter = display.XSyncCreateCounter(XSyncValue()) + result.handle.setProperty( + xaNetWMSyncRequestCounter, + xaCardinal, + 32, + @[result.xSyncCounter].asString + ) - result.style = style + result.style = style - windows.add result - created = true + windows.add result + except CatchableError: + destroy result + raise proc pollEvents(window: Window) = @@ -1009,8 +1089,8 @@ proc pollEvents(window: Window) = d: Display, event: ptr XEvent, userData: pointer - ): bool {.cdecl.} = - event.any.window == cast[Window](userData).handle + ): cint {.cdecl.} = + cint(event.any.window == cast[Window](userData).handle) template pushButtonEvent( button: Button, @@ -1032,7 +1112,7 @@ proc pollEvents(window: Window) = proc handleRune(window: Window, rune: Rune) = handleRuneTemplate() - while display.XCheckIfEvent(ev.addr, checkEvent, cast[pointer](window)): + while display.XCheckIfEvent(ev.addr, checkEvent, cast[pointer](window)) != 0: case ev.kind of xeClientMessage: @@ -1222,9 +1302,10 @@ proc pollEvents(window: Window) = of xeButtonPress, xeButtonRelease: template pushScrollEvent(delta: Vec2) = - window.perFrame.scrollDelta = delta - if window.onScroll != nil: - window.onScroll() + if ev.kind == xeButtonPress: + window.perFrame.scrollDelta += delta + if window.onScroll != nil: + window.onScroll() let now = getTime() @@ -1448,12 +1529,12 @@ proc processClipboardEvents: bool = d: Display, event: ptr XEvent, userData: pointer - ): bool {.cdecl.} = - event.any.window == cast[XWindow](userData) + ): cint {.cdecl.} = + cint(event.any.window == cast[XWindow](userData)) while display.XCheckIfEvent( ev.addr, checkEvent, cast[pointer](clipboardWindow) - ): + ) != 0: case ev.kind of xeSelection: template e: untyped = ev.selection @@ -1679,3 +1760,78 @@ proc saveFileDialog*( args.add("--file-filter=" & filter.name & " | " & filter.extensions.replace(";", " ").replace(",", " ")) result = runZenity(args) + +proc touches*(window: Window): seq[TouchContact] = + @[] +when defined(useCpu): + const NativeByteOrder = when cpuEndian == littleEndian: 0 else: 1 + + type CpuImage = object + width, height, xoffset, format: cint + data: cstring + byteOrder, bitmapUnit, bitmapBitOrder, bitmapPad, depth: cint + bytesPerLine, bitsPerPixel: cint + redMask, greenMask, blueMask: culong + obdata: pointer + functions: array[6, pointer] + proc createImage(d: Display, visual: ptr Visual, depth: cuint, format: cint, + offset: cint, data: cstring, width, height: cuint, pad, stride: cint): ptr CpuImage + {.cdecl, importc: "XCreateImage", dynlib: libX11.} + proc putImage(d: Display, window: XWindow, gc: GC, image: ptr CpuImage, + sx, sy, dx, dy: cint, width, height: cuint): cint + {.cdecl, importc: "XPutImage", dynlib: libX11.} + proc putPixel(image: ptr CpuImage, x, y: cint, pixel: culong): cint + {.cdecl, importc: "XPutPixel", dynlib: libX11.} + proc destroyImage(image: ptr CpuImage): cint + {.cdecl, importc: "XDestroyImage", dynlib: libX11.} + proc malloc(size: csize_t): pointer {.importc, header: "".} + + proc channel(value: uint8, mask: culong): culong = + var bits = mask + var shift = 0 + while bits != 0 and (bits and 1) == 0: + bits = bits shr 1 + inc shift + ((culong(value) * bits + 127) div 255 shl shift) and mask + + proc presentPixels*(window: Window, image: Image) = + if window.closed: + raise newException(WindyError, "Cannot present to a closed window") + if image == nil or image.width <= 0 or image.height <= 0: + raise newException(WindyError, "Invalid pixel image") + var attributes: XWindowAttributes + display.XGetWindowAttributes(window.handle, addr attributes) + let native = createImage(display, attributes.visual, cuint(attributes.depth), + 2, 0, nil, cuint(image.width), cuint(image.height), 32, 0) + if native == nil: + raise newException(WindyError, "Cannot create X11 pixel image") + defer: discard destroyImage(native) + native.data = cast[cstring](malloc(csize_t(native.bytesPerLine) * csize_t(image.height))) + if native.data == nil: + raise newException(WindyError, "Cannot allocate X11 pixel image") + let direct = native.bitsPerPixel == 32 and native.byteOrder == NativeByteOrder + var channels: array[3, array[256, culong]] + for value in 0 .. 255: + channels[0][value] = channel(uint8(value), native.redMask) + channels[1][value] = channel(uint8(value), native.greenMask) + channels[2][value] = channel(uint8(value), native.blueMask) + let destination = cast[ptr UncheckedArray[byte]](native.data) + for y in 0 ..< image.height: + let row = cast[ptr UncheckedArray[uint32]](addr destination[y * int(native.bytesPerLine)]) + for x in 0 ..< image.width: + let + pixel = image.data[y * image.width + x] + packed = channels[0][pixel.r] or channels[1][pixel.g] or channels[2][pixel.b] + if direct: + row[x] = uint32(packed) + else: + discard putPixel(native, cint(x), cint(y), packed) + discard putImage(display, window.handle, window.gc, native, 0, 0, 0, 0, + cuint(image.width), cuint(image.height)) + display.XFlush() + +proc hasWindows*(): bool = + for window in windows: + if not window.closed: return true + +include x11/pixels diff --git a/src/windy/platforms/linux/x11/glx.nim b/src/windy/platforms/linux/x11/glx.nim index e99aa8c..bd4096d 100644 --- a/src/windy/platforms/linux/x11/glx.nim +++ b/src/windy/platforms/linux/x11/glx.nim @@ -79,5 +79,5 @@ proc glXSwapBuffers*(d: Display, drawable: Drawable) {.libglx.} proc glXSwapIntervalEXT*(d: Display, drawable: Drawable, interval: cint) {.libglx.} -proc glXSwapIntervalMESA*(interval: cint) {.libglx.} -proc glXSwapIntervalSGI*(interval: cint) {.libglx.} +proc glXSwapIntervalMESA*(interval: cuint): cint {.libglx.} +proc glXSwapIntervalSGI*(interval: cint): cint {.libglx.} diff --git a/src/windy/platforms/linux/x11/pixels.nim b/src/windy/platforms/linux/x11/pixels.nim new file mode 100644 index 0000000..aa01232 --- /dev/null +++ b/src/windy/platforms/linux/x11/pixels.nim @@ -0,0 +1,167 @@ +export PixelRegion + +type + X11PixelPresenter* = ref object + window: Window + texture, program, vertices: GLuint + textureWidth, textureHeight: int + closed: bool + +const + VertexSource = """#version 120 +attribute vec2 position; +attribute vec2 uv; +varying vec2 tex; +void main(){gl_Position=vec4(position,0.0,1.0);tex=uv;} +""" + FragmentSource = """#version 120 +varying vec2 tex; +uniform sampler2D image; +void main(){gl_FragColor=vec4(texture2D(image,tex).rgb,1.0);} +""" + MaximumLogBytes = 65536 + +proc checkGl(operation: string) = + ## Report driver errors at the operation that owns them. + let code = glGetError() + if code != GL_NO_ERROR: + raise newException(WindyError, &"{operation}: OpenGL error {code.uint32}.") + +proc compileShader(kind: GLenum, source: string): GLuint = + ## Compile a small pixel shader, preserving the driver's diagnostic on failure. + result = glCreateShader(kind) + var text = source.cstring + glShaderSource(result, 1, cast[cstringArray](addr text), nil) + glCompileShader(result) + var status: GLint + glGetShaderiv(result, GL_COMPILE_STATUS, addr status) + if status == 0: + var length: GLint + glGetShaderiv(result, GL_INFO_LOG_LENGTH, addr length) + var message = newString(clamp(length.int, 1, MaximumLogBytes)) + glGetShaderInfoLog(result, message.len.GLsizei, nil, message.cstring) + glDeleteShader(result) + raise newException(WindyError, "Compiling X11 pixel shader: " & message) + +proc close*(presenter: X11PixelPresenter) = + ## Release GPU storage before its window; repeated close is harmless. + if presenter == nil or presenter.closed: return + if presenter.window != nil and not presenter.window.closed: + presenter.window.makeContextCurrent() + if presenter.texture != 0: glDeleteTextures(1, addr presenter.texture) + if presenter.vertices != 0: glDeleteBuffers(1, addr presenter.vertices) + if presenter.program != 0: glDeleteProgram(presenter.program) + presenter.closed = true + presenter.window = nil + +proc newX11PixelPresenter*(window: Window): X11PixelPresenter = + ## Claim an opt-in double-buffered X11 GPU pixel window on the creating thread. + if window == nil or not window.hasGpuPixelContext: + raise newException(WindyError, "X11 GPU pixels require LinuxWindowOptions(gpuPixels: true).") + window.makeContextCurrent() + loadExtensions() + result = X11PixelPresenter(window: window) + try: + let vertex = compileShader(GL_VERTEX_SHADER, VertexSource) + defer: glDeleteShader(vertex) + let fragment = compileShader(GL_FRAGMENT_SHADER, FragmentSource) + defer: glDeleteShader(fragment) + result.program = glCreateProgram() + glAttachShader(result.program, vertex) + glAttachShader(result.program, fragment) + glBindAttribLocation(result.program, 0, "position") + glBindAttribLocation(result.program, 1, "uv") + glLinkProgram(result.program) + var linked: GLint + glGetProgramiv(result.program, GL_LINK_STATUS, addr linked) + if linked == 0: + var length: GLint + glGetProgramiv(result.program, GL_INFO_LOG_LENGTH, addr length) + var message = newString(clamp(length.int, 1, MaximumLogBytes)) + glGetProgramInfoLog(result.program, message.len.GLsizei, nil, message.cstring) + raise newException(WindyError, "Linking X11 pixel shaders: " & message) + glGenTextures(1, addr result.texture) + glGenBuffers(1, addr result.vertices) + glBindTexture(GL_TEXTURE_2D, result.texture) + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST.GLint) + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST.GLint) + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE.GLint) + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE.GLint) + checkGl("Initializing X11 pixel presenter") + except: + result.close() + raise + +proc drawPixels*(presenter: X11PixelPresenter, image: Image, region: PixelRegion, + changed = true, scale = 0, fitDown = false) = + ## Upload opaque native RGBA and draw one integer-scaled crop into the back buffer. + if presenter == nil or presenter.closed or presenter.window.closed: + raise newException(WindyError, "X11 pixel presenter is closed.") + if scale < 0: raise newException(ValueError, "Pixel scale cannot be negative.") + let + width = if image == nil: 0 else: image.width + height = if image == nil: 0 else: image.height + size = presenter.window.framebufferSize + validateRegions(width, height, [region, PixelRegion()]) + if size.x <= 0 or size.y <= 0: return + presenter.window.makeContextCurrent() + glViewport(0, 0, size.x.GLsizei, size.y.GLsizei) + glDisable(GL_BLEND) + glDisable(GL_DEPTH_TEST) + glDisable(GL_STENCIL_TEST) + glDisable(GL_SCISSOR_TEST) + glDisable(GL_DITHER) + glClearColor(0, 0, 0, 1) + glClear(GL_COLOR_BUFFER_BIT) + if region != PixelRegion(): + var viewport = pixelViewport(region, size.x.int, size.y.int) + if scale > 0: + if size.x.int div region.width < scale or size.y.int div region.height < scale: + raise newException(ValueError, "Window is too small for the requested scale.") + viewport = PixelRegion(width: region.width * scale, height: region.height * scale) + viewport.x = (size.x.int - viewport.width) div 2 + viewport.y = (size.y.int - viewport.height) div 2 + elif fitDown and (region.width > size.x.int or region.height > size.y.int): + let factor = min(size.x.float64 / region.width.float64, size.y.float64 / region.height.float64) + viewport.width = max(1, floor(region.width.float64 * factor).int) + viewport.height = max(1, floor(region.height.float64 * factor).int) + viewport.x = (size.x.int - viewport.width) div 2 + viewport.y = (size.y.int - viewport.height) div 2 + glActiveTexture(GL_TEXTURE0) + glBindTexture(GL_TEXTURE_2D, presenter.texture) + glPixelStorei(GL_UNPACK_ALIGNMENT, 1) + if width != presenter.textureWidth or height != presenter.textureHeight: + static: doAssert sizeof(ColorRGBX) == 4 + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8.GLint, width.GLsizei, height.GLsizei, + 0, GL_RGBA, GL_UNSIGNED_BYTE, unsafeAddr image.data[0]) + presenter.textureWidth = width + presenter.textureHeight = height + elif changed: + glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, width.GLsizei, height.GLsizei, + GL_RGBA, GL_UNSIGNED_BYTE, unsafeAddr image.data[0]) + var data = pixelVertices(width, height, [region, PixelRegion()]) + glUseProgram(presenter.program) + glUniform1i(glGetUniformLocation(presenter.program, "image"), 0) + glBindBuffer(GL_ARRAY_BUFFER, presenter.vertices) + glBufferData(GL_ARRAY_BUFFER, 24 * sizeof(cfloat), addr data[0], GL_STREAM_DRAW) + glVertexAttribPointer(0, 2, cGL_FLOAT, GL_FALSE, (4 * sizeof(cfloat)).GLsizei, nil) + glVertexAttribPointer(1, 2, cGL_FLOAT, GL_FALSE, (4 * sizeof(cfloat)).GLsizei, + cast[pointer](2 * sizeof(cfloat))) + glEnableVertexAttribArray(0) + glEnableVertexAttribArray(1) + glViewport(viewport.x.GLint, (size.y.int - viewport.y - viewport.height).GLint, + viewport.width.GLsizei, viewport.height.GLsizei) + glDrawArrays(GL_TRIANGLES, 0, 6) + glDisableVertexAttribArray(0) + glDisableVertexAttribArray(1) + glUseProgram(0) + glBindBuffer(GL_ARRAY_BUFFER, 0) + glFlush() + checkGl("Drawing X11 pixels") + +proc swap*(presenter: X11PixelPresenter) = + ## Swap a completed back buffer after all windows in the frame have been drawn. + if presenter == nil or presenter.closed or presenter.window.closed: + raise newException(WindyError, "X11 pixel presenter is closed.") + presenter.window.makeContextCurrent() + presenter.window.swapBuffers() diff --git a/src/windy/platforms/linux/x11/xevent.nim b/src/windy/platforms/linux/x11/xevent.nim index c897b92..9ad8478 100644 --- a/src/windy/platforms/linux/x11/xevent.nim +++ b/src/windy/platforms/linux/x11/xevent.nim @@ -5,7 +5,7 @@ type d: Display, event: ptr XEvent, p: pointer - ): bool {.cdecl.} + ): cint {.cdecl.} ErrorHandleProc* = proc (d: Display, event: ptr XErrorEvent): cint {.cdecl.} XEventKind* {.size: cint.sizeof.} = enum @@ -432,7 +432,7 @@ type {.push, cdecl, dynlib: libX11, importc.} proc XCheckIfEvent*(d: Display; e: ptr XEvent, cb: IfEventProc, - userData: pointer): bool + userData: pointer): cint proc XSendEvent*(d: Display; window: Window, propogate: bool, mask: clong, e: ptr XEvent) proc Xutf8LookupString*(ic: XIC, e: ptr XKeyEvent, buffer: cstring, len: cint, diff --git a/src/windy/platforms/macos/platform.nim b/src/windy/platforms/macos/platform.nim index 44f6522..79eec0d 100644 --- a/src/windy/platforms/macos/platform.nim +++ b/src/windy/platforms/macos/platform.nim @@ -19,6 +19,7 @@ type Window* = ref object onCloseRequest*: Callback onFrame*: Callback + onTouch*: TouchCallback onMove*: Callback onResize*: Callback onFocusChange*: Callback @@ -1789,3 +1790,6 @@ proc saveFileDialog*( let url = panel.URL() if url.int != 0: result = $url.path() + +proc touches*(window: Window): seq[TouchContact] = + @[] diff --git a/src/windy/platforms/win32/platform.nim b/src/windy/platforms/win32/platform.nim index 3ecf022..16d1416 100644 --- a/src/windy/platforms/win32/platform.nim +++ b/src/windy/platforms/win32/platform.nim @@ -65,6 +65,7 @@ type Window* = ref object onCloseRequest*: Callback onFrame*: Callback + onTouch*: TouchCallback onMove*: Callback onResize*: Callback onFocusChange*: Callback @@ -2839,3 +2840,6 @@ proc openTempTextFile*(title, text: string) = createDir("tmp") writeFile("tmp/" & title, text) discard execShellCmd("notepad tmp/" & title) + +proc touches*(window: Window): seq[TouchContact] = + @[] diff --git a/tests/bench_native_upload.nim b/tests/bench_native_upload.nim new file mode 100644 index 0000000..933801f --- /dev/null +++ b/tests/bench_native_upload.nim @@ -0,0 +1,18 @@ +import + std/[monotimes, os, times], + pixie, + windy/platforms/linux/drm + +proc main() = + doAssert paramCount() == 1, "Choose a DRM connector." + let display = openDrm("/dev/dri/card0") + defer: display.close() + display.openOutput(@[paramStr(1)], 0) + let image = newImage(display.screens[0].width, display.screens[0].height) + image.fill(color(0.2, 0.4, 0.8, 1)) + let started = getMonoTime() + for iteration in 0 ..< 20: + display.upload(0, image) + echo "Upload mean us: ", (getMonoTime() - started).inMicroseconds div 20 + +main() diff --git a/tests/config.nims b/tests/config.nims index 2105526..5050cf6 100644 --- a/tests/config.nims +++ b/tests/config.nims @@ -2,3 +2,27 @@ when defined(emscripten): include "../examples/config.nims" + +import std/os +if projectName() in ["manual_drm_gpu", "manual_drm_pixelpair"] and not defined(debug): + # Measure the same shipped settings as the graphical applications. + switch("define", "release") + switch("define", "danger") +let testBackends = getEnv("WINDY_TEST_BACKENDS") +if projectName() == "test_x11_pixels": + switch("define", "useCpu") + switch("define", "windyBackends=" & (if testBackends.len > 0: testBackends else: "x11")) +if projectName() == "test_drm_pixelpair" and testBackends.len == 0: + switch("define", "useCpu") + switch("define", "windyBackends=drm") +if testBackends.len > 0: + switch("define", "useCpu") + switch("define", "windyBackends=" & testBackends) + +if getEnv("WINDY_TEST_TARGET") == "arm": + switch("cpu", "arm") +elif getEnv("WINDY_TEST_TARGET") == "arm64": + switch("cpu", "arm64") + switch("gcc.exe", getEnv("MONO_RETRO_CC", "aarch64-unknown-linux-gnu-gcc")) + switch("gcc.linkerexe", getEnv("MONO_RETRO_CC", "aarch64-unknown-linux-gnu-gcc")) + switch("passL", "-Wl,--dynamic-linker=/lib/ld-linux-aarch64.so.1") diff --git a/tests/drm_gpu_patterns.nim b/tests/drm_gpu_patterns.nim new file mode 100644 index 0000000..f86bfcb --- /dev/null +++ b/tests/drm_gpu_patterns.nim @@ -0,0 +1,93 @@ +type + PixelLayout* = enum + xbgrPixels, xrgbPixels + Viewport* = object + x*, y*, width*, height*: int + PairedFlip* = object + crtcs*: array[2, uint32] + completed*: set[range[0..1]] + timestamps*: array[2, uint64] + +const + SourceWidth* = 256 + +proc fit*(width, height, panelWidth, panelHeight: int): Viewport = + ## Center an image inside a panel using an integer scale. + if min(width, height) <= 0 or min(panelWidth, panelHeight) <= 0: + raise newException(ValueError, "Dimensions must be positive") + let scale = min(panelWidth div width, panelHeight div height) + if scale < 1: + raise newException(ValueError, "Source does not fit the panel") + result.width = width * scale + result.height = height * scale + result.x = (panelWidth - result.width) div 2 + result.y = (panelHeight - result.height) div 2 + +proc pixel*(x, y, frame, panelHeight: int): uint32 = + ## Generate opaque colour bars, single-pixel checks and a moving white seam. + let + panel = y div panelHeight + localY = y mod panelHeight + bars = [0xff0000ff'u32, 0xff00ff00'u32, 0xffff0000'u32, 0xffffffff'u32] + if x == frame mod SourceWidth or localY == frame mod panelHeight: + return 0xffffffff'u32 + if localY < panelHeight div 3: + return bars[(x div 64 + panel) mod bars.len] + if localY < panelHeight * 2 div 3: + return if (x + localY) mod 2 == 0: 0xffffffff'u32 else: 0xff000000'u32 + if panel == 0: 0xff00ffff'u32 else: 0xffffff00'u32 + +proc pack*(rgba: uint32, layout: PixelLayout): uint32 = + ## Preserve XBGR bytes or exchange red and blue for a standard XRGB source. + if layout == xbgrPixels: rgba + else: (rgba and 0xff00ff00'u32) or (rgba and 255) shl 16 or (rgba shr 16 and 255) + +proc generate*(data: var seq[uint32], panelHeight, frame: int, layout: PixelLayout) = + ## Fill one combined frame with an identical animation for both benchmarks. + data.setLen(SourceWidth * panelHeight * 2) + for y in 0 ..< panelHeight * 2: + for x in 0 ..< SourceWidth: + data[y * SourceWidth + x] = pack(pixel(x, y, frame, panelHeight), layout) + +proc complete*(pair: var PairedFlip, crtc: uint32, timestamp: uint64) = + ## Record exactly one completion per output before its buffer can be retired. + for index in 0..1: + if pair.crtcs[index] == crtc: + if index in pair.completed: + raise newException(ValueError, "Duplicate flip completion") + pair.completed.incl index + pair.timestamps[index] = timestamp + return + raise newException(ValueError, "Completion from an unexpected CRTC") + +proc ready*(pair: PairedFlip): bool = + ## Return whether both consumers have finished the paired update. + pair.completed == {0, 1} + +proc scalePanel*[T](source: seq[uint32], panel, sourceHeight: int, layout: PixelLayout, + output: var seq[T], width, height: int) = + ## Match the player's CPU row expansion, row copies and border-only clears. + let + view = fit(SourceWidth, sourceHeight, width, height) + scale = view.width div SourceWidth + static: doAssert sizeof(T) == sizeof(uint32) + output.setLen(width * height) + for y in 0 ..< height: + if y < view.y or y >= view.y + view.height: + for x in 0 ..< width: output[y * width + x] = cast[T](0xff000000'u32) + else: + for x in 0 ..< view.x: output[y * width + x] = cast[T](0xff000000'u32) + for x in view.x + view.width ..< width: output[y * width + x] = cast[T](0xff000000'u32) + for sourceY in 0 ..< sourceHeight: + let target = (view.y + sourceY * scale) * width + view.x + template expandRow(factor: int) = + for sourceX in 0 ..< SourceWidth: + let colour = pack(source[(panel * sourceHeight + sourceY) * SourceWidth + sourceX], layout) + for repeat in 0 ..< factor: output[target + sourceX * factor + repeat] = cast[T](colour) + case scale + of 2: expandRow(2) + of 3: expandRow(3) + of 4: expandRow(4) + else: expandRow(scale) + for repeat in 1 ..< scale: + copyMem(addr output[target + repeat * width], addr output[target], view.width * sizeof(uint32)) diff --git a/tests/manual_direct_displays.nim b/tests/manual_direct_displays.nim new file mode 100644 index 0000000..33335f2 --- /dev/null +++ b/tests/manual_direct_displays.nim @@ -0,0 +1,39 @@ +import + std/os, + pixie, windy + +proc main() = + let useDrm = paramStr(1) == "drm" + if useDrm: + doAssert paramCount() == 3, "Choose two DRM connectors." + setLinuxBackend(if useDrm: DrmBackend else: FbdevBackend) + for attempt in 0 .. 1: + let first = newWindow("First output", ivec2(0, 0), vsync = useDrm, + linuxOptions = LinuxWindowOptions(output: paramStr(2))) + var image = newImage(first.framebufferSize.x, first.framebufferSize.y) + image.fill(color(1, 0, 0, 1)) + let firstImage = image + first.presentPixels(image) + if useDrm: + let second = newWindow("Second output", ivec2(0, 0), vsync = true, + linuxOptions = LinuxWindowOptions(output: paramStr(3))) + image = newImage(second.framebufferSize.x, second.framebufferSize.y) + image.fill(color(0, 1, 0, 1)) + second.presentPixels(image) + second.presentPixels(image) + var rejected = false + try: + discard newWindow("Duplicate", ivec2(0, 0), vsync = false, + linuxOptions = LinuxWindowOptions(output: paramStr(3))) + except IOError: rejected = true + doAssert rejected + first.presentPixels(firstImage) + first.close() + image.fill(color(0, 0, 1, 1)) + second.presentPixels(image) + second.close() + else: + first.close() + doAssert first.closed + +main() diff --git a/tests/manual_drm_gpu.nim b/tests/manual_drm_gpu.nim new file mode 100644 index 0000000..bf83a31 --- /dev/null +++ b/tests/manual_drm_gpu.nim @@ -0,0 +1,665 @@ +# This diagnostic deliberately keeps experimental bindings outside Windy's API. +import + std/[algorithm, parseutils], + ./drm_gpu_patterns + +include windy/platforms/linux/drm + +type + GbmHandle {.union.} = object + pointerValue: pointer + u32: uint32 + u64: uint64 + AtomicRequest = pointer + AtomicApi = object + alloc: proc(): AtomicRequest {.cdecl.} + free: proc(request: AtomicRequest) {.cdecl.} + add: proc(request: AtomicRequest, id, property: uint32, value: uint64): cint {.cdecl.} + commit: proc(fd: cint, request: AtomicRequest, flags: uint32, data: pointer): cint {.cdecl.} + createBlob: proc(fd: cint, data: pointer, size: csize_t, id: ptr uint32): cint {.cdecl.} + destroyBlob: proc(fd: cint, id: uint32): cint {.cdecl.} + AtomicValue = object + id, property: uint32 + value: uint64 + PairEvents = object + version: cint + vblank, flip: FlipHandler + flip2: proc(fd: cint, sequence, seconds, microseconds, crtc: cuint, + data: pointer) {.cdecl.} + Probe = ref object + display: DrmDisplay + atomic: AtomicApi + restore, properties: seq[AtomicValue] + blobs: seq[uint32] + planes: array[2, uint32] + fbProperties: array[2, uint32] + pair: PairedFlip + armed, pending: bool + callbackError: string + gbm, surface, egl, context, eglSurface: pointer + program, texture, vertices: uint32 + gbmFrames: seq[tuple[bo: pointer, fb: uint32]] + locked: seq[pointer] + front: pointer + pixels: seq[uint32] + panelWidth, panelHeight, sourceHeight: int + layout: PixelLayout + skew: seq[float64] + Samples = object + generate, upload, render, submit, wait: seq[float64] + draw, swap, finish, acquire: seq[float64] + +const + PlaneObject = 0xeeeeeeee'u32 + CrtcObject = 0xcccccccc'u32 + ConnectorObject = 0xc0c0c0c0'u32 + AtomicTest = 0x100'u32 + AtomicNonblock = 0x200'u32 + AllowModeset = 0x400'u32 + Xrgb8888 = 0x34325258'u32 + GlTexture = 0x0de1'u32 + GlRgba = 0x1908'u32 + GlByte = 0x1401'u32 + GlFloat = 0x1406'u32 + GlTriangles = 4'u32 + GlColourBuffer = 0x4000'u32 + GlRenderer = 0x1f01'u32 + GlVersion = 0x1f02'u32 + GlVertexShader = 0x8b31'u32 + GlFragmentShader = 0x8b30'u32 + GlCompileStatus = 0x8b81'u32 + GlLinkStatus = 0x8b82'u32 + GlMagFilter = 0x2800'u32 + GlMinFilter = 0x2801'u32 + GlWrapS = 0x2802'u32 + GlWrapT = 0x2803'u32 + GlNearest = 0x2600.cint + GlClampEdge = 0x812f.cint + EglGbm = 0x31d7'u32 + EglEsApi = 0x30a0'u32 + EglNativeVisual = 0x302e.cint + EglClientVersion = 0x3098.cint + EglDepthSize = 0x3025.cint + EglStencilSize = 0x3026.cint + EglSamples = 0x3031.cint + EglSampleBuffers = 0x3032.cint + AtomicCapability = 3'u64 + PrimaryPlane = 1'u64 + GbmScanoutRendering = 5'u32 + GlArrayBuffer = 0x8892'u32 + GlStaticDraw = 0x88e4'u32 + VerticesPerPanel = 6 + VertexStride = 4 * sizeof(cfloat) + EglNone = 0x3038.cint + EglConfigAttributes = [0x3033.cint, 4, 0x3040, 4, 0x3024, 8, + 0x3023, 8, 0x3022, 8, 0x3025, 0, 0x3026, 0, EglNone] + VertexSource = "attribute vec2 position; attribute vec2 uv; varying vec2 tex; void main(){tex=uv;gl_Position=vec4(position,0.0,1.0);}" + FragmentSource = "precision highp float; varying vec2 tex; uniform sampler2D image; uniform float swapRB; void main(){vec4 c=texture2D(image,tex);gl_FragColor=vec4(mix(c.rgb,c.bgr,swapRB),1.0);}" + +static: + doAssert cpuEndian == littleEndian + doAssert sizeof(GbmHandle) == 8 + doAssert sizeof(PairEvents) == 32 + +{.push cdecl, importc, dynlib: "libgbm.so.1".} +proc gbm_create_device(fd: cint): pointer +proc gbm_device_destroy(device: pointer) +proc gbm_surface_create_with_modifiers2(device: pointer, width, height, format: uint32, + modifiers: ptr uint64, count, flags: uint32): pointer +proc gbm_surface_destroy(surface: pointer) +proc gbm_surface_lock_front_buffer(surface: pointer): pointer +proc gbm_surface_release_buffer(surface, bo: pointer) +proc gbm_bo_get_handle(bo: pointer): GbmHandle +proc gbm_bo_get_stride(bo: pointer): uint32 +proc gbm_bo_get_modifier(bo: pointer): uint64 +{.pop.} + +{.push cdecl, importc, dynlib: "libEGL.so.1".} +proc eglGetPlatformDisplay(platform: cuint, native: pointer, attributes: pointer): pointer +proc eglInitialize(display: pointer, major, minor: ptr cint): cuint +proc eglBindAPI(api: cuint): cuint +proc eglChooseConfig(display: pointer, attributes: ptr cint, configs: ptr pointer, + count: cint, found: ptr cint): cuint +proc eglGetConfigAttrib(display, config: pointer, attribute: cint, value: ptr cint): cuint +proc eglCreateContext(display, config, share: pointer, attributes: ptr cint): pointer +proc eglCreateWindowSurface(display, config, window: pointer, attributes: pointer): pointer +proc eglMakeCurrent(display, draw, read, context: pointer): cuint +proc eglSwapInterval(display: pointer, interval: cint): cuint +proc eglSwapBuffers(display, surface: pointer): cuint +proc eglDestroyContext(display, context: pointer): cuint +proc eglDestroySurface(display, surface: pointer): cuint +proc eglTerminate(display: pointer): cuint +proc eglGetError(): cuint +{.pop.} + +{.push cdecl, importc, dynlib: "libGLESv2.so.2".} +proc glGetString(name: cuint): cstring +proc glCreateShader(kind: cuint): cuint +proc glShaderSource(shader: cuint, count: cint, source: ptr cstring, lengths: pointer) +proc glCompileShader(shader: cuint) +proc glGetShaderiv(shader, name: cuint, value: ptr cint) +proc glGetShaderInfoLog(shader: cuint, size: cint, length: ptr cint, log: cstring) +proc glDeleteShader(shader: cuint) +proc glCreateProgram(): cuint +proc glAttachShader(program, shader: cuint) +proc glBindAttribLocation(program, index: cuint, name: cstring) +proc glLinkProgram(program: cuint) +proc glGetProgramiv(program, name: cuint, value: ptr cint) +proc glGetProgramInfoLog(program: cuint, size: cint, length: ptr cint, log: cstring) +proc glDeleteProgram(program: cuint) +proc glUseProgram(program: cuint) +proc glGetUniformLocation(program: cuint, name: cstring): cint +proc glUniform1f(location: cint, value: cfloat) +proc glUniform1i(location, value: cint) +proc glGenTextures(count: cint, textures: ptr cuint) +proc glDeleteTextures(count: cint, textures: ptr cuint) +proc glGenBuffers(count: cint, buffers: ptr cuint) +proc glBindBuffer(target, buffer: cuint) +proc glBufferData(target: cuint, size: int, data: pointer, usage: cuint) +proc glDeleteBuffers(count: cint, buffers: ptr cuint) +proc glBindTexture(target, texture: cuint) +proc glTexParameteri(target, name: cuint, value: cint) +proc glTexImage2D(target: cuint, level, internal, width, height, border: cint, + format, kind: cuint, data: pointer) +proc glTexSubImage2D(target: cuint, level, x, y, width, height: cint, + format, kind: cuint, data: pointer) +proc glViewport(x, y, width, height: cint) +proc glClearColor(red, green, blue, alpha: cfloat) +proc glClear(mask: cuint) +proc glEnableVertexAttribArray(index: cuint) +proc glVertexAttribPointer(index: cuint, size: cint, kind: cuint, normalized: uint8, + stride: cint, data: pointer) +proc glDrawArrays(mode: cuint, first, count: cint) +proc glFinish() +proc glReadPixels(x, y, width, height: cint, format, kind: cuint, data: pointer) +proc glGetError(): cuint +{.pop.} + +proc checked(value: bool, operation: string) = + ## Fail with the operation and current system error instead of continuing. + if not value: raise newException(IOError, operation & ": " & $osLastError()) + +proc checkedEgl(value: cuint, operation: string) = + ## Include EGL's own error code when context or buffer operations fail. + if value == 0: raise newException(IOError, operation & ": EGL " & $eglGetError()) + +proc loadAtomic(display: DrmDisplay): AtomicApi = + ## Load experimental entry points without changing the normal DRM backend. + template bindField(field: untyped, name: string) = + result.field = cast[type(result.field)](symAddr(display.api.library, name)) + checked(result.field != nil, name) + bindField(alloc, "drmModeAtomicAlloc") + bindField(free, "drmModeAtomicFree") + bindField(add, "drmModeAtomicAddProperty") + bindField(commit, "drmModeAtomicCommit") + bindField(createBlob, "drmModeCreatePropertyBlob") + bindField(destroyBlob, "drmModeDestroyPropertyBlob") + +proc property(probe: Probe, id, kind: uint32, name: string): AtomicValue = + ## Find a property by name and retain its original value for restoration. + let + api = probe.display.api + values = api.getProperties(probe.display.fd, id, kind) + checked(values != nil, "Reading properties") + defer: api.freeProperties(values) + for index in 0 ..< int(values.count): + let item = api.getProperty(probe.display.fd, values.ids[index]) + checked(item != nil, "Reading property") + let matches = $cast[cstring](addr item.name[0]) == name + api.freeProperty(item) + if matches: + return AtomicValue(id: id, property: values.ids[index], value: values.values[index]) + raise newException(IOError, "Missing DRM property " & name & " on " & $id) + +proc set(probe: Probe, id, kind: uint32, name: string, value: uint64) = + ## Append an atomic update and the matching original state exactly once. + let previous = probe.property(id, kind, name) + probe.restore.add previous + probe.properties.add AtomicValue(id: id, property: previous.property, value: value) + +proc blob(probe: Probe, mode: var ModeInfo): uint32 = + ## Retain a mode blob until after the display has been restored. + checked(probe.atomic.createBlob(probe.display.fd, addr mode, csize_t(sizeof(mode)), addr result) == 0, + "Creating mode blob") + probe.blobs.add result + +proc configure(probe: Probe) = + ## Discover primary planes and construct one transaction for two active outputs. + let + display = probe.display + api = display.api + planes = api.getPlaneResources(display.fd) + resources = api.getResources(display.fd) + checked(planes != nil and resources != nil, "Enumerating planes") + defer: + api.freePlaneResources(planes) + api.freeResources(resources) + for index, screen in display.screens: + var crtcIndex = -1 + for candidate in 0 ..< int(resources.countCrtcs): + if resources.crtcs[candidate] == screen.crtc: crtcIndex = candidate + checked(crtcIndex >= 0, "Finding CRTC index") + for candidate in 0 ..< int(planes.count): + let plane = api.getPlane(display.fd, planes.planes[candidate]) + checked(plane != nil, "Reading plane") + defer: api.freePlane(plane) + if (plane.possibleCrtcs and (1'u32 shl crtcIndex)) == 0: continue + if probe.property(plane.id, PlaneObject, "type").value != PrimaryPlane: continue + if plane.crtc notin [0'u32, screen.crtc]: continue + if plane.id in probe.planes: continue + var compatible = false + for format in 0 ..< int(plane.countFormats): + if plane.formats[format] == Xrgb8888: compatible = true + if compatible: + probe.planes[index] = plane.id + break + checked(probe.planes[index] != 0, "Finding compatible primary plane") + let plane = probe.planes[index] + probe.pair.crtcs[index] = screen.crtc + probe.set(screen.connector, ConnectorObject, "CRTC_ID", screen.crtc) + probe.set(screen.crtc, CrtcObject, "ACTIVE", 1) + probe.set(screen.crtc, CrtcObject, "MODE_ID", probe.blob(screen.mode)) + # Copy an active original mode into our own blob instead of retaining its ID. + if screen.original.modeValid != 0: + probe.restore[^1].value = probe.blob(screen.original.mode) + probe.set(plane, PlaneObject, "CRTC_ID", screen.crtc) + probe.set(plane, PlaneObject, "FB_ID", 0) + probe.fbProperties[index] = probe.properties[^1].property + probe.set(plane, PlaneObject, "SRC_X", 0) + probe.set(plane, PlaneObject, "SRC_Y", uint64(index * probe.panelHeight) shl 16) + probe.set(plane, PlaneObject, "SRC_W", uint64(probe.panelWidth) shl 16) + probe.set(plane, PlaneObject, "SRC_H", uint64(probe.panelHeight) shl 16) + probe.set(plane, PlaneObject, "CRTC_X", 0) + probe.set(plane, PlaneObject, "CRTC_Y", 0) + probe.set(plane, PlaneObject, "CRTC_W", uint64(probe.panelWidth)) + probe.set(plane, PlaneObject, "CRTC_H", uint64(probe.panelHeight)) + echo screen.name, ": CRTC ", screen.crtc, ", plane ", plane + +proc commit(probe: Probe, values: seq[AtomicValue], flags: uint32, data: pointer = nil) = + ## Submit a complete transaction or leave the current state unchanged on error. + let request = probe.atomic.alloc() + checked(request != nil, "Allocating atomic request") + defer: probe.atomic.free(request) + for item in values: + checked(probe.atomic.add(request, item.id, item.property, item.value) >= 0, "Adding atomic property") + checked(probe.atomic.commit(probe.display.fd, request, flags, data) == 0, "Committing paired display update") + +proc flip2(fd: cint, sequence, seconds, microseconds, crtc: cuint, data: pointer) {.cdecl.} = + ## Handle both events without allowing a Nim exception across a C callback. + let probe = cast[Probe](data) + try: + probe.pair.complete(crtc, uint64(seconds) * 1_000_000 + uint64(microseconds)) + except ValueError as error: + probe.callbackError = error.msg + +proc waitPair(probe: Probe) = + ## Retire a generation only after both display controllers have completed it. + if not probe.pending: return + let started = getMonoTime() + var context = PairEvents(version: 3, flip2: flip2) + while not probe.pair.ready: + let remaining = FlipTimeoutMs - int((getMonoTime() - started).inMilliseconds) + checked(remaining > 0, "Timed out waiting for paired flip") + var descriptor = TPollfd(fd: probe.display.fd, events: POLLIN) + let polled = posix.poll(addr descriptor, 1, cint(remaining)) + if polled < 0 and osLastError() == OSErrorCode(EINTR): continue + checked(polled > 0 and (descriptor.revents and POLLIN) != 0, "Polling paired flip") + checked(probe.display.api.handleEvent(probe.display.fd, cast[ptr EventContext](addr context)) == 0, + "Reading paired flip") + if probe.callbackError.len > 0: raise newException(IOError, probe.callbackError) + probe.pending = false + let timestamps = probe.pair.timestamps + probe.skew.add abs(timestamps[0].float64 - timestamps[1].float64) / 1000 + +proc shader(kind: uint32, source: string): uint32 = + ## Compile a shader and report the driver's compiler log on failure. + result = glCreateShader(kind) + var + text = source.cstring + compiled: cint + glShaderSource(result, 1, addr text, nil) + glCompileShader(result) + glGetShaderiv(result, GlCompileStatus, addr compiled) + if compiled == 0: + var log = newString(4096) + glGetShaderInfoLog(result, cint(log.len), nil, log.cstring) + glDeleteShader(result) + raise newException(IOError, "Compiling shader: " & log) + +proc openGpu(probe: Probe) = + ## Create one EGL context and one linear GBM render target for both panels. + probe.gbm = gbm_create_device(probe.display.fd) + checked(probe.gbm != nil, "Creating GBM device") + var linear = 0'u64 + probe.surface = gbm_surface_create_with_modifiers2(probe.gbm, uint32(probe.panelWidth), + uint32(probe.panelHeight * 2), Xrgb8888, addr linear, 1, GbmScanoutRendering) + checked(probe.surface != nil, "Creating linear combined GBM surface") + probe.egl = eglGetPlatformDisplay(EglGbm, probe.gbm, nil) + checked(probe.egl != nil, "Opening GBM EGL display") + checkedEgl(eglInitialize(probe.egl, nil, nil), "Initializing EGL") + checkedEgl(eglBindAPI(EglEsApi), "Selecting OpenGL ES") + var + attributes = EglConfigAttributes + count: cint + selected: pointer + checkedEgl(eglChooseConfig(probe.egl, addr attributes[0], nil, 0, addr count), "Counting EGL configs") + checked(count > 0, "Expected a window-compatible EGL config") + var + configs = newSeq[pointer](count) + sizes: array[4, cint] + checkedEgl(eglChooseConfig(probe.egl, addr attributes[0], addr configs[0], count, addr count), "Choosing EGL config") + for index in 0 ..< int(count): + var visual: cint + checkedEgl(eglGetConfigAttrib(probe.egl, configs[index], EglNativeVisual, addr visual), "Reading EGL visual") + if uint32(visual) != Xrgb8888: continue + for field, name in [EglDepthSize, EglStencilSize, EglSamples, EglSampleBuffers]: + checkedEgl(eglGetConfigAttrib(probe.egl, configs[index], name, addr sizes[field]), "Reading EGL buffers") + # EGL's zero attributes specify minimum sizes, not the absence of MSAA/depth. + if sizes == [0.cint, 0, 0, 0]: + selected = configs[index] + break + checked(selected != nil, "Finding XRGB EGL config without depth, stencil or MSAA") + echo "EGL depth/stencil/samples/sample buffers: ", sizes + var contextAttributes = [EglClientVersion, 2.cint, EglNone] + probe.context = eglCreateContext(probe.egl, selected, nil, addr contextAttributes[0]) + checked(probe.context != nil, "Creating GLES2 context") + probe.eglSurface = eglCreateWindowSurface(probe.egl, selected, probe.surface, nil) + checked(probe.eglSurface != nil, "Creating EGL window surface") + checkedEgl(eglMakeCurrent(probe.egl, probe.eglSurface, probe.eglSurface, probe.context), "Making context current") + checkedEgl(eglSwapInterval(probe.egl, 0), "Disabling EGL's separate refresh wait") + let + renderer = $glGetString(GlRenderer) + version = $glGetString(GlVersion) + echo "GPU: ", renderer, "; GLES: ", version + checked(renderer.len > 0, "Expected a hardware renderer") + for software in ["llvmpipe", "softpipe", "swrast", "swiftshader"]: + checked(software notin renderer.toLowerAscii, "Software rendering is not a GPU proof: " & renderer) + let + vertex = shader(GlVertexShader, VertexSource) + fragment = shader(GlFragmentShader, FragmentSource) + defer: + glDeleteShader(vertex) + glDeleteShader(fragment) + probe.program = glCreateProgram() + glAttachShader(probe.program, vertex) + glAttachShader(probe.program, fragment) + glBindAttribLocation(probe.program, 0, "position") + glBindAttribLocation(probe.program, 1, "uv") + glLinkProgram(probe.program) + var linked: cint + glGetProgramiv(probe.program, GlLinkStatus, addr linked) + if linked == 0: + var log = newString(4096) + glGetProgramInfoLog(probe.program, cint(log.len), nil, log.cstring) + raise newException(IOError, "Linking program: " & log) + glUseProgram(probe.program) + glUniform1i(glGetUniformLocation(probe.program, "image"), 0) + glUniform1f(glGetUniformLocation(probe.program, "swapRB"), if probe.layout == xrgbPixels: 1 else: 0) + glGenTextures(1, addr probe.texture) + glBindTexture(GlTexture, probe.texture) + for name in [GlMagFilter, GlMinFilter]: glTexParameteri(GlTexture, name, GlNearest) + for name in [GlWrapS, GlWrapT]: glTexParameteri(GlTexture, name, GlClampEdge) + glTexImage2D(GlTexture, 0, cint(GlRgba), SourceWidth, cint(probe.sourceHeight * 2), 0, GlRgba, GlByte, nil) + glEnableVertexAttribArray(0) + glEnableVertexAttribArray(1) + var vertices: seq[cfloat] + for index in 0..1: + let + first = cfloat(index) * 0.5 + last = first + 0.5 + vertices.add @[-1.cfloat, -1, 0, last, 1, -1, 1, last, -1, 1, 0, first, + -1, 1, 0, first, 1, -1, 1, last, 1, 1, 1, first] + glGenBuffers(1, addr probe.vertices) + glBindBuffer(GlArrayBuffer, probe.vertices) + glBufferData(GlArrayBuffer, vertices.len * sizeof(cfloat), addr vertices[0], GlStaticDraw) + +proc draw(probe: Probe) = + ## Enlarge two cropped texture regions with nearest-neighbour sampling. + glClearColor(0, 0, 0, 1) + glClear(GlColourBuffer) + let view = fit(SourceWidth, probe.sourceHeight, probe.panelWidth, probe.panelHeight) + for index in 0..1: + let offset = index * VerticesPerPanel * VertexStride + glViewport(cint(view.x), cint((1 - index) * probe.panelHeight + view.y), cint(view.width), cint(view.height)) + glVertexAttribPointer(0, 2, GlFloat, 0, VertexStride, cast[pointer](offset)) + glVertexAttribPointer(1, 2, GlFloat, 0, VertexStride, cast[pointer](offset + 2 * sizeof(cfloat))) + glDrawArrays(GlTriangles, 0, VerticesPerPanel) + checked(glGetError() == 0, "Drawing GPU pattern") + +proc readback(probe: Probe) = + ## Validate every rendered pixel, including borders, before touching scanout. + let + width = probe.panelWidth + height = probe.panelHeight * 2 + view = fit(SourceWidth, probe.sourceHeight, width, probe.panelHeight) + scale = view.width div SourceWidth + var output = newSeq[uint32](width * height) + glReadPixels(0, 0, cint(width), cint(height), GlRgba, GlByte, addr output[0]) + checked(glGetError() == 0, "Reading GPU pixels") + for y in 0 ..< height: + let localY = y mod probe.panelHeight + for x in 0 ..< width: + var expected = 0xff000000'u32 + if x >= view.x and x < view.x + view.width and localY >= view.y and localY < view.y + view.height: + expected = pixel((x - view.x) div scale, (localY - view.y) div scale + + (y div probe.panelHeight) * probe.sourceHeight, 0, probe.sourceHeight) + let actual = output[(height - 1 - y) * width + x] or 0xff000000'u32 + if actual != expected: + raise newException(IOError, &"GPU readback mismatch at {x},{y}: {actual} != {expected}") + echo "GPU readback: exact nearest-neighbour pixels and borders verified" + +proc framebuffer(probe: Probe, bo: pointer): uint32 = + ## Register each GBM allocation once and reject non-linear storage explicitly. + for frame in probe.gbmFrames: + if frame.bo == bo: return frame.fb + checked(gbm_bo_get_modifier(bo) == 0, "Expected a linear GBM buffer") + var + handles = [gbm_bo_get_handle(bo).u32, 0'u32, 0, 0] + pitches = [gbm_bo_get_stride(bo), 0'u32, 0, 0] + offsets: array[4, uint32] + checked(probe.display.api.addFb2(probe.display.fd, uint32(probe.panelWidth), + uint32(probe.panelHeight * 2), Xrgb8888, addr handles[0], addr pitches[0], + addr offsets[0], addr result, 0) == 0, "Registering combined GBM framebuffer") + probe.gbmFrames.add (bo, result) + +proc submit(probe: Probe, fb: uint32) = + ## Test the initial pair and then submit one vblank-synchronized transaction. + var values: seq[AtomicValue] + for item in probe.properties: + if probe.armed and item.property notin probe.fbProperties: continue + var value = item + if item.id in probe.planes: + for index in 0..1: + if item.id == probe.planes[index] and item.property == probe.fbProperties[index]: + value.value = fb + values.add value + if not probe.armed: + probe.commit(values, AtomicTest or AllowModeset) + echo "Atomic TEST_ONLY: combined framebuffer accepted by both outputs" + probe.commit(values, AllowModeset) + probe.armed = true + else: + probe.pair.completed = {} + probe.commit(values, AtomicNonblock or FlipEvent, cast[pointer](probe)) + probe.pending = true + +proc close(probe: Probe) = + ## Restore the saved transaction before freeing anything still used for scanout. + if probe.display == nil: return + var errors: seq[string] + template attempt(body: untyped) = + try: body + except CatchableError as error: errors.add error.msg + if probe.armed: + attempt: probe.waitPair() + attempt: + probe.commit(probe.restore, AllowModeset) + probe.armed = false + if probe.armed: + # A failed restore must not leave scanout pointing at freed GPU storage. + var disabled = true + for screen in probe.display.screens: + if probe.display.api.setCrtc(probe.display.fd, screen.crtc, 0, 0, 0, nil, 0, nil) < 0: + disabled = false + errors.add "Disabling output after failed restore: " & screen.name + if not disabled: + attempt: checked(posix.close(probe.display.fd) == 0, "Closing failed DRM session") + probe.display.fd = -1 + for screen in probe.display.screens: + for scanout in screen.buffers: + attempt: checked(munmap(scanout.memory, int(scanout.buffer.size)) == 0, "Unmapping abandoned CPU buffer") + screen.buffers.setLen(0) + for bo in probe.locked: gbm_surface_release_buffer(probe.surface, bo) + probe.locked.setLen(0) + for frame in probe.gbmFrames: + if probe.display.fd >= 0: + attempt: checked(probe.display.api.removeFb(probe.display.fd, frame.fb) == 0, "Removing GPU framebuffer") + if probe.egl != nil: + if probe.context != nil: + if probe.texture != 0: glDeleteTextures(1, addr probe.texture) + if probe.vertices != 0: glDeleteBuffers(1, addr probe.vertices) + if probe.program != 0: glDeleteProgram(probe.program) + attempt: checkedEgl(eglMakeCurrent(probe.egl, nil, nil, nil), "Releasing EGL context") + if probe.eglSurface != nil: + attempt: checkedEgl(eglDestroySurface(probe.egl, probe.eglSurface), "Destroying EGL surface") + if probe.context != nil: + attempt: checkedEgl(eglDestroyContext(probe.egl, probe.context), "Destroying EGL context") + attempt: checkedEgl(eglTerminate(probe.egl), "Terminating EGL") + if probe.surface != nil: gbm_surface_destroy(probe.surface) + if probe.gbm != nil: gbm_device_destroy(probe.gbm) + if probe.display.fd >= 0: + for blob in probe.blobs: + attempt: checked(probe.atomic.destroyBlob(probe.display.fd, blob) == 0, "Destroying mode blob") + attempt: probe.display.close() + probe.display = nil + if errors.len > 0: raise newException(IOError, errors.join("; ")) + +proc elapsed(started: MonoTime): float64 = + ## Measure an operation in milliseconds using the monotonic clock. + (getMonoTime() - started).inNanoseconds.float64 / 1_000_000 + +proc report(name: string, samples: seq[float64]) = + ## Print mean, tail and peak rather than hiding hitches behind average FPS. + if samples.len == 0: return + var + ordered = samples.sorted() + total = 0.0 + for sample in samples: total += sample + echo name, " ms mean/p95/max: ", formatFloat(total / samples.len.float64, ffDecimal, 3), "/", + formatFloat(ordered[min(ordered.high, ordered.len * 95 div 100)], ffDecimal, 3), "/", + formatFloat(ordered[^1], ffDecimal, 3) + +proc main() = + ## Run an explicitly selected, bounded diagnostic without opening game data. + if paramCount() notin [6, 7]: + raise newException(ValueError, "Usage: manual_drm_gpu CARD TOP BOTTOM ds|earthbound xbgr|xrgb gpu|cpu|inspect [seconds<=8]") + if paramStr(4) notin ["ds", "earthbound"] or paramStr(5) notin ["xbgr", "xrgb"] or + paramStr(6) notin ["gpu", "cpu", "inspect"]: + raise newException(ValueError, "Unknown layout, format or mode") + var seconds = 8 + if paramCount() == 7: + let value = paramStr(7) + if parseInt(value, seconds) != value.len or seconds notin 1..8: + raise newException(ValueError, "Duration must be 1..8 seconds") + let probe = Probe(sourceHeight: (if paramStr(4) == "ds": 192 else: 224), + layout: (if paramStr(5) == "xbgr": xbgrPixels else: xrgbPixels)) + defer: probe.close() + probe.display = openDrm(paramStr(1)) + probe.display.openOutput(@[paramStr(2), paramStr(3)], 0, vsync = true) + let first = probe.display.screens[0] + probe.panelWidth = first.width + probe.panelHeight = first.height + let second = probe.display.screens[1] + checked(first.width == second.width and first.height == second.height, "Expected equal panel sizes") + checked(first.crtc != second.crtc, "Expected independent display controllers") + probe.atomic = loadAtomic(probe.display) + checked(probe.display.api.setClientCap(probe.display.fd, AtomicCapability, 1) == 0, "Enabling atomic KMS") + probe.configure() + generate(probe.pixels, probe.sourceHeight, 0, probe.layout) + if paramStr(6) != "cpu": + # Retain discovery metadata without reserving unused CPU scanout storage. + for screen in probe.display.screens: + probe.display.closeScreen(screen) + screen.buffers.setLen(0) + probe.openGpu() + glTexSubImage2D(GlTexture, 0, 0, 0, SourceWidth, cint(probe.sourceHeight * 2), GlRgba, GlByte, addr probe.pixels[0]) + probe.draw() + probe.readback() + if paramStr(6) == "inspect": return + checked(probe.display.api.setMaster(probe.display.fd) == 0, "Acquiring DRM master") + probe.display.master = true + var + timings: Samples + frames = 0 + late = 0 + images = [newImage(probe.panelWidth, probe.panelHeight), newImage(probe.panelWidth, probe.panelHeight)] + let + started = getMonoTime() + cpuStarted = cpuTime() + while (getMonoTime() - started).inMilliseconds < seconds * 1000: + let frameStarted = getMonoTime() + var step = getMonoTime() + generate(probe.pixels, probe.sourceHeight, frames, probe.layout) + timings.generate.add elapsed(step) + if paramStr(6) == "gpu": + step = getMonoTime() + glTexSubImage2D(GlTexture, 0, 0, 0, SourceWidth, cint(probe.sourceHeight * 2), GlRgba, GlByte, addr probe.pixels[0]) + timings.upload.add elapsed(step) + step = getMonoTime() + probe.draw() + timings.draw.add elapsed(step) + var gpuStep = getMonoTime() + checkedEgl(eglSwapBuffers(probe.egl, probe.eglSurface), "Swapping EGL render buffer") + timings.swap.add elapsed(gpuStep) + # Include any resolve queued by EGL's swap before exposing the BO to KMS. + gpuStep = getMonoTime() + glFinish() + checked(glGetError() == 0, "Finishing GPU presentation") + timings.finish.add elapsed(gpuStep) + gpuStep = getMonoTime() + let bo = gbm_surface_lock_front_buffer(probe.surface) + checked(bo != nil, "Locking completed GBM buffer") + probe.locked.add bo + timings.acquire.add elapsed(gpuStep) + timings.render.add elapsed(step) + step = getMonoTime() + probe.submit(probe.framebuffer(bo)) + timings.submit.add elapsed(step) + step = getMonoTime() + probe.waitPair() + timings.wait.add elapsed(step) + if probe.front != nil: + gbm_surface_release_buffer(probe.surface, probe.front) + probe.locked.delete(probe.locked.find(probe.front)) + probe.front = bo + else: + step = getMonoTime() + for index in 0..1: + scalePanel(probe.pixels, index, probe.sourceHeight, probe.layout, images[index].data, + probe.panelWidth, probe.panelHeight) + timings.render.add elapsed(step) + step = getMonoTime() + for index in 0..1: + probe.display.present(index, images[index]) + timings.submit.add elapsed(step) + step = getMonoTime() + for screen in probe.display.screens: probe.display.waitForFlip(screen) + timings.wait.add elapsed(step) + inc frames + if elapsed(frameStarted) > 20: inc late + echo "Mode: ", paramStr(6), "; layout: ", paramStr(4), "; format: ", paramStr(5), + "; frames: ", frames, "; elapsed ms: ", elapsed(started), "; frames over 20 ms: ", late + echo "Main-thread CPU ms/frame: ", (cpuTime() - cpuStarted) * 1000 / frames.float64 + report("Generate", timings.generate) + report("Upload", timings.upload) + report("Render/finish", timings.render) + report("Draw calls", timings.draw) + report("EGL swap", timings.swap) + report("GL finish", timings.finish) + report("GBM acquire", timings.acquire) + report("Submit", timings.submit) + report("Wait", timings.wait) + report("Panel completion skew", probe.skew) + +when isMainModule: + main() diff --git a/tests/manual_drm_pixelpair.nim b/tests/manual_drm_pixelpair.nim new file mode 100644 index 0000000..74c09df --- /dev/null +++ b/tests/manual_drm_pixelpair.nim @@ -0,0 +1,64 @@ +include windy/platforms/linux/drm + +proc pattern(width, height: int): Image = + ## Generate asymmetric pixel colours without loading game data. + result = newImage(width, height) + for y in 0 ..< height: + for x in 0 ..< width: + result.data[y * width + x] = rgbx(uint8(x mod 256), uint8(y mod 256), + uint8((x xor y) mod 256), 255) + +proc verify(pair: GpuDisplay, image: Image, regions: array[2, PixelRegion]) = + ## Read every GPU pixel against source crops, integer scaling and black borders. + pair.gpuUpload(image, regions) + pair.gpuDraw() + let + width = pair.panelWidth + height = pair.panelHeight + var pixels = newSeq[uint32](width * height * 2) + pair.gpu.glReadPixels(0, 0, cint(width), cint(height * 2), GpuGlRgba, GpuGlByte, addr pixels[0]) + gpuChecked(pair.gpu.glGetError() == 0, "Reading pixel pair") + for index, crop in regions: + let + scale = if crop.width == 0: 1 else: max(1, min(width div crop.width, height div crop.height)) + left = (width - crop.width * scale) div 2 + top = (height - crop.height * scale) div 2 + for y in 0 ..< height: + for x in 0 ..< width: + var expected = 0xff000000'u32 + if crop.width > 0 and x >= left and y >= top and + x < left + crop.width * scale and y < top + crop.height * scale: + let + sourceX = crop.x + (x - left) div scale + sourceY = crop.y + (y - top) div scale + expected = cast[uint32](image.data[sourceY * image.width + sourceX]) + let actual = pixels[(height * 2 - 1 - index * height - y) * width + x] or 0xff000000'u32 + if actual != expected: + raise newException(IOError, &"Pair pixel mismatch on output {index} at {x},{y}: {actual} != {expected}") + pair.presentGpuPixels(image, regions) + +proc main() = + ## Run bounded silent pixel/flip checks against the production GPU implementation. + if paramCount() != 3: + raise newException(ValueError, "Usage: manual_drm_pixelpair CARD TOP BOTTOM") + let display = openDrm(paramStr(1)) + defer: display.close() + display.openOutput(@[paramStr(2), paramStr(3)], 0, true) + let pair = newGpuDisplay(display, [display.screens[0], display.screens[1]]) + defer: pair.close() + for panelHeight in [192, 224]: + pair.verify(pattern(256, panelHeight * 2), [PixelRegion(width: 256, height: panelHeight), + PixelRegion(y: panelHeight, width: 256, height: panelHeight)]) + let single = pattern(240, 160) + pair.verify(single, [PixelRegion(width: 240, height: 160), PixelRegion()]) + pair.verify(single, [PixelRegion(), PixelRegion(width: 240, height: 160)]) + pair.verify(pattern(19, 26), [PixelRegion(width: 19, height: 13), + PixelRegion(y: 13, width: 19, height: 13)]) + pair.verify(pattern(pair.panelWidth + 16, pair.panelHeight + 8), + [PixelRegion(width: pair.panelWidth + 16, height: pair.panelHeight + 8), PixelRegion()]) + pair.verify(pattern(320, 240), [PixelRegion(), PixelRegion(width: 320, height: 240)]) + pair.verify(nil, [PixelRegion(), PixelRegion()]) + echo "GPU pixel pair: all pixels, crops, resize, blank/swap, clipping and paired flips passed" + +when isMainModule: + main() diff --git a/tests/test_drm_gpu_patterns.nim b/tests/test_drm_gpu_patterns.nim new file mode 100644 index 0000000..8c867fc --- /dev/null +++ b/tests/test_drm_gpu_patterns.nim @@ -0,0 +1,50 @@ +import + ./drm_gpu_patterns + +proc main() = + ## Check layout, colour contracts and both possible completion orders. + for height in [192, 224]: + let view = fit(SourceWidth, height, 1024, 768) + if height == 192: + doAssert view == Viewport(x: 0, y: 0, width: 1024, height: 768) + else: + doAssert view == Viewport(x: 128, y: 48, width: 768, height: 672) + var xbgr, xrgb: seq[uint32] + generate(xbgr, height, 7, xbgrPixels) + generate(xrgb, height, 7, xrgbPixels) + doAssert xbgr.len == SourceWidth * height * 2 + for index, colour in xbgr: + doAssert colour shr 24 == 255 + doAssert pack(xrgb[index], xrgbPixels) == colour + doAssert pixel(1, height - 1, 7, height) != pixel(1, height * 2 - 1, 7, height) + var output: seq[uint32] + for layout in [xbgrPixels, xrgbPixels]: + for panel in 0..1: + scalePanel((if layout == xbgrPixels: xbgr else: xrgb), panel, height, layout, + output, 1024, 768) + for y in 0..<768: + for x in 0..<1024: + var expected = 0xff000000'u32 + if x >= view.x and x < view.x + view.width and y >= view.y and y < view.y + view.height: + expected = pixel((x - view.x) div (view.width div SourceWidth), + (y - view.y) div (view.width div SourceWidth) + panel * height, 7, height) + doAssert output[y * 1024 + x] == expected + for order in [[0, 1], [1, 0]]: + var pair = PairedFlip(crtcs: [69'u32, 70]) + pair.complete(pair.crtcs[order[0]], 100) + doAssert not pair.ready + pair.complete(pair.crtcs[order[1]], 105) + doAssert pair.ready + doAssert pair.timestamps[order[0]] == 100 + doAssert pair.timestamps[order[1]] == 105 + for crtc in [pair.crtcs[0], 999'u32]: + var rejected = false + try: pair.complete(crtc, 110) + except ValueError: rejected = true + doAssert rejected + var rejected = false + try: discard fit(2048, 768, 1024, 768) + except ValueError: rejected = true + doAssert rejected + +main() diff --git a/tests/test_drm_pixelpair.nim b/tests/test_drm_pixelpair.nim new file mode 100644 index 0000000..c711b57 --- /dev/null +++ b/tests/test_drm_pixelpair.nim @@ -0,0 +1,35 @@ +import + std/unittest, + windy, + windy/platforms/linux/drm, + windy/platforms/linux/native/pixels + +suite "GPU pixel pairs": + test "DS and EarthBound preserve integer pixels and borders": + check pixelViewport(PixelRegion(width: 256, height: 192), 1024, 768) == + PixelRegion(width: 1024, height: 768) + check pixelViewport(PixelRegion(width: 256, height: 224), 1024, 768) == + PixelRegion(x: 128, y: 48, width: 768, height: 672) + check pixelViewport(PixelRegion(width: 100, height: 80), 60, 50) == + PixelRegion(x: -20, y: -15, width: 100, height: 80) + test "source crops and black outputs validate before rendering": + validateRegions(0, 0, [PixelRegion(), PixelRegion()]) + validateRegions(256, 448, [PixelRegion(width: 256, height: 224), + PixelRegion(y: 224, width: 256, height: 224)]) + for region in [PixelRegion(width: 0, height: 1), + PixelRegion(x: -1, width: 1, height: 1), + PixelRegion(x: high(int), width: 1, height: 1), + PixelRegion(width: 257, height: 1)]: + expect ValueError: validateRegions(256, 448, [region, PixelRegion()]) + test "texture crops retain top and bottom orientation": + let vertices = pixelVertices(256, 448, [PixelRegion(width: 256, height: 224), + PixelRegion(y: 224, width: 256, height: 224)]) + check vertices[3] == 0.5 + check vertices[11] == 0 + check vertices[27] == 1 + check vertices[35] == 0.5 + test "invalid window ownership fails without opening GPU libraries": + expect WindyError: discard newDrmPixelPair(nil, nil) + expect WindyError: discard newDrmPixelPair(Window(nil), Window(nil)) + expect WindyError: discard newDrmPixelPair(Window(), Window()) + close(DrmPixelPair(nil)) diff --git a/tests/test_linux_display.nim b/tests/test_linux_display.nim new file mode 100644 index 0000000..7aa2c00 --- /dev/null +++ b/tests/test_linux_display.nim @@ -0,0 +1,71 @@ +import + std/os, + pixie, windy + +when HasX11: + import windy/platforms/linux/x11/[xlib, x] + + proc getImage(d: xlib.Display, drawable: x.Drawable, x, y: cint, + width, height: cuint, planes: culong, format: cint): pointer + {.cdecl, importc: "XGetImage", dynlib: libX11.} + proc getPixel(image: pointer, x, y: cint): culong + {.cdecl, importc: "XGetPixel", dynlib: libX11.} + proc destroyImage(image: pointer): cint + {.cdecl, importc: "XDestroyImage", dynlib: libX11.} + proc closeDisplay(d: xlib.Display): cint + {.cdecl, importc: "XCloseDisplay", dynlib: libX11.} + + setLinuxBackend(X11Backend) + let window = newWindow("Windy CPU regression", ivec2(64, 48), vsync = false) + let image = newImage(63, 47) + for index in 0 ..< image.data.len: + image.data[index] = rgbx(uint8(index mod 256), uint8((index div 63) * 5), + uint8((index mod 8) * 31), 255) + window.presentPixels(image) + pollEvents() + doAssert window.framebufferSize == ivec2(64, 48) + doAssert window.touches.len == 0 + let d = XOpenDisplay(cstring(getEnv("DISPLAY"))) + doAssert d != nil + d.XSync(false) + let captured = getImage(d, d.defaultRootWindow, 0, 0, 63, 47, culong.high, 2) + doAssert captured != nil + for y in 0 ..< image.height: + for x in 0 ..< image.width: + let + pixel = image.data[y * image.width + x] + expected = culong(pixel.r) shl 16 or culong(pixel.g) shl 8 or culong(pixel.b) + doAssert getPixel(captured, cint(x), cint(y)) == expected + discard destroyImage(captured) + discard closeDisplay(d) + let other = newWindow("Windy second CPU window", ivec2(64, 48), vsync = false) + other.focus() + for attempt in 0 ..< 20: + pollEvents() + sleep(1) + doAssert other.focused and not window.focused + window.focus() + for attempt in 0 ..< 20: + pollEvents() + sleep(1) + doAssert window.focused and not other.focused + other.close() + when HasDirect: + var rejected = false + try: setLinuxBackend(FbdevBackend) + except WindyError: rejected = true + doAssert rejected + window.close() + pollEvents() + doAssert window.closed + +when HasFbdev: + setLinuxBackend(FbdevBackend) + var rejectedFramebuffer = false + try: + discard newWindow("Invalid framebuffer", ivec2(0, 0), vsync = false, + linuxOptions = LinuxWindowOptions(device: "/dev/null")) + except OSError: + rejectedFramebuffer = true + doAssert rejectedFramebuffer + setLinuxBackend(FbdevBackend) diff --git a/tests/test_linux_drm.nim b/tests/test_linux_drm.nim new file mode 100644 index 0000000..7cbeeb2 --- /dev/null +++ b/tests/test_linux_drm.nim @@ -0,0 +1,148 @@ +import std/unittest + +include windy/platforms/linux/drm + +suite "DRM paired flips": + test "both controllers must complete in either order": + for reverse in [false, true]: + var pair = GpuFlipPair(crtcs: [69'u32, 70]) + pair.complete(if reverse: 70 else: 69, 100) + check not pair.ready + pair.complete(if reverse: 69 else: 70, 106) + check pair.ready + expect ValueError: pair.complete(69, 107) + expect ValueError: pair.complete(71, 107) + +var + signalFd: cint + queued: seq[pointer] + modesets, flips: int + failFlip, failEvents: bool + +proc setCrtc(fd: cint, id, buffer, x, y: uint32, connectors: ptr uint32, + count: cint, mode: ptr ModeInfo): cint {.cdecl.} = + ## Capture activation and restore calls without changing a physical display. + inc modesets + +proc pageFlip(fd: cint, crtc, framebuffer, flags: uint32, data: pointer): cint {.cdecl.} = + ## Queue completion notifications while preserving the active buffer. + doAssert flags == FlipEvent + if failFlip: return -1 + inc flips + queued.add data + var signal = 'F' + doAssert posix.write(signalFd, addr signal, 1) == 1 + +proc handleEvent(fd: cint, context: ptr EventContext): cint {.cdecl.} = + ## Deliver a completion through the real callback signature after polling a pipe. + if failEvents: return -1 + doAssert context.version == 2 + var signal: char + doAssert posix.read(fd, addr signal, 1) == 1 + let data = queued[0] + queued.delete(0) + context.pageFlipHandler(fd, 0, 0, 0, data) + +proc removeFb(fd: cint, id: uint32): cint {.cdecl.} = + ## Accept cleanup of a test-owned framebuffer registration. + discard + +proc newScreen(vsync: bool): DrmScreen = + ## Allocate padded anonymous buffers with the same lifetime as DRM mappings. + result = DrmScreen(name: "Test", width: 2, height: 2, rgba: true, vsync: vsync) + result.buffers.setLen(if vsync: 2 else: 1) + for index, scanout in result.buffers.mpairs: + scanout.buffer = DumbBuffer(width: 2, height: 2, pitch: 12, size: 24) + scanout.framebuffer = uint32(index + 1) + scanout.memory = mmap(nil, 24, PROT_READ or PROT_WRITE, MAP_PRIVATE or MAP_ANONYMOUS, -1, 0) + doAssert scanout.memory != MAP_FAILED + +proc firstPixel(screen: DrmScreen, index: int): ColorRGBX = + ## Observe a buffer without retaining a mutable alias. + cast[ptr ColorRGBX](screen.buffers[index].memory)[] + +proc main() = + ## Verify independent page flips, buffer ownership, failures and pending cleanup. + var pipeFds: array[2, cint] + doAssert pipe(pipeFds) == 0 + signalFd = pipeFds[1] + defer: + doAssert posix.close(pipeFds[0]) == 0 + doAssert posix.close(pipeFds[1]) == 0 + let + top = newScreen(true) + bottom = newScreen(true) + unsynced = newScreen(false) + display = DrmDisplay(fd: pipeFds[0], master: true, screens: @[top, bottom, unsynced], + api: DrmApi(setCrtc: setCrtc, pageFlip: pageFlip, handleEvent: handleEvent, removeFb: removeFb)) + image = newImage(2, 2) + image.fill(rgbx(255, 0, 0, 255)) + display.present(0, image) + display.present(1, image) + doAssert modesets == 2 and flips == 0 + image.fill(rgbx(0, 255, 0, 255)) + display.present(0, image) + display.present(1, image) + doAssert flips == 2 and top.pending and bottom.pending + doAssert top.firstPixel(0) == rgbx(255, 0, 0, 255) + doAssert top.firstPixel(1) == rgbx(0, 255, 0, 255) + doAssert top.front == 0 + image.fill(rgbx(0, 0, 255, 255)) + display.present(1, image) + doAssert not top.pending and top.front == 1 + doAssert bottom.pending and bottom.front == 1 + doAssert bottom.firstPixel(1) == rgbx(0, 255, 0, 255) + doAssert bottom.firstPixel(0) == rgbx(0, 0, 255, 255) + display.present(0, image) + doAssert top.firstPixel(1) == rgbx(0, 255, 0, 255) + doAssert top.firstPixel(0) == rgbx(0, 0, 255, 255) + display.closeScreen(bottom) + display.closeScreen(top) + doAssert not top.pending and not bottom.pending and queued.len == 0 + doAssert modesets == 4 + + display.present(2, image) + image.fill(rgbx(12, 34, 56, 255)) + display.present(2, image) + doAssert unsynced.firstPixel(0) == rgbx(12, 34, 56, 255) + doAssert not unsynced.pending and flips == 4 + display.closeScreen(unsynced) + + let failed = newScreen(true) + display.screens.add failed + display.present(3, image) + failFlip = true + var rejected = false + try: display.present(3, image) + except OSError: rejected = true + doAssert rejected and not failed.pending + display.closeScreen(failed) + + failFlip = false + let late = newScreen(true) + display.screens.add late + display.present(4, image) + display.present(4, image) + failEvents = true + rejected = false + try: display.closeScreen(late) + except IOError: rejected = true + doAssert rejected and display.retiredScreens == @[late] + display.screens.delete(4) + failEvents = false + let next = newScreen(true) + display.screens.add next + display.present(4, image) + display.present(4, image) + display.waitForFlip(next) + doAssert not late.pending and not next.pending and queued.len == 0 + next.pending = true + rejected = false + try: display.waitForFlip(next) + except IOError as error: + rejected = error.msg.startsWith("Timed out") + doAssert rejected + next.pending = false + display.closeScreen(next) + +main() diff --git a/tests/test_linux_framebuffer.nim b/tests/test_linux_framebuffer.nim new file mode 100644 index 0000000..466ad7b --- /dev/null +++ b/tests/test_linux_framebuffer.nim @@ -0,0 +1,44 @@ +include windy/platforms/linux/fbdev + +proc checkPixels(bits: int, red, green, blue, alpha: Bitfield, rotation: int) = + ## Compare packed scanout bytes with the generic converter, including padding. + let + image = newImage(31, 17) + physicalWidth = if rotation in [90, 270]: image.height else: image.width + physicalHeight = if rotation in [90, 270]: image.width else: image.height + bytes = bits div 8 + stride = ((physicalWidth * bytes + 3) div 4 + 1) * 4 + var + memory = newSeq[byte](stride * physicalHeight + 8) + expected = newSeq[byte](memory.len) + for index in 0 ..< memory.len: + memory[index] = 0xa5 + expected[index] = 0xa5 + for y in 0 ..< image.height: + for x in 0 ..< image.width: + let + pixel = rgbx(uint8(x * 8), uint8(y * 15), uint8((x + y) * 5), 127) + packed = packPixel(rgb(pixel), red, green, blue, alpha) + point = rotatedPoint(x, y, image.width, image.height, rotation) + offset = 4 + point.y * stride + point.x * bytes + image.data[y * image.width + x] = pixel + for index in 0 ..< bytes: + expected[offset + index] = byte((packed shr (index * 8)) and 255) + let fb = Framebuffer(memory: addr memory[4], width: image.width, + height: image.height, rotation: rotation, activated: true, + variable: VariableInfo(bits_per_pixel: uint32(bits), red: red, + green: green, blue: blue, transp: alpha), + fixed: FixedInfo(line_length: uint32(stride))) + fb.present(image) + doAssert memory == expected + +for rotation in [0, 90, 180, 270]: + for redOffset in [0'u32, 16]: + for alphaBits in [0'u32, 8]: + checkPixels(32, Bitfield(offset: redOffset, length: 8), + Bitfield(offset: 8, length: 8), Bitfield(offset: 16 - redOffset, length: 8), + Bitfield(offset: 24, length: alphaBits), rotation) + checkPixels(24, Bitfield(offset: 16, length: 8), Bitfield(offset: 8, length: 8), + Bitfield(length: 8), Bitfield(), rotation) + checkPixels(16, Bitfield(offset: 11, length: 5), Bitfield(offset: 5, length: 6), + Bitfield(length: 5), Bitfield(), rotation) diff --git a/tests/test_linux_glx.nim b/tests/test_linux_glx.nim new file mode 100644 index 0000000..80777c0 --- /dev/null +++ b/tests/test_linux_glx.nim @@ -0,0 +1,13 @@ +import opengl, windy + +let window = newWindow("Windy GLX regression", ivec2(64, 48), vsync = false) +window.makeContextCurrent() +loadExtensions() +doAssert glGetString(GL_VERSION) != nil +for frame in 0 ..< 3: + glClearColor(0.2, 0.4, 0.8, 1) + glClear(GL_COLOR_BUFFER_BIT) + window.swapBuffers() + pollEvents() +window.close() +doAssert window.closed diff --git a/tests/test_native_input.nim b/tests/test_native_input.nim new file mode 100644 index 0000000..02f337d --- /dev/null +++ b/tests/test_native_input.nim @@ -0,0 +1,61 @@ +import + pixie, + windy/common, + windy/platforms/linux/fbdev, + windy/platforms/linux/drm, + windy/platforms/linux/native/[touch, pixels] + +block: + let reader = TouchReader(width: 1024, height: 768, + minimumX: 100, maximumX: 1100, minimumY: 50, maximumY: 1050, + pending: @[Slot(id: -1), Slot(id: -1)], previous: @[Slot(id: -1), Slot(id: -1)]) + reader.pending[0] = Slot(id: 7, x: 100, y: 50) + reader.pending[1] = Slot(id: 9, x: 1100, y: 1050) + let began = reader.report() + doAssert began.len == 2 + doAssert began[0].phase == TouchBegin and began[0].contact.id == 7 + doAssert began[0].contact.position == vec2(0, 0) + doAssert began[1].contact.position == vec2(1023, 767) + doAssert reader.report().len == 0 + reader.pending[0].x = 600 + let moved = reader.report() + doAssert moved.len == 1 and moved[0].phase == TouchMove + doAssert moved[0].contact.id == 7 and moved[0].contact.position.x == 511 + reader.pending[0].id = 11 + let replaced = reader.report() + doAssert replaced.len == 2 + doAssert replaced[0].phase == TouchEnd and replaced[0].contact.id == 7 + doAssert replaced[1].phase == TouchBegin and replaced[1].contact.id == 11 + let cancelled = reader.cancel() + doAssert cancelled.len == 2 + for event in cancelled: doAssert event.phase == TouchCancel + doAssert reader.cancel().len == 0 + doAssert reader.report().len == 0 + +block: + for rotation in [0, 90, 180, 270]: + let reader = TouchReader(width: 4, height: 3, maximumX: 100, maximumY: 100, + rotation: rotation, pending: @[Slot(id: 1)], previous: @[Slot(id: -1)]) + let expected = case rotation + of 0: vec2(0, 0) + of 90: vec2(3, 0) + of 180: vec2(3, 2) + else: vec2(0, 2) + doAssert reader.report()[0].contact.position == expected + +block: + doAssert rgb(rgbx(255, 0, 0, 255)) == 0xff0000'u32 + doAssert rgb(rgbx(0, 255, 0, 255)) == 0x00ff00'u32 + doAssert rgb(rgbx(0, 0, 255, 255)) == 0x0000ff'u32 + doAssert packPixel(0xffffff, Bitfield(offset: 11, length: 5), + Bitfield(offset: 5, length: 6), Bitfield(length: 5), Bitfield()) == 0xffff + doAssert chooseCrtc([90'u32, 110, 130], 130, 5, []) == 130 + doAssert chooseCrtc([90'u32, 110, 130], 0, 5, [90'u32]) == 130 + +block: + var rejected = false + try: + discard openTouch("/dev/null", 20, 20, 0) + except OSError: + rejected = true + doAssert rejected diff --git a/tests/test_x11_pixels.nim b/tests/test_x11_pixels.nim new file mode 100644 index 0000000..5c486c7 --- /dev/null +++ b/tests/test_x11_pixels.nim @@ -0,0 +1,130 @@ +import + std/[monotimes, os, osproc, streams, strutils, times, unittest], + opengl, pixie, windy + +proc readBack(window: Window): Image = + ## Read only synthetic pixels from the back buffer on the isolated test display. + window.makeContextCurrent() + let size = window.framebufferSize + result = newImage(size.x, size.y) + glReadBuffer(GL_BACK) + glPixelStorei(GL_PACK_ALIGNMENT, 1) + glReadPixels(0, 0, size.x.GLsizei, size.y.GLsizei, GL_RGBA, GL_UNSIGNED_BYTE, + addr result.data[0]) + doAssert glGetError() == GL_NO_ERROR + for y in 0 ..< result.height div 2: + for x in 0 ..< result.width: + swap(result.data[y * result.width + x], + result.data[(result.height - 1 - y) * result.width + x]) + +proc checkCrop(presenter: X11PixelPresenter, window: Window, source: Image, + crop: PixelRegion, scale = 0, changed = true) = + ## Compare every GPU output pixel with an independent nearest-neighbor CPU reference. + presenter.drawPixels(source, crop, changed, scale) + let + output = readBack(window) + factor = if scale > 0: scale else: + max(1, min(output.width div crop.width, output.height div crop.height)) + left = (output.width - crop.width * factor) div 2 + top = (output.height - crop.height * factor) div 2 + for y in 0 ..< output.height: + for x in 0 ..< output.width: + let + sx = (x - left) div factor + sy = (y - top) div factor + var expected = rgbx(0, 0, 0, 255) + if x >= left and y >= top and x < left + crop.width * factor and + y < top + crop.height * factor: + let pixel = source[crop.x + sx, crop.y + sy] + expected = rgbx(pixel.r, pixel.g, pixel.b, 255) + doAssert output[x, y] == expected, + "GPU crop/orientation/scale/opacity differed at " & $x & "," & $y + +proc main() = + ## Exercise real GLX contexts under a private Xvfb server, never the user's desktop. + let + executable = findExe("Xvfb") + oldDisplay = getEnv("DISPLAY") + oldSoftware = getEnv("LIBGL_ALWAYS_SOFTWARE") + doAssert executable.len > 0, "Xvfb is required for this headless-only test." + let server = startProcess(executable, args = ["-displayfd", "1", "-screen", "0", + "1280x1024x24", "+extension", "GLX", "-nolisten", "tcp"], options = {}) + defer: + if server.running: server.terminate() + discard server.waitForExit(5000) + server.close() + putEnv("DISPLAY", oldDisplay) + if oldSoftware.len > 0: putEnv("LIBGL_ALWAYS_SOFTWARE", oldSoftware) + else: delEnv("LIBGL_ALWAYS_SOFTWARE") + let number = server.outputStream.readLine().strip() + doAssert number.len > 0 and number.allCharsInSet({'0' .. '9'}) + putEnv("DISPLAY", ":" & number) + putEnv("LIBGL_ALWAYS_SOFTWARE", "1") + block: + let cpu = newWindow("Headless CPU", ivec2(8, 8)) + doAssert not cpu.hasGpuPixelContext and not cpu.gpuPixelVsync + cpu.close() + let + first = newWindow("Headless GPU first", ivec2(67, 55), vsync = false, + linuxOptions = LinuxWindowOptions(gpuPixels: true)) + second = newWindow("Headless GPU second", ivec2(83, 69), vsync = false, + linuxOptions = LinuxWindowOptions(gpuPixels: true)) + defer: first.close() + defer: second.close() + let + a = newX11PixelPresenter(first) + b = newX11PixelPresenter(second) + source = newImage(17, 19) + defer: a.close() + defer: b.close() + for y in 0 ..< source.height: + for x in 0 ..< source.width: + source[x, y] = ColorRGBX(r: uint8(x * 13), g: uint8(y * 11), + b: uint8((x + y) * 7), a: 0) + let + top = PixelRegion(x: 1, y: 2, width: 13, height: 7) + bottom = PixelRegion(x: 3, y: 11, width: 11, height: 8) + for frame in 0 ..< 5: + checkCrop(a, first, source, top, changed = frame == 0) + checkCrop(b, second, source, bottom, changed = frame == 0) + a.swap() + b.swap() + let larger = newImage(29, 31) + larger.fill(rgbx(17, 31, 173, 255)) + checkCrop(a, first, larger, bottom, changed = false) + checkCrop(a, first, source, top, changed = false) + checkCrop(a, first, source, top, scale = 2, changed = false) + expect ValueError: a.drawPixels(source, top, scale = 100) + expect ValueError: a.drawPixels(source, PixelRegion(x: 18, width: 1, height: 1)) + first.size = ivec2(7, 5) + pollEvents() + a.drawPixels(source, top, changed = false, fitDown = true) + let fitted = readBack(first) + for y in 0 ..< fitted.height: + for x in 0 ..< fitted.width: + var expected = rgbx(0, 0, 0, 255) + if y in 1 .. 3: + let pixel = source[top.x + (2 * x + 1) * top.width div 14, + top.y + (2 * (y - 1) + 1) * top.height div 6] + expected = rgbx(pixel.r, pixel.g, pixel.b, 255) + doAssert fitted[x, y] == expected + first.size = ivec2(67, 55) + pollEvents() + let start = getMonoTime() + for frame in 0 ..< 60: + a.drawPixels(source, top, false) + b.drawPixels(source, bottom, false) + a.swap() + b.swap() + doAssert (getMonoTime() - start).inSeconds < 10, "Paired submission stalled." + a.drawPixels(nil, PixelRegion()) + for pixel in readBack(first).data: doAssert pixel == rgbx(0, 0, 0, 255) + a.close() + a.close() + first.close() + first.close() + b.close() + second.close() + second.close() + +main() diff --git a/tests/test_x11_wheel.nim b/tests/test_x11_wheel.nim new file mode 100644 index 0000000..3ab5619 --- /dev/null +++ b/tests/test_x11_wheel.nim @@ -0,0 +1,112 @@ +when defined(linux): + import + std/[os, osproc, streams, strutils], + windy, + windy/platforms/linux/x11/[x, xevent, xlib] + + type XWindow = x.Window + + const + WindowTitle = "Windy X11 wheel regression" + WheelUp = 4.cuint + WheelDown = 5.cuint + WheelLeft = 6.cuint + WheelRight = 7.cuint + WheelDelta = 10'f32 + + proc queryTree(display: Display, window: XWindow, root, parent: ptr XWindow, + children: ptr ptr UncheckedArray[XWindow], count: ptr cuint): cint + {.cdecl, importc: "XQueryTree", dynlib: libX11.} + proc fetchName(display: Display, window: XWindow, name: ptr cstring): cint + {.cdecl, importc: "XFetchName", dynlib: libX11.} + proc sendEvent(display: Display, window: XWindow, propagate: cint, mask: clong, + event: ptr XEvent): cint {.cdecl, importc: "XSendEvent", dynlib: libX11.} + proc closeDisplay(display: Display): cint + {.cdecl, importc: "XCloseDisplay", dynlib: libX11.} + proc sync(display: Display, discardEvents: cint): cint + {.cdecl, importc: "XSync", dynlib: libX11.} + + proc findWindow(display: Display): XWindow = + ## Locate only the newly created Windy window on the isolated X server. + var + root, parent: XWindow + children: ptr UncheckedArray[XWindow] + count: cuint + doAssert queryTree(display, display.defaultRootWindow, addr root, addr parent, + addr children, addr count) != 0 + defer: + if children != nil: XFree(children) + for index in 0 ..< count.int: + var name: cstring + if fetchName(display, children[index], addr name) != 0 and name != nil: + let matches = $name == WindowTitle + XFree(name) + if matches: return children[index] + raise newException(AssertionDefect, "Regression window was not created.") + + proc wheel(display: Display, window: XWindow, button: cuint, pressed: bool) = + ## Queue one authentic X11 wheel edge for Windy's public callback path. + var event = XEvent(button: XButtonEvent( + kind: if pressed: xeButtonPress else: xeButtonRelease, + display: display, window: window, root: display.defaultRootWindow, + button: button, same_screen: 1)) + doAssert sendEvent(display, window, 0, if pressed: 4 else: 8, addr event) != 0 + + proc main() = + ## Verify press-only callbacks and accumulated deltas through actual X11 events. + let + executable = findExe("Xvfb") + previousDisplay = getEnv("DISPLAY") + doAssert executable.len > 0, "Xvfb is required; this test never uses the desktop display." + let server = startProcess(executable, args = ["-displayfd", "1", "-screen", "0", + "320x240x24", "-nolisten", "tcp"], options = {}) + defer: + if server.running: server.terminate() + discard server.waitForExit(5000) + server.close() + defer: putEnv("DISPLAY", previousDisplay) + let number = server.outputStream.readLine().strip() + doAssert number.len > 0 and number.allCharsInSet({'0' .. '9'}) + let name = ":" & number + putEnv("DISPLAY", name) + let window = newWindow(WindowTitle, ivec2(64, 64), vsync = false) + defer: window.close() + let display = XOpenDisplay(name.cstring) + doAssert display != nil + defer: discard closeDisplay(display) + let handle = findWindow(display) + var calls = 0 + window.onScroll = proc() = + ## Count only public scroll callbacks generated by the event dispatcher. + inc calls + pollEvents() + for (button, expected) in [(WheelUp, vec2(0, -WheelDelta)), + (WheelDown, vec2(0, WheelDelta)), (WheelLeft, vec2(-WheelDelta, 0)), + (WheelRight, vec2(WheelDelta, 0))]: + calls = 0 + display.wheel(handle, button, true) + discard sync(display, 0) + pollEvents() + doAssert calls == 1, "Each wheel press must emit one callback." + doAssert window.scrollDelta == expected + display.wheel(handle, button, false) + discard sync(display, 0) + pollEvents() + doAssert calls == 1, "Wheel release must not emit another callback." + doAssert window.scrollDelta == vec2(0, 0) + calls = 0 + for gesture in 0 ..< 2: + display.wheel(handle, WheelUp, true) + display.wheel(handle, WheelUp, false) + discard sync(display, 0) + pollEvents() + doAssert calls == 2, "Two wheel gestures must emit exactly two callbacks." + doAssert window.scrollDelta == vec2(0, -2 * WheelDelta), + "Multiple presses in one poll must accumulate their scroll deltas." + pollEvents() + doAssert window.scrollDelta == vec2(0, 0) + echo "Windy X11 wheel: four directions, press-only callbacks, release suppression, batched accumulation, and per-frame reset passed." + + main() +else: + echo "Windy X11 wheel regression is Linux-only."