Skip to content

SyphonNative.kt

src/jvmMain/kotlin/afield/io/syphon/SyphonNative.kt · 238 lines

@file:afield.runtime.completion.CompletionCategory("Internal")
package afield.io.syphon

import java.lang.foreign.Arena
import java.lang.foreign.FunctionDescriptor
import java.lang.foreign.Linker
import java.lang.foreign.MemoryLayout
import java.lang.foreign.MemorySegment
import java.lang.foreign.SymbolLookup
import java.lang.foreign.ValueLayout.ADDRESS
import java.lang.foreign.ValueLayout.JAVA_BYTE
import java.lang.foreign.ValueLayout.JAVA_INT
import java.lang.foreign.ValueLayout.JAVA_LONG
import java.lang.invoke.MethodHandle
import java.lang.ref.Cleaner
import java.nio.file.Path
import kotlin.io.path.exists

The locator and foreign-function bindings for native/field-syphon, the optional macOS library under SyphonServer and SyphonClient.

The library is searched for in order: the afield.syphon.library system property, the AFIELD_SYPHON_LIBRARY environment variable, then build/field-syphon in the working directory.

object SyphonLibrary

Locates libfield_syphon.dylib and reports whether it is available.

object SyphonLibrary {
    private val os = System.getProperty("os.name").lowercase()

val isMac: Boolean get() = os.contains("mac") || os.contains("darwin")

const val fileName = "libfield_syphon.dylib"

val searchedPaths: List<Path>

    val searchedPaths: List<Path>
        get() = listOfNotNull(
            System.getProperty("afield.syphon.library")?.takeIf { it.isNotBlank() },
            System.getenv("AFIELD_SYPHON_LIBRARY")?.takeIf { it.isNotBlank() },
        ).map(Path::of) +
            // listOf, not a bare `+ Path`: Path is Iterable<Path>, so that appends its name components.
            listOf(Path.of("build", "field-syphon", fileName).toAbsolutePath())

val resolvedPath: Path? get() = if (!isMac) null else searchedPaths.firstOrNull { it.exists() }

Not cached: building it during a live session is picked up by the next box run.

val isAvailable: Boolean get() = resolvedPath != null

val probePath: Path? get()

The cross-process test peer built next to the library (field_syphon_probe).

    val probePath: Path? get() = resolvedPath?.resolveSibling("field_syphon_probe")?.takeIf { it.exists() }

const val HOW_TO_BUILD

    const val HOW_TO_BUILD = "Build it with `./gradlew buildSyphonNative` (macOS; needs Xcode, and the network once)."

fun describe(): String

    fun describe(): String = when {
        !isMac -> "Syphon is macOS-only."
        else -> resolvedPath?.let { "Syphon bridge at $it (ABI ${SyphonNative.version()})" }
            ?: "Syphon bridge not built. $HOW_TO_BUILD"
    }
}

internal object SyphonNative

The FFM plumbing under SyphonServer and SyphonClient: one downcall per fs_* entry point in native/field-syphon/field_syphon.h, whose comments are the contract.

val cleaner: Cleaner = Cleaner.create()

class Free(private val handle: MemorySegment, private val free: (MemorySegment) -> Unit) : Runnable {

Holds only the handle, never the owner, so the owner can be collected.

override fun run() = free(handle)

source — 50 lines, from line 72
    private val linker = Linker.nativeLinker()

    private val lookup: SymbolLookup by lazy {
        val path = SyphonLibrary.resolvedPath ?: throw UnsatisfiedLinkError(
            "${SyphonLibrary.describe()}\nLooked at: " + SyphonLibrary.searchedPaths.joinToString(", "))
        SymbolLookup.libraryLookup(path, Arena.global())
    }

    private fun fn(name: String, ret: MemoryLayout?, vararg args: MemoryLayout): Lazy<MethodHandle> = lazy {
        val symbol = lookup.find(name).orElseThrow {
            UnsatisfiedLinkError("${SyphonLibrary.fileName} has no $name; rebuild it with ./gradlew buildSyphonNative")
        }
        linker.downcallHandle(symbol, if (ret == null) FunctionDescriptor.ofVoid(*args) else FunctionDescriptor.of(ret, *args))
    }

    private val I = JAVA_INT
    private val A = ADDRESS
    private val L = JAVA_LONG

