Full project rename, decided 2026-06-10: - Crates/binaries: punktfunk-core / punktfunk-host / punktfunk-client-rs. - C ABI: punktfunk_* symbols, Punktfunk* types, include/punktfunk_core.h, PUNKTFUNK_FEATURE_QUIC guard (header regenerated; cbindgen renames updated, incl. PUNKTFUNK_BTN_*/PUNKTFUNK_AXIS_* wire constants). - Protocol: punktfunk/1 — control-plane magic LMN1 → PKF1, nonce salt lmn1 → pkf1. WIRE BREAK: clients must be rebuilt from this revision. - Env knobs: PUNKTFUNK_VIDEO_SOURCE / PUNKTFUNK_COMPOSITOR / PUNKTFUNK_ZEROCOPY / …. - Host config dir: ~/.config/punktfunk (the box's dir was migrated in place — the persistent identity is unchanged, pinned fingerprints stay valid). - Swift package: PunktfunkKit + PunktfunkCore.xcframework + PunktfunkConnection (Sources/PunktfunkClient app + tests renamed with it); build-xcframework.sh updated. - scripts/: 60-punktfunk.rules, punktfunk-host.service; OpenAPI doc regenerated. Also: scripts/headless/run-headless-kde.sh — full headless Plasma bringup. Root cause of "desktop but no apps/settings" over the stream: plasmashell launched without XDG_MENU_PREFIX=plasma-, so the launcher resolved a nonexistent applications.menu and rendered an empty menu. The script sets the complete KDE session env (menu prefix, KDE_FULL_SESSION, session version) and rebuilds ksycoca before starting plasmashell. Gate: 97/97 tests, clippy -D warnings (both feature sets), fmt, C-ABI harness PASS, zero lumen references left outside .git. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
// Unit tests for the Annex-B ⇄ AVCC plumbing (pure byte-level; no codec involved —
|
||||
// VideoToolboxRoundTripTests covers the real-bitstream path).
|
||||
|
||||
import XCTest
|
||||
@testable import PunktfunkKit
|
||||
|
||||
final class AnnexBTests: XCTestCase {
|
||||
/// NAL with the given HEVC type in bits 1..6 of the first header byte.
|
||||
private func nal(type: UInt8, payload: [UInt8]) -> Data {
|
||||
Data([type << 1, 0x01] + payload)
|
||||
}
|
||||
|
||||
private let start4: [UInt8] = [0, 0, 0, 1]
|
||||
private let start3: [UInt8] = [0, 0, 1]
|
||||
|
||||
func testSplitMixedStartCodes() {
|
||||
let a = nal(type: 32, payload: [0xAA])
|
||||
let b = nal(type: 33, payload: [0xBB, 0xBC])
|
||||
let c = nal(type: 19, payload: [0xCC, 0xCD, 0xCE])
|
||||
var au = Data(start4)
|
||||
au.append(a)
|
||||
au.append(contentsOf: start3)
|
||||
au.append(b)
|
||||
au.append(contentsOf: start4)
|
||||
au.append(c)
|
||||
|
||||
let nals = AnnexB.nalUnits(in: au)
|
||||
XCTAssertEqual(nals, [a, b, c])
|
||||
XCTAssertEqual(nals.map(AnnexB.hevcNalType), [32, 33, 19])
|
||||
}
|
||||
|
||||
func testSplitSingleNalNoTrailingCode() {
|
||||
let v = nal(type: 34, payload: [1, 2, 3])
|
||||
let au = Data(start3) + v
|
||||
XCTAssertEqual(AnnexB.nalUnits(in: au), [v])
|
||||
}
|
||||
|
||||
func testSplitEmptyAndGarbage() {
|
||||
XCTAssertEqual(AnnexB.nalUnits(in: Data()), [])
|
||||
// No start code at all → no NALs.
|
||||
XCTAssertEqual(AnnexB.nalUnits(in: Data([9, 8, 7, 6])), [])
|
||||
}
|
||||
|
||||
func testSplitDropsTrailingZeroPadding() {
|
||||
// trailing_zero_8bits between NALs (and >2 zeros forming a long separator) must
|
||||
// not leak into the preceding NAL.
|
||||
let a = nal(type: 33, payload: [0xAA])
|
||||
let b = nal(type: 19, payload: [0xBB])
|
||||
var au = Data(start4)
|
||||
au.append(a)
|
||||
au.append(contentsOf: [0, 0, 0, 0, 0, 1]) // padding + start code
|
||||
au.append(b)
|
||||
XCTAssertEqual(AnnexB.nalUnits(in: au), [a, b])
|
||||
}
|
||||
|
||||
func testAvccDropsParameterSetsAndPrefixesLengths() {
|
||||
let vps = nal(type: 32, payload: [0xAA])
|
||||
let sps = nal(type: 33, payload: [0xBB])
|
||||
let pps = nal(type: 34, payload: [0xCC])
|
||||
let idr = nal(type: 19, payload: [0xDD, 0xDE, 0xDF, 0xE0])
|
||||
var au = Data()
|
||||
for n in [vps, sps, pps, idr] {
|
||||
au.append(contentsOf: start4)
|
||||
au.append(n)
|
||||
}
|
||||
|
||||
let avcc = AnnexB.avcc(from: au)
|
||||
// Only the IDR survives: 4-byte BE length, then the NAL bytes.
|
||||
var expected = Data([0, 0, 0, UInt8(idr.count)])
|
||||
expected.append(idr)
|
||||
XCTAssertEqual(avcc, expected)
|
||||
}
|
||||
|
||||
func testFormatDescriptionNilWithoutParameterSets() {
|
||||
let idr = nal(type: 19, payload: [0xDD])
|
||||
let au = Data(start4) + idr
|
||||
XCTAssertNil(AnnexB.formatDescription(fromIDR: au))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// Integration: the Swift wrapper against a real punktfunk/1 host over QUIC + UDP on loopback —
|
||||
// the Swift twin of punktfunk-host's m3.rs::c_abi_connection_roundtrip, this time through the
|
||||
// statically linked xcframework. Driven by clients/apple/test-loopback.sh, which builds and
|
||||
// starts `punktfunk-host m3-host --source synthetic` and sets PUNKTFUNK_LOOPBACK_PORT.
|
||||
|
||||
import XCTest
|
||||
@testable import PunktfunkKit
|
||||
|
||||
final class LoopbackIntegrationTests: XCTestCase {
|
||||
func testSyntheticStreamRoundTrip() throws {
|
||||
guard let portStr = ProcessInfo.processInfo.environment["PUNKTFUNK_LOOPBACK_PORT"],
|
||||
let port = UInt16(portStr)
|
||||
else {
|
||||
throw XCTSkip("needs a running m3-host — use clients/apple/test-loopback.sh")
|
||||
}
|
||||
|
||||
let conn = try PunktfunkConnection(
|
||||
host: "127.0.0.1", port: port, width: 1280, height: 720, refreshHz: 60)
|
||||
XCTAssertEqual(conn.width, 1280)
|
||||
XCTAssertEqual(conn.height, 720)
|
||||
XCTAssertEqual(conn.refreshHz, 60)
|
||||
|
||||
// Pull 25 synthetic frames and byte-verify the documented pattern:
|
||||
// u32 LE frame index, then data[i] = (idx as u8) &+ (i as u8).
|
||||
var got = 0
|
||||
var lastIndex: UInt32 = 0
|
||||
let deadline = Date().addingTimeInterval(30)
|
||||
while got < 25 {
|
||||
XCTAssertLessThan(Date(), deadline, "timed out after \(got) frames")
|
||||
guard let au = try conn.nextAU(timeoutMs: 2000) else { continue }
|
||||
let idx = au.data.prefix(4).reversed().reduce(UInt32(0)) { ($0 << 8) | UInt32($1) }
|
||||
for (i, byte) in au.data.enumerated().dropFirst(4) {
|
||||
let expected = UInt8(truncatingIfNeeded: idx) &+ UInt8(truncatingIfNeeded: i)
|
||||
if byte != expected {
|
||||
XCTFail("frame \(idx) corrupt at offset \(i)")
|
||||
break
|
||||
}
|
||||
}
|
||||
XCTAssertGreaterThan(au.ptsNs, 0)
|
||||
lastIndex = idx
|
||||
got += 1
|
||||
}
|
||||
XCTAssertGreaterThanOrEqual(lastIndex, 24)
|
||||
|
||||
// Input goes the other way (enqueue-only; the host logs the count on close).
|
||||
conn.send(.mouseMove(dx: 1, dy: 2))
|
||||
conn.send(.key(0x41, down: true))
|
||||
conn.send(.key(0x41, down: false))
|
||||
|
||||
conn.close()
|
||||
XCTAssertThrowsError(try conn.nextAU(timeoutMs: 10)) { error in
|
||||
guard case PunktfunkClientError.closed = error else {
|
||||
return XCTFail("expected .closed, got \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func testConnectFailureThrows() {
|
||||
// Nothing listens on this port; connect must fail within its timeout, not hang.
|
||||
XCTAssertThrowsError(
|
||||
try PunktfunkConnection(
|
||||
host: "127.0.0.1", port: 9, width: 640, height: 480, refreshHz: 30,
|
||||
timeoutMs: 2000))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// First light, headless: the full client pipeline against a REAL remote host — QUIC
|
||||
// handshake over the LAN, NVENC HEVC AUs through FEC + AES-GCM, AnnexB conversion, and a
|
||||
// real VTDecompressionSession turning them into pixels. Everything the GUI does except
|
||||
// putting the layer on glass.
|
||||
//
|
||||
// Run (host side, on the Linux box):
|
||||
// PUNKTFUNK_COMPOSITOR=gamescope PUNKTFUNK_GAMESCOPE_APP=vkcube PUNKTFUNK_ZEROCOPY=1 \
|
||||
// punktfunk-host m3-host --source virtual --seconds 120
|
||||
// Then here:
|
||||
// PUNKTFUNK_REMOTE_HOST=192.168.1.70 swift test --filter RemoteFirstLightTests
|
||||
|
||||
import CoreMedia
|
||||
import VideoToolbox
|
||||
import XCTest
|
||||
@testable import PunktfunkKit
|
||||
|
||||
final class RemoteFirstLightTests: XCTestCase {
|
||||
func testRemoteStreamDecodesToPixels() throws {
|
||||
guard let host = ProcessInfo.processInfo.environment["PUNKTFUNK_REMOTE_HOST"] else {
|
||||
throw XCTSkip("set PUNKTFUNK_REMOTE_HOST (and start m3-host --source virtual there)")
|
||||
}
|
||||
let width: UInt32 = 1280
|
||||
let height: UInt32 = 720
|
||||
|
||||
let conn = try PunktfunkConnection(
|
||||
host: host, width: width, height: height, refreshHz: 60)
|
||||
defer { conn.close() }
|
||||
XCTAssertEqual(conn.width, width)
|
||||
XCTAssertEqual(conn.height, height)
|
||||
|
||||
var format: CMVideoFormatDescription?
|
||||
var decoder: VTDecompressionSession?
|
||||
defer { decoder.map { VTDecompressionSessionInvalidate($0) } }
|
||||
var received = 0
|
||||
var decoded = 0
|
||||
var firstPtsNs: UInt64 = 0
|
||||
var lastPtsNs: UInt64 = 0
|
||||
let deadline = Date().addingTimeInterval(30)
|
||||
|
||||
while decoded < 60, Date() < deadline {
|
||||
guard let au = try conn.nextAU(timeoutMs: 2000) else { continue }
|
||||
received += 1
|
||||
if firstPtsNs == 0 { firstPtsNs = au.ptsNs }
|
||||
lastPtsNs = au.ptsNs
|
||||
|
||||
if let f = AnnexB.formatDescription(fromIDR: au.data) {
|
||||
format = f
|
||||
if decoder == nil {
|
||||
let dims = CMVideoFormatDescriptionGetDimensions(f)
|
||||
XCTAssertEqual(UInt32(dims.width), width)
|
||||
XCTAssertEqual(UInt32(dims.height), height)
|
||||
var session: VTDecompressionSession?
|
||||
XCTAssertEqual(
|
||||
VTDecompressionSessionCreate(
|
||||
allocator: nil, formatDescription: f, decoderSpecification: nil,
|
||||
imageBufferAttributes: nil, outputCallback: nil,
|
||||
decompressionSessionOut: &session),
|
||||
noErr)
|
||||
decoder = session
|
||||
}
|
||||
}
|
||||
guard let f = format, let dec = decoder,
|
||||
let sample = AnnexB.sampleBuffer(au: au, format: f)
|
||||
else { continue }
|
||||
|
||||
var gotPixels = false
|
||||
VTDecompressionSessionDecodeFrame(
|
||||
dec, sampleBuffer: sample, flags: [], infoFlagsOut: nil
|
||||
) { status, _, imageBuffer, _, _ in
|
||||
gotPixels = status == noErr && imageBuffer != nil
|
||||
}
|
||||
if gotPixels { decoded += 1 }
|
||||
}
|
||||
|
||||
XCTAssertGreaterThanOrEqual(decoded, 60, "decoded \(decoded)/\(received) received AUs")
|
||||
// The host stamps pts with its capture wall clock — 60 frames should span ~1 s.
|
||||
let spanMs = Double(lastPtsNs &- firstPtsNs) / 1_000_000
|
||||
print("first light: \(decoded) frames decoded, \(received) received, pts span \(Int(spanMs)) ms")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
// Real-bitstream proof of the decode-prep path: VTCompressionSession encodes HEVC, we
|
||||
// rebuild the host's wire shape (Annex-B AU with in-band VPS/SPS/PPS — exactly what
|
||||
// punktfunk-host emits on every IDR), run it through AnnexB, and hand the result to a real
|
||||
// VTDecompressionSession. Pixels out = the whole client decode path is sound.
|
||||
|
||||
import AVFoundation
|
||||
import CoreMedia
|
||||
import VideoToolbox
|
||||
import XCTest
|
||||
@testable import PunktfunkKit
|
||||
|
||||
final class VideoToolboxRoundTripTests: XCTestCase {
|
||||
private let width = 320
|
||||
private let height = 240
|
||||
|
||||
func testEncodeAnnexBDecodeRoundTrip() throws {
|
||||
let (formatDesc, avccSample) = try encodeOneHEVCKeyframe()
|
||||
|
||||
// Rebuild the host's wire format: Annex-B AU, parameter sets in-band before the VCL.
|
||||
let annexB = try annexBAU(formatDesc: formatDesc, avccSample: avccSample)
|
||||
|
||||
// 1) Parameter-set extraction → format description.
|
||||
let rebuilt = try XCTUnwrap(
|
||||
AnnexB.formatDescription(fromIDR: annexB),
|
||||
"in-band VPS/SPS/PPS should yield a format description")
|
||||
let dims = CMVideoFormatDescriptionGetDimensions(rebuilt)
|
||||
XCTAssertEqual(Int(dims.width), width)
|
||||
XCTAssertEqual(Int(dims.height), height)
|
||||
|
||||
// 2) Annex-B → AVCC re-pack must reproduce the encoder's own sample bytes.
|
||||
XCTAssertEqual(AnnexB.avcc(from: annexB), avccSample)
|
||||
|
||||
// 3) Sample buffer → real decoder → pixels.
|
||||
let au = AccessUnit(data: annexB, ptsNs: 1_000_000, frameIndex: 0, flags: 0)
|
||||
let sample = try XCTUnwrap(AnnexB.sampleBuffer(au: au, format: rebuilt))
|
||||
|
||||
var session: VTDecompressionSession?
|
||||
XCTAssertEqual(
|
||||
VTDecompressionSessionCreate(
|
||||
allocator: nil, formatDescription: rebuilt, decoderSpecification: nil,
|
||||
imageBufferAttributes: nil, outputCallback: nil,
|
||||
decompressionSessionOut: &session),
|
||||
noErr)
|
||||
let decoder = try XCTUnwrap(session)
|
||||
defer { VTDecompressionSessionInvalidate(decoder) }
|
||||
|
||||
var decoded: CVImageBuffer?
|
||||
var decodeStatus: OSStatus = -1
|
||||
// No async flag → the handler runs before DecodeFrame returns.
|
||||
VTDecompressionSessionDecodeFrame(
|
||||
decoder, sampleBuffer: sample, flags: [], infoFlagsOut: nil
|
||||
) { status, _, imageBuffer, _, _ in
|
||||
decodeStatus = status
|
||||
decoded = imageBuffer
|
||||
}
|
||||
XCTAssertEqual(decodeStatus, noErr)
|
||||
let pixels = try XCTUnwrap(decoded) // CVImageBuffer and CVPixelBuffer are the same CF type
|
||||
XCTAssertEqual(CVPixelBufferGetWidth(pixels), width)
|
||||
XCTAssertEqual(CVPixelBufferGetHeight(pixels), height)
|
||||
}
|
||||
|
||||
// MARK: - encode helpers
|
||||
|
||||
/// One forced-IDR HEVC frame; returns its format description and raw AVCC sample bytes.
|
||||
private func encodeOneHEVCKeyframe() throws -> (CMVideoFormatDescription, Data) {
|
||||
var session: VTCompressionSession?
|
||||
let rc = VTCompressionSessionCreate(
|
||||
allocator: nil, width: Int32(width), height: Int32(height),
|
||||
codecType: kCMVideoCodecType_HEVC, encoderSpecification: nil,
|
||||
imageBufferAttributes: nil, compressedDataAllocator: nil,
|
||||
outputCallback: nil, refcon: nil, compressionSessionOut: &session)
|
||||
guard rc == noErr, let encoder = session else {
|
||||
throw XCTSkip("no HEVC encoder available (\(rc))")
|
||||
}
|
||||
defer { VTCompressionSessionInvalidate(encoder) }
|
||||
VTSessionSetProperty(encoder, key: kVTCompressionPropertyKey_RealTime, value: kCFBooleanTrue)
|
||||
VTSessionSetProperty(
|
||||
encoder, key: kVTCompressionPropertyKey_AllowFrameReordering, value: kCFBooleanFalse)
|
||||
|
||||
let lock = NSLock()
|
||||
var output: CMSampleBuffer?
|
||||
let done = expectation(description: "encoded")
|
||||
VTCompressionSessionEncodeFrame(
|
||||
encoder, imageBuffer: try gradientPixelBuffer(),
|
||||
presentationTimeStamp: CMTime(value: 0, timescale: 30),
|
||||
duration: CMTime(value: 1, timescale: 30),
|
||||
frameProperties: [kVTEncodeFrameOptionKey_ForceKeyFrame: kCFBooleanTrue] as CFDictionary,
|
||||
infoFlagsOut: nil
|
||||
) { status, _, sample in
|
||||
XCTAssertEqual(status, noErr)
|
||||
lock.lock()
|
||||
output = sample
|
||||
lock.unlock()
|
||||
done.fulfill()
|
||||
}
|
||||
VTCompressionSessionCompleteFrames(encoder, untilPresentationTimeStamp: .invalid)
|
||||
wait(for: [done], timeout: 10)
|
||||
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
let sample = try XCTUnwrap(output)
|
||||
let desc = try XCTUnwrap(CMSampleBufferGetFormatDescription(sample))
|
||||
let block = try XCTUnwrap(CMSampleBufferGetDataBuffer(sample))
|
||||
var bytes = Data(count: CMBlockBufferGetDataLength(block))
|
||||
try bytes.withUnsafeMutableBytes { raw in
|
||||
let rc = CMBlockBufferCopyDataBytes(
|
||||
block, atOffset: 0, dataLength: raw.count,
|
||||
destination: raw.baseAddress!)
|
||||
if rc != noErr { throw NSError(domain: "CMBlockBuffer", code: Int(rc)) }
|
||||
}
|
||||
return (desc, bytes)
|
||||
}
|
||||
|
||||
/// The host's wire shape: 4-byte start codes, VPS/SPS/PPS in-band, then the VCL NALs.
|
||||
private func annexBAU(formatDesc: CMVideoFormatDescription, avccSample: Data) throws -> Data {
|
||||
var au = Data()
|
||||
|
||||
var psCount = 0
|
||||
var nalHeaderLen: Int32 = 0
|
||||
XCTAssertEqual(
|
||||
CMVideoFormatDescriptionGetHEVCParameterSetAtIndex(
|
||||
formatDesc, parameterSetIndex: 0, parameterSetPointerOut: nil,
|
||||
parameterSetSizeOut: nil, parameterSetCountOut: &psCount,
|
||||
nalUnitHeaderLengthOut: &nalHeaderLen),
|
||||
noErr)
|
||||
XCTAssertEqual(nalHeaderLen, 4, "AnnexB.avcc assumes 4-byte NAL length prefixes")
|
||||
for i in 0..<psCount {
|
||||
var ptr: UnsafePointer<UInt8>?
|
||||
var size = 0
|
||||
XCTAssertEqual(
|
||||
CMVideoFormatDescriptionGetHEVCParameterSetAtIndex(
|
||||
formatDesc, parameterSetIndex: i, parameterSetPointerOut: &ptr,
|
||||
parameterSetSizeOut: &size, parameterSetCountOut: nil,
|
||||
nalUnitHeaderLengthOut: nil),
|
||||
noErr)
|
||||
au.append(contentsOf: [0, 0, 0, 1])
|
||||
au.append(Data(bytes: try XCTUnwrap(ptr), count: size))
|
||||
}
|
||||
|
||||
// AVCC sample (4-byte BE length per NAL) → start codes.
|
||||
var i = avccSample.startIndex
|
||||
while i + 4 <= avccSample.endIndex {
|
||||
let len = avccSample[i..<i + 4].reduce(0) { ($0 << 8) | Int($1) }
|
||||
let body = avccSample.index(i, offsetBy: 4)
|
||||
guard let end = avccSample.index(body, offsetBy: len, limitedBy: avccSample.endIndex)
|
||||
else { break }
|
||||
au.append(contentsOf: [0, 0, 0, 1])
|
||||
au.append(avccSample[body..<end])
|
||||
i = end
|
||||
}
|
||||
return au
|
||||
}
|
||||
|
||||
private func gradientPixelBuffer() throws -> CVPixelBuffer {
|
||||
var pb: CVPixelBuffer?
|
||||
let attrs = [kCVPixelBufferIOSurfacePropertiesKey: [:]] as CFDictionary
|
||||
XCTAssertEqual(
|
||||
CVPixelBufferCreate(nil, width, height, kCVPixelFormatType_32BGRA, attrs, &pb),
|
||||
kCVReturnSuccess)
|
||||
let buf = try XCTUnwrap(pb)
|
||||
CVPixelBufferLockBaseAddress(buf, [])
|
||||
defer { CVPixelBufferUnlockBaseAddress(buf, []) }
|
||||
let base = try XCTUnwrap(CVPixelBufferGetBaseAddress(buf))
|
||||
let stride = CVPixelBufferGetBytesPerRow(buf)
|
||||
for y in 0..<height {
|
||||
let row = base.advanced(by: y * stride).assumingMemoryBound(to: UInt8.self)
|
||||
for x in 0..<width {
|
||||
row[x * 4 + 0] = UInt8(x & 0xFF) // B
|
||||
row[x * 4 + 1] = UInt8(y & 0xFF) // G
|
||||
row[x * 4 + 2] = UInt8((x ^ y) & 0xFF) // R
|
||||
row[x * 4 + 3] = 0xFF
|
||||
}
|
||||
}
|
||||
return buf
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user