    private val versionH by fn("fs_version", I)
    private val lastErrorH by fn("fs_last_error", A)
    private val freeStringH by fn("fs_free_string", null, A)
    private val pumpH by fn("fs_pump", I, I)
    private val serverNewH by fn("fs_server_new", A, A)
    private val publishRgbaH by fn("fs_server_publish_rgba", I, A, A, I, I, I)
    private val publishBgraH by fn("fs_server_publish_bgra", I, A, A, I, I, I)
    private val publishSurfaceH by fn("fs_server_publish_iosurface", I, A, A, I)
    private val hasClientsH by fn("fs_server_has_clients", I, A)
    private val serverDescH by fn("fs_server_description", A, A)
    private val serverFreeH by fn("fs_server_free", null, A)
    private val surfaceCreateH by fn("fs_iosurface_create", A, I, I)
    private val listH by fn("fs_list_servers", A, I)
    private val clientNewH by fn("fs_client_new", A, A, I)
    private val frameCountH by fn("fs_client_frame_count", L, A)
    private val copyRgbaH by fn("fs_client_copy_rgba", I, A, A, L, A, A)
    private val frameSurfaceH by fn("fs_client_frame_iosurface", A, A, A, A)
    private val waitFrameH by fn("fs_client_wait_frame", L, A, L, I)
    private val clientDescH by fn("fs_client_description", A, A)
    private val clientFreeH by fn("fs_client_free", null, A)

    private fun lastError(): String = (lastErrorH.invokeExact() as MemorySegment).reinterpret(Long.MAX_VALUE).getString(0)

    private fun fail(what: String): Nothing = throw IllegalStateException("Syphon: $what failed: ${lastError()}")

    private fun check(status: Int, what: String): Int = if (status < 0) fail(what) else status

    private fun takeString(p: MemorySegment, what: String): String {
        if (p == MemorySegment.NULL) fail(what)
        try { return p.reinterpret(Long.MAX_VALUE).getString(0) } finally { freeStringH.invokeExact(p) }
    }

fun version(): Int = versionH.invokeExact() as Int

fun pump(ms: Int): Boolean = (pumpH.invokeExact(ms) as Int) == 1

fun serverNew(name: String?): MemorySegment

    fun serverNew(name: String?): MemorySegment = Arena.ofConfined().use { a ->
        val n = if (name == null) MemorySegment.NULL else a.allocateFrom(name)
        val h = serverNewH.invokeExact(n) as MemorySegment
        if (h == MemorySegment.NULL) fail("creating server \"$name\"")
        h
    }

fun publish(s: MemorySegment, pixels: ByteArray, width: Int, height: Int, bgra: Boolean)

    fun publish(s: MemorySegment, pixels: ByteArray, width: Int, height: Int, bgra: Boolean) {
        require(width > 0 && height > 0 && pixels.size >= width * height * 4) {
            "need ${width * height * 4} bytes for $width x $height, got ${pixels.size}"
        }
        Arena.ofConfined().use { a ->
            val seg = a.allocateFrom(JAVA_BYTE, *pixels)
            val status = if (bgra) publishBgraH.invokeExact(s, seg, width, height, 0) as Int
            else publishRgbaH.invokeExact(s, seg, width, height, 0) as Int
            check(status, "publishing a frame")
        }
    }

fun publish(s: MemorySegment, pixels: MemorySegment, width: Int, height: Int, stride: Int, bgra: Boolean)

    fun publish(s: MemorySegment, pixels: MemorySegment, width: Int, height: Int, stride: Int, bgra: Boolean) {
        val status = if (bgra) publishBgraH.invokeExact(s, pixels, width, height, stride) as Int
        else publishRgbaH.invokeExact(s, pixels, width, height, stride) as Int
        check(status, "publishing a frame")
    }

fun publishSurface(s: MemorySegment, ioSurfaceRef: Long, flipped: Boolean)

    fun publishSurface(s: MemorySegment, ioSurfaceRef: Long, flipped: Boolean) {
        check(publishSurfaceH.invokeExact(s, MemorySegment.ofAddress(ioSurfaceRef), if (flipped) 1 else 0) as Int, "publishing an IOSurface")
    }

fun ioSurfaceCreate(width: Int, height: Int): Long

A new BGRA8, page-padded IOSurface; the caller owns the +1 retain (MetalIOSurface.release).

    fun ioSurfaceCreate(width: Int, height: Int): Long {
        val p = surfaceCreateH.invokeExact(width, height) as MemorySegment
        if (p == MemorySegment.NULL) fail("creating a $width x $height IOSurface")
        return p.address()
    }

fun hasClients(s: MemorySegment): Boolean

    fun hasClients(s: MemorySegment): Boolean = check(hasClientsH.invokeExact(s) as Int, "hasClients") == 1

fun serverDescription(s: MemorySegment): String

    fun serverDescription(s: MemorySegment): String = takeString(serverDescH.invokeExact(s) as MemorySegment, "describing the server")

fun serverFree(s: MemorySegment) { serverFreeH.invokeExact(s) }

fun list(waitMs: Int): String

    fun list(waitMs: Int): String = takeString(listH.invokeExact(waitMs) as MemorySegment, "listing servers")

fun clientNew(match: String?, waitMs: Int): MemorySegment?

Null when no server matched within waitMs; the reason is in lastErrorMessage.

    fun clientNew(match: String?, waitMs: Int): MemorySegment? = Arena.ofConfined().use { a ->
        val m = if (match == null) MemorySegment.NULL else a.allocateFrom(match)
        (clientNewH.invokeExact(m, waitMs) as MemorySegment).takeIf { it != MemorySegment.NULL }
    }

fun lastErrorMessage(): String = lastError()

fun frameCount(c: MemorySegment): Long = frameCountH.invokeExact(c) as Long

fun waitFrame(c: MemorySegment, after: Long, waitMs: Int): Long

    fun waitFrame(c: MemorySegment, after: Long, waitMs: Int): Long = waitFrameH.invokeExact(c, after, waitMs) as Long

fun copyRgba(c: MemorySegment, reuse: ByteArray?): Triple<ByteArray, Int, Int>?

The latest frame as (RGBA bytes, width, height), or null if none has arrived.

    fun copyRgba(c: MemorySegment, reuse: ByteArray?): Triple<ByteArray, Int, Int>? = Arena.ofConfined().use { a ->
        val w = a.allocate(I)
        val h = a.allocate(I)
        var cap = (reuse?.size ?: 0).toLong()
        var buf = if (cap > 0) a.allocate(cap) else MemorySegment.NULL
        var status = copyRgbaH.invokeExact(c, buf, cap, w, h) as Int
        if (status == 0) return@use null
        if (status == -2) {
            cap = w.get(I, 0).toLong() * h.get(I, 0) * 4
            buf = a.allocate(cap)
            status = copyRgbaH.invokeExact(c, buf, cap, w, h) as Int
        }
        if (status == 0) return@use null
        check(status, "reading a frame")
        cpuCopies.incrementAndGet()
        val width = w.get(I, 0)
        val height = h.get(I, 0)
        val n = width * height * 4
        val out = if (reuse != null && reuse.size == n) reuse else ByteArray(n)
        MemorySegment.copy(buf, JAVA_BYTE, 0, out, 0, n)
        Triple(out, width, height)
    }

val cpuCopies = java.util.concurrent.atomic.AtomicLong()

Frames read back through memory by copyRgba / copyRgbaInto in this JVM (each one a GPU blit into a staging buffer and a CPU swizzle). A zero-copy receiver leaves it unchanged.

fun frameSurface(c: MemorySegment): Long

The IOSurface the client's latest frame lives in, +1 retained (release with MetalIOSurface.release), or 0 if there is no frame yet (or it is not IOSurface-backed; lastErrorMessage then says so). No pixels move.

    fun frameSurface(c: MemorySegment): Long = Arena.ofConfined().use { a ->
        val w = a.allocate(I)
        val h = a.allocate(I)
        val p = frameSurfaceH.invokeExact(c, w, h) as MemorySegment
        if (p == MemorySegment.NULL) 0L else p.address()
    }

fun copyRgbaInto(c: MemorySegment, dst: MemorySegment): Pair<Int, Int>?

Copy the latest frame straight into dst; returns (width, height), or null if none yet or it does not fit.

    fun copyRgbaInto(c: MemorySegment, dst: MemorySegment): Pair<Int, Int>? = Arena.ofConfined().use { a ->
        val w = a.allocate(I)
        val h = a.allocate(I)
        val status = copyRgbaH.invokeExact(c, dst, dst.byteSize(), w, h) as Int
        if (status == 1) cpuCopies.incrementAndGet()
        if (status == 1) w.get(I, 0) to h.get(I, 0) else if (status == 0 || status == -2) null else fail("reading a frame")
    }

fun clientDescription(c: MemorySegment): String

    fun clientDescription(c: MemorySegment): String = takeString(clientDescH.invokeExact(c) as MemorySegment, "describing the client")

fun clientFree(c: MemorySegment) { clientFreeH.invokeExact(c) }