From 2ca06faba4d9198bc68993aae491c4e21f6eea7f Mon Sep 17 00:00:00 2001 From: Oleg Zuev Date: Tue, 8 Sep 2026 17:25:34 +0300 Subject: [PATCH 1/3] Add native VideoToolbox decoding for guest VA-API applications --- .gitattributes | 3 +- README.md | 6 +- THIRD_PARTY_NOTICES.md | 9 + docs/native-video.md | 144 + guest/build.sh | 5 + .../lib/try-omarchy/install-vivaldi-arm64 | 28 +- guest/scripts/finalize-rootfs.sh | 15 + guest/scripts/register-local-repository.sh | 1 + guest/scripts/register-native-video.sh | 182 + guest/spec.json | 30 +- guest/tests/verify.py | 3 +- guest/video/arm64-browser-compat.c | 31 + guest/video/broker.cpp | 206 + guest/video/client.hpp | 65 + guest/video/connection.hpp | 62 + guest/video/driver.cpp | 796 +++ guest/video/environment.sh | 22 + guest/video/ffmpeg-full-bitstream.patch | 141 + guest/video/firefox | 9 + guest/video/firefox-prefs.js | 12 + guest/video/firefox-video-bootstrap.cpp | 43 + guest/video/mpv | 8 + guest/video/omarchy-video-broker.service | 21 + guest/video/omarchy-video-firefox.desktop | 11 + guest/video/vivaldi.sh | 15 + guest/video/wire.hpp | 106 + .../NativeAV1Configuration.swift | 17 + .../NativeVP9Configuration.swift | 17 + .../OmarchyVMHelper/NativeVideoBridge.swift | 384 ++ .../NativeVideoGPUChannel.swift | 98 + .../OmarchyVMHelper/NativeVideoProtocol.swift | 172 + .../NativeVideoSharedMemory.swift | 124 + macos/Sources/OmarchyVMHelper/main.swift | 25 +- .../NativeVideoProtocolTests.swift | 120 + macos/Tests/run-qemu-ssh-contract.test.sh | 3 +- macos/build-qemu-gpu-runtime.sh | 65 +- macos/patches/qemu-display-cadence.patch | 37 + macos/patches/qemu-native-video-shmem.patch | 560 ++ macos/patches/virglrenderer-angle-video.patch | 165 + .../patches/virglrenderer-macos-1.0.33.patch | 5595 +++++++++++++++++ macos/prepare-qemu-gpu-runtime.sh | 15 +- macos/run-qemu-gpu.sh | 72 +- tests/native-video-benchmark.c | 218 + tests/native-video-firefox-pool.cpp | 25 + tests/native-video-gpu.cpp | 134 + tests/native-video-smoke.py | 128 + tests/native-video-surface.cpp | 79 + tests/native-video-transport.cpp | 58 + 48 files changed, 10069 insertions(+), 16 deletions(-) create mode 100644 docs/native-video.md create mode 100755 guest/scripts/register-native-video.sh create mode 100644 guest/video/arm64-browser-compat.c create mode 100644 guest/video/broker.cpp create mode 100644 guest/video/client.hpp create mode 100644 guest/video/connection.hpp create mode 100644 guest/video/driver.cpp create mode 100644 guest/video/environment.sh create mode 100644 guest/video/ffmpeg-full-bitstream.patch create mode 100755 guest/video/firefox create mode 100644 guest/video/firefox-prefs.js create mode 100644 guest/video/firefox-video-bootstrap.cpp create mode 100755 guest/video/mpv create mode 100644 guest/video/omarchy-video-broker.service create mode 100644 guest/video/omarchy-video-firefox.desktop create mode 100644 guest/video/vivaldi.sh create mode 100644 guest/video/wire.hpp create mode 100644 macos/Sources/OmarchyVMHelper/NativeAV1Configuration.swift create mode 100644 macos/Sources/OmarchyVMHelper/NativeVP9Configuration.swift create mode 100644 macos/Sources/OmarchyVMHelper/NativeVideoBridge.swift create mode 100644 macos/Sources/OmarchyVMHelper/NativeVideoGPUChannel.swift create mode 100644 macos/Sources/OmarchyVMHelper/NativeVideoProtocol.swift create mode 100644 macos/Sources/OmarchyVMHelper/NativeVideoSharedMemory.swift create mode 100644 macos/Tests/OmarchyVMHelperTests/NativeVideoProtocolTests.swift create mode 100644 macos/patches/qemu-display-cadence.patch create mode 100644 macos/patches/qemu-native-video-shmem.patch create mode 100644 macos/patches/virglrenderer-angle-video.patch create mode 100644 macos/patches/virglrenderer-macos-1.0.33.patch create mode 100644 tests/native-video-benchmark.c create mode 100644 tests/native-video-firefox-pool.cpp create mode 100644 tests/native-video-gpu.cpp create mode 100644 tests/native-video-smoke.py create mode 100644 tests/native-video-surface.cpp create mode 100644 tests/native-video-transport.cpp diff --git a/.gitattributes b/.gitattributes index a0848b62..9fb5c3f3 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,2 @@ -*.patch whitespace=-blank-at-eol +# Blank context lines, including the final hunk's context, are patch syntax. +*.patch whitespace=-blank-at-eol,-blank-at-eof diff --git a/README.md b/README.md index 4d85f003..bee48524 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ Omarchy's trademark rights. - Hardware-accelerated ARM64 virtualization and VirGL graphics - Nested KVM virtualization on M3 and newer Apple Silicon +- Hardware video decoding through the Mac's VideoToolbox media engine - Resizable native window with automatic guest resolution and HiDPI scale updates - Mac audio input/output selection inside Omarchy, with live routing and system-default fallback - FaceTime HD and other Mac cameras exposed to Omarchy as an on-demand 720p webcam @@ -25,7 +26,10 @@ Omarchy's trademark rights. - One optional shared Mac folder, available inside Omarchy under the same name (`~/Work` stays `~/Work`) - Loopback-only TCP and UDP port forwarding from the Mac into Omarchy -> **Current limitation:** Video decoding is CPU-only, so playback can be slow, especially at high resolutions. An improved video path is in development. +The native video path supports HEVC, AV1 and VP9 in the bundled mpv integration, +HEVC in Firefox, and VP9 YouTube playback in Vivaldi. Available codecs depend on +the Mac's hardware. See [native video](docs/native-video.md) for application +setup, verification and measured performance limits. ## Changes in this fork diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 2baf3cae..861f35a2 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -18,6 +18,15 @@ code. their verified upstream license is retained in the rebuilt guest package. - **ANGLE, VirGLRenderer, libepoxy, SDL, libslirp, GLib, Pixman, and other QEMU dependencies** — retain their respective upstream licenses. +- **FFmpeg 9.0.1** — the private native-video build enables GPL and version 3 + components and is distributed under GPL-3.0-or-later. Its pinned source + archive, local bitstream-preservation patch and build recipe are included in + the guest under `/usr/share/try-omarchy/native-video-source`; its GPL notice + is in `/usr/share/licenses/try-omarchy-native-video`. The system FFmpeg + package remains separate. Linked Arch packages retain their own notices. +- **PyYAML and packaging** — pinned build dependencies used to build + VirGLRenderer and QEMU, respectively; MIT for PyYAML and Apache-2.0/BSD for + packaging. They are not loaded by the application at runtime. - **mise** — MIT; the reviewed ARM64 release is pinned in `guest/spec.json`. - **ttfx** — MIT; the reviewed source release and locked Rust dependencies are pinned in `guest/spec.json` and built natively for ARM64. Its packaged diff --git a/docs/native-video.md b/docs/native-video.md new file mode 100644 index 00000000..0eaff391 --- /dev/null +++ b/docs/native-video.md @@ -0,0 +1,144 @@ +# Native hardware video + +The guest's `omarchy` VA-API driver sends compressed video to a supervised Swift +helper on the Mac. VideoToolbox sessions require hardware decoding and must +confirm `UsingHardwareAcceleratedVideoDecoder=true` before they are exposed to +the guest. Unsupported hardware or streams fall back through the application's +normal software decoder. + +## Applications + +| Application | Verified path | Launch | +| --- | --- | --- | +| mpv | HEVC Main/Main10, AV1 Main, VP9 profiles 0/2 | `mpv video.mp4` | +| Firefox | HEVC Main10 with its RDD sandbox enabled | `firefox`, or **Firefox with hardware video** | +| Vivaldi | VP9 4K YouTube | Install Vivaldi from Omarchy's browser menu | + +The reviewed ARM Chromium package was built without VA-API support. Adding +Chromium command-line switches cannot enable code absent from its binary. +Vivaldi's launcher advertises the VP9 path verified with its own decoder. +HEVC and AV1 in mpv use the private FFmpeg build, which preserves compressed +headers that a remote decoder needs. The patch is conditional on the Omarchy +VA-API vendor and leaves other VA-API drivers' submissions unchanged. + +Firefox opens two restricted decoder connections in its RDD process before +installing that process's sandbox. The supplied default preferences disable +the forkserver so the RDD constructor runs. Seccomp and the RDD sandbox remain +enabled. More than two concurrent RDD decoder connections can fall back to +software. The `Firefox with hardware video` desktop entry uses this launcher; +Arch's separate desktop entry can launch Firefox directly without it. + +Firefox's slow-frame software fallback is disabled for this VM. Guest/host +scheduling can make individual frames late despite a low average decode time; +switching decoders midstream can otherwise fail HEVC playback. Unsupported +codecs and failed hardware initialization still take the software path. + +The launchers set library paths only for their own processes. They do not +replace Arch's FFmpeg libraries or alter `ld.so.conf`. The mpv wrapper checks +the FFmpeg ABI; after an incompatible Arch update it uses the system stack +until the matching native-video package is available. Explicit mpv options +supplied by the user take precedence over the wrapper's defaults. + +## Transport and lifetime + +Compressed messages use `dev.tryomarchy.video`. For exported VA surfaces, the +host passes the decoder's IOSurface to QEMU through a private Mach capability. +QEMU imports its two planes through ANGLE and copies them on the GPU into the +guest's existing VirGL textures. Neither the guest nor the helper resolves an +arbitrary global IOSurface ID. The helper's Unix channel and Mach service are +private to that QEMU instance. + +The GPU copies are flushed before acknowledging a frame. QEMU retains both the +IOSurface capability and imported textures until a GL fence signals completion, +without blocking the main loop. At most 32 imports may be pending; additional +frames use the memory path. A renderer reset cannot reuse stale GL names: any +imports belonging to the previous context stay bounded and retained until exit. + +The memory path uses a dedicated 512 MiB PCI aperture created by QEMU, never VM +RAM or a shared user directory. The root guest broker copies each client's +frames into its own sealed, read-only-exported 64 MiB buffer. Only the guest +`video` group can connect. Eight broker connections are available; retaining a +descriptor after disconnect cannot expose a subsequent client's buffer. + +Every frame must be released before its slot is reused. The broker completes +the host transaction even when a player exits during delivery. Message lengths, +dimensions and frame offsets are bounded; stalled partial messages and writes +time out. The helper exits with QEMU, the launcher supervises failures, and +QEMU plus launcher cleanup unlink the private aperture on shutdown. + +The VA driver updates exported GPU buffers when a surface is reused. Derived +or copied CPU images retain the same pixels, including after direct GPU uploads. +NV12 and P010 integration tests verify these properties on the actual virtio +GPU. CPU readback after a host GPU copy explicitly synchronizes the VirGL +resource, since the guest's Mesa cache cannot observe the host's texture write. + +## Build and verification + +`guest/scripts/register-native-video.sh` builds against the staged guest's +headers and libraries after the reviewed Hyprland compiler setup. It verifies +the FFmpeg source and patch hashes from `guest/spec.json`, installs a local +`try-omarchy-native-video` package, and records source and binary hashes in +`/usr/share/try-omarchy/native-video.json`. The package joins the guest's local +Pacman repository. Its corresponding source is included with the libraries. + +Run the normal repository checks with `make test`. The following additional +integration tests require Linux and, where indicated, the running VM: + +```sh +c++ -std=c++17 -O2 -pthread tests/native-video-transport.cpp -o /tmp/video-transport +/tmp/video-transport +c++ -std=c++17 -O2 -pthread tests/native-video-surface.cpp \ + $(pkg-config --cflags --libs libva gbm libdrm) -o /tmp/video-surface +/tmp/video-surface +c++ -std=c++17 -O2 tests/native-video-firefox-pool.cpp -o /tmp/video-pool +LD_PRELOAD=/usr/local/lib/omarchy-video/firefox-video-bootstrap.so \ + /tmp/video-pool -contentproc rdd +c++ -std=c++17 -O2 -pthread tests/native-video-gpu.cpp \ + $(pkg-config --cflags --libs libva gbm libdrm libavformat libavcodec) \ + -o /tmp/video-gpu +/tmp/video-gpu hevc-main10.mp4 +/tmp/video-gpu av1-main10.mp4 +``` + +`pacman -Qkk try-omarchy-native-video` verifies the installed package. +`systemctl status omarchy-video-broker` checks the running service. For a player +diagnostic, `OMARCHY_VIDEO_LOG=1 mpv video.mp4` reports the codec and hardware +session; mpv must also report `Using hardware decoding (vaapi)`. + +## Playback verification + +On the M5 Pro test machine, 4K/10-bit HEVC and the downloaded AV1 YouTube sample +matched software decoding frame for frame. VP9 comparison also passed. These +pixel hashes establish correctness, not playback throughput. + +On 2026-09-08, the installed application and the upgraded existing user disk +passed these tests on an M5 Pro with 18 vCPUs and 12 GiB guest RAM. The VM and +the tested application were visible in the foreground, with the video shown +fullscreen. Resolutions below describe the source video; the desktop scaled +the image to the Mac's display. + +| Application and stream | Measured playback | Dropped frames | +| --- | --- | --- | +| Vivaldi, [LG YouTube video](https://www.youtube.com/watch?v=njX2bu-_Vw4), VP9 3840×2160 at 59.94 FPS | Entire 126.56-second clip; 125.706 media seconds in 125.703 wall seconds during measurement | 0 of 7,529 frames in the measurement interval | +| mpv, the downloaded AV1 10-bit version of the same video | Entire 126.54-second clip at normal speed, `hwdec-current=vaapi` | 1 presentation drop; 0 decoder drops | +| mpv, HEVC 3840×2160 10-bit at 30 FPS | 58.87 seconds, `hwdec-current=vaapi` | 0 presentation or decoder drops | +| Firefox, HEVC 3840×2160 10-bit at 30 FPS | Entire 64-second clip in 64.008 wall seconds | 2 of 1,920 frames | + +YouTube stayed at 2160p60 throughout the measurement, with no waiting, stalled, +error or premature pause events. Its subsequent autoplay transition is excluded +from the result. Firefox completed without a playback error; its RDD process +loaded the packaged VA driver and private FFmpeg with `NoNewPrivs=1` and +`Seccomp=2`. A network `stalled` event occurred while the local clip was already +buffered; presentation continued, with a maximum frame callback gap of 81 ms. +The broker stayed active with zero restarts throughout the main VM tests. + +Background and occluded-window runs can be throttled by the desktop and must +not be used to establish foreground playback performance. Decode-only throughput +or playback that slows the media clock likewise does not prove display cadence. +These measurements establish 4K60 playback for this machine and tested streams; +other Macs and streams need their own playback measurements. + +The local upgrade used for these tests retained the application's existing +factory image. **Reset Omarchy restores that earlier guest baseline.** A new +factory image built from this branch includes the native-video package through +the guest build integration described above. diff --git a/guest/build.sh b/guest/build.sh index aa5dc796..69e8f768 100755 --- a/guest/build.sh +++ b/guest/build.sh @@ -258,6 +258,11 @@ python3 "$guest_dir/scripts/apply-omarchy-backports.py" --root "$root" --spec "$ --work "$work" \ --spec "$spec" \ --pacman-config "$pacman_config" +"$guest_dir/scripts/register-native-video.sh" \ + --root "$root" \ + --work "$work" \ + --spec "$spec" \ + --pacman-config "$pacman_config" "$guest_dir/scripts/register-local-repository.sh" --root "$root" --spec "$spec" arch-chroot "$root" /usr/local/lib/try-omarchy/finalize-rootfs arch-chroot "$root" pacman -Q | LC_ALL=C sort >"$root/usr/share/try-omarchy/packages.lock.txt" diff --git a/guest/native-overlay/usr/local/lib/try-omarchy/install-vivaldi-arm64 b/guest/native-overlay/usr/local/lib/try-omarchy/install-vivaldi-arm64 index 342e3bae..46316663 100755 --- a/guest/native-overlay/usr/local/lib/try-omarchy/install-vivaldi-arm64 +++ b/guest/native-overlay/usr/local/lib/try-omarchy/install-vivaldi-arm64 @@ -7,10 +7,20 @@ fail() { exit 1 } -[[ $(uname -m) == aarch64 ]] || fail "the pinned Vivaldi package supports only aarch64" - spec=/usr/share/try-omarchy/build-spec.json key=/usr/local/share/try-omarchy/vivaldi/linux_signing_key.pub +package_output= +while (($#)); do + case "$1" in + --build-only) package_output=${2:-}; shift 2 ;; + --spec) spec=${2:-}; shift 2 ;; + --key) key=${2:-}; shift 2 ;; + *) fail "unknown option: $1" ;; + esac +done +[[ $(uname -m) == aarch64 ]] || fail "the pinned Vivaldi package supports only aarch64" +[[ -z $package_output || ( $package_output == /* && ! -L $package_output ) ]] || + fail "package output must be an absolute non-symlink path" [[ -f $spec && ! -L $spec ]] || fail "the Try Omarchy build spec is missing or unsafe" [[ -f $key && ! -L $key ]] || fail "the Vivaldi package key is missing or unsafe" @@ -71,6 +81,7 @@ expected_url="https://downloads.vivaldi.com/stable/vivaldi-stable-$version-$rpm_ package_version="$version-$pkgrel" repo_dir=/usr/share/try-omarchy/repo repo_name=try-omarchy +if [[ -z $package_output ]]; then [[ -d $repo_dir && ! -L $repo_dir ]] || fail "the local Try Omarchy repository is missing or unsafe" grep -Fq $'[try-omarchy]\n' /etc/pacman.conf || fail "the local Try Omarchy repository is not configured" if installed=$(pacman -Q vivaldi 2>/dev/null); then @@ -84,8 +95,10 @@ if installed=$(pacman -Q vivaldi 2>/dev/null); then echo "Re-registering installed Vivaldi in the local Try Omarchy repository" fi fi +fi if ! command -v rpmkeys >/dev/null || ! command -v rpm >/dev/null; then + [[ -z $package_output ]] || fail "rpm-tools is required for standalone package builds" omarchy-pkg-add rpm-tools || fail "could not install the RPM signature verifier" fi @@ -191,6 +204,10 @@ if text.count(environment_marker) != 1 or text.count(exec_marker) != 1: raise SystemExit(1) flags = r'''XDG_CONFIG_HOME=${XDG_CONFIG_HOME:-$HOME/.config} VIVALDI_USER_FLAGS=() +OMARCHY_VIDEO_FLAGS=() +if [[ -r /usr/local/lib/omarchy-video/vivaldi.sh ]]; then + source /usr/local/lib/omarchy-video/vivaldi.sh +fi if [[ -f "$XDG_CONFIG_HOME/vivaldi-$CHROME_VERSION_EXTRA.conf" ]]; then while IFS= read -r flag; do trimmed=${flag#"${flag%%[![:space:]]*}"} @@ -200,7 +217,7 @@ fi ''' text = text.replace(environment_marker, environment_marker + flags) -text = text.replace(exec_marker, 'exec -a "$0" "$HERE/vivaldi-bin" "${VIVALDI_USER_FLAGS[@]}" "$@"\n') +text = text.replace(exec_marker, 'exec -a "$0" "$HERE/vivaldi-bin" "${OMARCHY_VIDEO_FLAGS[@]}" "${VIVALDI_USER_FLAGS[@]}" "$@"\n') launcher.write_text(text) PY @@ -296,6 +313,11 @@ tar \ [[ $(pacman -Qp "$package_archive") == "vivaldi $package_version" ]] || fail "the generated Vivaldi package has an unexpected identity" +if [[ -n $package_output ]]; then + install -m 0644 "$package_archive" "$package_output" + echo "Built signed-source Vivaldi package: $package_output" + exit 0 +fi # Publish into the immutable-style local sync repository before installing so # pacman does not classify Vivaldi as a foreign/AUR package. Omarchy's updater diff --git a/guest/scripts/finalize-rootfs.sh b/guest/scripts/finalize-rootfs.sh index 013beae2..42bec915 100755 --- a/guest/scripts/finalize-rootfs.sh +++ b/guest/scripts/finalize-rootfs.sh @@ -103,6 +103,21 @@ printf '%s %s\n' "$expected_vivaldi_key_sha256" "$vivaldi_key" | sha256sum -c - systemctl enable omarchy-provision-owner.service systemctl enable sddm.service systemctl enable omarchy-native-mac-share.service +[[ $(pacman -Qoq /usr/lib/dri/omarchy_drv_video.so) == try-omarchy-native-video ]] || { + echo "Native video driver is missing its package ownership" >&2 + exit 1 +} +python3 - <<'PY' +import hashlib, json, pathlib +record = json.loads(pathlib.Path('/usr/share/try-omarchy/native-video.json').read_text()) +spec = json.loads(pathlib.Path('/usr/share/try-omarchy/build-spec.json').read_text()) +if record['supplyChain'] != spec['supplyChain']['nativeVideo']: + raise SystemExit('Native video provenance does not match the build spec') +for name, expected in record['binarySha256'].items(): + if hashlib.sha256(pathlib.Path('/' + name).read_bytes()).hexdigest() != expected: + raise SystemExit('Native video binary digest mismatch: ' + name) +PY +systemctl enable omarchy-video-broker.service # The app expands only the writable APFS clone to 24 GiB. Grow ext4 online so # Omarchy's update-safety check sees that working capacity. diff --git a/guest/scripts/register-local-repository.sh b/guest/scripts/register-local-repository.sh index c2ae641e..6e6002df 100755 --- a/guest/scripts/register-local-repository.sh +++ b/guest/scripts/register-local-repository.sh @@ -90,6 +90,7 @@ expected_archive_count=6 [[ ${archives[*]} == *'/try-omarchy-mise-'* ]] || fail "factory repository is missing pinned mise" [[ ${archives[*]} == *'/try-omarchy-ttfx-'* ]] || fail "factory repository is missing pinned ttfx" [[ ${archives[*]} == *'/try-omarchy-yay-'* ]] || fail "factory repository is missing pinned yay" +[[ ${archives[*]} == *'/try-omarchy-native-video-'* ]] || fail "factory repository is missing native video" [[ ${archives[*]} == *"/hyprland-$expected_hyprland_version-aarch64.pkg.tar.zst"* ]] || fail "factory repository is missing patched Hyprland" [[ ${archives[*]} == *"/voxtype-bin-$expected_voxtype_version-aarch64.pkg.tar.zst"* ]] || diff --git a/guest/scripts/register-native-video.sh b/guest/scripts/register-native-video.sh new file mode 100755 index 00000000..6ad971dd --- /dev/null +++ b/guest/scripts/register-native-video.sh @@ -0,0 +1,182 @@ +#!/bin/bash +# Build the Linux side of the VideoToolbox bridge against the staged guest ABI. +set -euo pipefail +fail() { echo "register-native-video: $*" >&2; exit 1; } +root= work= spec= pacman_config= output= +while (($#)); do + case "$1" in + --root) root=${2:-}; shift 2 ;; + --work) work=${2:-}; shift 2 ;; + --spec) spec=${2:-}; shift 2 ;; + --pacman-config) pacman_config=${2:-}; shift 2 ;; + --output) output=${2:-}; shift 2 ;; + *) fail "unknown option: $1" ;; + esac +done +[[ $root == /* && -d $root && $work == /* && -d $work ]] || fail "absolute root and work directories are required" +root=$(realpath "$root"); work=$(realpath "$work") +case "$root" in /|/bin|/boot|/etc|/home|/opt|/root|/usr|/var) fail "unsafe staged root" ;; esac +[[ $root != "$work" && $work != "$root/"* ]] || fail "work must be outside the staged root" +[[ -f $spec && -f $pacman_config ]] || fail "spec and pacman config are required" +[[ -z $output || ( $output == /* && ! -L $output ) ]] || fail "output must be an absolute non-symlink path" +[[ $(uname -m) == aarch64 && $(uname -s) == Linux ]] || fail "native Linux ARM64 builder required" +guest_dir=$(cd "$(dirname "$0")/.." && pwd -P) +metadata=$(python3 - "$spec" "$guest_dir" <<'PY' +import hashlib, json, pathlib, sys +s = json.loads(pathlib.Path(sys.argv[1]).read_text()) +c = s['supplyChain']['nativeVideo'] +expected = dict(version='1.0.0', ffmpegVersion='9.0.1', + ffmpegUrl='https://ffmpeg.org/releases/ffmpeg-9.0.1.tar.xz', + ffmpegSha256='cf38e0e28c7e5605942c4a77755349b0145804a397af37eb1fb4c77cb237f635', + patch='video/ffmpeg-full-bitstream.patch', + patchSha256='57301544bb9fd26bf50b1cc07288b58257201993e73bd71d53513045815a325a', + license='GPL-3.0-or-later') +if c != expected or s['image']['architecture'] != 'aarch64': + raise SystemExit('unreviewed native video supply chain') +patch = pathlib.Path(sys.argv[2]) / c['patch'] +if hashlib.sha256(patch.read_bytes()).hexdigest() != c['patchSha256']: + raise SystemExit('native video patch digest mismatch') +for key in ('version', 'ffmpegVersion', 'ffmpegUrl', 'ffmpegSha256'): + print(c[key]) +print(s['image']['sourceDateEpoch']) +PY +) || fail "invalid native video metadata" +mapfile -t values <<<"$metadata" +version=${values[0]}; ffmpeg_version=${values[1]}; url=${values[2]}; digest=${values[3]}; epoch=${values[4]} +for command in gcc g++ make pkg-config patch curl bsdtar gzip tar zstd pacman sha256sum; do + command -v "$command" >/dev/null || fail "missing build tool: $command" +done + +cache="$work/download-cache" +install -d -m 0755 "$cache" +archive="$cache/ffmpeg-$ffmpeg_version.tar.xz" +[[ ! -L $archive ]] || fail "symlinked FFmpeg cache entry" +verify_archive() { printf '%s %s\n' "$digest" "$1" | sha256sum -c - >/dev/null; } +if [[ ! -f $archive ]] || ! verify_archive "$archive"; then + temporary=$(mktemp "$cache/.ffmpeg.XXXXXX") + if ! curl --fail --location --proto '=https' --tlsv1.2 --silent --show-error "$url" -o "$temporary" || + ! verify_archive "$temporary"; then + rm -f "$temporary"; fail "FFmpeg download or digest verification failed" + fi + chmod 0644 "$temporary"; mv "$temporary" "$archive" +fi +build=$(mktemp -d "$work/native-video-build.XXXXXX") +trap 'rm -rf "$build"' EXIT +stage="$build/package" +mkdir -p "$stage" +tar -xJf "$archive" --no-same-owner -C "$build" +source_dir="$build/ffmpeg-$ffmpeg_version" +patch -d "$source_dir" -p1 --batch --fuzz=0 <"$guest_dir/video/ffmpeg-full-bitstream.patch" + +# Hyprland's preceding build installs the reviewed compiler toolchain. Resolve +# every media header and library against this exact guest, not builder packages. +export PKG_CONFIG_SYSROOT_DIR="$root" +export PKG_CONFIG_LIBDIR="$root/usr/lib/pkgconfig:$root/usr/share/pkgconfig" +python3 - "$build" "$root" <<'PY' +import pathlib, shlex, sys +for name, compiler in [('cc', 'gcc'), ('cxx', 'g++')]: + p = pathlib.Path(sys.argv[1]) / name + p.write_text('#!/bin/sh\nexec '+compiler+' '+shlex.quote('--sysroot='+sys.argv[2])+' "$@"\n') + p.chmod(0o755) +PY +jobs=${OMARCHY_GUEST_BUILD_JOBS:-$(nproc)} +[[ $jobs =~ ^[1-9][0-9]*$ ]] || fail "invalid build job count" +( + cd "$source_dir" + ./configure --prefix=/usr/local/lib/omarchy-video/ffmpeg \ + --cc="$build/cc" --cxx="$build/cxx" \ + --enable-shared --disable-static --disable-doc --disable-programs --disable-debug \ + --disable-autodetect --enable-gpl --enable-version3 --enable-vaapi --enable-libdrm \ + --enable-libdav1d --enable-libvpx --enable-libx264 --enable-libx265 --disable-x86asm + make -j"$jobs" + make DESTDIR="$stage" install +) +install -d "$stage/usr/local/libexec" "$stage/usr/lib/dri" "$stage/usr/local/lib/omarchy-video" \ + "$stage/usr/local/bin" "$stage/usr/lib/systemd/system" \ + "$stage/etc/systemd/system/multi-user.target.wants" \ + "$stage/usr/lib/firefox/defaults/pref" "$stage/usr/share/applications" +"$build/cxx" -std=c++17 -O2 -pthread "$guest_dir/video/broker.cpp" -o "$stage/usr/local/libexec/omarchy-video-broker" +"$build/cxx" -std=c++17 -O2 -shared -fPIC -pthread \ + "$guest_dir/video/driver.cpp" -o "$stage/usr/lib/dri/omarchy_drv_video.so" \ + $(pkg-config --cflags --libs libva gbm libdrm) +"$build/cxx" -std=c++17 -O2 -shared -fPIC -pthread \ + "$guest_dir/video/firefox-video-bootstrap.cpp" -o "$stage/usr/local/lib/omarchy-video/firefox-video-bootstrap.so" +"$build/cc" -O2 -shared -fPIC -pthread "$guest_dir/video/arm64-browser-compat.c" \ + -o "$stage/usr/local/lib/omarchy-video/arm64-browser-compat.so" -ldl +install -m 0644 "$guest_dir/video/environment.sh" "$guest_dir/video/vivaldi.sh" "$stage/usr/local/lib/omarchy-video/" +install -m 0755 "$guest_dir/video/mpv" "$guest_dir/video/firefox" "$stage/usr/local/bin/" +install -m 0644 "$guest_dir/video/firefox-prefs.js" "$stage/usr/lib/firefox/defaults/pref/try-omarchy-video.js" +install -m 0644 "$guest_dir/video/omarchy-video-firefox.desktop" "$stage/usr/share/applications/" +install -m 0644 "$guest_dir/video/omarchy-video-broker.service" "$stage/usr/lib/systemd/system/" +ln -s /usr/lib/systemd/system/omarchy-video-broker.service \ + "$stage/etc/systemd/system/multi-user.target.wants/omarchy-video-broker.service" + +# Ship corresponding source and the exact local patch/build recipe with the +# private FFmpeg libraries. System FFmpeg and its linker configuration stay owned +# by Arch and continue to update normally. +sources="$stage/usr/share/try-omarchy/native-video-source" +licenses="$stage/usr/share/licenses/try-omarchy-native-video" +install -d "$sources" "$licenses" +install -m 0644 "$archive" "$sources/" +cp -a "$guest_dir/video" "$sources/" +install -m 0644 "$0" "$sources/register-native-video.sh" +install -m 0644 "$source_dir/COPYING.GPLv3" "$licenses/FFmpeg-GPL-3.0" +install -m 0644 "$guest_dir/../LICENSE" "$licenses/Try-Omarchy-MIT" +python3 - "$stage" "$spec" "$guest_dir" <<'PY' +import hashlib, json, pathlib, sys +stage, spec, guest = map(pathlib.Path, sys.argv[1:]) +record = {'supplyChain': json.loads(spec.read_text())['supplyChain']['nativeVideo'], + 'sourceSha256': {p.name: hashlib.sha256(p.read_bytes()).hexdigest() for p in sorted((guest/'video').iterdir()) if p.is_file()}, + 'binarySha256': {str(p.relative_to(stage)): hashlib.sha256(p.read_bytes()).hexdigest() + for p in sorted(stage.rglob('*')) if p.is_file() and not p.is_symlink() and + (p.suffix == '.so' or p.name == 'omarchy-video-broker' or '.so.' in p.name)}} +(stage/'usr/share/try-omarchy/native-video.json').write_text(json.dumps(record, indent=2)+'\n') +PY +size=$(du -sb "$stage" | awk '{print $1}') +cat >"$stage/.PKGINFO" <.MTREE +) +package="$build/try-omarchy-native-video-$version-1-aarch64.pkg.tar.zst" +tar --sort=name --mtime="@$epoch" --owner=0 --group=0 --numeric-owner --format=gnu \ + -C "$stage" -cf - .PKGINFO .MTREE usr etc | zstd --quiet -12 --threads=1 -o "$package" +if [[ -n $output ]]; then + install -m 0644 "$package" "$output" + echo "Built native video package: $output" + exit 0 +fi +pacman --noconfirm --config "$pacman_config" --root "$root" --dbpath "$root/var/lib/pacman" \ + --logfile "$root/var/log/pacman.log" -U "$package" +pacman --config "$pacman_config" --root "$root" --dbpath "$root/var/lib/pacman" -Qkk try-omarchy-native-video +install -d "$root/usr/share/try-omarchy/repo" +install -m 0644 "$package" "$root/usr/share/try-omarchy/repo/" +echo "Registered native video $version with private FFmpeg $ffmpeg_version" diff --git a/guest/spec.json b/guest/spec.json index 79a43124..00e8bf4b 100644 --- a/guest/spec.json +++ b/guest/spec.json @@ -112,7 +112,7 @@ "vivaldi": { "version": "8.2.4133.33", "rpmRelease": 1, - "pkgrel": 2, + "pkgrel": 3, "repository": "https://repo.vivaldi.com/stable", "rpmUrl": "https://downloads.vivaldi.com/stable/vivaldi-stable-8.2.4133.33-1.aarch64.rpm", "rpmSha256": "99fe7542199ba11d16d9af02783540c8c03554c37d80597a219595751414503d", @@ -173,6 +173,15 @@ "signatureSha256": "f48b8071f78fde0b2d20072e875a8b8e0d8fab4caf6c8ec91cd8e2aa0b031063" } } + }, + "nativeVideo": { + "version": "1.0.0", + "ffmpegVersion": "9.0.1", + "ffmpegUrl": "https://ffmpeg.org/releases/ffmpeg-9.0.1.tar.xz", + "ffmpegSha256": "cf38e0e28c7e5605942c4a77755349b0145804a397af37eb1fb4c77cb237f635", + "patch": "video/ffmpeg-full-bitstream.patch", + "patchSha256": "57301544bb9fd26bf50b1cc07288b58257201993e73bd71d53513045815a325a", + "license": "GPL-3.0-or-later" } }, "themes": [ @@ -502,7 +511,22 @@ "virtio-balloon-pci", "intel-hda", "hda-micro", - "virtio-9p-pci" - ] + "virtio-9p-pci", + "omarchy-video-shmem" + ], + "video": { + "device": "virtserialport", + "port": "dev.tryomarchy.video", + "frameDevice": "omarchy-video-shmem", + "protocolVersion": 1, + "hostDecoder": "videotoolbox", + "guestDriver": "vaapi", + "codecs": [ + "av1", + "hevc", + "vp9" + ], + "maximumSessions": 8 + } } } diff --git a/guest/tests/verify.py b/guest/tests/verify.py index 814cd274..e1ca3ebd 100755 --- a/guest/tests/verify.py +++ b/guest/tests/verify.py @@ -315,7 +315,7 @@ def main() -> None: == { "version": "8.2.4133.33", "rpmRelease": 1, - "pkgrel": 2, + "pkgrel": 3, "repository": "https://repo.vivaldi.com/stable", "rpmUrl": "https://downloads.vivaldi.com/stable/vivaldi-stable-8.2.4133.33-1.aarch64.rpm", "rpmSha256": "99fe7542199ba11d16d9af02783540c8c03554c37d80597a219595751414503d", @@ -696,6 +696,7 @@ def main() -> None: and "factory repository is missing pinned yay" in local_repository and "factory repository is missing patched Hyprland" in local_repository and "factory repository is missing pinned Voxtype" in local_repository + and "factory repository is missing native video" in local_repository and "immutable local repository does not have priority" in local_repository and "resolve patched and ARM64-only packages locally" in local_repository and "refusing canonical unsafe root" in local_repository, diff --git a/guest/video/arm64-browser-compat.c b/guest/video/arm64-browser-compat.c new file mode 100644 index 00000000..3cc5600e --- /dev/null +++ b/guest/video/arm64-browser-compat.c @@ -0,0 +1,31 @@ +// Work around SME routines compiled with a non-streaming SVE prologue. +// Vivaldi 8.2.4133.33 calls CNTD before SMSTART on SME-only Apple CPUs, +// causing SIGILL while converting YouTube frames. Keep NEON and all other +// capabilities; only hide SME from this opt-in browser process when SVE is +// absent. This library is never preloaded into the system or other apps. +#define _GNU_SOURCE +#include +#include +#include +#include +#include + +static unsigned long (*original_getauxval)(unsigned long); +static pthread_once_t resolve_once = PTHREAD_ONCE_INIT; +static void resolve_getauxval(void) { + original_getauxval = (unsigned long (*)(unsigned long))dlsym(RTLD_NEXT, "getauxval"); + if (!original_getauxval) _exit(127); +} +unsigned long getauxval(unsigned long type) { + pthread_once(&resolve_once, resolve_getauxval); + unsigned long value = original_getauxval(type); +#if defined(__aarch64__) + if (type == AT_HWCAP2) { + int saved_errno = errno; + if (!(original_getauxval(AT_HWCAP) & (1UL << 22))) + value &= ~((1UL << 23) | (1UL << 37)); + errno = saved_errno; + } +#endif + return value; +} diff --git a/guest/video/broker.cpp b/guest/video/broker.cpp new file mode 100644 index 00000000..e9ecf88d --- /dev/null +++ b/guest/video/broker.cpp @@ -0,0 +1,206 @@ +#include "wire.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +std::mutex transport_lock; +std::array, 8> used{}; +int transport = -1; +uint8_t *aperture = nullptr; + +std::string read_attribute(const std::filesystem::path& path) { + std::ifstream input(path); + std::string value; + input >> value; + return value; +} + +std::string video_resource() { + for (const auto& entry : std::filesystem::directory_iterator("/sys/bus/pci/devices")) { + auto p = entry.path(); + if (read_attribute(p / "vendor") == "0x1af4" && + read_attribute(p / "device") == "0x1110" && + read_attribute(p / "subsystem_device") == "0x5654") return (p / "resource2_wc").string(); + } + throw std::runtime_error("native video PCI frame device is unavailable"); +} + +void greeting(int client, int memory) { + // A read-only descriptor prevents clients from writing a decoded frame or + // creating another writable mapping of the broker's per-client buffer. + std::string path = "/proc/self/fd/" + std::to_string(memory); + tovd::FD readonly(open(path.c_str(), O_RDONLY | O_CLOEXEC)); + if (readonly.get() < 0) throw std::runtime_error("cannot share frame buffer read-only"); + std::array hello{}; + memcpy(hello.data(), "TOVM", 4); + tovd::put(hello.data() + 4, 1, 4); tovd::put(hello.data() + 8, tovd::slot_size, 8); + alignas(cmsghdr) std::array control{}; + iovec io{hello.data(), hello.size()}; + msghdr msg{}; + msg.msg_iov = &io; msg.msg_iovlen = 1; + msg.msg_control = control.data(); msg.msg_controllen = control.size(); + auto *cmsg = CMSG_FIRSTHDR(&msg); + cmsg->cmsg_level = SOL_SOCKET; cmsg->cmsg_type = SCM_RIGHTS; + cmsg->cmsg_len = CMSG_LEN(sizeof(int)); + int fd = readonly.get(); + memcpy(CMSG_DATA(cmsg), &fd, sizeof(fd)); + if (sendmsg(client, &msg, MSG_NOSIGNAL) != ssize_t(hello.size())) + throw std::runtime_error("cannot send video service greeting"); +} + +void serve(int client_fd, unsigned index) { + tovd::FD client(client_fd); + tovd::FD memory(memfd_create("omarchy-video-frame", MFD_CLOEXEC | MFD_ALLOW_SEALING)); + uint8_t *frame = nullptr; + bool open = false; + uint32_t session = index + 1; + try { + if (memory.get() < 0 || ftruncate(memory.get(), tovd::slot_size)) + throw std::runtime_error("cannot allocate video frame buffer"); + frame = static_cast(mmap(nullptr, tovd::slot_size, PROT_READ | PROT_WRITE, + MAP_SHARED, memory.get(), 0)); + if (frame == MAP_FAILED) { frame = nullptr; throw std::runtime_error("cannot map video frame buffer"); } + if (fcntl(memory.get(), F_ADD_SEALS, F_SEAL_GROW | F_SEAL_SHRINK | F_SEAL_FUTURE_WRITE | F_SEAL_SEAL)) + throw std::runtime_error("cannot seal video frame buffer"); + greeting(client.get(), memory.get()); + for (;;) { + auto request = tovd::Message::receive(client.get(), true); + if (request.session != 1 || request.op < 1 || request.op > 4 || + (request.op == 1 ? (request.flags != 1 || request.arg0 > 2) : + request.op == 2 && request.flags == 4 ? (!request.arg0 || !request.arg1 || request.arg0 == request.arg1) : + (request.flags || request.arg0 || request.arg1)) || + ((request.op == 3 || request.op == 4) && !request.payload.empty())) + throw std::runtime_error("invalid native video client request"); + request.session = session; + std::lock_guard lock(transport_lock); + request.send(transport); + bool client_gone = false; + for (;;) { + auto response = tovd::Message::receive(transport); + if (response.session != session) throw std::runtime_error("native video transport lost session ordering"); + if (response.op == 0x8100) { + if (!(response.flags & tovd::shared_flag) || response.payload.size() != 16) + throw std::runtime_error("invalid host video frame descriptor"); + uint64_t offset = tovd::get(response.payload.data(), 8); + size_t size = tovd::get(response.payload.data() + 8, 4); + bool gpu = response.flags & tovd::gpu_flag; + if (tovd::get(response.payload.data() + 12, 4) || + (gpu ? (size != 0 || offset != 0) : + (!size || size > tovd::slot_size || offset > tovd::aperture_size || size > tovd::aperture_size - offset))) + throw std::runtime_error("host video frame is outside shared memory"); + if (!client_gone) { + try { + if (!gpu) memcpy(frame, aperture + offset, size); + std::atomic_thread_fence(std::memory_order_release); + tovd::put(response.payload.data(), 0, 8); + response.session = 1; + response.send(client.get()); + auto release = tovd::Message::receive(client.get()); + if (release.op != 5 || release.session != 1 || release.token != response.token || + release.flags || release.arg0 || release.arg1 || !release.payload.empty()) + throw std::runtime_error("client did not release its video frame"); + } catch (...) { client_gone = true; } + } + // Closing a player can race any frame. Always finish the + // host transaction, even when its client has disappeared, + // so another player's decoder and the broker stay alive. + tovd::Message release; + release.op = 5; release.session = session; release.token = response.token; + release.send(transport); + } else { + if (response.op != 0xffff && + (response.op != (request.op | 0x8000) || response.token != request.token)) + throw std::runtime_error("unexpected host video acknowledgement"); + if (response.op == 0x8001) open = true; + if (response.op == 0x8004 || response.op == 0xffff) open = false; + response.session = 1; + if (client_gone) throw std::runtime_error("client disconnected during frame delivery"); + response.send(client.get()); + break; + } + } + } + } catch (const std::exception& error) { + std::cerr << "[video-broker] session " << session << ": " << error.what() << '\n'; + } + if (open) { + try { + std::lock_guard lock(transport_lock); + tovd::Message close; + close.op = 4; close.session = session; close.send(transport); + auto ack = tovd::Message::receive(transport); + if (ack.op != 0x8004 || ack.session != session) throw std::runtime_error("cannot close host decoder"); + } catch (...) { + // A interrupted frame transaction cannot be reused safely. Let + // systemd restart the broker and reset every host decoder session. + _exit(1); + } + } + if (frame) munmap(frame, tovd::slot_size); + used[index] = false; +} +} + +int main(int argc, char **argv) try { + if (geteuid() != 0) throw std::runtime_error("native video broker requires root for its PCI aperture"); + if (argc > 2) throw std::runtime_error("usage: omarchy-video-broker [SOCKET]"); + const char *socket_path = argc == 2 ? argv[1] : "/run/omarchy-video.sock"; + tovd::FD port(open("/dev/virtio-ports/dev.tryomarchy.video", O_RDWR | O_CLOEXEC | O_NONBLOCK)); + if (port.get() < 0 || flock(port.get(), LOCK_EX | LOCK_NB)) + throw std::runtime_error("cannot acquire native video virtio port"); + transport = port.get(); + tovd::FD resource(open(video_resource().c_str(), O_RDONLY | O_CLOEXEC)); + if (resource.get() < 0) throw std::runtime_error("cannot open native video frame aperture"); + aperture = static_cast(mmap(nullptr, tovd::aperture_size, PROT_READ, MAP_SHARED, resource.get(), 0)); + if (aperture == MAP_FAILED) throw std::runtime_error("cannot map native video frame aperture"); + tovd::Message reset; + reset.op = 6; reset.session = 0; reset.send(transport); + auto reset_ack = tovd::Message::receive(transport); + if (reset_ack.op != 0x8006 || reset_ack.session) throw std::runtime_error("cannot reset native video sessions"); + + tovd::FD listener(socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0)); + sockaddr_un address{}; + address.sun_family = AF_UNIX; + if (strlen(socket_path) >= sizeof(address.sun_path)) throw std::runtime_error("video socket path too long"); + strcpy(address.sun_path, socket_path); + struct stat existing{}; + if (lstat(socket_path, &existing) == 0) { + if (!S_ISSOCK(existing.st_mode) || existing.st_uid != 0) throw std::runtime_error("unsafe existing video service path"); + if (unlink(socket_path)) throw std::runtime_error("cannot replace video service socket"); + } + umask(0077); + if (listener.get() < 0 || bind(listener.get(), reinterpret_cast(&address), sizeof(address)) || + listen(listener.get(), 8)) throw std::runtime_error("cannot listen for native video clients"); + auto *group = getgrnam("video"); + if (!group || chown(socket_path, 0, group->gr_gid) || chmod(socket_path, 0660)) + throw std::runtime_error("cannot grant the video group access to the decoder"); + signal(SIGTERM, [](int) { _exit(0); }); + signal(SIGINT, [](int) { _exit(0); }); + std::cerr << "[video-broker] Ready: private frame buffers, 8 hardware sessions\n"; + for (;;) { + int fd = accept4(listener.get(), nullptr, nullptr, SOCK_CLOEXEC); + if (fd < 0 && errno == EINTR) continue; + if (fd < 0) throw std::runtime_error("cannot accept native video client"); + unsigned index = 0; + for (; index < used.size(); ++index) { + bool available = false; + if (used[index].compare_exchange_strong(available, true)) break; + } + if (index == used.size()) close(fd); + else std::thread(serve, fd, index).detach(); + } +} catch (const std::exception& error) { + std::cerr << "[video-broker] " << error.what() << '\n'; + return 1; +} diff --git a/guest/video/client.hpp b/guest/video/client.hpp new file mode 100644 index 00000000..be29cf6a --- /dev/null +++ b/guest/video/client.hpp @@ -0,0 +1,65 @@ +#pragma once +#include "connection.hpp" +#include +#include + +// Optional capability pool populated by the Firefox launcher before its RDD +// sandbox starts. Other applications use the regular broker connection. +extern "C" int tovd_acquire_preopened_client(int *, const uint8_t **) __attribute__((weak)); +extern "C" void tovd_release_preopened_client(int, int) __attribute__((weak)); + +namespace tovd { +class Client { + std::unique_ptr connection_; + int socket_ = -1; + const uint8_t *pixels_ = nullptr; + int pool_lease_ = 0; + bool opened_ = false, healthy_ = true; + bool gpu_ = false; +public: + explicit Client(const char *path = "/run/omarchy-video.sock") { + if (!strcmp(path, "/run/omarchy-video.sock") && tovd_acquire_preopened_client && + tovd_release_preopened_client) { + pool_lease_ = tovd_acquire_preopened_client(&socket_, &pixels_); + if (pool_lease_) return; + } + connection_ = std::make_unique(path); + socket_ = connection_->socket_fd(); pixels_ = connection_->pixels(); + } + ~Client() { + if (opened_ && healthy_) { try { Message m; m.op = 4; exchange(m, {}); } catch (...) {} } + if (pool_lease_) tovd_release_preopened_client(pool_lease_, healthy_); + } + using Frame = std::function; + bool supports_gpu() const { return gpu_; } + Message exchange(Message request, const Frame& frame) try { + request.send(socket_); + for (;;) { + Message m = Message::receive(socket_); + if (m.session != 1) throw std::runtime_error("invalid native video session"); + if (m.op == 0xffff) throw std::runtime_error(std::string(m.payload.begin(), m.payload.end())); + if (m.op == 0x8100) { + if (!(m.flags & shared_flag) || m.payload.size() != 16 || get(m.payload.data(), 8) != 0 || + get(m.payload.data() + 12, 4)) throw std::runtime_error("invalid shared video frame"); + size_t size = get(m.payload.data() + 8, 4); + bool gpu = m.flags & gpu_flag; + if ((gpu ? (!gpu_ || size != 0) : (!size || size > slot_size)) || !frame) + throw std::runtime_error("unexpected decoded frame"); + frame(m, gpu ? nullptr : pixels_, size); + Message release; + release.op = 5; release.token = m.token; + release.send(socket_); + } else if (m.op == (request.op | 0x8000) && m.token == request.token && m.payload.empty()) { + if (m.op == 0x8001) { + if ((m.flags & (hardware_flag | shared_flag)) != (hardware_flag | shared_flag)) + throw std::runtime_error("native video did not confirm hardware decoding"); + opened_ = true; + gpu_ = m.flags & gpu_flag; + } + if (m.op == 0x8004) opened_ = false; + return m; + } else throw std::runtime_error("unexpected native video response"); + } + } catch (...) { healthy_ = false; throw; } +}; +} diff --git a/guest/video/connection.hpp b/guest/video/connection.hpp new file mode 100644 index 00000000..1f072c19 --- /dev/null +++ b/guest/video/connection.hpp @@ -0,0 +1,62 @@ +#pragma once +#include "wire.hpp" +#include +#include +#include +#include +#include + +namespace tovd { +class Connection { + FD socket_; + uint8_t *pixels_ = nullptr; +public: + explicit Connection(const char *path) + : socket_(socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0)) { + sockaddr_un address{}; + address.sun_family = AF_UNIX; + if (strlen(path) >= sizeof(address.sun_path)) throw std::runtime_error("video socket path too long"); + strcpy(address.sun_path, path); + if (socket_.get() < 0 || connect(socket_.get(), reinterpret_cast(&address), sizeof(address))) + throw std::runtime_error(std::string("native video service unavailable: ") + strerror(errno)); + std::array hello{}; + alignas(cmsghdr) std::array control{}; + iovec io{hello.data(), hello.size()}; + msghdr msg{}; + msg.msg_iov = &io; msg.msg_iovlen = 1; + msg.msg_control = control.data(); msg.msg_controllen = control.size(); + pollfd pending{socket_.get(), POLLIN, 0}; + int ready; + do { ready = poll(&pending, 1, 2000); } while (ready < 0 && errno == EINTR); + if (ready <= 0) throw std::runtime_error("native video greeting timed out"); + ssize_t received = recvmsg(socket_.get(), &msg, MSG_DONTWAIT | MSG_CMSG_CLOEXEC); + int frame_fd = -1; + unsigned descriptors = 0; + for (auto *cmsg = CMSG_FIRSTHDR(&msg); cmsg; cmsg = CMSG_NXTHDR(&msg, cmsg)) { + if (cmsg->cmsg_level != SOL_SOCKET || cmsg->cmsg_type != SCM_RIGHTS) continue; + for (size_t offset = CMSG_LEN(0); offset + sizeof(int) <= cmsg->cmsg_len; offset += sizeof(int)) { + int fd; memcpy(&fd, reinterpret_cast(cmsg) + offset, sizeof(fd)); + if (descriptors++ == 0) frame_fd = fd; + else close(fd); + } + } + FD owned(frame_fd); + if (received <= 0 || descriptors != 1 || (msg.msg_flags & (MSG_CTRUNC | MSG_TRUNC))) + throw std::runtime_error("invalid native video service greeting"); + // SCM_RIGHTS accompanies the first byte. Stream sockets may split the + // remaining greeting; bound that read instead of blocking in MSG_WAITALL. + transfer(socket_.get(), hello.data() + received, hello.size() - received, false, 2000); + struct stat info{}; + if (fstat(frame_fd, &info) || !S_ISREG(info.st_mode) || info.st_size != slot_size || + (fcntl(frame_fd, F_GETFL) & O_ACCMODE) != O_RDONLY || + memcmp(hello.data(), "TOVM", 4) || get(hello.data() + 4, 4) != 1 || + get(hello.data() + 8, 8) != slot_size || frame_fd < 0) + throw std::runtime_error("invalid native video service greeting"); + pixels_ = static_cast(mmap(nullptr, slot_size, PROT_READ, MAP_SHARED, frame_fd, 0)); + if (pixels_ == MAP_FAILED) { pixels_ = nullptr; throw std::runtime_error("cannot map decoded video frames"); } + } + ~Connection() { if (pixels_) munmap(pixels_, slot_size); } + int socket_fd() const { return socket_.get(); } + const uint8_t *pixels() const { return pixels_; } +}; +} diff --git a/guest/video/driver.cpp b/guest/video/driver.cpp new file mode 100644 index 00000000..81e6498f --- /dev/null +++ b/guest/video/driver.cpp @@ -0,0 +1,796 @@ +// VA-API frontend for Try Omarchy's Mac media-engine bridge. +#include "client.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +using Bytes = std::vector; +struct Config { VAProfile profile; }; +struct Buffer { + VABufferType type; + unsigned size, count; + std::shared_ptr bytes; +}; +struct Surface { + unsigned width, height, fourcc; + bool ready = false, dirty = true, submitted = false, cache_valid = true; + bool host_written = false; + std::shared_ptr pixels; + gbm_bo *planes[2]{}; + uint32_t gpu_resources[2]{}; + ~Surface() { for (auto *plane : planes) if (plane) gbm_bo_destroy(plane); } + unsigned stride() const { return width * (fourcc == VA_FOURCC_P010 ? 2 : 1); } + size_t size() const { return size_t(stride()) * height * 3 / 2; } + void allocate() { if (!pixels) pixels = std::make_shared(size()); } +}; +struct Context { + VAProfile profile; + unsigned width, height; + VASurfaceID target = VA_INVALID_SURFACE; + VADecPictureParameterBufferAV1 av1{}; + VAPictureParameterBufferHEVC hevc{}; + VADecPictureParameterBufferVP9 vp9{}; + bool picture = false; + std::vector slices; + std::vector hevc_slices; + Bytes data, sequence; + std::unique_ptr client; +}; +struct Driver { + std::recursive_mutex mutex; + uint32_t next = 1; + gbm_device *gbm = nullptr; + std::unordered_map configs; + std::unordered_map buffers; + std::unordered_map> surfaces; + std::unordered_map> contexts; + std::unordered_map images; + ~Driver() { contexts.clear(); surfaces.clear(); if (gbm) gbm_device_destroy(gbm); } + uint32_t id() { + if (next == VA_INVALID_ID) throw std::runtime_error("video object identifier space exhausted"); + return next++; + } +}; + +bool logging() { return getenv("OMARCHY_VIDEO_LOG") != nullptr; } +template struct Guard; +template +struct Guard { + static VAStatus call(VADriverContextP ctx, A... args) noexcept { + try { + if (!ctx || !ctx->pDriverData) return VA_STATUS_ERROR_INVALID_DISPLAY; + auto *driver = static_cast(ctx->pDriverData); + std::lock_guard lock(driver->mutex); + return F(ctx, args...); + } catch (const std::bad_alloc&) { return VA_STATUS_ERROR_ALLOCATION_FAILED; } + catch (const std::exception& error) { + if (logging()) fprintf(stderr, "[omarchy-vaapi] %s\n", error.what()); + return VA_STATUS_ERROR_OPERATION_FAILED; + } + } +}; +Driver& drv(VADriverContextP ctx) { return *static_cast(ctx->pDriverData); } +Surface& surface(VADriverContextP ctx, uint32_t id) { return *drv(ctx).surfaces.at(id); } +Context& context(VADriverContextP ctx, uint32_t id) { return *drv(ctx).contexts.at(id); } + +VAStatus terminate(VADriverContextP ctx) { + delete static_cast(ctx->pDriverData); + ctx->pDriverData = nullptr; + return VA_STATUS_SUCCESS; +} +bool supported(VAProfile profile) { + if (getenv("OMARCHY_VIDEO_VP9_ONLY")) + return profile == VAProfileVP9Profile0 || profile == VAProfileVP9Profile2; + return profile == VAProfileAV1Profile0 || profile == VAProfileHEVCMain || profile == VAProfileHEVCMain10 || + profile == VAProfileVP9Profile0 || profile == VAProfileVP9Profile2; +} +VAStatus profiles(VADriverContextP, VAProfile *out, int *count) { + if (!out || !count) return VA_STATUS_ERROR_INVALID_PARAMETER; + if (getenv("OMARCHY_VIDEO_VP9_ONLY")) { + out[0] = VAProfileVP9Profile0; out[1] = VAProfileVP9Profile2; + *count = 2; return VA_STATUS_SUCCESS; + } + out[0] = VAProfileAV1Profile0; out[1] = VAProfileHEVCMain; out[2] = VAProfileHEVCMain10; + out[3] = VAProfileVP9Profile0; out[4] = VAProfileVP9Profile2; + *count = 5; return VA_STATUS_SUCCESS; +} +VAStatus entrypoints(VADriverContextP, VAProfile profile, VAEntrypoint *out, int *count) { + if (!out || !count) return VA_STATUS_ERROR_INVALID_PARAMETER; + *count = 0; + if (!supported(profile)) return VA_STATUS_ERROR_UNSUPPORTED_PROFILE; + out[0] = VAEntrypointVLD; *count = 1; return VA_STATUS_SUCCESS; +} +VAStatus get_attributes(VADriverContextP, VAProfile profile, VAEntrypoint entry, + VAConfigAttrib *out, int count) { + if (!supported(profile)) return VA_STATUS_ERROR_UNSUPPORTED_PROFILE; + if (entry != VAEntrypointVLD) return VA_STATUS_ERROR_UNSUPPORTED_ENTRYPOINT; + if (count < 0 || (count && !out)) return VA_STATUS_ERROR_INVALID_PARAMETER; + for (int i = 0; i < count; ++i) { + switch (out[i].type) { + case VAConfigAttribRTFormat: out[i].value = VA_RT_FORMAT_YUV420 | VA_RT_FORMAT_YUV420_10; break; + case VAConfigAttribMaxPictureWidth: out[i].value = 8192; break; + case VAConfigAttribMaxPictureHeight: out[i].value = 4320; break; + case VAConfigAttribDecAV1Features: out[i].value = 0; break; + case VAConfigAttribDecSliceMode: out[i].value = VA_DEC_SLICE_MODE_NORMAL; break; + default: out[i].value = VA_ATTRIB_NOT_SUPPORTED; break; + } + } + return VA_STATUS_SUCCESS; +} +VAStatus create_config(VADriverContextP ctx, VAProfile profile, VAEntrypoint entry, + VAConfigAttrib *attributes, int count, VAConfigID *out) { + if (!out || count < 0 || (count && !attributes)) return VA_STATUS_ERROR_INVALID_PARAMETER; + if (!supported(profile)) return VA_STATUS_ERROR_UNSUPPORTED_PROFILE; + if (entry != VAEntrypointVLD) return VA_STATUS_ERROR_UNSUPPORTED_ENTRYPOINT; + if (drv(ctx).configs.size() >= 16) return VA_STATUS_ERROR_MAX_NUM_EXCEEDED; + for (int i = 0; i < count; ++i) { + if (attributes[i].type == VAConfigAttribRTFormat && + (attributes[i].value & ~(VA_RT_FORMAT_YUV420 | VA_RT_FORMAT_YUV420_10))) + return VA_STATUS_ERROR_UNSUPPORTED_RT_FORMAT; + } + *out = drv(ctx).id(); drv(ctx).configs.emplace(*out, Config{profile}); return VA_STATUS_SUCCESS; +} +VAStatus destroy_config(VADriverContextP ctx, VAConfigID id) { + return drv(ctx).configs.erase(id) ? VA_STATUS_SUCCESS : VA_STATUS_ERROR_INVALID_CONFIG; +} +VAStatus query_config(VADriverContextP ctx, VAConfigID id, VAProfile *profile, VAEntrypoint *entry, + VAConfigAttrib *attributes, int *count) { + auto found = drv(ctx).configs.find(id); + if (found == drv(ctx).configs.end()) return VA_STATUS_ERROR_INVALID_CONFIG; + if (!profile || !entry || !count || !attributes) return VA_STATUS_ERROR_INVALID_PARAMETER; + *profile = found->second.profile; *entry = VAEntrypointVLD; *count = 1; + attributes[0] = {VAConfigAttribRTFormat, VA_RT_FORMAT_YUV420 | VA_RT_FORMAT_YUV420_10}; + return VA_STATUS_SUCCESS; +} + +bool dimensions(unsigned width, unsigned height) { + return width && height && width <= 8192 && height <= 4352 && !(width & 1) && !(height & 1); +} +VAStatus create_surfaces2(VADriverContextP ctx, unsigned format, unsigned width, unsigned height, + VASurfaceID *out, unsigned count, VASurfaceAttrib *attributes, unsigned num_attributes) { + if (!out || !count || !dimensions(width, height) || (num_attributes && !attributes)) + return VA_STATUS_ERROR_INVALID_PARAMETER; + if (count > 128 || drv(ctx).surfaces.size() + count > 128) return VA_STATUS_ERROR_MAX_NUM_EXCEEDED; + unsigned fourcc; + if (format == VA_RT_FORMAT_YUV420) fourcc = VA_FOURCC_NV12; + else if (format == VA_RT_FORMAT_YUV420_10) fourcc = VA_FOURCC_P010; + else return VA_STATUS_ERROR_UNSUPPORTED_RT_FORMAT; + for (unsigned i = 0; i < num_attributes; ++i) { + const auto& a = attributes[i]; + if (a.type == VASurfaceAttribPixelFormat && a.value.type == VAGenericValueTypeInteger) fourcc = a.value.value.i; + if (a.type == VASurfaceAttribMemoryType && a.value.value.i != VA_SURFACE_ATTRIB_MEM_TYPE_VA) + return VA_STATUS_ERROR_UNSUPPORTED_MEMORY_TYPE; + } + if (fourcc != VA_FOURCC_NV12 && fourcc != VA_FOURCC_P010) return VA_STATUS_ERROR_UNSUPPORTED_RT_FORMAT; + if (size_t(width) * height * 3 / (fourcc == VA_FOURCC_P010 ? 1 : 2) > tovd::slot_size) + return VA_STATUS_ERROR_RESOLUTION_NOT_SUPPORTED; + for (unsigned i = 0; i < count; ++i) { + auto s = std::make_unique(); + s->width = width; s->height = height; s->fourcc = fourcc; + out[i] = drv(ctx).id(); drv(ctx).surfaces.emplace(out[i], std::move(s)); + } + return VA_STATUS_SUCCESS; +} +VAStatus create_surfaces(VADriverContextP ctx, int width, int height, int format, int count, VASurfaceID *out) { + if (count < 0 || width < 0 || height < 0) return VA_STATUS_ERROR_INVALID_PARAMETER; + return create_surfaces2(ctx, format, width, height, out, count, nullptr, 0); +} +VAStatus destroy_surfaces(VADriverContextP ctx, VASurfaceID *ids, int count) { + if (count < 0 || (count && !ids)) return VA_STATUS_ERROR_INVALID_PARAMETER; + for (int i = 0; i < count; ++i) if (!drv(ctx).surfaces.count(ids[i])) return VA_STATUS_ERROR_INVALID_SURFACE; + for (int i = 0; i < count; ++i) drv(ctx).surfaces.erase(ids[i]); + return VA_STATUS_SUCCESS; +} +VAStatus surface_attributes(VADriverContextP ctx, VAConfigID id, VASurfaceAttrib *out, unsigned *count) { + if (!drv(ctx).configs.count(id)) return VA_STATUS_ERROR_INVALID_CONFIG; + if (!count) return VA_STATUS_ERROR_INVALID_PARAMETER; + constexpr unsigned total = 7; + if (!out) { *count = total; return VA_STATUS_SUCCESS; } + if (*count < total) { *count = total; return VA_STATUS_ERROR_MAX_NUM_EXCEEDED; } + auto set = [&](unsigned i, VASurfaceAttribType type, int value, unsigned flags) { + out[i] = {}; out[i].type = type; out[i].flags = flags; + out[i].value.type = VAGenericValueTypeInteger; out[i].value.value.i = value; + }; + set(0, VASurfaceAttribPixelFormat, VA_FOURCC_NV12, VA_SURFACE_ATTRIB_GETTABLE | VA_SURFACE_ATTRIB_SETTABLE); + set(1, VASurfaceAttribPixelFormat, VA_FOURCC_P010, VA_SURFACE_ATTRIB_GETTABLE | VA_SURFACE_ATTRIB_SETTABLE); + set(2, VASurfaceAttribMinWidth, 2, VA_SURFACE_ATTRIB_GETTABLE); + set(3, VASurfaceAttribMinHeight, 2, VA_SURFACE_ATTRIB_GETTABLE); + set(4, VASurfaceAttribMaxWidth, 8192, VA_SURFACE_ATTRIB_GETTABLE); + set(5, VASurfaceAttribMaxHeight, 4320, VA_SURFACE_ATTRIB_GETTABLE); + set(6, VASurfaceAttribMemoryType, VA_SURFACE_ATTRIB_MEM_TYPE_VA, VA_SURFACE_ATTRIB_GETTABLE | VA_SURFACE_ATTRIB_SETTABLE); + *count = total; return VA_STATUS_SUCCESS; +} + +VAStatus create_context(VADriverContextP ctx, VAConfigID config, int width, int height, int, + VASurfaceID *, int, VAContextID *out) { + if (!out || !dimensions(width, height)) return VA_STATUS_ERROR_INVALID_PARAMETER; + auto found = drv(ctx).configs.find(config); + if (found == drv(ctx).configs.end()) return VA_STATUS_ERROR_INVALID_CONFIG; + if (drv(ctx).contexts.size() >= 8) return VA_STATUS_ERROR_MAX_NUM_EXCEEDED; + auto c = std::make_unique(); + c->profile = found->second.profile; c->width = width; c->height = height; + *out = drv(ctx).id(); drv(ctx).contexts.emplace(*out, std::move(c)); + return VA_STATUS_SUCCESS; +} +VAStatus destroy_context(VADriverContextP ctx, VAContextID id) { + return drv(ctx).contexts.erase(id) ? VA_STATUS_SUCCESS : VA_STATUS_ERROR_INVALID_CONTEXT; +} +VAStatus create_buffer(VADriverContextP ctx, VAContextID id, VABufferType type, + unsigned size, unsigned count, void *data, VABufferID *out) { + if (id != VA_INVALID_ID && !drv(ctx).contexts.count(id)) return VA_STATUS_ERROR_INVALID_CONTEXT; + if (!out || !size || !count || size_t(size) * count > tovd::slot_size) + return VA_STATUS_ERROR_INVALID_PARAMETER; + if (drv(ctx).buffers.size() >= 512) return VA_STATUS_ERROR_MAX_NUM_EXCEEDED; + auto bytes = std::make_shared(size_t(size) * count); + if (data) memcpy(bytes->data(), data, bytes->size()); + *out = drv(ctx).id(); drv(ctx).buffers.emplace(*out, Buffer{type, size, count, std::move(bytes)}); + return VA_STATUS_SUCCESS; +} +VAStatus set_buffer_elements(VADriverContextP ctx, VABufferID id, unsigned count) { + auto found = drv(ctx).buffers.find(id); + if (found == drv(ctx).buffers.end()) return VA_STATUS_ERROR_INVALID_BUFFER; + if (!count || size_t(found->second.size) * count > found->second.bytes->size()) + return VA_STATUS_ERROR_INVALID_PARAMETER; + found->second.count = count; return VA_STATUS_SUCCESS; +} +VAStatus map_buffer(VADriverContextP ctx, VABufferID id, void **out) { + auto found = drv(ctx).buffers.find(id); + if (found == drv(ctx).buffers.end()) return VA_STATUS_ERROR_INVALID_BUFFER; + if (!out) return VA_STATUS_ERROR_INVALID_PARAMETER; + *out = found->second.bytes->data(); return VA_STATUS_SUCCESS; +} +VAStatus map_buffer2(VADriverContextP ctx, VABufferID id, void **out, uint32_t) { return map_buffer(ctx, id, out); } +VAStatus unmap_buffer(VADriverContextP ctx, VABufferID id) { + return drv(ctx).buffers.count(id) ? VA_STATUS_SUCCESS : VA_STATUS_ERROR_INVALID_BUFFER; +} +VAStatus destroy_buffer(VADriverContextP ctx, VABufferID id) { + return drv(ctx).buffers.erase(id) ? VA_STATUS_SUCCESS : VA_STATUS_ERROR_INVALID_BUFFER; +} +VAStatus buffer_info(VADriverContextP ctx, VABufferID id, VABufferType *type, unsigned *size, unsigned *count) { + auto found = drv(ctx).buffers.find(id); + if (found == drv(ctx).buffers.end()) return VA_STATUS_ERROR_INVALID_BUFFER; + if (!type || !size || !count) return VA_STATUS_ERROR_INVALID_PARAMETER; + *type = found->second.type; *size = found->second.size; *count = found->second.count; return VA_STATUS_SUCCESS; +} + +VAStatus begin_picture(VADriverContextP ctx, VAContextID id, VASurfaceID target) { + if (!drv(ctx).contexts.count(id)) return VA_STATUS_ERROR_INVALID_CONTEXT; + if (!drv(ctx).surfaces.count(target)) return VA_STATUS_ERROR_INVALID_SURFACE; + auto& c = context(ctx, id); + c.target = target; c.picture = false; c.slices.clear(); c.hevc_slices.clear(); c.data.clear(); + surface(ctx, target).ready = false; + surface(ctx, target).submitted = true; + return VA_STATUS_SUCCESS; +} +VAStatus render_picture(VADriverContextP ctx, VAContextID id, VABufferID *buffers, int count) { + if (!drv(ctx).contexts.count(id)) return VA_STATUS_ERROR_INVALID_CONTEXT; + if (count < 0 || (count && !buffers)) return VA_STATUS_ERROR_INVALID_PARAMETER; + auto& c = context(ctx, id); + if (c.target == VA_INVALID_SURFACE) return VA_STATUS_ERROR_INVALID_SURFACE; + for (int i = 0; i < count; ++i) { + auto found = drv(ctx).buffers.find(buffers[i]); + if (found == drv(ctx).buffers.end()) return VA_STATUS_ERROR_INVALID_BUFFER; + auto& b = found->second; + size_t size = size_t(b.size) * b.count; + if (c.profile == VAProfileVP9Profile0 || c.profile == VAProfileVP9Profile2) { + if (b.type == VAPictureParameterBufferType) { + if (size != sizeof(c.vp9)) return VA_STATUS_ERROR_INVALID_PARAMETER; + memcpy(&c.vp9, b.bytes->data(), size); c.picture = true; + } else if (b.type == VASliceDataBufferType) { + if (!c.data.empty()) return VA_STATUS_ERROR_INVALID_PARAMETER; + c.data.assign(b.bytes->begin(), b.bytes->begin() + size); + } else if (b.type != VASliceParameterBufferType) return VA_STATUS_ERROR_UNSUPPORTED_BUFFERTYPE; + continue; + } + if (c.profile != VAProfileAV1Profile0) { + if (b.type == VAPictureParameterBufferType) { + if (size != sizeof(c.hevc)) return VA_STATUS_ERROR_INVALID_PARAMETER; + memcpy(&c.hevc, b.bytes->data(), size); c.picture = true; + } else if (b.type == VASliceParameterBufferType) { + if (b.size != sizeof(VASliceParameterBufferHEVC) || b.count > 512 || c.hevc_slices.size() + b.count > 512) + return VA_STATUS_ERROR_INVALID_PARAMETER; + size_t old = c.hevc_slices.size(); c.hevc_slices.resize(old + b.count); + memcpy(c.hevc_slices.data() + old, b.bytes->data(), size); + } else if (b.type == VASliceDataBufferType) { + if (size > tovd::slot_size - c.data.size()) return VA_STATUS_ERROR_INVALID_PARAMETER; + c.data.insert(c.data.end(), b.bytes->begin(), b.bytes->begin() + size); + } else if (b.type != VAIQMatrixBufferType) return VA_STATUS_ERROR_UNSUPPORTED_BUFFERTYPE; + continue; + } + if (b.type == VAPictureParameterBufferType) { + if (size != sizeof(c.av1)) return VA_STATUS_ERROR_INVALID_PARAMETER; + memcpy(&c.av1, b.bytes->data(), size); c.picture = true; + } else if (b.type == VASliceParameterBufferType) { + if (b.size != sizeof(VASliceParameterBufferAV1) || b.count > 512 || c.slices.size() + b.count > 512) + return VA_STATUS_ERROR_INVALID_PARAMETER; + size_t old = c.slices.size(); c.slices.resize(old + b.count); + memcpy(c.slices.data() + old, b.bytes->data(), size); + } else if (b.type == VASliceDataBufferType) { + if (c.data.empty()) c.data.assign(b.bytes->begin(), b.bytes->begin() + size); + else if (c.data.size() != size || memcmp(c.data.data(), b.bytes->data(), size)) + return VA_STATUS_ERROR_INVALID_PARAMETER; + } else return VA_STATUS_ERROR_UNSUPPORTED_BUFFERTYPE; + } + return VA_STATUS_SUCCESS; +} + +struct OBU { size_t start, payload, end; unsigned type; }; +std::vector obus(const Bytes& bytes) { + std::vector result; + size_t offset = 0; + while (offset < bytes.size()) { + size_t start = offset; + uint8_t h = bytes[offset++]; + if (h & 0x81) throw std::runtime_error("invalid AV1 OBU header"); + if (h & 4) { if (offset == bytes.size()) throw std::runtime_error("truncated AV1 OBU extension"); ++offset; } + uint64_t size = 0; + if (h & 2) { + unsigned shift = 0; + for (;;) { + if (offset == bytes.size() || shift >= 56) throw std::runtime_error("invalid AV1 OBU length"); + uint8_t b = bytes[offset++]; size |= uint64_t(b & 127) << shift; + if (!(b & 128)) break; + shift += 7; + } + } else size = bytes.size() - offset; + if (size > bytes.size() - offset) throw std::runtime_error("truncated AV1 OBU"); + result.push_back({start, offset, offset + size, unsigned((h >> 3) & 15)}); + offset += size; + } + return result; +} + +// DMA-BUF consumers may keep their exported handles across many decodes. +// Refresh those same backing buffers when a surface is reused. +void upload_pixels(Surface& s, const uint8_t *pixels, unsigned source_stride, unsigned source_height) { + for (unsigned plane = 0; plane < 2; ++plane) { + if (!s.planes[plane]) continue; + unsigned width = plane ? s.width / 2 : s.width; + unsigned height = plane ? s.height / 2 : s.height; + uint32_t stride = 0; + void *map_data = nullptr; + auto *mapped = static_cast(gbm_bo_map(s.planes[plane], 0, 0, width, height, + GBM_BO_TRANSFER_WRITE, &stride, &map_data)); + if (!mapped) throw std::runtime_error("cannot map virtio video plane for upload"); + if (stride < s.stride()) { + gbm_bo_unmap(s.planes[plane], map_data); + throw std::runtime_error("virtio video plane stride is too small"); + } + for (unsigned row = 0; row < height; ++row) + memcpy(mapped + size_t(row) * stride, + pixels + (plane ? size_t(source_stride) * source_height : 0) + size_t(row) * source_stride, s.stride()); + gbm_bo_unmap(s.planes[plane], map_data); + } + if (s.planes[0] && s.planes[1]) s.dirty = false; +} + +void upload_surface(Surface& s) { + if (s.dirty) upload_pixels(s, s.pixels->data(), s.stride(), s.height); +} + +// Browsers retain DMA-BUF handles and rarely request a CPU image. Keep that +// cache lazy after direct uploads, while preserving vaDeriveImage/vaGetImage. +void cache_surface(Surface& s) { + s.allocate(); + if (s.cache_valid) return; + for (unsigned plane = 0; plane < 2; ++plane) { + unsigned width = plane ? s.width / 2 : s.width; + unsigned height = plane ? s.height / 2 : s.height; + if (s.host_written) { + // Mesa did not submit the host's IOSurface blit and considers its + // old CPU copy clean. Explicitly refresh the backing memory before + // mapping it. Legacy VirGL resources require zero stride fields. + int fd = gbm_device_get_fd(gbm_bo_get_device(s.planes[plane])); + drm_virtgpu_3d_transfer_from_host transfer{}; + transfer.bo_handle = gbm_bo_get_handle(s.planes[plane]).u32; + transfer.box.w = width; transfer.box.h = height; transfer.box.d = 1; + drm_virtgpu_3d_wait wait{};wait.handle = transfer.bo_handle; + if (drmIoctl(fd, DRM_IOCTL_VIRTGPU_TRANSFER_FROM_HOST, &transfer) || + drmIoctl(fd, DRM_IOCTL_VIRTGPU_WAIT, &wait)) + throw std::runtime_error("cannot synchronize host video texture for CPU readback"); + } + uint32_t stride = 0; + void *map_data = nullptr; + auto *mapped = static_cast(gbm_bo_map(s.planes[plane], 0, 0, width, height, + GBM_BO_TRANSFER_READ, &stride, &map_data)); + if (!mapped) throw std::runtime_error("cannot map virtio video plane for readback"); + if (stride < s.stride()) { + gbm_bo_unmap(s.planes[plane], map_data); + throw std::runtime_error("virtio video plane stride is too small"); + } + for (unsigned row = 0; row < height; ++row) + memcpy(s.pixels->data() + (plane ? size_t(s.stride()) * s.height : 0) + size_t(row) * s.stride(), + mapped + size_t(row) * stride, s.stride()); + gbm_bo_unmap(s.planes[plane], map_data); + } + s.cache_valid = true; + s.host_written = false; +} + +void store_frame(Surface& s, const tovd::Message& m, const uint8_t *pixels, size_t length) { + unsigned format = m.flags & 0xff; + unsigned bytes = format == 2 ? 2 : 1; + if ((format != 1 && format != 2) || !dimensions(m.arg0, m.arg1) || + m.arg0 > s.width || m.arg1 > s.height || + (s.fourcc == VA_FOURCC_P010) != (format == 2) || + ((m.flags & tovd::gpu_flag) ? (length != 0 || pixels != nullptr || !s.planes[0] || !s.planes[1]) : + (length != size_t(m.arg0) * m.arg1 * 3 / 2 * bytes))) + throw std::runtime_error("decoded frame does not match its VA surface"); + if (m.flags & tovd::gpu_flag) { + s.ready = true; s.dirty = false; s.cache_valid = false; s.host_written = true; + if (s.pixels.use_count() > 1) cache_surface(s); + return; + } + unsigned source_stride = m.arg0 * bytes; + s.host_written = false; + if (s.planes[0] && s.planes[1] && s.pixels.use_count() <= 1 && + m.arg0 == s.width && m.arg1 == s.height) { + s.cache_valid = false; + upload_pixels(s, pixels, source_stride, m.arg1); + s.ready = true; + return; + } + cache_surface(s); + for (unsigned plane = 0; plane < 2; ++plane) { + unsigned rows = plane ? m.arg1 / 2 : m.arg1; + auto *dst = s.pixels->data() + (plane ? size_t(s.stride()) * s.height : 0); + auto *src = pixels + (plane ? size_t(source_stride) * m.arg1 : 0); + for (unsigned row = 0; row < rows; ++row) + memcpy(dst + size_t(row) * s.stride(), src + size_t(row) * source_stride, source_stride); + } + s.ready = true; s.dirty = true; + upload_surface(s); +} + +void attach_gpu_targets(VADriverContextP ctx, Context& c, tovd::Message& request) { + auto& s = surface(ctx, c.target); + if (!c.client->supports_gpu() || !s.planes[0] || !s.planes[1] || s.pixels.use_count() > 1) return; + for (unsigned p = 0; p < 2; ++p) { + if (s.gpu_resources[p]) continue; + drm_virtgpu_resource_info info{}; + info.bo_handle = gbm_bo_get_handle(s.planes[p]).u32; + if (drmIoctl(gbm_device_get_fd(drv(ctx).gbm), DRM_IOCTL_VIRTGPU_RESOURCE_INFO, &info) || !info.res_handle) + return; + s.gpu_resources[p] = info.res_handle; + } + request.flags = 4; request.arg0 = s.gpu_resources[0]; request.arg1 = s.gpu_resources[1]; +} + +VAStatus end_hevc_picture(VADriverContextP ctx, Context& c) { + if (!c.picture || c.data.empty() || c.hevc_slices.empty() || !drv(ctx).surfaces.count(c.target)) + return VA_STATUS_ERROR_INVALID_PARAMETER; + if (c.hevc.pic_fields.bits.chroma_format_idc != 1 || c.hevc.pic_fields.bits.separate_colour_plane_flag || + (c.hevc.bit_depth_luma_minus8 != 0 && c.hevc.bit_depth_luma_minus8 != 2) || + c.hevc.bit_depth_chroma_minus8 != c.hevc.bit_depth_luma_minus8) + return VA_STATUS_ERROR_UNSUPPORTED_PROFILE; + // The private FFmpeg submission preserves original parameter sets and VCL + // NAL units, each prefixed with its 32-bit network-order length. + Bytes sets[3], packet; + for (size_t offset = 0; offset < c.data.size();) { + if (c.data.size() - offset < 4) throw std::runtime_error("truncated HEVC NAL length"); + auto *p = c.data.data() + offset; + size_t length = uint32_t(p[0]) << 24 | uint32_t(p[1]) << 16 | uint32_t(p[2]) << 8 | p[3]; + if (length < 2 || length > c.data.size() - offset - 4) throw std::runtime_error("invalid HEVC NAL length"); + unsigned type = (p[4] >> 1) & 63; + if (type >= 32 && type <= 34) { + if (length > 65535) throw std::runtime_error("HEVC parameter set exceeds hvcC limit"); + sets[type - 32].assign(p + 4, p + 4 + length); + } else if (type < 32) packet.insert(packet.end(), p, p + 4 + length); + offset += 4 + length; + } + if (packet.empty()) return VA_STATUS_ERROR_INVALID_PARAMETER; + if (!sets[0].empty() && !sets[1].empty() && !sets[2].empty()) { + Bytes config(23); + config[0] = 1; config[1] = c.hevc.bit_depth_luma_minus8 ? 2 : 1; + config[12] = 153; config[13] = 0xf0; config[15] = 0xfc; config[16] = 0xfd; + config[17] = config[18] = 0xf8 | c.hevc.bit_depth_luma_minus8; + config[21] = 3; config[22] = 3; + for (unsigned i = 0; i < 3; ++i) { + config.insert(config.end(), {uint8_t(0x80 | (32 + i)), 0, 1, + uint8_t(sets[i].size() >> 8), uint8_t(sets[i].size())}); + config.insert(config.end(), sets[i].begin(), sets[i].end()); + } + if (config != c.sequence) { c.client.reset(); c.sequence = std::move(config); } + } + if (!c.client) { + if (c.sequence.empty()) throw std::runtime_error("HEVC submission is missing VPS/SPS/PPS"); + const char *path = getenv("OMARCHY_VIDEO_SOCKET"); + c.client = std::make_unique(path ? path : "/run/omarchy-video.sock"); + tovd::Message open; open.op = 1; open.flags = 1; open.payload = c.sequence; + c.client->exchange(std::move(open), {}); + if (logging()) fprintf(stderr, "[omarchy-vaapi] HEVC %ux%u %u-bit hardware decoder opened\n", + c.hevc.pic_width_in_luma_samples, c.hevc.pic_height_in_luma_samples, + 8 + c.hevc.bit_depth_luma_minus8); + } + tovd::Message decode; decode.op = 2; decode.token = c.target; decode.payload = std::move(packet); + attach_gpu_targets(ctx, c, decode); + c.client->exchange(std::move(decode), [&](const tovd::Message& m, const uint8_t *pixels, size_t size) { + if (!drv(ctx).surfaces.count(m.token)) throw std::runtime_error("decoded HEVC frame refers to a released surface"); + store_frame(surface(ctx, m.token), m, pixels, size); + }); + if (!surface(ctx, c.target).ready) throw std::runtime_error("hardware decoder produced no VA frame"); + c.target = VA_INVALID_SURFACE; + return VA_STATUS_SUCCESS; +} + +VAStatus end_vp9_picture(VADriverContextP ctx, Context& c) { + if (!c.picture || c.data.empty() || !drv(ctx).surfaces.count(c.target)) + return VA_STATUS_ERROR_INVALID_PARAMETER; + unsigned width = c.vp9.frame_width, height = c.vp9.frame_height; + if (!dimensions(width, height) || !c.vp9.pic_fields.bits.subsampling_x || + !c.vp9.pic_fields.bits.subsampling_y || + !((c.vp9.profile == 0 && c.vp9.bit_depth == 8) || (c.vp9.profile == 2 && c.vp9.bit_depth == 10))) + return VA_STATUS_ERROR_UNSUPPORTED_PROFILE; + Bytes config = {1, 0, 0, 0, c.vp9.profile, 51, uint8_t(c.vp9.bit_depth << 4), 2, 2, 2, 0, 0}; + if (config != c.sequence || width != c.width || height != c.height) { + c.client.reset(); c.sequence = config; c.width = width; c.height = height; + } + if (!c.client) { + const char *path = getenv("OMARCHY_VIDEO_SOCKET"); + c.client = std::make_unique(path ? path : "/run/omarchy-video.sock"); + tovd::Message open; open.op = 1; open.arg0 = 2; open.arg1 = width | (height << 16); + open.flags = 1; open.payload = config; + c.client->exchange(std::move(open), {}); + if (logging()) fprintf(stderr, "[omarchy-vaapi] VP9 %ux%u %u-bit hardware decoder opened\n", width, height, c.vp9.bit_depth); + } + tovd::Message decode; decode.op = 2; decode.token = c.target; decode.payload = std::move(c.data); + attach_gpu_targets(ctx, c, decode); + c.client->exchange(std::move(decode), [&](const tovd::Message& m, const uint8_t *pixels, size_t size) { + if (!drv(ctx).surfaces.count(m.token)) throw std::runtime_error("decoded VP9 frame refers to a released surface"); + store_frame(surface(ctx, m.token), m, pixels, size); + }); + if (!surface(ctx, c.target).ready) throw std::runtime_error("hardware decoder produced no VA frame"); + c.target = VA_INVALID_SURFACE; + return VA_STATUS_SUCCESS; +} + +VAStatus end_picture(VADriverContextP ctx, VAContextID id) { + if (!drv(ctx).contexts.count(id)) return VA_STATUS_ERROR_INVALID_CONTEXT; + auto& c = context(ctx, id); + if (c.profile == VAProfileVP9Profile0 || c.profile == VAProfileVP9Profile2) return end_vp9_picture(ctx, c); + if (c.profile != VAProfileAV1Profile0) return end_hevc_picture(ctx, c); + if (!c.picture || c.data.empty() || c.slices.empty() || !drv(ctx).surfaces.count(c.target)) + return VA_STATUS_ERROR_INVALID_PARAMETER; + if (c.av1.profile != 0 || c.av1.bit_depth_idx > 1 || c.av1.seq_info_fields.fields.mono_chrome || + !c.av1.seq_info_fields.fields.subsampling_x || !c.av1.seq_info_fields.fields.subsampling_y) + return VA_STATUS_ERROR_UNSUPPORTED_PROFILE; + auto units = obus(c.data); + Bytes sequence; + for (const auto& unit : units) if (unit.type == 1) + sequence.assign(c.data.begin() + unit.start, c.data.begin() + unit.end); + if (!sequence.empty() && c.sequence != sequence) { c.client.reset(); c.sequence = sequence; } + unsigned width = unsigned(c.av1.frame_width_minus1) + 1; + unsigned height = unsigned(c.av1.frame_height_minus1) + 1; + if (!dimensions(width, height)) return VA_STATUS_ERROR_RESOLUTION_NOT_SUPPORTED; + if (!c.client) { + if (c.sequence.empty()) throw std::runtime_error("AV1 submission is missing its sequence header"); + const char *path = getenv("OMARCHY_VIDEO_SOCKET"); + c.client = std::make_unique(path ? path : "/run/omarchy-video.sock"); + tovd::Message open; + open.op = 1; open.arg0 = 1; open.arg1 = width | (height << 16); open.flags = 1; + open.payload = {0x81, 13, uint8_t(c.av1.bit_depth_idx ? 0x4c : 0x0c), 0}; + open.payload.insert(open.payload.end(), c.sequence.begin(), c.sequence.end()); + c.client->exchange(std::move(open), {}); + if (logging()) fprintf(stderr, "[omarchy-vaapi] AV1 %ux%u %u-bit hardware decoder opened\n", width, height, c.av1.bit_depth_idx ? 10 : 8); + } + size_t first_tile = c.data.size(), last_tile = 0; + for (const auto& slice : c.slices) { + if (slice.slice_data_flag != VA_SLICE_DATA_FLAG_ALL || slice.slice_data_offset > c.data.size() || + slice.slice_data_size > c.data.size() - slice.slice_data_offset) return VA_STATUS_ERROR_INVALID_PARAMETER; + first_tile = std::min(first_tile, size_t(slice.slice_data_offset)); + last_tile = std::max(last_tile, size_t(slice.slice_data_offset) + slice.slice_data_size); + } + size_t begin = c.data.size(), end = 0, last_header = c.data.size(); + for (const auto& unit : units) { + if (unit.type == 3) last_header = unit.start; + if (first_tile >= unit.payload && first_tile < unit.end) + begin = unit.type == 4 && last_header != c.data.size() ? last_header : unit.start; + if (last_tile > unit.payload && last_tile <= unit.end) end = unit.end; + } + if (begin >= end || end > c.data.size()) throw std::runtime_error("cannot locate AV1 frame OBUs from tile offsets"); + tovd::Message decode; + decode.op = 2; decode.token = c.target; + decode.payload.assign(c.data.begin() + begin, c.data.begin() + end); + if (c.av1.current_display_picture == VA_INVALID_SURFACE || c.av1.current_display_picture == c.target) + attach_gpu_targets(ctx, c, decode); + c.client->exchange(std::move(decode), [&](const tovd::Message& m, const uint8_t *pixels, size_t size) { + if (!drv(ctx).surfaces.count(m.token)) throw std::runtime_error("decoded AV1 frame refers to a released surface"); + store_frame(surface(ctx, m.token), m, pixels, size); + auto display = c.av1.current_display_picture; + if (display != VA_INVALID_SURFACE && display != m.token && drv(ctx).surfaces.count(display)) + store_frame(surface(ctx, display), m, pixels, size); + }); + if (!surface(ctx, c.target).ready) throw std::runtime_error("hardware decoder produced no VA frame"); + c.target = VA_INVALID_SURFACE; + return VA_STATUS_SUCCESS; +} + +VAStatus sync_surface(VADriverContextP ctx, VASurfaceID id) { + if (!drv(ctx).surfaces.count(id)) return VA_STATUS_ERROR_INVALID_SURFACE; + const auto& s = surface(ctx, id); + return !s.submitted || s.ready ? VA_STATUS_SUCCESS : VA_STATUS_ERROR_DECODING_ERROR; +} +VAStatus sync_surface2(VADriverContextP ctx, VASurfaceID id, uint64_t) { return sync_surface(ctx, id); } +VAStatus query_surface(VADriverContextP ctx, VASurfaceID id, VASurfaceStatus *out) { + if (!drv(ctx).surfaces.count(id)) return VA_STATUS_ERROR_INVALID_SURFACE; + if (!out) return VA_STATUS_ERROR_INVALID_PARAMETER; + *out = VASurfaceReady; return VA_STATUS_SUCCESS; +} +VAStatus query_error(VADriverContextP, VASurfaceID, VAStatus, void **out) { + if (out) *out = nullptr; + return VA_STATUS_SUCCESS; +} + +VAImageFormat image_format(unsigned fourcc) { + VAImageFormat f{}; f.fourcc = fourcc; f.byte_order = VA_LSB_FIRST; + f.bits_per_pixel = fourcc == VA_FOURCC_P010 ? 24 : 12; return f; +} +VAStatus image_formats(VADriverContextP, VAImageFormat *out, int *count) { + if (!out || !count) return VA_STATUS_ERROR_INVALID_PARAMETER; + out[0] = image_format(VA_FOURCC_NV12); out[1] = image_format(VA_FOURCC_P010); + *count = 2; return VA_STATUS_SUCCESS; +} +VAStatus make_image(VADriverContextP ctx, unsigned fourcc, unsigned width, unsigned height, + std::shared_ptr bytes, VAImage *out) { + if (!out || !dimensions(width, height)) return VA_STATUS_ERROR_INVALID_PARAMETER; + if (drv(ctx).images.size() >= 128 || drv(ctx).buffers.size() >= 512) return VA_STATUS_ERROR_MAX_NUM_EXCEEDED; + if (fourcc != VA_FOURCC_NV12 && fourcc != VA_FOURCC_P010) return VA_STATUS_ERROR_UNSUPPORTED_RT_FORMAT; + unsigned stride = width * (fourcc == VA_FOURCC_P010 ? 2 : 1); + size_t size = size_t(stride) * height * 3 / 2; + if (size > tovd::slot_size) return VA_STATUS_ERROR_INVALID_PARAMETER; + if (!bytes) bytes = std::make_shared(size); + *out = {}; out->image_id = drv(ctx).id(); out->buf = drv(ctx).id(); + out->format = image_format(fourcc); out->width = width; out->height = height; + out->data_size = size; out->num_planes = 2; + out->pitches[0] = out->pitches[1] = stride; out->offsets[1] = stride * height; + drv(ctx).buffers.emplace(out->buf, Buffer{VAImageBufferType, unsigned(size), 1, std::move(bytes)}); + drv(ctx).images.emplace(out->image_id, *out); return VA_STATUS_SUCCESS; +} +VAStatus create_image(VADriverContextP ctx, VAImageFormat *format, int width, int height, VAImage *out) { + if (!format || width < 0 || height < 0) return VA_STATUS_ERROR_INVALID_PARAMETER; + return make_image(ctx, format->fourcc, width, height, {}, out); +} +VAStatus derive_image(VADriverContextP ctx, VASurfaceID id, VAImage *out) { + if (!drv(ctx).surfaces.count(id)) return VA_STATUS_ERROR_INVALID_SURFACE; + auto& s = surface(ctx, id); cache_surface(s); + return make_image(ctx, s.fourcc, s.width, s.height, s.pixels, out); +} +VAStatus destroy_image(VADriverContextP ctx, VAImageID id) { + auto found = drv(ctx).images.find(id); + if (found == drv(ctx).images.end()) return VA_STATUS_ERROR_INVALID_IMAGE; + drv(ctx).buffers.erase(found->second.buf); drv(ctx).images.erase(found); return VA_STATUS_SUCCESS; +} +VAStatus get_image(VADriverContextP ctx, VASurfaceID id, int x, int y, unsigned width, unsigned height, VAImageID image) { + if (!drv(ctx).surfaces.count(id)) return VA_STATUS_ERROR_INVALID_SURFACE; + auto found = drv(ctx).images.find(image); + if (found == drv(ctx).images.end()) return VA_STATUS_ERROR_INVALID_IMAGE; + auto& s = surface(ctx, id); auto& i = found->second; + if (!s.ready || x || y || width > s.width || height > s.height || i.width < width || i.height < height || i.format.fourcc != s.fourcc) + return VA_STATUS_ERROR_INVALID_PARAMETER; + auto& buffer = drv(ctx).buffers.at(i.buf); + cache_surface(s); + unsigned row_bytes = width * (s.fourcc == VA_FOURCC_P010 ? 2 : 1); + for (unsigned plane = 0; plane < 2; ++plane) { + unsigned rows = plane ? height / 2 : height; + for (unsigned row = 0; row < rows; ++row) + memcpy(buffer.bytes->data() + i.offsets[plane] + size_t(row) * i.pitches[plane], + s.pixels->data() + (plane ? size_t(s.stride()) * s.height : 0) + size_t(row) * s.stride(), row_bytes); + } + return VA_STATUS_SUCCESS; +} + +VAStatus export_surface(VADriverContextP ctx, VASurfaceID id, uint32_t mem_type, uint32_t flags, void *output) { + if (!output || mem_type != VA_SURFACE_ATTRIB_MEM_TYPE_DRM_PRIME_2) return VA_STATUS_ERROR_UNSUPPORTED_MEMORY_TYPE; + if (!drv(ctx).surfaces.count(id)) return VA_STATUS_ERROR_INVALID_SURFACE; + auto& s = surface(ctx, id); + // Clients probe DMA-BUF interoperability immediately after allocating a + // surface, before decoding into it. Export initialized storage in that case. + s.allocate(); + if (!drv(ctx).gbm) return VA_STATUS_ERROR_UNIMPLEMENTED; + uint32_t formats[2] = {s.fourcc == VA_FOURCC_P010 ? DRM_FORMAT_R16 : DRM_FORMAT_R8, + s.fourcc == VA_FOURCC_P010 ? DRM_FORMAT_GR1616 : DRM_FORMAT_GR88}; + for (unsigned plane = 0; plane < 2; ++plane) { + unsigned width = plane ? s.width / 2 : s.width, height = plane ? s.height / 2 : s.height; + if (!s.planes[plane]) s.planes[plane] = gbm_bo_create(drv(ctx).gbm, width, height, formats[plane], + GBM_BO_USE_LINEAR | GBM_BO_USE_RENDERING); + if (!s.planes[plane]) throw std::runtime_error("virtio GPU cannot export the decoded video plane"); + } + upload_surface(s); + auto *out = static_cast(output); + *out = {}; out->fourcc = s.fourcc; out->width = s.width; out->height = s.height; out->num_objects = 2; + for (unsigned plane = 0; plane < 2; ++plane) { + out->objects[plane].fd = gbm_bo_get_fd(s.planes[plane]); + if (out->objects[plane].fd < 0) { + if (plane) close(out->objects[0].fd); + throw std::runtime_error("cannot export virtio video plane descriptor"); + } + out->objects[plane].size = gbm_bo_get_stride(s.planes[plane]) * gbm_bo_get_height(s.planes[plane]); + out->objects[plane].drm_format_modifier = gbm_bo_get_modifier(s.planes[plane]); + } + bool separate = flags & VA_EXPORT_SURFACE_SEPARATE_LAYERS; + out->num_layers = separate ? 2 : 1; + if (separate) { + for (unsigned plane = 0; plane < 2; ++plane) { + out->layers[plane].drm_format = formats[plane]; out->layers[plane].num_planes = 1; + out->layers[plane].object_index[0] = plane; + out->layers[plane].pitch[0] = gbm_bo_get_stride(s.planes[plane]); + } + } else { + out->layers[0].drm_format = s.fourcc; out->layers[0].num_planes = 2; + for (unsigned plane = 0; plane < 2; ++plane) { + out->layers[0].object_index[plane] = plane; + out->layers[0].pitch[plane] = gbm_bo_get_stride(s.planes[plane]); + } + } + return VA_STATUS_SUCCESS; +} + +// Mandatory legacy VA hooks. No display, subpicture or processing capabilities +// are advertised; applications receive explicit unsupported-operation results. +VAStatus palette(VADriverContextP, VAImageID, unsigned char *) { return VA_STATUS_ERROR_UNIMPLEMENTED; } +VAStatus put_image(VADriverContextP, VASurfaceID, VAImageID, int, int, unsigned, unsigned, int, int, unsigned, unsigned) { return VA_STATUS_ERROR_UNIMPLEMENTED; } +VAStatus sub_formats(VADriverContextP, VAImageFormat *, unsigned *, unsigned *count) { if (count) *count = 0; return VA_STATUS_SUCCESS; } +VAStatus sub_create(VADriverContextP, VAImageID, VASubpictureID *) { return VA_STATUS_ERROR_UNIMPLEMENTED; } +VAStatus sub_destroy(VADriverContextP, VASubpictureID) { return VA_STATUS_ERROR_UNIMPLEMENTED; } +VAStatus sub_image(VADriverContextP, VASubpictureID, VAImageID) { return VA_STATUS_ERROR_UNIMPLEMENTED; } +VAStatus sub_chroma(VADriverContextP, VASubpictureID, unsigned, unsigned, unsigned) { return VA_STATUS_ERROR_UNIMPLEMENTED; } +VAStatus sub_alpha(VADriverContextP, VASubpictureID, float) { return VA_STATUS_ERROR_UNIMPLEMENTED; } +VAStatus sub_associate(VADriverContextP, VASubpictureID, VASurfaceID *, int, short, short, unsigned short, unsigned short, short, short, unsigned short, unsigned short, unsigned) { return VA_STATUS_ERROR_UNIMPLEMENTED; } +VAStatus sub_deassociate(VADriverContextP, VASubpictureID, VASurfaceID *, int) { return VA_STATUS_ERROR_UNIMPLEMENTED; } +VAStatus display_query(VADriverContextP, VADisplayAttribute *, int *count) { if (count) *count = 0; return VA_STATUS_SUCCESS; } +VAStatus display_attributes(VADriverContextP, VADisplayAttribute *, int count) { return count ? VA_STATUS_ERROR_ATTR_NOT_SUPPORTED : VA_STATUS_SUCCESS; } +} + +extern "C" VAStatus __vaDriverInit_1_0(VADriverContextP ctx) noexcept { + try { + auto d = std::make_unique(); + if (ctx->drm_state) { + auto *state = static_cast(ctx->drm_state); + if (state->fd >= 0) d->gbm = gbm_create_device(state->fd); + } + ctx->version_major = VA_MAJOR_VERSION; ctx->version_minor = VA_MINOR_VERSION; + ctx->max_profiles = 5; ctx->max_entrypoints = 1; ctx->max_attributes = 8; + ctx->max_image_formats = 2; ctx->max_subpic_formats = 1; ctx->max_display_attributes = 1; + ctx->str_vendor = "Try Omarchy VideoToolbox hardware decode"; + auto *v = ctx->vtable; + v->vaTerminate = terminate; +#define BIND(field, fn) v->va##field = Guard::call + BIND(QueryConfigProfiles, profiles); BIND(QueryConfigEntrypoints, entrypoints); + BIND(GetConfigAttributes, get_attributes); BIND(CreateConfig, create_config); + BIND(DestroyConfig, destroy_config); BIND(QueryConfigAttributes, query_config); + BIND(CreateSurfaces, create_surfaces); BIND(CreateSurfaces2, create_surfaces2); + BIND(DestroySurfaces, destroy_surfaces); BIND(QuerySurfaceAttributes, surface_attributes); + BIND(CreateContext, create_context); BIND(DestroyContext, destroy_context); + BIND(CreateBuffer, create_buffer); BIND(BufferSetNumElements, set_buffer_elements); + BIND(MapBuffer, map_buffer); BIND(MapBuffer2, map_buffer2); BIND(UnmapBuffer, unmap_buffer); + BIND(DestroyBuffer, destroy_buffer); BIND(BufferInfo, buffer_info); + BIND(BeginPicture, begin_picture); BIND(RenderPicture, render_picture); BIND(EndPicture, end_picture); + BIND(SyncSurface, sync_surface); BIND(SyncSurface2, sync_surface2); + BIND(QuerySurfaceStatus, query_surface); BIND(QuerySurfaceError, query_error); + BIND(QueryImageFormats, image_formats); BIND(CreateImage, create_image); BIND(DeriveImage, derive_image); + BIND(DestroyImage, destroy_image); BIND(GetImage, get_image); BIND(ExportSurfaceHandle, export_surface); + BIND(SetImagePalette, palette); BIND(PutImage, put_image); BIND(QuerySubpictureFormats, sub_formats); + BIND(CreateSubpicture, sub_create); BIND(DestroySubpicture, sub_destroy); BIND(SetSubpictureImage, sub_image); + BIND(SetSubpictureChromakey, sub_chroma); BIND(SetSubpictureGlobalAlpha, sub_alpha); + BIND(AssociateSubpicture, sub_associate); BIND(DeassociateSubpicture, sub_deassociate); + BIND(QueryDisplayAttributes, display_query); BIND(GetDisplayAttributes, display_attributes); + BIND(SetDisplayAttributes, display_attributes); +#undef BIND + ctx->pDriverData = d.release(); + return VA_STATUS_SUCCESS; + } catch (...) { return VA_STATUS_ERROR_ALLOCATION_FAILED; } +} diff --git a/guest/video/environment.sh b/guest/video/environment.sh new file mode 100644 index 00000000..cad8c11c --- /dev/null +++ b/guest/video/environment.sh @@ -0,0 +1,22 @@ +# Sourced by the native-video launchers; never changes the global loader path. +omarchy_video_available() { + [[ -c /dev/virtio-ports/dev.tryomarchy.video && -S /run/omarchy-video.sock && + -r /run/omarchy-video.sock && -w /run/omarchy-video.sock && + -f /usr/lib/dri/omarchy_drv_video.so ]] +} + +omarchy_video_environment() { + export LIBVA_DRIVER_NAME=omarchy + export LIBVA_DRIVERS_PATH=/usr/lib/dri +} + +omarchy_video_private_ffmpeg() { + local program=$1 + # An Arch update may change FFmpeg's ABI. In that case use the system stack + # until the matching bridge package is installed. + if ldd "$program" 2>/dev/null | grep -q 'libavcodec\.so\.63 '; then + export LD_LIBRARY_PATH="/usr/local/lib/omarchy-video/ffmpeg/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" + return 0 + fi + return 1 +} diff --git a/guest/video/ffmpeg-full-bitstream.patch b/guest/video/ffmpeg-full-bitstream.patch new file mode 100644 index 00000000..0d22391a --- /dev/null +++ b/guest/video/ffmpeg-full-bitstream.patch @@ -0,0 +1,141 @@ +--- a/libavcodec/vaapi_av1.c ++++ b/libavcodec/vaapi_av1.c +@@ -436,10 +436,50 @@ + }; + } + ++ /* VideoToolbox requires the original frame and sequence OBUs. Preserve ++ * those bytes for the Omarchy frontend, while keeping normal VA drivers' ++ * compact tile-only submission unchanged. Tile offsets remain relative ++ * to the submitted VASliceDataBuffer, as required by VA-API. */ ++ const char *vendor = vaQueryVendorString(ctx->base.hwctx->display); ++ uint8_t *coded = NULL; ++ if (vendor && strstr(vendor, "Try Omarchy VideoToolbox")) { ++ size_t coded_size = s->seq_data_ref ? s->seq_data_ref->size : 0; ++ size_t tile_base = SIZE_MAX, offset; ++ if (!coded_size) ++ return AVERROR_INVALIDDATA; ++ for (int j = 0; j < s->current_obu.nb_units; j++) { ++ const CodedBitstreamUnit *unit = &s->current_obu.units[j]; ++ uintptr_t begin = (uintptr_t)unit->data; ++ uintptr_t tile = (uintptr_t)buffer; ++ if (tile >= begin && tile - begin <= unit->data_size && ++ size <= unit->data_size - (tile - begin)) ++ tile_base = coded_size + (tile - begin); ++ if (unit->data_size > INT_MAX - coded_size) ++ return AVERROR_INVALIDDATA; ++ coded_size += unit->data_size; ++ } ++ if (tile_base == SIZE_MAX || coded_size > 64 * 1024 * 1024) ++ return AVERROR_INVALIDDATA; ++ coded = av_malloc(coded_size); ++ if (!coded) ++ return AVERROR(ENOMEM); ++ memcpy(coded, s->seq_data_ref->data, s->seq_data_ref->size); ++ offset = s->seq_data_ref->size; ++ for (int j = 0; j < s->current_obu.nb_units; j++) { ++ const CodedBitstreamUnit *unit = &s->current_obu.units[j]; ++ memcpy(coded + offset, unit->data, unit->data_size); ++ offset += unit->data_size; ++ } ++ for (int j = 0; j < nb_params; j++) ++ ctx->slice_params[j].slice_data_offset += tile_base; ++ buffer = coded; ++ size = coded_size; ++ } + err = ff_vaapi_decode_make_slice_buffer(avctx, pic, ctx->slice_params, nb_params, + sizeof(VASliceParameterBufferAV1), + buffer, + size); ++ av_free(coded); + if (err) + goto fail; + +--- a/libavcodec/vaapi_hevc.c ++++ b/libavcodec/vaapi_hevc.c +@@ -24,6 +24,9 @@ + #include + + #include "avcodec.h" ++#include "internal.h" ++#include "libavutil/intreadwrite.h" ++#include "libavutil/mem.h" + #include "hwaccel_internal.h" + #include "vaapi_decode.h" + #include "vaapi_hevc.h" +@@ -44,6 +47,57 @@ + + VAAPIDecodePicture pic; + } VAAPIDecodePictureHEVC; ++ ++/* Preserve the complete HEVC parameter NALs for the VideoToolbox frontend. ++ * Other VA drivers continue receiving the normal, unmodified slice payload. */ ++static int omarchy_hevc_make_slice_buffer(AVCodecContext *avctx, VAAPIDecodePicture *pic, ++ const void *params, int count, size_t param_size, ++ const uint8_t *buffer, size_t size) ++{ ++ VAAPIDecodeContext *ctx = avctx->internal->hwaccel_priv_data; ++ const char *vendor = vaQueryVendorString(ctx->hwctx->display); ++ const HEVCContext *h = avctx->priv_data; ++ const HEVCSPS *sps = h->pps->sps; ++ uint8_t *coded; ++ size_t offset = 0, capacity = size + 16; ++ const uint8_t *sets[3] = { sps->vps->data, sps->data, h->pps->data }; ++ int lengths[3] = { sps->vps->data_size, sps->data_size, h->pps->data_size }; ++ int ret; ++ VASliceParameterBufferHEVC param; ++ if (!vendor || !strstr(vendor, "Try Omarchy VideoToolbox")) ++ return ff_vaapi_decode_make_slice_buffer(avctx, pic, params, count, param_size, buffer, size); ++ if (count != 1 || param_size != sizeof(param) || size > 64 * 1024 * 1024 - 1024 * 1024) ++ return AVERROR_INVALIDDATA; ++ for (int i = 0; i < 3; ++i) { ++ if (!sets[i] || lengths[i] < 2 || lengths[i] > 65535) ++ return AVERROR_INVALIDDATA; ++ capacity += lengths[i] * 2; ++ } ++ coded = av_malloc(capacity); ++ if (!coded) ++ return AVERROR(ENOMEM); ++ for (int i = 0; i < 3; ++i) { ++ size_t start = offset; ++ unsigned zeros = 0; ++ offset += 4; ++ /* Parser parameter sets contain RBSP; restore emulation prevention. */ ++ for (int j = 0; j < lengths[i]; ++j) { ++ uint8_t b = sets[i][j]; ++ if (zeros == 2 && b <= 3) { coded[offset++] = 3; zeros = 0; } ++ coded[offset++] = b; ++ zeros = b == 0 ? zeros + 1 : 0; ++ } ++ AV_WB32(coded + start, offset - start - 4); ++ } ++ AV_WB32(coded + offset, size); ++ memcpy(coded + offset + 4, buffer, size); ++ param = *(const VASliceParameterBufferHEVC *)params; ++ param.slice_data_offset += offset + 4; ++ offset += size + 4; ++ ret = ff_vaapi_decode_make_slice_buffer(avctx, pic, ¶m, 1, sizeof(param), coded, offset); ++ av_free(coded); ++ return ret; ++} + + static void init_vaapi_pic(VAPictureHEVC *va_pic) + { +@@ -356,7 +410,7 @@ + + if (pic->last_size) { + last_slice_param->LongSliceFlags.fields.LastSliceOfPic = 1; +- ret = ff_vaapi_decode_make_slice_buffer(avctx, &pic->pic, ++ ret = omarchy_hevc_make_slice_buffer(avctx, &pic->pic, + &pic->last_slice_param, 1, slice_param_size, + pic->last_buffer, pic->last_size); + if (ret < 0) +@@ -474,7 +528,7 @@ + int err, i, list_idx; + + if (!sh->first_slice_in_pic_flag) { +- err = ff_vaapi_decode_make_slice_buffer(avctx, &pic->pic, ++ err = omarchy_hevc_make_slice_buffer(avctx, &pic->pic, + &pic->last_slice_param, 1, slice_param_size, + pic->last_buffer, pic->last_size); + pic->last_buffer = NULL; diff --git a/guest/video/firefox b/guest/video/firefox new file mode 100755 index 00000000..5614e54d --- /dev/null +++ b/guest/video/firefox @@ -0,0 +1,9 @@ +#!/bin/bash +set -euo pipefail +source /usr/local/lib/omarchy-video/environment.sh +if omarchy_video_available && [[ -f /usr/lib/libavcodec.so.63 ]]; then + omarchy_video_environment + export LD_LIBRARY_PATH="/usr/local/lib/omarchy-video/ffmpeg/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" + export LD_PRELOAD="/usr/local/lib/omarchy-video/firefox-video-bootstrap.so${LD_PRELOAD:+:$LD_PRELOAD}" +fi +exec /usr/lib/firefox/firefox "$@" diff --git a/guest/video/firefox-prefs.js b/guest/video/firefox-prefs.js new file mode 100644 index 00000000..45fd6520 --- /dev/null +++ b/guest/video/firefox-prefs.js @@ -0,0 +1,12 @@ +// RDD opens the restricted bridge descriptors before installing its sandbox. +// The child must exec to run that constructor; its seccomp sandbox stays on. +pref("dom.ipc.forkserver.enable", false); +pref("media.ffmpeg.vaapi.enabled", true); +// A delayed VM frame can trip Firefox's slow-decoder heuristic even when the +// hardware decoder is faster on average. Reinitializing midstream then fails +// for HEVC. Keep a working hardware session across those scheduling delays; +// unsupported codecs and failed decoder initialization still use software. +pref("media.ffmpeg.disable-software-fallback", true); +pref("media.hardware-video-decoding.force-enabled", true); +pref("media.ffvpx.enabled", false); +pref("media.hevc.enabled", true); diff --git a/guest/video/firefox-video-bootstrap.cpp b/guest/video/firefox-video-bootstrap.cpp new file mode 100644 index 00000000..96204d89 --- /dev/null +++ b/guest/video/firefox-video-bootstrap.cpp @@ -0,0 +1,43 @@ +// Firefox's RDD sandbox allows existing descriptors but denies new broker +// connections. Open only two decoder capabilities in the RDD process before +// sandbox initialization. No filesystem or syscall sandbox rules are changed. +// The launcher disables the forkserver so the RDD exec runs this constructor. +#include "connection.hpp" +#include +#include +#include +#include + +namespace { +struct Slot { std::unique_ptr connection; bool busy = false; }; +struct Pool { std::mutex mutex; std::array slots; }; +Pool& pool() { static Pool instance; return instance; } +__attribute__((constructor)) void initialize(int argc, char **argv, char **) { + if (argc < 3 || strcmp(argv[1], "-contentproc") || strcmp(argv[argc - 1], "rdd")) return; + for (auto& slot : pool().slots) { + try { slot.connection = std::make_unique("/run/omarchy-video.sock"); } + catch (const std::exception& e) { + fprintf(stderr, "[omarchy-video] RDD bootstrap: %s\n", e.what()); + break; + } + } +} +} +extern "C" int tovd_acquire_preopened_client(int *fd, const uint8_t **pixels) { + auto& p = pool(); std::lock_guard lock(p.mutex); + for (unsigned i = 0; i < p.slots.size(); ++i) { + auto& s = p.slots[i]; + if (!s.busy && s.connection) { + s.busy = true; *fd = s.connection->socket_fd(); *pixels = s.connection->pixels(); + return i + 1; + } + } + return 0; +} +extern "C" void tovd_release_preopened_client(int lease, int healthy) { + auto& p = pool(); std::lock_guard lock(p.mutex); + if (lease < 1 || unsigned(lease) > p.slots.size()) return; + auto& s = p.slots[lease - 1]; + if (!healthy) s.connection.reset(); + s.busy = false; +} diff --git a/guest/video/mpv b/guest/video/mpv new file mode 100755 index 00000000..27d75087 --- /dev/null +++ b/guest/video/mpv @@ -0,0 +1,8 @@ +#!/bin/bash +set -euo pipefail +source /usr/local/lib/omarchy-video/environment.sh +if omarchy_video_available && omarchy_video_private_ffmpeg /usr/bin/mpv; then + omarchy_video_environment + exec /usr/bin/mpv --hwdec=vaapi --vo=gpu --gpu-api=opengl "$@" +fi +exec /usr/bin/mpv "$@" diff --git a/guest/video/omarchy-video-broker.service b/guest/video/omarchy-video-broker.service new file mode 100644 index 00000000..ff4a5abb --- /dev/null +++ b/guest/video/omarchy-video-broker.service @@ -0,0 +1,21 @@ +[Unit] +Description=Try Omarchy hardware video decoder +After=systemd-udev-settle.service +Wants=systemd-udev-settle.service +ConditionPathExists=/dev/virtio-ports/dev.tryomarchy.video + +[Service] +Type=simple +ExecStart=/usr/local/libexec/omarchy-video-broker +Restart=on-failure +RestartSec=1 +NoNewPrivileges=yes +ProtectSystem=strict +ProtectHome=yes +PrivateTmp=yes +ReadWritePaths=/run +RestrictAddressFamilies=AF_UNIX +UMask=0077 + +[Install] +WantedBy=multi-user.target diff --git a/guest/video/omarchy-video-firefox.desktop b/guest/video/omarchy-video-firefox.desktop new file mode 100644 index 00000000..0e7cd2a8 --- /dev/null +++ b/guest/video/omarchy-video-firefox.desktop @@ -0,0 +1,11 @@ +[Desktop Entry] +Version=1.0 +Name=Firefox with hardware video +Comment=Browse with the Mac hardware video decoder +Exec=/usr/local/bin/firefox %u +Icon=firefox +Terminal=false +Type=Application +Categories=Network;WebBrowser; +MimeType=text/html;x-scheme-handler/http;x-scheme-handler/https; +StartupWMClass=firefox diff --git a/guest/video/vivaldi.sh b/guest/video/vivaldi.sh new file mode 100644 index 00000000..fdf7a4f3 --- /dev/null +++ b/guest/video/vivaldi.sh @@ -0,0 +1,15 @@ +# Sourced by the signed ARM64 Vivaldi launcher's local integration. +source /usr/local/lib/omarchy-video/environment.sh +OMARCHY_VIDEO_FLAGS=() +if omarchy_video_available; then + omarchy_video_environment + # Vivaldi's VP9 VA-API path carries the complete compressed frame. Its HEVC + # and AV1 paths do not use our private FFmpeg bitstream-preservation patch. + export OMARCHY_VIDEO_VP9_ONLY=1 + export LD_PRELOAD="/usr/local/lib/omarchy-video/arm64-browser-compat.so${LD_PRELOAD:+:$LD_PRELOAD}" + OMARCHY_VIDEO_FLAGS+=( + --ozone-platform=wayland --use-gl=angle --use-angle=gles + --enable-features=AcceleratedVideoDecodeLinuxGL,AcceleratedVideoDecodeLinuxZeroCopyGL,VaapiIgnoreDriverChecks + --ignore-gpu-blocklist + ) +fi diff --git a/guest/video/wire.hpp b/guest/video/wire.hpp new file mode 100644 index 00000000..c5469a51 --- /dev/null +++ b/guest/video/wire.hpp @@ -0,0 +1,106 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace tovd { +constexpr size_t header_size = 40; +constexpr size_t slot_size = 64u * 1024 * 1024; +constexpr size_t aperture_size = 8 * slot_size; +constexpr uint32_t hardware_flag = 0x100, shared_flag = 0x200, gpu_flag = 0x400; + +inline uint64_t get(const uint8_t *p, unsigned n) { + uint64_t v = 0; + for (unsigned i = 0; i < n; ++i) v |= uint64_t(p[i]) << (8 * i); + return v; +} +inline void put(uint8_t *p, uint64_t v, unsigned n) { + for (unsigned i = 0; i < n; ++i) p[i] = uint8_t(v >> (8 * i)); +} + +inline void transfer(int fd, void *data, size_t size, bool sending, int timeout_ms = 5000) { + auto *p = static_cast(data); + auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_ms); + while (size) { + pollfd wait{fd, short(sending ? POLLOUT : POLLIN), 0}; + int remaining = -1; + if (timeout_ms >= 0) { + auto duration = deadline - std::chrono::steady_clock::now(); + if (duration <= decltype(duration)::zero()) throw std::runtime_error("video transport timed out"); + remaining = std::chrono::duration_cast(duration).count() + 1; + } + int ready = poll(&wait, 1, remaining); + if (ready < 0 && errno == EINTR) continue; + if (ready <= 0) throw std::runtime_error("video transport timed out"); + ssize_t count; + if (sending) { + count = send(fd, p, size, MSG_NOSIGNAL | MSG_DONTWAIT); + if (count < 0 && errno == ENOTSOCK) count = write(fd, p, size); + } else { + count = recv(fd, p, size, MSG_DONTWAIT); + if (count < 0 && errno == ENOTSOCK) count = read(fd, p, size); + } + if (count < 0 && (errno == EINTR || errno == EAGAIN || errno == EWOULDBLOCK)) continue; + if (count <= 0) throw std::runtime_error("video transport closed"); + p += count; + size -= count; + } +} + +struct Message { + uint16_t op = 0; + uint32_t session = 1; + uint64_t token = 0; + uint32_t arg0 = 0, arg1 = 0, flags = 0; + std::vector payload; + + void send(int fd) const { + if (payload.size() > slot_size) throw std::runtime_error("video payload too large"); + std::array h{}; + memcpy(h.data(), "TOVD", 4); + put(h.data() + 4, 1, 2); put(h.data() + 6, op, 2); + put(h.data() + 8, session, 4); put(h.data() + 12, payload.size(), 4); + put(h.data() + 16, token, 8); put(h.data() + 24, arg0, 4); + put(h.data() + 28, arg1, 4); put(h.data() + 32, flags, 4); + transfer(fd, h.data(), h.size(), true); + transfer(fd, const_cast(payload.data()), payload.size(), true); + } + + static Message receive(int fd, bool idle = false) { + std::array h{}; + // Idle clients can remain paused indefinitely. Once a message starts, + // bound every remaining read so one client cannot stall other decoders. + transfer(fd, h.data(), 1, false, idle ? -1 : 5000); + transfer(fd, h.data() + 1, h.size() - 1, false); + if (memcmp(h.data(), "TOVD", 4) || get(h.data() + 4, 2) != 1 || get(h.data() + 36, 4)) + throw std::runtime_error("invalid video message header"); + size_t size = get(h.data() + 12, 4); + if (size > slot_size) throw std::runtime_error("video payload too large"); + Message m; + m.op = get(h.data() + 6, 2); m.session = get(h.data() + 8, 4); + m.token = get(h.data() + 16, 8); m.arg0 = get(h.data() + 24, 4); + m.arg1 = get(h.data() + 28, 4); m.flags = get(h.data() + 32, 4); + m.payload.resize(size); + transfer(fd, m.payload.data(), size, false); + return m; + } +}; + +class FD { + int fd_ = -1; +public: + explicit FD(int fd = -1) : fd_(fd) {} + ~FD() { if (fd_ >= 0) close(fd_); } + FD(const FD&) = delete; + FD& operator=(const FD&) = delete; + int get() const { return fd_; } +}; +} diff --git a/macos/Sources/OmarchyVMHelper/NativeAV1Configuration.swift b/macos/Sources/OmarchyVMHelper/NativeAV1Configuration.swift new file mode 100644 index 00000000..e056a5b2 --- /dev/null +++ b/macos/Sources/OmarchyVMHelper/NativeAV1Configuration.swift @@ -0,0 +1,17 @@ +import Foundation + +/// AV1CodecConfigurationRecord, as carried by the av1C sample entry. +/// Only Main profile 4:2:0 8/10-bit is advertised by the bridge. +struct NativeAV1Configuration { + let tenBit: Bool + + init(_ data: Data) throws { + guard data.count >= 4, data.count <= 1024 * 1024, + data[0] == 0x81, data[1] >> 5 == 0, + data[2] & 0x30 == 0, data[2] & 0x0c == 0x0c, + data[3] & 0xe0 == 0 else { + throw HelperError.io("unsupported AV1 configuration; expected Main 4:2:0 8/10-bit av1C") + } + tenBit = data[2] & 0x40 != 0 + } +} diff --git a/macos/Sources/OmarchyVMHelper/NativeVP9Configuration.swift b/macos/Sources/OmarchyVMHelper/NativeVP9Configuration.swift new file mode 100644 index 00000000..cfcf10f2 --- /dev/null +++ b/macos/Sources/OmarchyVMHelper/NativeVP9Configuration.swift @@ -0,0 +1,17 @@ +import Foundation + +struct NativeVP9Configuration { + let tenBit: Bool + + init(_ data: Data) throws { + let bytes = [UInt8](data) + guard bytes.count == 12, bytes[0] == 1, + bytes[1...3].allSatisfy({ $0 == 0 }), bytes[10] == 0, bytes[11] == 0, + ((bytes[4] == 0 && bytes[6] >> 4 == 8) || + (bytes[4] == 2 && bytes[6] >> 4 == 10)), + (bytes[6] >> 1) & 7 <= 1 else { + throw HelperError.io("unsupported VP9 configuration; expected 8/10-bit 4:2:0 vpcC") + } + tenBit = bytes[6] >> 4 == 10 + } +} diff --git a/macos/Sources/OmarchyVMHelper/NativeVideoBridge.swift b/macos/Sources/OmarchyVMHelper/NativeVideoBridge.swift new file mode 100644 index 00000000..560e8c69 --- /dev/null +++ b/macos/Sources/OmarchyVMHelper/NativeVideoBridge.swift @@ -0,0 +1,384 @@ +import CoreMedia +import CoreVideo +import Darwin +import Foundation +import VideoToolbox + +/// Decodes guest-supplied access units using the Mac media engine. +/// No paths, URLs or host file operations are exposed by this protocol. +final class NativeVideoBridge: @unchecked Sendable { + private let descriptor: Int32 + private let targetPID: pid_t + private let writeLock = NSLock() + private let stateLock = NSLock() + private var stopped = false + private var sessions: [UInt32: NativeVideoDecoder] = [:] + private let sharedMemory: NativeVideoSharedMemory? + private let gpuChannel: NativeVideoGPUChannel? + + init(targetPID: pid_t, socketPath: String, sharedMemoryName: String? = nil, gpuSocketPath: String? = nil) throws { + guard let identity = KernelProcessIdentity.capture(processIdentifier: targetPID), + identity.isQEMUSystemProcess else { + throw HelperError.io("native video bridge target is not a QEMU system process") + } + self.targetPID = targetPID + sharedMemory = try sharedMemoryName.map { try NativeVideoSharedMemory(name: $0) } + gpuChannel = try gpuSocketPath.map { try NativeVideoGPUChannel(path: $0, targetPID: targetPID) } + descriptor = try NativeBridgeSocket.connectSecure(path: socketPath, label: "video bridge") + var timeout = timeval(tv_sec: 5, tv_usec: 0) + guard setsockopt(descriptor, SOL_SOCKET, SO_SNDTIMEO, &timeout, socklen_t(MemoryLayout.size)) == 0 else { + Darwin.close(descriptor) + throw HelperError.io("cannot set video channel write timeout") + } + } + + deinit { stop(); Darwin.close(descriptor) } + + func stop() { + stateLock.lock() + defer { stateLock.unlock() } + if !stopped { + stopped = true + Darwin.shutdown(descriptor, SHUT_RDWR) + } + } + + func run() throws { + let watcher = DispatchSource.makeProcessSource(identifier: targetPID, eventMask: .exit, + queue: .global(qos: .userInitiated)) + watcher.setEventHandler { [weak self] in self?.stop() } + watcher.resume() + defer { watcher.cancel(); sessions.removeAll() } + while let request = try NativeVideoMessage.read(from: descriptor) { + do { try handle(request) } + catch { + // A bad codec stream destroys only its own decoder. Framing + // errors, handled by read(), terminate the entire connection. + sessions.removeValue(forKey: request.session) + try send(NativeVideoMessage( + operation: .error, session: request.session, token: request.token, + payload: Data(error.localizedDescription.prefix(1024).utf8) + )) + } + } + } + + private func send(_ message: NativeVideoMessage) throws { + let data = try message.encoded() + writeLock.lock() + defer { writeLock.unlock() } + try NativeBridgeSocket.writeAll(data, to: descriptor, label: "video") + } + + private func handle(_ request: NativeVideoMessage) throws { + if request.operation == .reset { + guard request.session == 0, request.arg0 == 0, request.arg1 == 0, + request.flags == 0, request.payload.isEmpty else { throw HelperError.io("invalid video reset") } + sessions.removeAll() + try send(NativeVideoMessage(operation: .resetComplete, session: 0, token: request.token)) + return + } + guard request.session != 0, (request.operation == .open + ? request.flags <= 1 && request.arg0 <= 2 + : request.operation == .decode && request.flags == 4 + ? gpuChannel != nil && request.arg0 != 0 && request.arg1 != 0 && request.arg0 != request.arg1 + : request.flags == 0 && request.arg0 == 0 && request.arg1 == 0) else { + throw HelperError.io("invalid video request fields") + } + switch request.operation { + case .open: + guard sessions[request.session] == nil, sessions.count < 8 else { + throw HelperError.io("video decoder session is already open or the limit of 8 was reached") + } + var storage: NativeVideoSharedSlot? + if request.flags == 1 { + guard let sharedMemory else { throw HelperError.io("shared video frames are not configured") } + storage = try sharedMemory.allocateSlot() + } + let decoder = try NativeVideoDecoder(configuration: request.payload, codec: request.arg0, dimensions: request.arg1, frameStorage: storage, gpuChannel: storage == nil ? nil : gpuChannel) { [weak self, storage] frame in + var message = frame + message.session = request.session + guard let self else { throw HelperError.io("video bridge was stopped") } + try self.send(message) + if storage != nil { + guard let release = try NativeVideoMessage.read(from: self.descriptor, timeoutMilliseconds: 5_000), + release.operation == .release, release.session == request.session, + release.token == message.token, release.payload.isEmpty, + release.arg0 == 0, release.arg1 == 0, release.flags == 0 else { + throw HelperError.io("video frame must be released before its slot is reused") + } + } + } + sessions[request.session] = decoder + try send(NativeVideoMessage( + operation: .opened, session: request.session, token: request.token, + arg0: UInt32(decoder.width), arg1: UInt32(decoder.height), + flags: decoder.formatFlag | 0x100 | (storage == nil ? 0 : 0x200) | (storage != nil && gpuChannel != nil ? 0x400 : 0) + )) + case .decode: + guard let decoder = sessions[request.session] else { throw HelperError.io("video session is not open") } + try decoder.decode(request.payload, token: request.token, + gpuTargets: request.flags == 4 ? (request.arg0, request.arg1) : nil) + try send(NativeVideoMessage(operation: .decoded, session: request.session, token: request.token)) + case .drain: + guard request.payload.isEmpty, let decoder = sessions[request.session] else { + throw HelperError.io("invalid video drain request") + } + try decoder.drain() + try send(NativeVideoMessage(operation: .drained, session: request.session, token: request.token)) + case .close: + guard request.payload.isEmpty else { throw HelperError.io("invalid video close request") } + sessions.removeValue(forKey: request.session) + try send(NativeVideoMessage(operation: .closed, session: request.session, token: request.token)) + default: + throw HelperError.io("unsupported video operation") + } + } +} + +final class NativeVideoDecoder: @unchecked Sendable { + let width: Int + let height: Int + let formatFlag: UInt32 + private let hevcConfiguration: NativeHEVCConfiguration? + private let tenBit: Bool + private let format: CMVideoFormatDescription + private var decoder: VTDecompressionSession? + private let output: (NativeVideoMessage) throws -> Void + private let frameStorage: NativeVideoSharedSlot? + private let errorLock = NSLock() + private var outputError: Error? + private let gpuChannel: NativeVideoGPUChannel? + private var gpuTargets: [UInt64: (UInt32, UInt32)] = [:] + + init(configuration data: Data, codec: UInt32 = 0, dimensions: UInt32 = 0, + frameStorage: NativeVideoSharedSlot? = nil, + gpuChannel: NativeVideoGPUChannel? = nil, + output: @escaping (NativeVideoMessage) throws -> Void) throws { + self.output = output + self.frameStorage = frameStorage + self.gpuChannel = gpuChannel + var description: CMFormatDescription? + if codec == 0 { + let configuration = try NativeHEVCConfiguration(data) + hevcConfiguration = configuration + tenBit = configuration.tenBit + let storage = configuration.parameterSets.map { $0 as NSData } + let pointers = storage.map { $0.bytes.assumingMemoryBound(to: UInt8.self) } + let lengths = storage.map { $0.length } + let result = pointers.withUnsafeBufferPointer { p in + lengths.withUnsafeBufferPointer { n in + CMVideoFormatDescriptionCreateFromHEVCParameterSets( + allocator: kCFAllocatorDefault, parameterSetCount: storage.count, + parameterSetPointers: p.baseAddress!, parameterSetSizes: n.baseAddress!, + nalUnitHeaderLength: Int32(configuration.nalLengthSize), extensions: nil, + formatDescriptionOut: &description + ) + } + } + guard result == noErr else { throw HelperError.io("HEVC format rejected (\(result))") } + } else if codec == 1 { + let configuration = try NativeAV1Configuration(data) + hevcConfiguration = nil + tenBit = configuration.tenBit + let extensions: [String: Any] = [ + kCMFormatDescriptionExtension_SampleDescriptionExtensionAtoms as String: ["av1C": data], + ] + let result = CMVideoFormatDescriptionCreate( + allocator: kCFAllocatorDefault, codecType: kCMVideoCodecType_AV1, + width: Int32(dimensions & 0xffff), height: Int32(dimensions >> 16), + extensions: extensions as CFDictionary, formatDescriptionOut: &description + ) + guard result == noErr else { throw HelperError.io("AV1 format rejected (\(result))") } + } else if codec == 2 { + let configuration = try NativeVP9Configuration(data) + hevcConfiguration = nil + tenBit = configuration.tenBit + VTRegisterSupplementalVideoDecoderIfAvailable(kCMVideoCodecType_VP9) + let extensions: [String: Any] = [ + kCMFormatDescriptionExtension_SampleDescriptionExtensionAtoms as String: ["vpcC": data], + ] + let result = CMVideoFormatDescriptionCreate( + allocator: kCFAllocatorDefault, codecType: kCMVideoCodecType_VP9, + width: Int32(dimensions & 0xffff), height: Int32(dimensions >> 16), + extensions: extensions as CFDictionary, formatDescriptionOut: &description + ) + guard result == noErr else { throw HelperError.io("VP9 format rejected (\(result))") } + } else { throw HelperError.io("unsupported video codec") } + guard let description else { throw HelperError.io("missing video format") } + formatFlag = tenBit ? 2 : 1 + format = description + let size = CMVideoFormatDescriptionGetDimensions(description) + width = Int(size.width) + height = Int(size.height) + guard width > 0, height > 0, width <= 8192, height <= 4320, + width % 2 == 0, height % 2 == 0, + width * height * 3 / (tenBit ? 1 : 2) <= NativeVideoMessage.maxPayload else { + throw HelperError.io("unsupported video dimensions") + } + var callback = VTDecompressionOutputCallbackRecord( + decompressionOutputCallback: { context, _, status, _, image, pts, _ in + guard let context else { return } + let owner = Unmanaged.fromOpaque(context).takeUnretainedValue() + owner.didDecode(status: status, image: image, pts: pts) + }, + decompressionOutputRefCon: Unmanaged.passUnretained(self).toOpaque() + ) + let specifications: [String: Any] = [ + kVTVideoDecoderSpecification_RequireHardwareAcceleratedVideoDecoder as String: true, + ] + let attributes: [String: Any] = [ + kCVPixelBufferPixelFormatTypeKey as String: tenBit + ? kCVPixelFormatType_420YpCbCr10BiPlanarVideoRange + : kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange, + kCVPixelBufferIOSurfacePropertiesKey as String: [:] as [String: Any], + ] + let status = VTDecompressionSessionCreate( + allocator: kCFAllocatorDefault, formatDescription: format, + decoderSpecification: specifications as CFDictionary, + imageBufferAttributes: attributes as CFDictionary, + outputCallback: &callback, decompressionSessionOut: &decoder + ) + guard status == noErr, let decoder else { + throw HelperError.io("hardware video decoder unavailable (\(status))") + } + var hardware: Unmanaged? + let probe = VTSessionCopyProperty( + decoder, key: kVTDecompressionPropertyKey_UsingHardwareAcceleratedVideoDecoder, + allocator: kCFAllocatorDefault, valueOut: &hardware + ) + let hardwareValue = hardware?.takeRetainedValue() + guard probe == noErr, (hardwareValue as? NSNumber)?.boolValue == true else { + VTDecompressionSessionInvalidate(decoder) + self.decoder = nil + throw HelperError.io("VideoToolbox did not confirm hardware video decoding") + } + let codecName = codec == 0 ? "HEVC" : codec == 1 ? "AV1" : "VP9" + fputs("[video-bridge] \(codecName) \(width)x\(height) \(tenBit ? 10 : 8)-bit: UsingHardwareAcceleratedVideoDecoder=true\n", stderr) + } + + deinit { + if let decoder { + VTDecompressionSessionWaitForAsynchronousFrames(decoder) + VTDecompressionSessionInvalidate(decoder) + } + } + + func decode(_ data: Data, token: UInt64, gpuTargets targets: (UInt32, UInt32)? = nil) throws { + guard let decoder else { throw HelperError.io("video decoder was closed") } + guard !data.isEmpty else { throw HelperError.io("empty compressed access unit") } + try hevcConfiguration?.validatePacket(data) + errorLock.lock() + let hasCapacity = gpuTargets.count < 256 + if hasCapacity { gpuTargets[token] = targets } + errorLock.unlock() + guard hasCapacity else { throw HelperError.io("too many pending GPU video frames") } + var block: CMBlockBuffer? + try check(CMBlockBufferCreateWithMemoryBlock( + allocator: kCFAllocatorDefault, memoryBlock: nil, blockLength: data.count, + blockAllocator: kCFAllocatorDefault, customBlockSource: nil, offsetToData: 0, + dataLength: data.count, flags: 0, blockBufferOut: &block + )) + guard let block else { throw HelperError.io("cannot allocate compressed video buffer") } + try data.withUnsafeBytes { bytes in + try check(CMBlockBufferReplaceDataBytes( + with: bytes.baseAddress!, blockBuffer: block, offsetIntoDestination: 0, dataLength: data.count + )) + } + var timing = CMSampleTimingInfo( + duration: .invalid, + presentationTimeStamp: CMTime(value: Int64(bitPattern: token), timescale: 1_000_000), + decodeTimeStamp: .invalid + ) + var size = data.count + var sample: CMSampleBuffer? + try check(CMSampleBufferCreateReady( + allocator: kCFAllocatorDefault, dataBuffer: block, formatDescription: format, + sampleCount: 1, sampleTimingEntryCount: 1, sampleTimingArray: &timing, + sampleSizeEntryCount: 1, sampleSizeArray: &size, sampleBufferOut: &sample + )) + guard let sample else { throw HelperError.io("cannot create compressed video sample") } + try check(VTDecompressionSessionDecodeFrame( + decoder, sampleBuffer: sample, flags: [], frameRefcon: nil, infoFlagsOut: nil + )) + try checkOutput() + } + + func drain() throws { + guard let decoder else { throw HelperError.io("video decoder was closed") } + try check(VTDecompressionSessionFinishDelayedFrames(decoder)) + try check(VTDecompressionSessionWaitForAsynchronousFrames(decoder)) + try checkOutput() + } + + private func check(_ status: OSStatus) throws { + guard status == noErr else { throw HelperError.io("VideoToolbox failed (\(status))") } + } + + private func checkOutput() throws { + errorLock.lock() + let error = outputError + errorLock.unlock() + if let error { throw error } + } + + private func didDecode(status: OSStatus, image: CVImageBuffer?, pts: CMTime) { + do { + try check(status) + guard let image else { throw HelperError.io("VideoToolbox returned no decoded frame") } + let pixelFormat = CVPixelBufferGetPixelFormatType(image) + let expected = tenBit ? kCVPixelFormatType_420YpCbCr10BiPlanarVideoRange + : kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange + guard pixelFormat == expected, CVPixelBufferGetWidth(image) == width, + CVPixelBufferGetHeight(image) == height, CVPixelBufferGetPlaneCount(image) == 2 else { + throw HelperError.io("VideoToolbox returned an unexpected frame layout") + } + errorLock.lock() + let targets = gpuTargets.removeValue(forKey: UInt64(bitPattern: pts.value)) + errorLock.unlock() + if let targets, let gpuChannel, + try gpuChannel.copy(image, yResource: targets.0, uvResource: targets.1, format: formatFlag) { + try output(NativeVideoMessage(operation: .frame, session: 0, token: UInt64(bitPattern: pts.value), + arg0: UInt32(width), arg1: UInt32(height), flags: formatFlag | 0x600, payload: Data(count: 16))) + return + } + let locked = CVPixelBufferLockBaseAddress(image, .readOnly) + guard locked == kCVReturnSuccess else { throw HelperError.io("cannot access decoded frame") } + defer { CVPixelBufferUnlockBaseAddress(image, .readOnly) } + let rowBytes = width * (tenBit ? 2 : 1) + let fill: (UnsafeMutableRawPointer) throws -> Void = { [height] destination in + var offset = 0 + for plane in 0..<2 { + guard let source = CVPixelBufferGetBaseAddressOfPlane(image, plane) else { + throw HelperError.io("decoded frame has no plane storage") + } + let rows = plane == 0 ? height : height / 2 + let stride = CVPixelBufferGetBytesPerRowOfPlane(image, plane) + guard stride >= rowBytes, CVPixelBufferGetHeightOfPlane(image, plane) >= rows else { + throw HelperError.io("decoded frame has an invalid plane stride") + } + for row in 0...size)) == 0 else { + Darwin.close(descriptor) + throw HelperError.io("cannot set video GPU write timeout") + } + do { + // Like shm_open, this public C function is omitted by the Swift + // Darwin module on some SDK versions; retain its exact C ABI. + typealias Lookup = @convention(c) (mach_port_t, UnsafePointer, UnsafeMutablePointer) -> kern_return_t + guard let symbol = dlsym(UnsafeMutableRawPointer(bitPattern: -2), "bootstrap_look_up") else { + throw HelperError.io("Mach service lookup is unavailable") + } + let lookup = unsafeBitCast(symbol, to: Lookup.self) + guard let greeting = try NativeVideoMessage.readExactly(128, from: descriptor, allowEOF: false, initialTimeout: 5_000), + greeting.prefix(8) == Data([0x54, 0x4f, 0x47, 0x4d, 1, 0, 0, 0]), + let end = greeting[8...].firstIndex(of: 0), + let name = String(data: greeting[8.. Bool { + guard let surface = CVPixelBufferGetIOSurface(image)?.takeUnretainedValue() else { return false } + lock.lock() + defer { lock.unlock() } + sequence &+= 1 + let surfacePort = IOSurfaceCreateMachPort(surface) + guard surfacePort != 0 else { return false } + defer { mach_port_deallocate(mach_task_self_, surfacePort) } + var capability = Data() + func machAppend(_ value: T) { + var v = value.littleEndian + withUnsafeBytes(of: &v) { capability.append(contentsOf: $0) } + } + // mach_msg_header_t, one mach_msg_port_descriptor_t, then the token. + machAppend(UInt32(MACH_MSGH_BITS_COMPLEX) | UInt32(MACH_MSG_TYPE_COPY_SEND)) + machAppend(UInt32(48)); machAppend(surfaceService) + machAppend(UInt32(0)); machAppend(UInt32(0)); machAppend(UInt32(0x544f5647)) + machAppend(UInt32(1)); machAppend(surfacePort); machAppend(UInt32(0)) + machAppend(UInt16(0)); machAppend(UInt8(MACH_MSG_TYPE_COPY_SEND)); machAppend(UInt8(MACH_MSG_PORT_DESCRIPTOR)) + machAppend(sequence) + let sent = capability.withUnsafeMutableBytes { bytes in + mach_msg(bytes.bindMemory(to: mach_msg_header_t.self).baseAddress!, + MACH_SEND_MSG | MACH_SEND_TIMEOUT, 48, 0, 0, 5_000, 0) + } + guard sent == MACH_MSG_SUCCESS else { throw HelperError.io("cannot transfer decoded IOSurface capability (\(sent))") } + var request = Data() + func append(_ value: T) { + var v = value.littleEndian + withUnsafeBytes(of: &v) { request.append(contentsOf: $0) } + } + append(UInt32(0x47564f54)); append(UInt16(1)); append(UInt16(0)) + append(sequence); append(IOSurfaceGetID(surface)); append(yResource); append(uvResource) + append(UInt32(CVPixelBufferGetWidth(image))); append(UInt32(CVPixelBufferGetHeight(image))); append(format) + try NativeBridgeSocket.writeAll(request, to: descriptor, label: "video GPU") + guard var response = try NativeVideoMessage.readExactly(40, from: descriptor, allowEOF: false, initialTimeout: 5_000) else { + throw HelperError.io("video GPU channel closed") + } + let status = response.withUnsafeBytes { UInt16(littleEndian: $0.loadUnaligned(fromByteOffset: 6, as: UInt16.self)) } + response[6] = 0; response[7] = 0 + guard response == request else { throw HelperError.io("video GPU acknowledgement does not match its frame") } + if status == 0 && !reported { + reported = true + fputs("[video-bridge] IOSurface GPU frame transfer active\n", stderr) + } + if status != 0 && !reportedRejection { + reportedRejection = true + fputs("[video-bridge] IOSurface GPU import unavailable (\(status)); using shared frames\n", stderr) + } + return status == 0 + } +} diff --git a/macos/Sources/OmarchyVMHelper/NativeVideoProtocol.swift b/macos/Sources/OmarchyVMHelper/NativeVideoProtocol.swift new file mode 100644 index 00000000..aa7b7163 --- /dev/null +++ b/macos/Sources/OmarchyVMHelper/NativeVideoProtocol.swift @@ -0,0 +1,172 @@ +import Darwin +import Foundation + +/// Length-bounded messages on the private virtio channel. All integers are LE. +/// The token is opaque to the bridge and is returned with the decoded picture. +struct NativeVideoMessage { + static let headerSize = 40 + static let maxPayload = 64 * 1024 * 1024 + static let magic: UInt32 = 0x44564f54 // TOVD + enum Operation: UInt16 { + case open = 1, decode = 2, drain = 3, close = 4, release = 5, reset = 6 + case opened = 0x8001, decoded = 0x8002, drained = 0x8003, closed = 0x8004 + case resetComplete = 0x8006 + case frame = 0x8100, error = 0xffff + } + + var operation: Operation + var session: UInt32 + var token: UInt64 = 0 + var arg0: UInt32 = 0 + var arg1: UInt32 = 0 + var flags: UInt32 = 0 + var payload = Data() + + func encoded() throws -> Data { + guard payload.count <= Self.maxPayload else { + throw HelperError.io("video payload exceeds 64 MiB") + } + var data = Data(capacity: Self.headerSize + payload.count) + Self.append(Self.magic, to: &data) + Self.append(UInt16(1), to: &data) + Self.append(operation.rawValue, to: &data) + Self.append(session, to: &data) + Self.append(UInt32(payload.count), to: &data) + Self.append(token, to: &data) + Self.append(arg0, to: &data) + Self.append(arg1, to: &data) + Self.append(flags, to: &data) + Self.append(UInt32(0), to: &data) + data.append(payload) + return data + } + + static func parseHeader(_ data: Data) throws -> (NativeVideoMessage, Int) { + guard data.count == headerSize, + integer(data, at: 0, as: UInt32.self) == magic, + integer(data, at: 4, as: UInt16.self) == 1, + integer(data, at: 36, as: UInt32.self) == 0, + let operation = Operation(rawValue: integer(data, at: 6, as: UInt16.self)) else { + throw HelperError.io("invalid video protocol header") + } + let length = Int(integer(data, at: 12, as: UInt32.self)) + guard length <= maxPayload else { throw HelperError.io("video payload exceeds 64 MiB") } + return (NativeVideoMessage( + operation: operation, + session: integer(data, at: 8, as: UInt32.self), + token: integer(data, at: 16, as: UInt64.self), + arg0: integer(data, at: 24, as: UInt32.self), + arg1: integer(data, at: 28, as: UInt32.self), + flags: integer(data, at: 32, as: UInt32.self) + ), length) + } + + private static func append(_ value: T, to data: inout Data) { + var value = value.littleEndian + withUnsafeBytes(of: &value) { data.append(contentsOf: $0) } + } + + private static func integer(_ data: Data, at offset: Int, as: T.Type) -> T { + data.withUnsafeBytes { T(littleEndian: $0.loadUnaligned(fromByteOffset: offset, as: T.self)) } + } + + static func read(from descriptor: Int32, timeoutMilliseconds: Int32? = nil) throws -> NativeVideoMessage? { + guard let header = try readExactly(headerSize, from: descriptor, allowEOF: true, initialTimeout: timeoutMilliseconds) else { return nil } + var (message, length) = try parseHeader(header) + message.payload = try readExactly(length, from: descriptor, allowEOF: false, initialTimeout: 5_000) ?? Data() + return message + } + + static func readExactly(_ length: Int, from descriptor: Int32, allowEOF: Bool, initialTimeout: Int32?) throws -> Data? { + var result = Data(count: length) + let complete = try result.withUnsafeMutableBytes { bytes -> Bool in + var offset = 0 + var deadline: UInt64? = initialTimeout.map { DispatchTime.now().uptimeNanoseconds + UInt64($0) * 1_000_000 } + while offset < length { + let remaining: Int32 + if let deadline { + let now = DispatchTime.now().uptimeNanoseconds + guard now < deadline else { throw HelperError.io("video message timed out") } + remaining = Int32(min((deadline - now + 999_999) / 1_000_000, UInt64(Int32.max))) + } else { remaining = -1 } + var item = pollfd(fd: descriptor, events: Int16(POLLIN), revents: 0) + let ready = Darwin.poll(&item, 1, remaining) + if ready < 0 && errno == EINTR { continue } + guard ready > 0 else { throw HelperError.io("video message timed out or polling failed") } + let count = Darwin.read(descriptor, bytes.baseAddress!.advanced(by: offset), length - offset) + if count > 0 { + offset += count + if deadline == nil { deadline = DispatchTime.now().uptimeNanoseconds + 5_000_000_000 } + } + else if count < 0 && errno == EINTR { continue } + else if count == 0 && offset == 0 && allowEOF { return false } + else { throw HelperError.io("video channel closed during a message") } + } + return true + } + return complete ? result : nil + } +} + +/// Parse hvcC without trusting array counts or NAL lengths from the guest. +struct NativeHEVCConfiguration { + let parameterSets: [Data] + let nalLengthSize: Int + let tenBit: Bool + + init(_ data: Data) throws { + let bytes = [UInt8](data) + guard bytes.count >= 23, bytes.count <= 1024 * 1024, bytes[0] == 1, + (bytes[17] & 7 == 0 || bytes[17] & 7 == 2), + bytes[18] & 7 == bytes[17] & 7 else { + throw HelperError.io("unsupported HEVC configuration; expected 8/10-bit hvcC") + } + nalLengthSize = Int(bytes[21] & 3) + 1 + tenBit = (bytes[17] & 7) > 0 + var sets: [Data] = [] + var types = Set() + var offset = 23 + for _ in 0..= 2, offset + length <= bytes.count else { + throw HelperError.io("truncated HEVC parameter set") + } + if [32, 33, 34].contains(type) { + guard (bytes[offset] >> 1) & 0x3f == type else { + throw HelperError.io("HEVC array type does not match its NAL unit") + } + sets.append(Data(bytes[offset..= 2, length <= data.count - offset else { + throw HelperError.io("invalid HEVC packet NAL length") + } + offset += length + } + } +} diff --git a/macos/Sources/OmarchyVMHelper/NativeVideoSharedMemory.swift b/macos/Sources/OmarchyVMHelper/NativeVideoSharedMemory.swift new file mode 100644 index 00000000..688a5fa7 --- /dev/null +++ b/macos/Sources/OmarchyVMHelper/NativeVideoSharedMemory.swift @@ -0,0 +1,124 @@ +import Darwin +import Foundation + +/// A dedicated frame aperture: this is never the virtual machine's RAM. +/// QEMU creates the private POSIX object and unlinks it when the VM exits. +final class NativeVideoSharedMemory { + static let slotSize = 64 * 1024 * 1024 + static let slotCount = 8 + static let size = slotSize * slotCount + private let descriptor: Int32 + fileprivate let address: UnsafeMutableRawPointer + private let lock = NSLock() + private var used = Set() + + private static func openOwned(name: String, allowMissing: Bool = false, allowEmpty: Bool = false) throws -> Int32 { + guard name.hasPrefix("/tovd."), name.utf8.count <= 31, + name.dropFirst().allSatisfy({ $0.isASCII && ($0.isLetter || $0.isNumber || $0 == "." || $0 == "-") }) else { + throw HelperError.io("invalid video shared memory name") + } + // Darwin declares shm_open variadic. Without O_CREAT it has exactly + // two arguments; resolve that C signature because Swift cannot import it. + typealias OpenSharedMemory = @convention(c) (UnsafePointer, Int32) -> Int32 + guard let symbol = dlsym(UnsafeMutableRawPointer(bitPattern: -2), "shm_open") else { + throw HelperError.io("POSIX shared memory is unavailable") + } + let openSharedMemory = unsafeBitCast(symbol, to: OpenSharedMemory.self) + let fd = name.withCString { openSharedMemory($0, O_RDWR) } + if fd < 0 && allowMissing && errno == ENOENT { return -1 } + guard fd >= 0 else { throw HelperError.io("cannot open video shared memory") } + _ = fcntl(fd, F_SETFD, FD_CLOEXEC) + var info = stat() + guard fstat(fd, &info) == 0, info.st_uid == getuid(), + info.st_mode & 0o077 == 0, (info.st_size == Self.size || (allowEmpty && info.st_size == 0)) else { + Darwin.close(fd) + throw HelperError.io("video shared memory must be private, owned and 512 MiB") + } + return fd + } + + static func unlinkIfOwned(name: String) throws { + let fd = try openOwned(name: name, allowMissing: true, allowEmpty: true) + guard fd >= 0 else { return } + defer { Darwin.close(fd) } + guard name.withCString({ shm_unlink($0) }) == 0 || errno == ENOENT else { + throw HelperError.io("cannot unlink video shared memory") + } + } + + init(name: String) throws { + // QEMU publishes its chardev before realizing the PCI aperture. Wait + // only for a missing/empty object; ownership and permission failures + // remain immediate errors. Use a monotonic deadline across host sleep. + let deadline = ContinuousClock.now + .seconds(5) + var fd: Int32 = -1 + while true { + fd = try Self.openOwned(name: name, allowMissing: true, allowEmpty: true) + if fd >= 0 { + var info = stat() + if fstat(fd, &info) == 0 && info.st_size == Self.size { break } + Darwin.close(fd) + } + guard ContinuousClock.now < deadline else { + throw HelperError.io("QEMU did not initialize video shared memory within five seconds") + } + Thread.sleep(forTimeInterval: 0.02) + } + let mapped = mmap(nil, Self.size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0) + guard mapped != MAP_FAILED, let mapped else { + Darwin.close(fd) + throw HelperError.io("cannot map video shared memory") + } + descriptor = fd + address = mapped + } + + deinit { munmap(address, Self.size); Darwin.close(descriptor) } + + func allocateSlot() throws -> NativeVideoSharedSlot { + lock.lock() + defer { lock.unlock() } + guard let index = (0.. Void) throws -> Data { + guard length > 0, length <= NativeVideoSharedMemory.slotSize else { + throw HelperError.io("decoded frame does not fit shared memory") + } + let offset = index * NativeVideoSharedMemory.slotSize + try fill(memory.address.advanced(by: offset)) + // A descriptor consists of offset (u64), byte length (u32), reserved (u32). + var descriptor = Data(count: 16) + descriptor.withUnsafeMutableBytes { + $0.storeBytes(of: UInt64(offset).littleEndian, toByteOffset: 0, as: UInt64.self) + $0.storeBytes(of: UInt32(length).littleEndian, toByteOffset: 8, as: UInt32.self) + } + return descriptor + } +} diff --git a/macos/Sources/OmarchyVMHelper/main.swift b/macos/Sources/OmarchyVMHelper/main.swift index 2c0046b4..7f93c8c5 100644 --- a/macos/Sources/OmarchyVMHelper/main.swift +++ b/macos/Sources/OmarchyVMHelper/main.swift @@ -5,7 +5,7 @@ import Foundation private var terminationSignalSources: [DispatchSourceSignal] = [] private func usage() -> Never { - fputs("Usage: omarchy-vm-helper --run-qemu [--ephemeral | --reset-storage | --reset-storage-only] [GUEST_DIR] | --bridge-command-super QEMU_PID QMP_SOCKET | --bridge-native-audio QEMU_PID SOCKET ROUTE_DIRECTORY | --bridge-native-authentication QEMU_PID SOCKET | --bridge-native-camera QEMU_PID SOCKET | --bridge-native-clipboard QEMU_PID SOCKET\n", stderr) + fputs("Usage: omarchy-vm-helper --run-qemu [--ephemeral | --reset-storage | --reset-storage-only] [GUEST_DIR] | --bridge-command-super QEMU_PID QMP_SOCKET | --bridge-native-audio QEMU_PID SOCKET ROUTE_DIRECTORY | --bridge-native-authentication QEMU_PID SOCKET | --bridge-native-camera QEMU_PID SOCKET | --bridge-native-clipboard QEMU_PID SOCKET | --bridge-native-video QEMU_PID SOCKET SHM_NAME [GPU_SOCKET] | --remove-native-video-memory SHM_NAME\n", stderr) exit(64) } @@ -19,6 +19,29 @@ private func effectiveArguments() -> [String] { let arguments = effectiveArguments() do { + if arguments.first == "--remove-native-video-memory" { + guard arguments.count == 2 else { usage() } + try NativeVideoSharedMemory.unlinkIfOwned(name: arguments[1]) + exit(0) + } + if arguments.first == "--bridge-native-video" { + guard (3...5).contains(arguments.count), + let processIdentifier = Int32(arguments[1]), + processIdentifier > 1 else { usage() } + let bridge = try NativeVideoBridge(targetPID: processIdentifier, socketPath: arguments[2], + sharedMemoryName: arguments.count >= 4 ? arguments[3] : nil, + gpuSocketPath: arguments.count == 5 ? arguments[4] : nil) + for signalNumber in [SIGINT, SIGTERM] { + Darwin.signal(signalNumber, SIG_IGN) + let source = DispatchSource.makeSignalSource(signal: signalNumber, queue: .global(qos: .userInitiated)) + source.setEventHandler { bridge.stop() } + source.resume() + terminationSignalSources.append(source) + } + try bridge.run() + exit(0) + } + if arguments.first == "--bridge-native-audio" { guard arguments.count == 4, let processIdentifier = Int32(arguments[1]), diff --git a/macos/Tests/OmarchyVMHelperTests/NativeVideoProtocolTests.swift b/macos/Tests/OmarchyVMHelperTests/NativeVideoProtocolTests.swift new file mode 100644 index 00000000..20428834 --- /dev/null +++ b/macos/Tests/OmarchyVMHelperTests/NativeVideoProtocolTests.swift @@ -0,0 +1,120 @@ +import Foundation +import Testing +@testable import OmarchyVMHelper + +struct NativeVideoProtocolTests { + @Test func packetHeaderPreservesOpaqueTokenAndBoundsPayload() throws { + let message = NativeVideoMessage(operation: .decode, session: 7, token: UInt64.max, + payload: Data([0, 0, 0, 2, 0x26, 1])) + let encoded = try message.encoded() + let (decoded, size) = try NativeVideoMessage.parseHeader(Data(encoded.prefix(40))) + #expect(decoded.operation == .decode) + #expect(decoded.session == 7) + #expect(decoded.token == UInt64.max) + #expect(size == 6) + #expect(encoded.suffix(6) == message.payload) + } + + @Test func rejectsFramingChangesAndOversizedAllocationBeforeReadingPayload() throws { + let good = try NativeVideoMessage(operation: .open, session: 1).encoded() + for index in [0, 4, 7, 36] { + var bad = good + bad[index] = 0xfe + #expect(throws: (any Error).self) { try NativeVideoMessage.parseHeader(bad) } + } + var oversized = good + oversized[15] = 5 // 80 MiB; must reject before allocating this amount + #expect(throws: (any Error).self) { try NativeVideoMessage.parseHeader(oversized) } + for length in 0..<40 { + #expect(throws: (any Error).self) { try NativeVideoMessage.parseHeader(Data(good.prefix(length))) } + } + } + + private func config() -> Data { + var bytes = Data(repeating: 0, count: 23) + bytes[0] = 1 + bytes[21] = 3 + bytes[22] = 3 + // Minimal length-valid NAL arrays for the wire parser. These are not + // valid SPS syntax and deliberately never go into VideoToolbox. + for type: UInt8 in [32, 33, 34] { + bytes.append(contentsOf: [type, 0, 1, 0, 2, type << 1, 1]) + } + return bytes + } + + @Test func rejectsEveryTruncatedConfigurationAndTrailingData() throws { + let good = config() + let parsed = try NativeHEVCConfiguration(good) + #expect(parsed.parameterSets.count == 3) + #expect(parsed.nalLengthSize == 4) + for length in 0../dev/null; do sleep 0.02 @@ -81,7 +82,7 @@ case " $* " in *' -display help '*) printf '%s\n' cocoa ;; *' -device help '*) for device in \ - hda-micro intel-hda virtconsole virtserialport virtio-balloon-pci \ + hda-micro intel-hda omarchy-video-shmem virtconsole virtserialport virtio-balloon-pci \ virtio-9p-pci virtio-blk-pci virtio-gpu-gl-pci virtio-keyboard-pci \ virtio-net-pci virtio-rng-pci virtio-serial-pci virtio-tablet-pci; do printf 'name "%s"\n' "$device" diff --git a/macos/build-qemu-gpu-runtime.sh b/macos/build-qemu-gpu-runtime.sh index 4d9412e0..6b0fa142 100755 --- a/macos/build-qemu-gpu-runtime.sh +++ b/macos/build-qemu-gpu-runtime.sh @@ -52,6 +52,10 @@ pause_ownership_patch="$native_dir/patches/qemu-cocoa-pause-ownership.patch" audio_device_patch="$native_dir/patches/qemu-sdl-audio-device-selection.patch" shared_folder_patch="$native_dir/patches/qemu-9p-guest-owner.patch" strchrnul_patch="$native_dir/patches/qemu-darwin-strchrnul-compat.patch" +video_shmem_patch="$native_dir/patches/qemu-native-video-shmem.patch" +display_cadence_patch="$native_dir/patches/qemu-display-cadence.patch" +virgl_macos_patch="$native_dir/patches/virglrenderer-macos-1.0.33.patch" +virgl_video_patch="$native_dir/patches/virglrenderer-angle-video.patch" prepare_runtime="$native_dir/prepare-qemu-gpu-runtime.sh" pinned_bottles="$native_dir/pinned-runtime-bottles.sh" @@ -71,6 +75,7 @@ pause_ownership_patch_sha256=1a5729b36eb3e437395d41883a10c3c652df71d289d5df84d95 audio_device_patch_sha256=03aca71c26163c337338cc3b2013c35430690fc0e8b66c5ce92a42f59a9b3334 shared_folder_patch_sha256=41247692501655393ae3a40f56915472ab29b6e89c5173e33db1f62cca56632f strchrnul_patch_sha256=ec1048dd0e8ebe53bf7e8a3bca9bf2f5f4336cd607d4cd077437470e9a32094a +video_shmem_patch_sha256="d14639df4b08d31cf54828386eab022fd8408e7072aa9517db225dec24243af4" macos_deployment_target=15.0 keycodemap_commit=f5772a62ec52591ff6870b7e8ef32482371f22c6 @@ -103,6 +108,11 @@ pip_archive_name=pip-26.2.1-py3-none-any.whl pip_url="https://files.pythonhosted.org/packages/f3/6e/1736e5b4ae2b778ef2f81c47d797de9f891d4d8acb047a24ca37a60294dd/$pip_archive_name" pip_sha256=71138adf1f4ca900cdb7d289c21b7494329f2332b6d85f0e1c42108c0384ed3e +# wheel 0.48 requires packaging even in QEMU's offline Python environment. +packaging_archive_name=packaging-25.0-py3-none-any.whl +packaging_url="https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/$packaging_archive_name" +packaging_sha256=29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484 + virgl_archive_name=virglrenderer-1.0.33.arm64_sequoia.bottle.tar.gz virgl_url="https://github.com/startergo/homebrew-virglrenderer/releases/download/v1.0.33/$virgl_archive_name" virgl_sha256=26ad3e927d300587024cd92276d38bf813f6228d130a1800c97f1c18688b34ba @@ -117,6 +127,19 @@ epoxy_archive_name=libepoxy-1.0.4.arm64_sequoia.bottle.tar.gz epoxy_url="https://github.com/startergo/homebrew-libepoxy/releases/download/v1.0.4/$epoxy_archive_name" epoxy_sha256=8787cc8c34921834665262dff4941216dd6717edddf2c6d5cdfe04f03b24c517 +# Rebuild the existing macOS VirGL port with its GLES video fixes. +virgl_source_commit=f019de64b666a5a9ff00266099dcfed6137b0768 +virgl_source_root="virglrenderer-$virgl_source_commit" +virgl_source_archive_name="$virgl_source_root.tar.gz" +virgl_source_url="https://gitlab.freedesktop.org/virgl/virglrenderer/-/archive/$virgl_source_commit/$virgl_source_archive_name" +virgl_source_sha256=239726ecd47b350d7faf7fc4ab59fa359742b7e59b084945225f39213788053b +display_cadence_patch_sha256=d5689bdd84ede6f4f04ba5be0b8e6d586ccf890264df2044bdcfc252477c04d1 +virgl_macos_patch_sha256=ad20d5154883c6a2c56846adbe6cb65a7fbfbc6a65f858e60e7100bc5168317b +virgl_video_patch_sha256=420813fbef4b325ff5c4471e3124d326c398366231462bb91f00a4c0c93975a8 +pyyaml_archive_name=pyyaml-6.0.3.tar.gz +pyyaml_url=https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz +pyyaml_sha256=d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f + die() { echo "qemu-source-build: $*" >&2 exit 1 @@ -275,17 +298,22 @@ keycodemap_archive="$archive_dir/$keycodemap_archive_name" dtc_archive="$archive_dir/$dtc_archive_name" ninja_archive="$archive_dir/$ninja_archive_name" virgl_archive="$archive_dir/$virgl_archive_name" +virgl_source_archive="$archive_dir/$virgl_source_archive_name" +pyyaml_archive="$archive_dir/$pyyaml_archive_name" angle_archive="$archive_dir/$angle_archive_name" epoxy_archive="$archive_dir/$epoxy_archive_name" setuptools_archive="$archive_dir/$setuptools_archive_name" wheel_archive="$archive_dir/$wheel_archive_name" pip_archive="$archive_dir/$pip_archive_name" +packaging_archive="$archive_dir/$packaging_archive_name" obtain_and_verify "QEMU $qemu_commit" "$qemu_url" "$qemu_sha256" "$qemu_archive" obtain_and_verify "keycodemapdb $keycodemap_commit" "$keycodemap_url" "$keycodemap_sha256" "$keycodemap_archive" obtain_and_verify "dtc $dtc_commit" "$dtc_url" "$dtc_sha256" "$dtc_archive" obtain_and_verify "Ninja $ninja_version" "$ninja_url" "$ninja_sha256" "$ninja_archive" obtain_and_verify "virglrenderer $virgl_version" "$virgl_url" "$virgl_sha256" "$virgl_archive" +obtain_and_verify "VirGL source" "$virgl_source_url" "$virgl_source_sha256" "$virgl_source_archive" +obtain_and_verify "PyYAML" "$pyyaml_url" "$pyyaml_sha256" "$pyyaml_archive" obtain_and_verify "ANGLE $angle_version" "$angle_url" "$angle_sha256" "$angle_archive" obtain_and_verify "libepoxy $epoxy_version" "$epoxy_url" "$epoxy_sha256" "$epoxy_archive" while IFS=$'\t' read -r formula version archive_name archive_root archive_sha; do @@ -299,6 +327,7 @@ done < <(pinned_core_bottle_manifest) obtain_and_verify "setuptools" "$setuptools_url" "$setuptools_sha256" "$setuptools_archive" obtain_and_verify "wheel" "$wheel_url" "$wheel_sha256" "$wheel_archive" obtain_and_verify "pip" "$pip_url" "$pip_sha256" "$pip_archive" +obtain_and_verify "packaging" "$packaging_url" "$packaging_sha256" "$packaging_archive" validate_tar_root "QEMU $qemu_commit" "$qemu_archive" "$qemu_root" "$listing_dir/qemu.txt" validate_tar_root "keycodemapdb" "$keycodemap_archive" "$keycodemap_root" "$listing_dir/keycodemapdb.txt" @@ -307,6 +336,15 @@ validate_tar_root "virglrenderer" "$virgl_archive" "virglrenderer/$virgl_version validate_tar_root "ANGLE" "$angle_archive" "angle/$angle_version" "$listing_dir/angle.txt" validate_tar_root "libepoxy" "$epoxy_archive" "libepoxy/$epoxy_version" "$listing_dir/libepoxy.txt" +validate_tar_root "VirGL source" "$virgl_source_archive" "$virgl_source_root" "$listing_dir/virgl-source.txt" +validate_tar_root "PyYAML" "$pyyaml_archive" "pyyaml-6.0.3" "$listing_dir/pyyaml.txt" +tar -xzf "$virgl_source_archive" -C "$source_parent" +tar -xzf "$pyyaml_archive" -C "$source_parent" +verify_file_sha "VirGL macOS port" "$virgl_macos_patch" "$virgl_macos_patch_sha256" +verify_file_sha "VirGL ANGLE video support" "$virgl_video_patch" "$virgl_video_patch_sha256" +patch -d "$source_parent/$virgl_source_root" -p1 -f -i "$virgl_macos_patch" +patch -d "$source_parent/$virgl_source_root" -p1 -f -i "$virgl_video_patch" + tar -xzf "$qemu_archive" -C "$source_parent" tar -xzf "$virgl_archive" -C "$dependency_root" tar -xzf "$angle_archive" -C "$dependency_root" @@ -319,7 +357,7 @@ source_dir="$source_parent/$qemu_root" [[ -f $source_dir/configure && -f $source_dir/ui/cocoa.m ]] || \ die "QEMU source archive is incomplete" -install -m 0644 "$setuptools_archive" "$wheel_archive" "$pip_archive" \ +install -m 0644 "$setuptools_archive" "$wheel_archive" "$pip_archive" "$packaging_archive" \ "$source_dir/python/wheels/" mkdir -p "$source_dir/subprojects/keycodemapdb" "$source_dir/subprojects/dtc" @@ -344,6 +382,8 @@ verify_file_sha "Try Omarchy 9p shared-folder patch" \ verify_file_sha "Try Omarchy Darwin strchrnul compatibility patch" \ "$strchrnul_patch" "$strchrnul_patch_sha256" +verify_file_sha "Try Omarchy native video shared-memory patch" "$video_shmem_patch" "$video_shmem_patch_sha256" + log "Applying the exact render, identity, display, immersive, pause-ownership, audio, folder, and Darwin compatibility patches" patch -d "$source_dir" -p1 -f -i "$texture_patch" patch -d "$source_dir" -p1 -f -i "$gpu_fix_patch" @@ -355,6 +395,9 @@ patch -d "$source_dir" -p1 -f -i "$pause_ownership_patch" patch -d "$source_dir" -p1 -f -i "$audio_device_patch" patch -d "$source_dir" -p1 -f -i "$shared_folder_patch" patch -d "$source_dir" -p1 -f -i "$strchrnul_patch" +patch -d "$source_dir" -p1 -f -i "$video_shmem_patch" +verify_file_sha "QEMU display cadence" "$display_cadence_patch" "$display_cadence_patch_sha256" +patch -d "$source_dir" -p1 -f -i "$display_cadence_patch" virgl_root="$dependency_root/virglrenderer/$virgl_version" angle_root="$dependency_root/angle/$angle_version" @@ -473,6 +516,25 @@ log "Configuring QEMU 11.1.1 (HVF-only, Cocoa/VirGL, SLIRP, SDL audio, virtio-9p --ninja="$ninja" ) +log "Building VirGL with ANGLE multisample textures and video uploads" +virgl_build_dir="$source_parent/$virgl_source_root/build-native" +( + export MACOSX_DEPLOYMENT_TARGET="$macos_deployment_target" + export CFLAGS="-mmacosx-version-min=$macos_deployment_target -Werror=unguarded-availability-new" + export OBJCFLAGS="$CFLAGS" + export LDFLAGS="-mmacosx-version-min=$macos_deployment_target" + export PKG_CONFIG_PATH= + export PKG_CONFIG_LIBDIR="$pkg_config_libdir" + export DYLD_LIBRARY_PATH="$private_libraries" + export PYTHONPATH="$source_parent/pyyaml-6.0.3/lib" + export PATH="$build_dir/pyvenv/bin:$PATH" + export NINJA="$ninja" + "$build_dir/pyvenv/bin/meson" setup "$virgl_build_dir" "$source_parent/$virgl_source_root" \ + --buildtype=release --wrap-mode=nodownload -Dvenus=true -Dvideo=false \ + -Ddrm-renderers=[] -Dtests=false + "$ninja" -C "$virgl_build_dir" src/libvirglrenderer.1.dylib +) + config_host="$build_dir/config-host.h" [[ -f $config_host && ! -L $config_host ]] || die "QEMU configure did not create config-host.h" if grep -Eq '^[[:space:]]*#define[[:space:]]+HAVE_STRCHRNUL([[:space:]]+1)?[[:space:]]*$' \ @@ -531,6 +593,7 @@ description=$(file -b "$qemu_binary") log "Relocating, capability-gating, signing, and publishing the runtime" "$prepare_runtime" \ --source-qemu "$qemu_binary" \ + --source-virgl "$virgl_build_dir/src/libvirglrenderer.1.dylib" \ --archive-dir "$archive_dir" log "Pinned patched runtime is ready; scratch source and archives will now be removed" diff --git a/macos/patches/qemu-display-cadence.patch b/macos/patches/qemu-display-cadence.patch new file mode 100644 index 00000000..ccda45f5 --- /dev/null +++ b/macos/patches/qemu-display-cadence.patch @@ -0,0 +1,37 @@ +--- a/ui/console.c ++++ b/ui/console.c +@@ -103,6 +103,10 @@ + DisplayState *ds = opaque; + DisplayChangeListener *dcl; + ++ /* Synchronous frontends may wait for the host display while rendering. ++ * Count that work inside the refresh interval instead of appending a full ++ * extra interval after it, which turns a 120 Hz guest into ~60-80 Hz. */ ++ ds->last_update = qemu_clock_get_ms(QEMU_CLOCK_REALTIME); + ds->refreshing = true; + dpy_refresh(ds); + ds->refreshing = false; +@@ -118,7 +122,6 @@ + ds->update_interval = interval; + trace_console_refresh(interval); + } +- ds->last_update = qemu_clock_get_ms(QEMU_CLOCK_REALTIME); + timer_mod(ds->gui_timer, ds->last_update + interval); + } + +--- a/hw/display/virtio-gpu-virgl.c ++++ b/hw/display/virtio-gpu-virgl.c +@@ -1389,7 +1389,13 @@ + virgl_renderer_poll(); + virtio_gpu_process_cmdq(g); + if (!QTAILQ_EMPTY(&g->cmdq) || !QTAILQ_EMPTY(&g->fenceq)) { ++#ifdef CONFIG_DARWIN ++ /* ANGLE fences are polled on the render thread. The generic 10 ms ++ * interval exceeds an entire 120 Hz frame and stalls guest uploads. */ ++ timer_mod(gl->fence_poll, qemu_clock_get_ms(QEMU_CLOCK_VIRTUAL) + 1); ++#else + timer_mod(gl->fence_poll, qemu_clock_get_ms(QEMU_CLOCK_VIRTUAL) + 10); ++#endif + } + } + diff --git a/macos/patches/qemu-native-video-shmem.patch b/macos/patches/qemu-native-video-shmem.patch new file mode 100644 index 00000000..9a595e46 --- /dev/null +++ b/macos/patches/qemu-native-video-shmem.patch @@ -0,0 +1,560 @@ +--- a/hw/misc/Kconfig ++++ b/hw/misc/Kconfig +@@ -258,3 +258,8 @@ + bool + + source macio/Kconfig ++ ++config OMARCHY_VIDEO_SHMEM ++ bool ++ default y ++ depends on PCI +--- a/hw/misc/meson.build ++++ b/hw/misc/meson.build +@@ -168,3 +168,5 @@ + + # HPPA devices + system_ss.add(when: 'CONFIG_LASI', if_true: files('lasi.c')) ++ ++system_ss.add(when: 'CONFIG_OMARCHY_VIDEO_SHMEM', if_true: files('omarchy-video-shmem.c')) +--- a/hw/misc/meson.build ++++ b/hw/misc/meson.build +@@ -172,1 +172,6 @@ + system_ss.add(when: 'CONFIG_OMARCHY_VIDEO_SHMEM', if_true: files('omarchy-video-shmem.c')) ++if host_os == 'darwin' and virgl.found() and opengl.found() ++ system_ss.add(when: 'CONFIG_OMARCHY_VIDEO_SHMEM', ++ if_true: [virgl, opengl, ++ dependency('appleframeworks', modules: 'IOSurface')]) ++endif +--- /dev/null ++++ b/hw/misc/omarchy-video-shmem.c +@@ -0,0 +1,529 @@ ++/* ++ * Try Omarchy video frame shared memory transport. ++ * Based on QEMU's ivshmem-plain memory BAR, without Linux eventfd/irqfd. ++ * SPDX-License-Identifier: GPL-2.0-or-later ++ */ ++#include "qemu/osdep.h" ++#include "qapi/error.h" ++#include "qemu/module.h" ++#include "qemu/units.h" ++#include "hw/pci/pci_device.h" ++#include "hw/core/qdev-properties.h" ++#include "system/hostmem.h" ++#include "system/system.h" ++#include "migration/vmstate.h" ++#include "chardev/char-fe.h" ++#include "hw/core/qdev-properties-system.h" ++#include "qemu/bswap.h" ++#include "qemu/timer.h" ++#ifdef CONFIG_DARWIN ++#include ++#include ++#endif ++#if defined(CONFIG_DARWIN) && defined(VIRGL_VERSION_MAJOR) && defined(CONFIG_OPENGL) ++#include "ui/egl-helpers.h" ++#include "hw/virtio/virtio-gpu.h" ++#include ++#include ++ ++typedef struct VideoImport { ++ EGLDisplay display; ++ EGLContext context; ++ EGLSurface imports[2]; ++ GLuint textures[2], fb[2]; ++ GLsync fence; ++ IOSurfaceRef surface; ++ mach_port_t port; ++ struct VideoImport *next; ++} VideoImport; ++ ++/* QEMU dispatches both the channel and timer under the main loop lock. Keep ++ * the Mach capability as well as the textures until the GPU finishes: the ++ * decoder can recycle a CVPixelBuffer as soon as it receives our reply. */ ++static VideoImport *video_pending; ++static unsigned video_pending_count; ++static QEMUTimer *video_reap_timer; ++ ++static void video_reap(void *opaque) ++{ ++ Object *gpu = object_resolve_path_type("", TYPE_VIRTIO_GPU_GL, NULL); ++ if (video_pending && gpu && VIRTIO_GPU_GL(gpu)->renderer_state == RS_INITED) { ++ virgl_renderer_force_ctx_0(); ++ VideoImport **link = &video_pending; ++ while (*link) { ++ VideoImport *p = *link; ++ /* A renderer reset must not apply stale GL names to a new context. ++ * Keep these bounded capabilities until QEMU exits in that case. */ ++ if (eglGetCurrentDisplay() != p->display || eglGetCurrentContext() != p->context) { ++ link = &p->next; ++ continue; ++ } ++ GLenum status = glClientWaitSync(p->fence, 0, 0); ++ if (status != GL_ALREADY_SIGNALED && status != GL_CONDITION_SATISFIED) { ++ link = &p->next; ++ continue; ++ } ++ glDeleteSync(p->fence); ++ glDeleteFramebuffers(2, p->fb); ++ for (int i = 0; i < 2; i++) { ++ eglReleaseTexImage(p->display, p->imports[i], EGL_BACK_BUFFER); ++ eglDestroySurface(p->display, p->imports[i]); ++ } ++ glDeleteTextures(2, p->textures); ++ CFRelease(p->surface); ++ mach_port_deallocate(mach_task_self(), p->port); ++ *link = p->next; ++ g_free(p); ++ video_pending_count--; ++ } ++ } ++ if (video_pending) { ++ timer_mod(video_reap_timer, qemu_clock_get_ms(QEMU_CLOCK_REALTIME) + 2); ++ } ++} ++ ++/* The IOSurface capability comes exclusively from the host decoder over a ++ * Mach port. Guest requests may choose only their VirGL targets. */ ++static unsigned video_import(const uint8_t *request, uint32_t surface_port) ++{ ++ struct virgl_renderer_resource_info info[2] = {0}; ++ uint32_t width = ldl_le_p(request + 28), height = ldl_le_p(request + 32); ++ uint32_t format = ldl_le_p(request + 36); ++ IOSurfaceRef surface = NULL; ++ EGLDisplay display; ++ EGLContext context; ++ EGLConfig config; ++ EGLint config_id, count, target; ++ EGLSurface imports[2] = {EGL_NO_SURFACE, EGL_NO_SURFACE}; ++ GLuint textures[2] = {0}, fb[2] = {0}; ++ unsigned result = 2; ++ bool bound[2] = {false, false}; ++ ++ if (video_pending_count >= 32) { ++ return 16; ++ } ++ ++ if (!width || !height || width > 8192 || height > 4320 || ++ (width & 1) || (height & 1) || (format != 1 && format != 2) || ++ !ldl_le_p(request + 20) || !ldl_le_p(request + 24) || ++ ldl_le_p(request + 20) == ldl_le_p(request + 24) || ++ qemu_egl_display == EGL_NO_DISPLAY) { ++ return result; ++ } ++ Object *gpu = object_resolve_path_type("", TYPE_VIRTIO_GPU_GL, NULL); ++ if (!gpu || VIRTIO_GPU_GL(gpu)->renderer_state != RS_INITED) { ++ return 10; ++ } ++ virgl_renderer_force_ctx_0(); ++ display = eglGetCurrentDisplay(); ++ context = eglGetCurrentContext(); ++ if (display == EGL_NO_DISPLAY || context == EGL_NO_CONTEXT || ++ !epoxy_has_egl_extension(display, "EGL_ANGLE_iosurface_client_buffer") || ++ !eglQueryContext(display, context, EGL_CONFIG_ID, &config_id)) { ++ return 1; ++ } ++ EGLint config_attrs[] = {EGL_CONFIG_ID, config_id, EGL_NONE}; ++ if (!eglChooseConfig(display, config_attrs, &config, 1, &count) || !count || ++ !eglGetConfigAttrib(display, config, 0x348d, &target) || ++ target != EGL_TEXTURE_2D) { ++ return 1; ++ } ++ for (int p = 0; p < 2; p++) { ++ if (virgl_renderer_resource_get_info(ldl_le_p(request + 20 + p * 4), &info[p]) || ++ !info[p].tex_id || info[p].depth != 1 || ++ info[p].width < (width >> p) || info[p].height < (height >> p) || ++ info[p].virgl_format != (format == 1 ? 64 : 48) + p) { ++ return 11 + p; ++ } ++ } ++ surface = IOSurfaceLookupFromMachPort(surface_port); ++ if (!surface) { ++ return 13; ++ } ++ if (IOSurfaceGetID(surface) != ldl_le_p(request + 16) || IOSurfaceGetPlaneCount(surface) != 2 || ++ IOSurfaceGetWidth(surface) != width || IOSurfaceGetHeight(surface) != height || ++ IOSurfaceGetPixelFormat(surface) != (format == 1 ? 0x34323076 : 0x78343230)) { ++ result = 14; ++ goto done; ++ } ++ while (glGetError() != GL_NO_ERROR) {} ++ glGenTextures(2, textures); ++ glGenFramebuffers(2, fb); ++ glDisable(GL_SCISSOR_TEST); ++ result = 3; ++ for (int p = 0; p < 2; p++) { ++ if (IOSurfaceGetWidthOfPlane(surface, p) != (width >> p) || ++ IOSurfaceGetHeightOfPlane(surface, p) != (height >> p) || ++ IOSurfaceGetBytesPerElementOfPlane(surface, p) != (format == 1 ? 1 : 2) * (p + 1)) { ++ goto done; ++ } ++ EGLint attrs[] = { ++ EGL_WIDTH, width >> p, EGL_HEIGHT, height >> p, ++ 0x345a, p, /* EGL_IOSURFACE_PLANE_ANGLE */ ++ EGL_TEXTURE_TARGET, target, EGL_TEXTURE_FORMAT, EGL_TEXTURE_RGBA, ++ 0x345d, p ? GL_RG : GL_RED, /* EGL_TEXTURE_INTERNAL_FORMAT_ANGLE */ ++ 0x345c, format == 1 ? GL_UNSIGNED_BYTE : GL_UNSIGNED_SHORT, ++ EGL_NONE, ++ }; ++ imports[p] = eglCreatePbufferFromClientBuffer(display, 0x3454, ++ (EGLClientBuffer)surface, config, attrs); ++ if (imports[p] == EGL_NO_SURFACE) { ++ goto done; ++ } ++ glBindTexture(GL_TEXTURE_2D, textures[p]); ++ bound[p] = eglBindTexImage(display, imports[p], EGL_BACK_BUFFER); ++ if (!bound[p]) { ++ goto done; ++ } ++ glBindFramebuffer(GL_READ_FRAMEBUFFER, fb[0]); ++ glFramebufferTexture2D(GL_READ_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, ++ GL_TEXTURE_2D, textures[p], 0); ++ glBindFramebuffer(GL_DRAW_FRAMEBUFFER, fb[1]); ++ glFramebufferTexture2D(GL_DRAW_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, ++ GL_TEXTURE_2D, info[p].tex_id, 0); ++ if (glCheckFramebufferStatus(GL_READ_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE || ++ glCheckFramebufferStatus(GL_DRAW_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE) { ++ goto done; ++ } ++ glBlitFramebuffer(0, 0, width >> p, height >> p, 0, 0, width >> p, height >> p, ++ GL_COLOR_BUFFER_BIT, GL_NEAREST); ++ if (glGetError() != GL_NO_ERROR) { ++ goto done; ++ } ++ } ++ result = 0; ++ GLsync fence = glFenceSync(GL_SYNC_GPU_COMMANDS_COMPLETE, 0); ++ if (fence && mach_port_mod_refs(mach_task_self(), surface_port, ++ MACH_PORT_RIGHT_SEND, 1) == KERN_SUCCESS) { ++ VideoImport *pending = g_new0(VideoImport, 1); ++ pending->display = display; ++ pending->context = context; ++ memcpy(pending->imports, imports, sizeof(imports)); ++ memcpy(pending->textures, textures, sizeof(textures)); ++ memcpy(pending->fb, fb, sizeof(fb)); ++ pending->fence = fence; ++ pending->surface = surface; ++ pending->port = surface_port; ++ pending->next = video_pending; ++ video_pending = pending; ++ video_pending_count++; ++ /* ANGLE's contexts share DisplayMtl's Metal command queue. Submit the ++ * copies before the guest is allowed to submit sampling commands. */ ++ glFlush(); ++ glBindFramebuffer(GL_FRAMEBUFFER, 0); ++ if (!video_reap_timer) { ++ video_reap_timer = timer_new_ms(QEMU_CLOCK_REALTIME, video_reap, NULL); ++ } ++ timer_mod(video_reap_timer, qemu_clock_get_ms(QEMU_CLOCK_REALTIME) + 2); ++ return 0; ++ } ++ if (fence) { ++ glDeleteSync(fence); ++ } ++done: ++ if (fb[0]) { ++ /* Keep the decoder's CVPixelBuffer alive until both GPU copies finish. */ ++ glFinish(); ++ glBindFramebuffer(GL_FRAMEBUFFER, 0); ++ glDeleteFramebuffers(2, fb); ++ for (int p = 0; p < 2; p++) { ++ if (bound[p]) { ++ eglReleaseTexImage(display, imports[p], EGL_BACK_BUFFER); ++ } ++ if (imports[p] != EGL_NO_SURFACE) { ++ eglDestroySurface(display, imports[p]); ++ } ++ } ++ glDeleteTextures(2, textures); ++ } ++ CFRelease(surface); ++ return result; ++} ++#else ++static unsigned video_import(const uint8_t *request, uint32_t surface_port) { return 1; } ++#endif ++ ++#define TYPE_OMARCHY_VIDEO_SHMEM "omarchy-video-shmem" ++OBJECT_DECLARE_SIMPLE_TYPE(OmarchyVideoShmem, OMARCHY_VIDEO_SHMEM) ++ ++struct OmarchyVideoShmem { ++ PCIDevice parent_obj; ++ char *shm_name; ++ bool create; ++ bool owns_name; ++ Notifier exit_notifier; ++ MemoryRegion frame_memory; ++ MemoryRegion registers; ++ uint64_t size; ++ CharFrontend gpu_channel; ++ uint8_t gpu_request[40]; ++ unsigned gpu_received; ++#ifdef CONFIG_DARWIN ++ mach_port_t surface_port; ++ char surface_service[120]; ++ bool surface_service_registered; ++#endif ++}; ++ ++#ifdef CONFIG_DARWIN ++typedef struct VideoSurfaceMessage { ++ mach_msg_header_t header; ++ mach_msg_body_t body; ++ mach_msg_port_descriptor_t surface; ++ uint64_t token; ++} VideoSurfaceMessage; ++G_STATIC_ASSERT(sizeof(VideoSurfaceMessage) == 48); ++ ++static mach_port_t video_receive_surface(OmarchyVideoShmem *s, uint64_t token) ++{ ++ struct { ++ VideoSurfaceMessage message; ++ mach_msg_max_trailer_t trailer; ++ } buffer = {0}; ++ VideoSurfaceMessage *m = &buffer.message; ++ mach_port_t port = MACH_PORT_NULL; ++ if (mach_msg(&m->header, MACH_RCV_MSG | MACH_RCV_TIMEOUT, 0, sizeof(buffer), ++ s->surface_port, 0, MACH_PORT_NULL) != MACH_MSG_SUCCESS) { ++ return port; ++ } ++ if (m->header.msgh_size == sizeof(*m) && m->header.msgh_id == 0x544f5647 && ++ (m->header.msgh_bits & MACH_MSGH_BITS_COMPLEX) && ++ m->body.msgh_descriptor_count == 1 && ++ m->surface.type == MACH_MSG_PORT_DESCRIPTOR && m->token == token) { ++ port = m->surface.name; ++ m->surface.name = MACH_PORT_NULL; ++ } ++ mach_msg_destroy(&m->header); ++ return port; ++} ++ ++static void video_surface_cleanup(OmarchyVideoShmem *s) ++{ ++ if (s->surface_port) { ++ if (s->surface_service_registered) { ++ kern_return_t ignored = bootstrap_register(bootstrap_port, s->surface_service, MACH_PORT_NULL); ++ (void)ignored; ++ s->surface_service_registered = false; ++ } ++ mach_port_destroy(mach_task_self(), s->surface_port); ++ s->surface_port = MACH_PORT_NULL; ++ } ++} ++#else ++static void video_surface_cleanup(OmarchyVideoShmem *s) {} ++#endif ++ ++static int video_gpu_can_read(void *opaque) ++{ ++ OmarchyVideoShmem *s = opaque; ++ return sizeof(s->gpu_request) - s->gpu_received; ++} ++ ++static void video_gpu_read(void *opaque, const uint8_t *data, int size) ++{ ++ OmarchyVideoShmem *s = opaque; ++ unsigned status = 4; ++ if (size <= 0 || size > video_gpu_can_read(s)) { ++ return; ++ } ++ memcpy(s->gpu_request + s->gpu_received, data, size); ++ s->gpu_received += size; ++ if (s->gpu_received != sizeof(s->gpu_request)) { ++ return; ++ } ++ if (!memcmp(s->gpu_request, "TOVG", 4) && ++ lduw_le_p(s->gpu_request + 4) == 1 && !lduw_le_p(s->gpu_request + 6)) { ++#ifdef CONFIG_DARWIN ++ mach_port_t surface = video_receive_surface(s, ldq_le_p(s->gpu_request + 8)); ++ status = surface ? video_import(s->gpu_request, surface) : 15; ++ if (surface) { ++ mach_port_deallocate(mach_task_self(), surface); ++ } ++#endif ++ } ++ stw_le_p(s->gpu_request + 6, status); ++ qemu_chr_fe_write_all(&s->gpu_channel, s->gpu_request, sizeof(s->gpu_request)); ++ s->gpu_received = 0; ++} ++ ++static void video_gpu_event(void *opaque, QEMUChrEvent event) ++{ ++ OmarchyVideoShmem *s = opaque; ++ if (event == CHR_EVENT_CLOSED || event == CHR_EVENT_OPENED) { ++ s->gpu_received = 0; ++#ifdef CONFIG_DARWIN ++ /* Drop capabilities left by an interrupted decoder before reconnect. */ ++ for (int i = 0; i < 8; i++) { ++ mach_port_t port = video_receive_surface(s, 0); ++ if (port) { ++ mach_port_deallocate(mach_task_self(), port); ++ } ++ } ++ if (event == CHR_EVENT_OPENED) { ++ uint8_t greeting[128] = {0}; ++ memcpy(greeting, "TOGM", 4); ++ stl_le_p(greeting + 4, 1); ++ memcpy(greeting + 8, s->surface_service, strlen(s->surface_service)); ++ qemu_chr_fe_write_all(&s->gpu_channel, greeting, sizeof(greeting)); ++ } ++#endif ++ } ++} ++ ++static uint64_t video_read(void *opaque, hwaddr addr, unsigned size) ++{ ++ OmarchyVideoShmem *s = opaque; ++ switch (addr) { ++ case 0: return 0x44564f54; /* TOVD */ ++ case 4: return 1; /* ABI version */ ++ case 8: return s->size; /* byte size of BAR 2 */ ++ default: return 0; ++ } ++} ++ ++static void video_write(void *opaque, hwaddr addr, uint64_t value, ++ unsigned size) ++{ ++ /* Control is read-only. Requests use the private virtio serial channel. */ ++} ++ ++static const MemoryRegionOps video_ops = { ++ .read = video_read, ++ .write = video_write, ++ .endianness = DEVICE_LITTLE_ENDIAN, ++ .valid = { .min_access_size = 4, .max_access_size = 4 }, ++ .impl = { .min_access_size = 4, .max_access_size = 4 }, ++}; ++ ++static void video_unlink(OmarchyVideoShmem *s) ++{ ++ if (s->owns_name) { ++ shm_unlink(s->shm_name); ++ s->owns_name = false; ++ } ++} ++ ++static void video_exit_notify(Notifier *notifier, void *data) ++{ ++ OmarchyVideoShmem *s = container_of(notifier, OmarchyVideoShmem, exit_notifier); ++ video_unlink(s); ++ video_surface_cleanup(s); ++} ++ ++static void video_unrealize(PCIDevice *dev) ++{ ++ OmarchyVideoShmem *s = OMARCHY_VIDEO_SHMEM(dev); ++ qemu_remove_exit_notifier(&s->exit_notifier); ++ qemu_chr_fe_deinit(&s->gpu_channel, false); ++ video_unlink(s); ++ video_surface_cleanup(s); ++} ++ ++static void video_realize(PCIDevice *dev, Error **errp) ++{ ++ OmarchyVideoShmem *s = OMARCHY_VIDEO_SHMEM(dev); ++ struct stat st; ++ int fd; ++ if (!s->shm_name || !g_str_has_prefix(s->shm_name, "/tovd.") || ++ strlen(s->shm_name) > 31 || strchr(s->shm_name + 1, '/')) { ++ error_setg(errp, "video shared memory requires a /tovd. POSIX object name"); ++ return; ++ } ++ fd = shm_open(s->shm_name, O_RDWR | (s->create ? O_CREAT | O_EXCL : 0), 0600); ++ if (fd < 0) { ++ error_setg_errno(errp, errno, "cannot open video shared memory"); ++ return; ++ } ++ s->owns_name = s->create; ++ if (s->create && ftruncate(fd, 512 * MiB)) { ++ error_setg_errno(errp, errno, "cannot size video shared memory"); ++ close(fd); ++ video_unlink(s); ++ return; ++ } ++ if (fstat(fd, &st) || st.st_uid != getuid() || (st.st_mode & 077) || ++ st.st_size != 512 * MiB) { ++ close(fd); ++ video_unlink(s); ++ error_setg(errp, "video shared memory must be private, owned and 512 MiB"); ++ return; ++ } ++ s->size = st.st_size; ++ if (!memory_region_init_ram_from_fd(&s->frame_memory, OBJECT(s), ++ "omarchy-video-frames", s->size, RAM_SHARED, fd, 0, errp)) { ++ close(fd); ++ video_unlink(s); ++ return; ++ } ++ s->exit_notifier.notify = video_exit_notify; ++ qemu_add_exit_notifier(&s->exit_notifier); ++ memory_region_init_io(&s->registers, OBJECT(s), &video_ops, s, ++ "omarchy-video-control", 256); ++ pci_register_bar(dev, 0, PCI_BASE_ADDRESS_SPACE_MEMORY, &s->registers); ++ pci_register_bar(dev, 2, PCI_BASE_ADDRESS_SPACE_MEMORY | ++ PCI_BASE_ADDRESS_MEM_TYPE_64 | PCI_BASE_ADDRESS_MEM_PREFETCH, ++ &s->frame_memory); ++ if (qemu_chr_fe_backend_connected(&s->gpu_channel)) { ++#ifdef CONFIG_DARWIN ++ snprintf(s->surface_service, sizeof(s->surface_service), ++ "com.tryomarchy.video.%u.%s", (unsigned)getpid(), s->shm_name + 1); ++ if (mach_port_allocate(mach_task_self(), MACH_PORT_RIGHT_RECEIVE, &s->surface_port) != KERN_SUCCESS || ++ mach_port_insert_right(mach_task_self(), s->surface_port, s->surface_port, MACH_MSG_TYPE_MAKE_SEND) != KERN_SUCCESS || ++ bootstrap_register(bootstrap_port, s->surface_service, s->surface_port) != KERN_SUCCESS) { ++ error_setg(errp, "cannot register private video surface channel"); ++ qemu_remove_exit_notifier(&s->exit_notifier); ++ video_surface_cleanup(s); ++ video_unlink(s); ++ return; ++ } ++ s->surface_service_registered = true; ++#endif ++ qemu_chr_fe_set_handlers(&s->gpu_channel, video_gpu_can_read, video_gpu_read, ++ video_gpu_event, NULL, s, NULL, true); ++ } ++} ++ ++static const Property video_properties[] = { ++ DEFINE_PROP_STRING("shm-name", OmarchyVideoShmem, shm_name), ++ DEFINE_PROP_BOOL("shm-create", OmarchyVideoShmem, create, false), ++ DEFINE_PROP_CHR("gpu-chardev", OmarchyVideoShmem, gpu_channel), ++}; ++ ++static const VMStateDescription video_vmstate = { ++ .name = TYPE_OMARCHY_VIDEO_SHMEM, ++ .unmigratable = 1, ++}; ++ ++static void video_class_init(ObjectClass *klass, const void *data) ++{ ++ DeviceClass *dc = DEVICE_CLASS(klass); ++ PCIDeviceClass *pc = PCI_DEVICE_CLASS(klass); ++ pc->realize = video_realize; ++ pc->exit = video_unrealize; ++ pc->vendor_id = PCI_VENDOR_ID_REDHAT_QUMRANET; ++ pc->device_id = 0x1110; ++ pc->subsystem_vendor_id = PCI_VENDOR_ID_REDHAT_QUMRANET; ++ pc->subsystem_id = 0x5654; ++ pc->class_id = PCI_CLASS_MEMORY_RAM; ++ pc->revision = 1; ++ dc->desc = "Try Omarchy video frame shared memory"; ++ dc->vmsd = &video_vmstate; ++ device_class_set_props(dc, video_properties); ++ set_bit(DEVICE_CATEGORY_MISC, dc->categories); ++} ++ ++static const TypeInfo video_info = { ++ .name = TYPE_OMARCHY_VIDEO_SHMEM, ++ .parent = TYPE_PCI_DEVICE, ++ .instance_size = sizeof(OmarchyVideoShmem), ++ .class_init = video_class_init, ++ .interfaces = (const InterfaceInfo[]) { ++ { INTERFACE_CONVENTIONAL_PCI_DEVICE }, { } ++ }, ++}; ++ ++static void video_register_types(void) ++{ ++ type_register_static(&video_info); ++} ++type_init(video_register_types) diff --git a/macos/patches/virglrenderer-angle-video.patch b/macos/patches/virglrenderer-angle-video.patch new file mode 100644 index 00000000..84a1e6d7 --- /dev/null +++ b/macos/patches/virglrenderer-angle-video.patch @@ -0,0 +1,165 @@ +--- a/src/vrend/vrend_formats.c ++++ b/src/vrend/vrend_formats.c +@@ -24,6 +24,7 @@ + #include + + #include "vrend_renderer.h" ++#include "vrend_angle_msaa.h" + #include "util/u_memory.h" + #include "util/u_format.h" + +@@ -772,7 +773,7 @@ + } + } else { + /* OpenGL ES path */ +- if (epoxy_gl_version() >= 31) { ++ if (epoxy_gl_version() >= 31 || vrend_angle_msaa()) { + has_tex_storage_ms = true; + has_tex_image_ms = true; + } +--- a/src/vrend/vrend_renderer.c ++++ b/src/vrend/vrend_renderer.c +@@ -50,6 +50,7 @@ + #include "vrend_shader.h" + + #include "vrend_renderer.h" ++#include "vrend_angle_msaa.h" + #include "vrend_blitter.h" + #include "vrend_debug.h" + #include "vrend_winsys.h" +@@ -315,7 +316,7 @@ + FEAT(ssbo_barrier, 43, 31, "GL_ARB_shader_storage_buffer_object"), + FEAT(srgb_write_control, 30, UNAVAIL, "GL_EXT_sRGB_write_control"), + FEAT(stencil_texturing, 43, 31, "GL_ARB_stencil_texturing" ), +- FEAT(storage_multisample, 43, 31, "GL_ARB_texture_storage_multisample" ), ++ FEAT(storage_multisample, 43, 31, "GL_ARB_texture_storage_multisample", "GL_ANGLE_texture_multisample" ), + FEAT(tessellation, 40, 32, "GL_ARB_tessellation_shader", "GL_OES_tessellation_shader", "GL_EXT_tessellation_shader" ), + FEAT(texture_array, 30, 30, "GL_EXT_texture_array" ), + FEAT(texture_barrier, 45, UNAVAIL, "GL_ARB_texture_barrier" ), +@@ -8180,6 +8181,10 @@ + + /* only texture 2d and 2d array can have multiple samples */ + if (args->nr_samples > 1) { ++ if (vrend_angle_msaa() && args->target == PIPE_TEXTURE_2D_ARRAY) { ++ snprintf(errmsg, 256, "ANGLE does not support multisample array textures"); ++ return -1; ++ } + if (!vrend_format_can_multisample(args->format)) { + snprintf(errmsg, 256, "Unsupported multisample texture format %s", + util_format_name(args->format)); +@@ -9483,6 +9488,7 @@ + GLenum glformat; + GLenum gltype; + int need_temp = 0; ++ GLuint upload_buffer = 0; + int elsize = util_format_get_blocksize(res->base.format); + int x = 0, y = 0; + bool compressed; +@@ -9538,13 +9544,41 @@ + return EINVAL; + } + +- data = malloc(send_size); ++ /* Video planes are uploaded every frame. A pixel-unpack buffer ++ * lets ANGLE copy directly from shared Metal storage instead of ++ * allocating and copying a second CPU staging buffer. */ ++ bool plane_upload = vrend_state.use_gles && res->target == GL_TEXTURE_2D && ++ !invert && (res->base.format == VIRGL_FORMAT_R8_UNORM || ++ res->base.format == VIRGL_FORMAT_R8G8_UNORM || ++ res->base.format == VIRGL_FORMAT_R16_UNORM || ++ res->base.format == VIRGL_FORMAT_R16G16_UNORM); ++ if (plane_upload) { ++ glGenBuffers(1, &upload_buffer); ++ glBindBuffer(GL_PIXEL_UNPACK_BUFFER, upload_buffer); ++ glBufferData(GL_PIXEL_UNPACK_BUFFER, send_size, NULL, GL_STREAM_DRAW); ++ data = glMapBufferRange(GL_PIXEL_UNPACK_BUFFER, 0, send_size, ++ GL_MAP_WRITE_BIT | GL_MAP_INVALIDATE_BUFFER_BIT); ++ } else { ++ data = malloc(send_size); ++ } + if (!data) { ++ if (upload_buffer) { ++ glBindBuffer(GL_PIXEL_UNPACK_BUFFER, 0); ++ glDeleteBuffers(1, &upload_buffer); ++ } + virgl_error("Memory allocation failed for %"PRIu64"\n", send_size); + return ENOMEM; + } + read_transfer_data(iov, num_iovs, data, res->base.format, info->offset, + stride, layer_stride, info->box, invert); ++ if (upload_buffer) { ++ if (!glUnmapBuffer(GL_PIXEL_UNPACK_BUFFER)) { ++ glBindBuffer(GL_PIXEL_UNPACK_BUFFER, 0); ++ glDeleteBuffers(1, &upload_buffer); ++ return EIO; ++ } ++ data = NULL; /* offset zero in the bound pixel-unpack buffer */ ++ } + } else { + if (send_size > iov[0].iov_len - info->offset) + return EINVAL; +@@ -9736,8 +9770,12 @@ + + glPixelStorei(GL_UNPACK_ALIGNMENT, 4); + +- if (need_temp) ++ if (upload_buffer) { ++ glBindBuffer(GL_PIXEL_UNPACK_BUFFER, 0); ++ glDeleteBuffers(1, &upload_buffer); ++ } else if (need_temp) { + free(data); ++ } + } + return 0; + } +@@ -13035,7 +13073,7 @@ + const char *version = (const char *)glGetString(GL_VERSION); + bool is_metal = (version && strstr(version, "Metal")); + bool is_angle = (version && strstr(version, "ANGLE")); +- if ((is_metal || is_angle) && caps->v1.max_samples > 1) { ++ if ((is_metal || is_angle) && !vrend_angle_msaa() && caps->v1.max_samples > 1) { + virgl_debug("[VREND CAPS] %s backend: Overriding max_samples %u -> 1 for fake_sw_msaa\n", + is_angle ? "ANGLE" : "Metal", + caps->v1.max_samples); +@@ -13301,7 +13339,10 @@ + if (has_feature(feat_khr_debug)) + caps->v2.capability_bits_v2 |= VIRGL_CAP_V2_STRING_MARKER; + +- if (has_feature(feat_implicit_msaa)) ++ /* ANGLE supports explicit multisample textures, but virgl's implicit ++ * path can attach a single-sample depth/stencil texture to a multisample ++ * color target. Keep Mesa on the explicit resolve path. */ ++ if (has_feature(feat_implicit_msaa) && !vrend_angle_msaa()) + caps->v2.capability_bits_v2 |= VIRGL_CAP_V2_IMPLICIT_MSAA; + + if (vrend_winsys_different_gpu()) +--- /dev/null ++++ b/src/vrend/vrend_angle_msaa.h +@@ -0,0 +1,27 @@ ++/* ANGLE's Metal renderer exposes real multisample textures in GLES 3.0 ++ * through GL_ANGLE_texture_multisample. The unsuffixed ES 3.1 entry point ++ * rejects these calls; dispatch the advertised extension explicitly. */ ++#ifndef VREND_ANGLE_MSAA_H ++#define VREND_ANGLE_MSAA_H ++#include ++#include ++static inline bool vrend_angle_msaa(void) ++{ ++ return !epoxy_is_desktop_gl() && ++ epoxy_has_gl_extension("GL_ANGLE_texture_multisample"); ++} ++static inline void vrend_tex_storage_2d_multisample(GLenum target, GLsizei samples, ++ GLenum format, GLsizei width, ++ GLsizei height, GLboolean fixed) ++{ ++ if (vrend_angle_msaa()) { ++ typedef void (*storage_fn)(GLenum, GLsizei, GLenum, GLsizei, GLsizei, GLboolean); ++ storage_fn storage = (storage_fn)eglGetProcAddress("glTexStorage2DMultisampleANGLE"); ++ storage(target, samples, format, width, height, fixed); ++ } else { ++ epoxy_glTexStorage2DMultisample(target, samples, format, width, height, fixed); ++ } ++} ++#undef glTexStorage2DMultisample ++#define glTexStorage2DMultisample vrend_tex_storage_2d_multisample ++#endif diff --git a/macos/patches/virglrenderer-macos-1.0.33.patch b/macos/patches/virglrenderer-macos-1.0.33.patch new file mode 100644 index 00000000..02ab9290 --- /dev/null +++ b/macos/patches/virglrenderer-macos-1.0.33.patch @@ -0,0 +1,5595 @@ +diff --git a/config.h.meson b/config.h.meson +--- a/config.h.meson 2026-01-01 08:05:29 ++++ b/config.h.meson 2026-01-11 23:47:51 +@@ -38,6 +38,7 @@ + #mesondefine ENABLE_VULKAN_DLOAD + #mesondefine ENABLE_VULKAN_PRELOAD + #mesondefine ENABLE_GBM ++#mesondefine ENABLE_METAL + #mesondefine ENABLE_DRM + #mesondefine ENABLE_DRM_MSM + #mesondefine ENABLE_DRM_AMDGPU +diff --git a/meson.build b/meson.build +--- a/meson.build 2026-01-01 08:05:29 ++++ b/meson.build 2026-01-11 23:47:51 +@@ -84,6 +84,7 @@ + thread_dep = dependency('threads') + epoxy_dep = dependency('epoxy', version: '>= 1.5.4') + m_dep = cc.find_library('m', required : false) ++all_languages = ['c'] + + conf_data = configuration_data() + conf_data.set('VERSION', meson.project_version()) +@@ -203,6 +204,14 @@ + conf_data.set('HAVE_LINUX_UDMABUF_H', 1) + endif + ++metal_dep = dependency('appleframeworks', modules : ['Metal'], required: false) ++if metal_dep.found() ++ conf_data.set('ENABLE_METAL', 1) ++ add_languages('objc', required: true, native: false) ++ objc = meson.get_compiler('objc') ++ all_languages += 'objc' ++endif ++ + foreach b : ['bswap32', 'bswap64', 'clz', 'clzll', 'expect', 'ffs', 'ffsll', + 'popcount', 'popcountll', 'types_compatible_p', 'unreachable'] + if cc.has_function(b) +@@ -262,7 +271,7 @@ + endif + + if get_option('buildtype') == 'debug' +- add_project_arguments('-DDEBUG=1', language : 'c') ++ add_project_arguments('-DDEBUG=1', language : all_languages) + endif + + platforms = get_option('platforms') +@@ -334,11 +343,11 @@ + with_render_server_worker = get_option('render-server-worker') + render_server_install_dir = get_option('prefix') / get_option('libexecdir') + if with_venus +- venus_dep = [] ++ venus_deps = [] + if get_option('vulkan-dload') + conf_data.set('ENABLE_VULKAN_DLOAD', 1) + else +- venus_dep = dependency('vulkan') ++ venus_deps += [dependency('vulkan')] + endif + + if host_machine.system() in ['freebsd', 'openbsd'] +@@ -349,6 +358,11 @@ + epoll_dep = [] + endif + ++ if host_machine.system() == 'darwin' ++ corefoundation = dependency('appleframeworks', modules : ['CoreFoundation']) ++ venus_deps += [corefoundation, metal_dep] ++ endif ++ + conf_data.set('ENABLE_VENUS', 1) + conf_data.set('ENABLE_RENDER_SERVER', 1) + conf_data.set('RENDER_SERVER_EXEC_PATH', +@@ -400,8 +414,8 @@ + output : 'config.h', + configuration : conf_data) + +-add_project_arguments('-imacros', meson.current_build_dir() / 'config.h', language : 'c') +-add_project_arguments('-DHAVE_CONFIG_H=1', language : 'c') ++add_project_arguments('-imacros', meson.current_build_dir() / 'config.h', language : all_languages) ++add_project_arguments('-DHAVE_CONFIG_H=1', language : all_languages) + + inc_configuration = include_directories(['.', 'src']) + +@@ -434,6 +448,7 @@ + 'egl': have_egl, + 'glx': have_glx, + 'gbm': gbm_dep.found(), ++ 'metal': metal_dep.found(), + 'venus': with_venus, + 'drm-amdgpu': with_drm_amdgpu, + 'drm-asahi': with_drm_asahi, +diff --git a/server/meson.build b/server/meson.build +--- a/server/meson.build 2026-01-01 08:05:29 ++++ b/server/meson.build 2026-01-11 23:47:51 +@@ -27,6 +27,7 @@ + virgl_render_server = executable( + 'virgl_render_server', + virgl_render_server_sources, ++ c_args : [ '-DSTANDALONE_SERVER=1' ], + dependencies : virgl_render_server_depends, + install : true, + install_dir : render_server_install_dir, +diff --git a/server/render_client.c b/server/render_client.c +--- a/server/render_client.c 2026-01-01 08:05:29 ++++ b/server/render_client.c 2026-01-11 23:47:51 +@@ -76,13 +76,15 @@ + init_context_args(struct render_context_args *ctx_args, + uint32_t init_flags, + const struct render_client_op_create_context_request *req, +- int ctx_fd) ++ int ctx_fd, ++ bool in_process) + { + *ctx_args = (struct render_context_args){ + .valid = true, + .init_flags = init_flags, + .ctx_id = req->ctx_id, + .ctx_fd = ctx_fd, ++ .in_process = in_process, + }; + + static_assert(sizeof(ctx_args->ctx_name) == sizeof(req->ctx_name), ""); +@@ -123,7 +125,8 @@ + int remote_fd = socket_fds[1]; + + struct render_context_args ctx_args; +- init_context_args(&ctx_args, client->init_flags, req, ctx_fd); ++ bool in_process = srv->context_args->in_process; ++ init_context_args(&ctx_args, client->init_flags, req, ctx_fd, in_process); + + #ifdef ENABLE_RENDER_SERVER_WORKER_THREAD + rec->worker = render_worker_create(srv->worker_jail, render_client_worker_thread, +diff --git a/server/render_common.c b/server/render_common.c +--- a/server/render_common.c 2026-01-01 08:05:29 ++++ b/server/render_common.c 2026-01-11 23:47:51 +@@ -4,6 +4,9 @@ + */ + + #include "render_common.h" ++#ifndef STANDALONE_SERVER ++#include "virgl_util.h" ++#endif + + #include + #include +@@ -12,7 +15,9 @@ + void + render_log_init(void) + { ++#ifdef STANDALONE_SERVER + openlog(NULL, LOG_NDELAY | LOG_PERROR | LOG_PID, LOG_USER); ++#endif + } + + void +@@ -21,6 +26,10 @@ + va_list va; + + va_start(va, fmt); ++#ifdef STANDALONE_SERVER + vsyslog(LOG_DEBUG, fmt, va); ++#else ++ virgl_prefixed_logv("server", VIRGL_LOG_LEVEL_INFO, fmt, va); ++#endif + va_end(va); + } +diff --git a/server/render_context.c b/server/render_context.c +--- a/server/render_context.c 2026-01-01 08:05:29 ++++ b/server/render_context.c 2026-01-11 23:47:51 +@@ -108,16 +108,28 @@ + struct render_context_op_create_resource_reply reply = { + .fd_type = VIRGL_RESOURCE_FD_INVALID, + }; +- int res_fd; ++ int res_fd = -1; + bool ok = render_state_create_resource(ctx->ctx_id, req->res_id, req->blob_id, + req->blob_size, req->blob_flags, &reply.fd_type, +- &res_fd, &reply.map_info, &reply.vulkan_info); ++ &res_fd, &reply.res_ptr, &reply.map_info, ++ &reply.vulkan_info); + if (!ok) + return render_socket_send_reply(&ctx->socket, &reply, sizeof(reply)); + +- ok = +- render_socket_send_reply_with_fds(&ctx->socket, &reply, sizeof(reply), &res_fd, 1); +- close(res_fd); ++ if (res_fd >= 0) { ++ ok = ++ render_socket_send_reply_with_fds(&ctx->socket, &reply, sizeof(reply), &res_fd, 1); ++ close(res_fd); ++ } else { ++ if (!ctx->in_process) { ++ /* not in_process, cannot send pointers */ ++ render_log("cannot send pointer for resource %d", req->res_id); ++ render_state_destroy_resource(ctx->ctx_id, req->res_id); ++ reply.fd_type = VIRGL_RESOURCE_FD_INVALID; ++ reply.res_ptr = NULL; ++ } ++ ok = render_socket_send_reply(&ctx->socket, &reply, sizeof(reply)); ++ } + + return ok; + } +@@ -343,6 +355,7 @@ + render_socket_init(&ctx->socket, args->ctx_fd); + ctx->shmem_fd = -1; + ctx->fence_eventfd = -1; ++ ctx->in_process = args->in_process; + + if (!render_context_init_name(ctx, args->ctx_id, args->ctx_name)) + return false; +diff --git a/server/render_context.h b/server/render_context.h +--- a/server/render_context.h 2026-01-01 08:05:29 ++++ b/server/render_context.h 2026-01-11 23:47:51 +@@ -25,12 +25,15 @@ + + int timeline_count; + ++ bool in_process; ++ + /* optional */ + int fence_eventfd; + }; + + struct render_context_args { + bool valid; ++ bool in_process; + + uint32_t init_flags; + +diff --git a/server/render_protocol.h b/server/render_protocol.h +--- a/server/render_protocol.h 2026-01-01 08:05:29 ++++ b/server/render_protocol.h 2026-01-11 23:47:51 +@@ -148,9 +148,11 @@ + struct render_context_op_create_resource_reply { + enum virgl_resource_fd_type fd_type; + uint32_t map_info; /* VIRGL_RENDERER_MAP_* */ +- /* vulkan_info is set if the fd_type is VIRGL_RESOURCE_FD_OPAQUE */ ++ /* vulkan_info is set if the fd_type is opaque or Metal */ + struct virgl_resource_vulkan_info vulkan_info; +- /* followed by 1 fd if not VIRGL_RESOURCE_FD_INVALID */ ++ /* When fd_type == VIRGL_RESOURCE_METAL_HEAP */ ++ void *res_ptr; ++ /* otherwise followed by 1 fd if not VIRGL_RESOURCE_FD_INVALID */ + }; + + /* Import a blob resource to the context +@@ -217,6 +219,17 @@ + struct render_context_op_destroy_resource_request destroy_resource; + struct render_context_op_submit_cmd_request submit_cmd; + struct render_context_op_submit_fence_request submit_fence; ++}; ++ ++/** ++ * When we do not have SOCK_SEQPACKET support, we need to manage framing. ++ * This will be sent as the header of each packet when necessary. ++ */ ++struct render_context_socket_header { ++ union { ++ uint32_t length; ++ uint8_t b[sizeof(uint32_t)]; ++ }; + }; + + #endif /* RENDER_PROTOCOL_H */ +diff --git a/server/render_server.c b/server/render_server.c +--- a/server/render_server.c 2026-01-01 08:05:29 ++++ b/server/render_server.c 2026-01-11 23:47:51 +@@ -148,7 +148,7 @@ + return false; + } + +- if (srv->client_fd < 0 || !render_socket_is_seqpacket(srv->client_fd)) { ++ if (srv->client_fd < 0) { + render_log("no valid client fd specified"); + return false; + } +diff --git a/server/render_socket.c b/server/render_socket.c +--- a/server/render_socket.c 2026-01-01 08:05:29 ++++ b/server/render_socket.c 2026-01-11 23:47:51 +@@ -12,6 +12,30 @@ + + #define RENDER_SOCKET_MAX_FD_COUNT 8 + ++#if !defined(MSG_CMSG_CLOEXEC) || !defined(SOCK_CLOEXEC) ++#include ++static int ++render_socket_set_cloexec(int fd) ++{ ++ long flags; ++ ++ if (fd == -1) ++ return -1; ++ ++ flags = fcntl(fd, F_GETFD); ++ if (flags == -1) ++ goto err; ++ ++ if (fcntl(fd, F_SETFD, flags | FD_CLOEXEC) == -1) ++ goto err; ++ ++ return 0; ++ ++err: ++ return -1; ++} ++#endif ++ + /* The socket pair between the server process and the client process is set up + * by the client process (or yet another process). Because render_server_run + * does not poll yet, the fd is expected to be blocking. +@@ -28,7 +52,24 @@ + bool + render_socket_pair(int out_fds[static 2]) + { +- int ret = socketpair(AF_UNIX, SOCK_SEQPACKET | SOCK_CLOEXEC, 0, out_fds); ++#ifdef __APPLE__ ++ int type = SOCK_SEQPACKET; ++#else ++ int type = SOCK_SEQPACKET; ++#endif ++#ifdef SOCK_CLOEXEC ++ type |= SOCK_CLOEXEC; ++#endif ++ ++ int ret = socketpair(AF_UNIX, type, 0, out_fds); ++#ifndef SOCK_CLOEXEC ++ if (!ret) { ++ ret = render_socket_set_cloexec(out_fds[0]); ++ } ++ if (!ret) { ++ ret = render_socket_set_cloexec(out_fds[1]); ++ } ++#endif + if (ret) { + render_log("failed to create socket pair"); + return false; +@@ -50,9 +91,11 @@ + void + render_socket_init(struct render_socket *socket, int fd) + { ++ bool is_seqpacket = render_socket_is_seqpacket(fd); + assert(fd >= 0); + *socket = (struct render_socket){ + .fd = fd, ++ .is_seqpacket = is_seqpacket, + }; + } + +@@ -76,15 +119,47 @@ + return (const int *)CMSG_DATA(cmsg); + } + ++enum socket_state { ++ SOCKET_STATE_FIRST_MSG, ++ SOCKET_STATE_HEADER, ++ SOCKET_STATE_DATA, ++}; ++ + static bool + render_socket_recvmsg(struct render_socket *socket, struct msghdr *msg, size_t *out_size) + { +- do { +- const ssize_t s = recvmsg(socket->fd, msg, MSG_CMSG_CLOEXEC); +- if (unlikely(s <= 0)) { +- if (!s) +- return false; ++ int flags = 0; ++#ifdef MSG_CMSG_CLOEXEC ++ flags = MSG_CMSG_CLOEXEC; ++#endif + ++ enum socket_state state = SOCKET_STATE_FIRST_MSG; ++ struct render_context_socket_header hdr = {0}; ++ ssize_t want = sizeof(hdr); ++ struct msghdr _msg = { ++ .msg_iov = ++ &(struct iovec){ ++ .iov_base = &hdr, ++ .iov_len = want, ++ }, ++ .msg_iovlen = 1, ++ .msg_control = msg->msg_control, ++ .msg_controllen = msg->msg_controllen, ++ }; ++ socklen_t _msg_controllen; ++ ++ assert(msg->msg_iovlen == 1); ++ ++ if (socket->is_seqpacket) { ++ _msg.msg_iov[0].iov_base = msg->msg_iov[0].iov_base; ++ _msg.msg_iov[0].iov_len = msg->msg_iov[0].iov_len; ++ want = 0; ++ } ++ ++ *out_size = 0; ++ do { ++ const ssize_t s = recvmsg(socket->fd, &_msg, flags); ++ if (unlikely(s < 0)) { + if (errno == EAGAIN || errno == EINTR) + continue; + +@@ -92,20 +167,65 @@ + return false; + } + +- if (unlikely(msg->msg_flags & (MSG_TRUNC | MSG_CTRUNC))) { +- render_log("failed to receive message: truncated"); ++ if (state == SOCKET_STATE_FIRST_MSG) { ++ _msg_controllen = _msg.msg_controllen; ++ state = socket->is_seqpacket ? SOCKET_STATE_DATA : SOCKET_STATE_HEADER; ++ } else { ++ /* retain the cmsg from first message */ ++ assert(_msg.msg_controllen == 0); ++ } + ++ if (unlikely(_msg.msg_flags & MSG_CTRUNC || ++ (socket->is_seqpacket && ++ (_msg.msg_flags & MSG_TRUNC) || ++ _msg.msg_iov[0].iov_len != (size_t)s))) { ++ render_log("failed to receive message: truncated or incomplete"); ++ + int fd_count; +- const int *fds = get_received_fds(msg, &fd_count); ++ const int *fds = get_received_fds(&_msg, &fd_count); + for (int i = 0; i < fd_count; i++) + close(fds[i]); + + return false; + } + +- *out_size = s; +- return true; ++ if (s <= want) { ++ _msg.msg_iov[0].iov_base = (char *)_msg.msg_iov[0].iov_base + s; ++ _msg.msg_iov[0].iov_len -= s; ++ want -= s; ++ } ++ ++ if (state == SOCKET_STATE_DATA) { ++ *out_size += s; ++ } ++ ++ if (!want && state == SOCKET_STATE_HEADER) { ++ want = ntohl(hdr.length); ++ _msg.msg_iov[0].iov_base = msg->msg_iov[0].iov_base; ++ _msg.msg_iov[0].iov_len = want; ++ state = SOCKET_STATE_DATA; ++ } else if (!want && state == SOCKET_STATE_DATA) { ++ msg->msg_controllen = _msg_controllen; ++ break; ++ } + } while (true); ++ ++#ifndef MSG_CMSG_CLOEXEC ++ int fd_count; ++ int ret = 0; ++ const int *fds = get_received_fds(msg, &fd_count); ++ for (int i = 0; !ret && i < fd_count; i++) { ++ ret = render_socket_set_cloexec(fds[i]); ++ } ++ if (ret) { ++ for (int i = 0; i < fd_count; i++) { ++ close(fds[i]); ++ } ++ return false; ++ } ++#endif ++ ++ return true; + } + + static bool +@@ -196,8 +316,32 @@ + static bool + render_socket_sendmsg(struct render_socket *socket, const struct msghdr *msg) + { ++ enum socket_state state = SOCKET_STATE_FIRST_MSG; ++ struct render_context_socket_header hdr = { ++ .length = htonl(msg->msg_iov[0].iov_len), ++ }; ++ ssize_t want = sizeof(hdr); ++ struct msghdr _msg = { ++ .msg_iov = ++ &(struct iovec){ ++ .iov_base = &hdr, ++ .iov_len = want, ++ }, ++ .msg_iovlen = 1, ++ .msg_control = msg->msg_control, ++ .msg_controllen = msg->msg_controllen, ++ }; ++ ++ assert(msg->msg_iovlen == 1); ++ ++ if (socket->is_seqpacket) { ++ _msg.msg_iov[0].iov_base = msg->msg_iov[0].iov_base; ++ _msg.msg_iov[0].iov_len = msg->msg_iov[0].iov_len; ++ want = 0; ++ } ++ + do { +- const ssize_t s = sendmsg(socket->fd, msg, MSG_NOSIGNAL); ++ const ssize_t s = sendmsg(socket->fd, &_msg, MSG_NOSIGNAL); + if (unlikely(s < 0)) { + if (errno == EAGAIN || errno == EINTR) + continue; +@@ -206,9 +350,30 @@ + return false; + } + +- /* no partial send since the socket type is SOCK_SEQPACKET */ +- assert(msg->msg_iovlen == 1 && msg->msg_iov[0].iov_len == (size_t)s); +- return true; ++ if (socket->is_seqpacket) { ++ /* no partial send since the socket type is SOCK_SEQPACKET */ ++ assert(_msg.msg_iovlen == 1 && _msg.msg_iov[0].iov_len == (size_t)s); ++ state = SOCKET_STATE_DATA; ++ } else if (state == SOCKET_STATE_FIRST_MSG) { ++ _msg.msg_controllen = 0; ++ _msg.msg_control = NULL; ++ state = SOCKET_STATE_HEADER; ++ } ++ ++ if (s <= want) { ++ _msg.msg_iov[0].iov_base = (char *)_msg.msg_iov[0].iov_base + s; ++ _msg.msg_iov[0].iov_len -= s; ++ want -= s; ++ } ++ ++ if (!want && state == SOCKET_STATE_HEADER) { ++ want = ntohl(hdr.length); ++ _msg.msg_iov[0].iov_base = msg->msg_iov[0].iov_base; ++ _msg.msg_iov[0].iov_len = want; ++ state = SOCKET_STATE_DATA; ++ } else if (!want && state == SOCKET_STATE_DATA) { ++ return true; ++ } + } while (true); + } + +diff --git a/server/render_socket.h b/server/render_socket.h +--- a/server/render_socket.h 2026-01-01 08:05:29 ++++ b/server/render_socket.h 2026-01-11 23:47:51 +@@ -10,6 +10,7 @@ + + struct render_socket { + int fd; ++ bool is_seqpacket; + }; + + bool +diff --git a/server/render_state.c b/server/render_state.c +--- a/server/render_state.c 2026-01-01 08:05:29 ++++ b/server/render_state.c 2026-01-11 23:47:51 +@@ -93,6 +93,7 @@ + return ctx; + } + ++#ifdef STANDALONE_SERVER + static void + render_state_cb_debug_logger(UNUSED enum virgl_log_level_flags log_level, + const char *message, +@@ -100,6 +101,7 @@ + { + render_log(message); + } ++#endif + + static void + render_state_cb_retire_fence(uint32_t ctx_id, uint32_t ring_idx, uint64_t fence_id) +@@ -112,7 +114,9 @@ + } + + static const struct vkr_renderer_callbacks render_state_cbs = { ++#ifdef STANDALONE_SERVER + .debug_logger = render_state_cb_debug_logger, ++#endif + .retire_fence = render_state_cb_retire_fence, + }; + +@@ -221,13 +225,14 @@ + uint32_t blob_flags, + enum virgl_resource_fd_type *out_fd_type, + int *out_res_fd, ++ void **out_res_ptr, + uint32_t *out_map_info, + struct virgl_resource_vulkan_info *out_vulkan_info) + { + SCOPE_LOCK_RENDERER(); + return vkr_renderer_create_resource(ctx_id, res_id, blob_id, blob_size, blob_flags, +- out_fd_type, out_res_fd, out_map_info, +- out_vulkan_info); ++ out_fd_type, out_res_fd, out_res_ptr, ++ out_map_info, out_vulkan_info); + } + + bool +diff --git a/server/render_state.h b/server/render_state.h +--- a/server/render_state.h 2026-01-01 08:05:29 ++++ b/server/render_state.h 2026-01-11 23:47:51 +@@ -40,6 +40,7 @@ + uint32_t blob_flags, + enum virgl_resource_fd_type *out_fd_type, + int *out_res_fd, ++ void **out_res_ptr, + uint32_t *out_map_info, + struct virgl_resource_vulkan_info *out_vulkan_info); + +diff --git a/server/render_worker.c b/server/render_worker.c +--- a/server/render_worker.c 2026-01-01 08:05:29 ++++ b/server/render_worker.c 2026-01-11 23:47:51 +@@ -3,6 +3,12 @@ + * SPDX-License-Identifier: MIT + */ + ++#ifndef __APPLE__ ++#include ++#else ++#include ++#undef LIST_ENTRY ++#endif + #include "render_worker.h" + + /* One and only one of ENABLE_RENDER_SERVER_WORKER_* must be set. +@@ -24,10 +30,11 @@ + #include + #include + #include +-#include + #include + #include +-#include ++#ifdef ENABLE_RENDER_SERVER_WORKER_THREAD ++#include "c11/threads.h" ++#endif + #include + + struct minijail; +@@ -161,9 +168,51 @@ + + #ifndef ENABLE_RENDER_SERVER_WORKER_THREAD + ++#ifdef __APPLE__ + static int + create_sigchld_fd(void) + { ++ /* On macOS, use kqueue to monitor SIGCHLD */ ++ int kq = kqueue(); ++ if (kq == -1) { ++ render_log("failed to create kqueue"); ++ return -1; ++ } ++ ++ /* Set up kqueue to monitor SIGCHLD signal */ ++ struct kevent kev; ++ EV_SET(&kev, SIGCHLD, EVFILT_SIGNAL, EV_ADD, 0, 0, NULL); ++ ++ if (kevent(kq, &kev, 1, NULL, 0, NULL) == -1) { ++ render_log("failed to add SIGCHLD to kqueue"); ++ close(kq); ++ return -1; ++ } ++ ++ /* Block SIGCHLD so it's only handled through kqueue */ ++ sigset_t set; ++ sigemptyset(&set); ++ sigaddset(&set, SIGCHLD); ++ if (sigprocmask(SIG_BLOCK, &set, NULL) == -1) { ++ render_log("failed to block SIGCHLD"); ++ close(kq); ++ return -1; ++ } ++ ++ /* Set kqueue to non-blocking mode */ ++ int flags = fcntl(kq, F_GETFL); ++ if (flags == -1 || fcntl(kq, F_SETFL, flags | O_NONBLOCK) == -1) { ++ render_log("failed to set kqueue non-blocking"); ++ close(kq); ++ return -1; ++ } ++ ++ return kq; ++} ++#else ++static int ++create_sigchld_fd(void) ++{ + /* restore the ability of waitid() to catch SIGCHLD, in case parent disabled + * it (e.g. virgl_test_server) */ + struct sigaction sa = { 0 }; +@@ -196,6 +245,7 @@ + + return fd; + } ++#endif /* __APPLE__ */ + + #endif /* !ENABLE_RENDER_SERVER_WORKER_THREAD */ + +@@ -321,7 +371,32 @@ + if (jail->sigchld_fd < 0) + return true; + ++#ifdef __APPLE__ ++ /* On macOS, drain kqueue events */ + do { ++ struct kevent events[8]; ++ struct timespec timeout = {0, 0}; /* non-blocking */ ++ int nevents = kevent(jail->sigchld_fd, NULL, 0, events, 8, &timeout); ++ ++ if (nevents == -1) { ++ if (errno == EINTR) ++ continue; ++ render_log("failed to read kqueue events"); ++ return false; ++ } ++ ++ if (nevents == 0) ++ break; /* no more events */ ++ ++ /* Process SIGCHLD events - we don't need to do anything special ++ * as the signal count is in events[i].data, but we just need to drain */ ++ if (nevents < 8) ++ break; /* got fewer events than requested, so we're done */ ++ ++ } while (true); ++#else ++ /* On Linux, drain signalfd */ ++ do { + struct signalfd_siginfo siginfos[8]; + const ssize_t r = read(jail->sigchld_fd, siginfos, sizeof(siginfos)); + if (r == sizeof(siginfos)) +@@ -332,6 +407,7 @@ + render_log("failed to read signalfd"); + return false; + } while (true); ++#endif + + return true; + } +diff --git a/src/meson.build b/src/meson.build +--- a/src/meson.build 2026-01-01 08:05:29 ++++ b/src/meson.build 2026-01-11 23:47:51 +@@ -60,6 +60,10 @@ + 'vrend/vrend_winsys_glx.c', + ] + ++vrend_metal_sources = [ ++ 'vrend/vrend_metal.m', ++] ++ + venus_sources = [ + 'venus/vkr_acceleration_structure.c', + 'venus/vkr_allocator.c', +@@ -133,6 +137,16 @@ + 'proxy/proxy_socket.c', + ] + ++server_sources = [ ++ '../server/render_client.c', ++ '../server/render_common.c', ++ '../server/render_context.c', ++ '../server/render_server.c', ++ '../server/render_socket.c', ++ '../server/render_state.c', ++ '../server/render_worker.c', ++] ++ + video_sources = [ + 'vrend/virgl_video.c', + 'vrend/vrend_video.c', +@@ -174,10 +188,15 @@ + virgl_depends += [glx_dep] + endif + ++if metal_dep.found() ++ virgl_sources += vrend_metal_sources ++ virgl_depends += [metal_dep] ++endif ++ + if with_venus + virgl_sources += venus_sources + virgl_sources += venus_codegen +- virgl_depends += [venus_dep] ++ virgl_depends += venus_deps + endif + + if with_drm_renderers +@@ -199,6 +218,10 @@ + + if with_render_server + virgl_sources += proxy_sources ++endif ++ ++if with_render_server_worker == 'thread' ++ virgl_sources += server_sources + endif + + if with_video +diff --git a/src/proxy/proxy_client.c b/src/proxy/proxy_client.c +--- a/src/proxy/proxy_client.c 2026-01-01 08:05:29 ++++ b/src/proxy/proxy_client.c 2026-01-11 23:47:51 +@@ -55,11 +55,6 @@ + return false; + } + +- if (!proxy_socket_is_seqpacket(ctx_fd)) { +- close(ctx_fd); +- return false; +- } +- + *out_ctx_fd = ctx_fd; + return true; + } +diff --git a/src/proxy/proxy_context.c b/src/proxy/proxy_context.c +--- a/src/proxy/proxy_context.c 2026-01-01 08:05:29 ++++ b/src/proxy/proxy_context.c 2026-01-11 23:47:51 +@@ -367,7 +367,16 @@ + return -1; + } + +- if (!reply_fd_count) { ++ if (reply.fd_type == VIRGL_RESOURCE_METAL_HEAP) { ++ blob->type = reply.fd_type; ++ blob->u.metal_heap = reply.res_ptr; ++ blob->map_info = reply.map_info; ++ blob->vulkan_info = reply.vulkan_info; ++ ++ proxy_context_resource_add(ctx, res_id); ++ ++ return 0; ++ } else if (!reply_fd_count) { + proxy_log("invalid reply for blob %" PRIu64, blob_id); + return -1; + } +diff --git a/src/proxy/proxy_renderer.c b/src/proxy/proxy_renderer.c +--- a/src/proxy/proxy_renderer.c 2026-01-01 08:05:29 ++++ b/src/proxy/proxy_renderer.c 2026-01-11 23:47:51 +@@ -15,12 +15,14 @@ + int + proxy_renderer_init(const struct proxy_renderer_cbs *cbs, uint32_t flags) + { ++ bool in_process = !(flags & VIRGL_RENDERER_RENDER_SERVER); ++ + assert(flags & VIRGL_RENDERER_NO_VIRGL); + + proxy_renderer.cbs = cbs; + proxy_renderer.flags = flags; + +- proxy_renderer.server = proxy_server_create(); ++ proxy_renderer.server = proxy_server_create(in_process); + if (!proxy_renderer.server) + goto fail; + +diff --git a/src/proxy/proxy_server.c b/src/proxy/proxy_server.c +--- a/src/proxy/proxy_server.c 2026-01-01 08:05:29 ++++ b/src/proxy/proxy_server.c 2026-01-11 23:47:51 +@@ -9,7 +9,9 @@ + #include + #include + ++#include "server/render_context.h" + #include "server/render_protocol.h" ++#include "server/render_server.h" + + int + proxy_server_connect(struct proxy_server *srv) +@@ -33,6 +35,13 @@ + if (srv->client_fd >= 0) + close(srv->client_fd); + ++#ifdef ENABLE_RENDER_SERVER_WORKER_THREAD ++ if (srv->in_process) { ++ thrd_join(srv->thread, NULL); ++ srv->in_process = false; ++ } ++#endif ++ + free(srv); + } + +@@ -95,8 +104,54 @@ + return true; + } + ++#ifdef ENABLE_RENDER_SERVER_WORKER_THREAD ++static int ++proxy_server_start_thread(void *args) ++{ ++ int remote_fd = (int)(uintptr_t)args; ++ char fd_str[16]; ++ snprintf(fd_str, sizeof(fd_str), "%d", remote_fd); ++ char *argv[] = { ++ RENDER_SERVER_EXEC_PATH, ++ "--socket-fd", ++ fd_str, ++ NULL, ++ }; ++ struct render_context_args ctx_args; ++ ++ ctx_args.in_process = true; ++ bool ok = render_server_main(3, argv, &ctx_args); ++ ++ return ok ? 0 : -1; ++} ++ ++static bool ++proxy_server_init_thread(struct proxy_server *srv) ++{ ++ int socket_fds[2]; ++ ++ if (!proxy_socket_pair(socket_fds)) ++ return false; ++ ++ const int client_fd = socket_fds[0]; ++ const uintptr_t remote_fd = socket_fds[1]; ++ ++ bool ok = thrd_create(&srv->thread, proxy_server_start_thread, (void *)remote_fd) == thrd_success; ++ ++ if (ok) { ++ srv->client_fd = client_fd; ++ srv->in_process = true; ++ } else { ++ close(client_fd); ++ close(remote_fd); ++ } ++ ++ return ok; ++} ++#endif ++ + struct proxy_server * +-proxy_server_create(void) ++proxy_server_create(bool in_process) + { + struct proxy_server *srv = calloc(1, sizeof(*srv)); + if (!srv) +@@ -104,7 +159,18 @@ + + srv->pid = -1; + +- if (!proxy_server_init_fd(srv)) { ++ if (in_process) { ++#ifdef ENABLE_RENDER_SERVER_WORKER_THREAD ++ if (!proxy_server_init_thread(srv)) { ++ free(srv); ++ return NULL; ++ } ++#else ++ proxy_log("in process server not supported"); ++ free(srv); ++ return NULL; ++#endif ++ } else if (!proxy_server_init_fd(srv)) { + /* start the render server on demand when the client does not provide a + * server fd + */ +@@ -112,13 +178,6 @@ + free(srv); + return NULL; + } +- } +- +- if (!proxy_socket_is_seqpacket(srv->client_fd)) { +- proxy_log("invalid client fd type"); +- close(srv->client_fd); +- free(srv); +- return NULL; + } + + proxy_log("proxy server with pid %d", srv->pid); +diff --git a/src/proxy/proxy_server.h b/src/proxy/proxy_server.h +--- a/src/proxy/proxy_server.h 2026-01-01 08:05:29 ++++ b/src/proxy/proxy_server.h 2026-01-11 23:47:51 +@@ -10,13 +10,21 @@ + + #include + ++#ifdef ENABLE_RENDER_SERVER_WORKER_THREAD ++#include "c11/threads.h" ++#endif ++ + struct proxy_server { + pid_t pid; + int client_fd; ++#ifdef ENABLE_RENDER_SERVER_WORKER_THREAD ++ bool in_process; ++ thrd_t thread; ++#endif + }; + + struct proxy_server * +-proxy_server_create(void); ++proxy_server_create(bool in_process); + + void + proxy_server_destroy(struct proxy_server *srv); +diff --git a/src/proxy/proxy_socket.c b/src/proxy/proxy_socket.c +--- a/src/proxy/proxy_socket.c 2026-01-01 08:05:29 ++++ b/src/proxy/proxy_socket.c 2026-01-11 23:47:51 +@@ -4,6 +4,7 @@ + */ + + #include "proxy_socket.h" ++#include "server/render_protocol.h" + + #include + #include +@@ -13,11 +14,40 @@ + + #define PROXY_SOCKET_MAX_FD_COUNT 8 + ++#ifndef MSG_CMSG_CLOEXEC ++#include ++static int ++proxy_socket_set_cloexec(int fd) ++{ ++ long flags; ++ ++ if (fd == -1) ++ return -1; ++ ++ flags = fcntl(fd, F_GETFD); ++ if (flags == -1) ++ goto err; ++ ++ if (fcntl(fd, F_SETFD, flags | FD_CLOEXEC) == -1) ++ goto err; ++ ++ return 0; ++ ++err: ++ return -1; ++} ++#endif ++ + /* this is only used when the render server is started on demand */ + bool + proxy_socket_pair(int out_fds[static 2]) + { +- int ret = socketpair(AF_UNIX, SOCK_SEQPACKET, 0, out_fds); ++#ifdef __APPLE__ ++ int type = SOCK_SEQPACKET; ++#else ++ int type = SOCK_SEQPACKET; ++#endif ++ int ret = socketpair(AF_UNIX, type, 0, out_fds); + if (ret) { + proxy_log("failed to create socket pair"); + return false; +@@ -41,10 +71,12 @@ + void + proxy_socket_init(struct proxy_socket *socket, int fd) + { ++ bool is_seqpacket = proxy_socket_is_seqpacket(fd); + /* TODO make fd non-blocking and perform io with timeout */ + assert(fd >= 0); + *socket = (struct proxy_socket){ + .fd = fd, ++ .is_seqpacket = is_seqpacket, + }; + } + +@@ -96,11 +128,45 @@ + return (const int *)CMSG_DATA(cmsg); + } + ++enum socket_state { ++ SOCKET_STATE_FIRST_MSG, ++ SOCKET_STATE_HEADER, ++ SOCKET_STATE_DATA, ++}; ++ + static bool + proxy_socket_recvmsg(struct proxy_socket *socket, struct msghdr *msg) + { ++ int flags = 0; ++#ifdef MSG_CMSG_CLOEXEC ++ flags = MSG_CMSG_CLOEXEC; ++#endif ++ ++ enum socket_state state = SOCKET_STATE_FIRST_MSG; ++ struct render_context_socket_header hdr = {0}; ++ ssize_t want = sizeof(hdr); ++ struct msghdr _msg = { ++ .msg_iov = ++ &(struct iovec){ ++ .iov_base = &hdr, ++ .iov_len = want, ++ }, ++ .msg_iovlen = 1, ++ .msg_control = msg->msg_control, ++ .msg_controllen = msg->msg_controllen, ++ }; ++ socklen_t _msg_controllen; ++ ++ assert(msg->msg_iovlen == 1); ++ ++ if (socket->is_seqpacket) { ++ _msg.msg_iov[0].iov_base = msg->msg_iov[0].iov_base; ++ _msg.msg_iov[0].iov_len = msg->msg_iov[0].iov_len; ++ want = 0; ++ } ++ + do { +- const ssize_t s = recvmsg(socket->fd, msg, MSG_CMSG_CLOEXEC); ++ const ssize_t s = recvmsg(socket->fd, &_msg, flags); + if (unlikely(s < 0)) { + if (errno == EAGAIN || errno == EINTR) + continue; +@@ -109,21 +175,61 @@ + return false; + } + +- assert(msg->msg_iovlen == 1); +- if (unlikely((msg->msg_flags & (MSG_TRUNC | MSG_CTRUNC)) || +- msg->msg_iov[0].iov_len != (size_t)s)) { ++ if (state == SOCKET_STATE_FIRST_MSG) { ++ _msg_controllen = _msg.msg_controllen; ++ state = socket->is_seqpacket ? SOCKET_STATE_DATA : SOCKET_STATE_HEADER; ++ } else { ++ /* retain the cmsg from first message */ ++ assert(_msg.msg_controllen == 0); ++ } ++ ++ if (unlikely(_msg.msg_flags & MSG_CTRUNC || ++ (socket->is_seqpacket && ++ (_msg.msg_flags & MSG_TRUNC) || ++ _msg.msg_iov[0].iov_len != (size_t)s))) { + proxy_log("failed to receive message: truncated or incomplete"); + + int fd_count; +- const int *fds = get_received_fds(msg, &fd_count); ++ const int *fds = get_received_fds(&_msg, &fd_count); + for (int i = 0; i < fd_count; i++) + close(fds[i]); + + return false; + } + +- return true; ++ if (s <= want) { ++ _msg.msg_iov[0].iov_base = (char *)_msg.msg_iov[0].iov_base + s; ++ _msg.msg_iov[0].iov_len -= s; ++ want -= s; ++ } ++ ++ if (!want && state == SOCKET_STATE_HEADER) { ++ want = ntohl(hdr.length); ++ _msg.msg_iov[0].iov_base = msg->msg_iov[0].iov_base; ++ _msg.msg_iov[0].iov_len = want; ++ state = SOCKET_STATE_DATA; ++ } else if (!want && state == SOCKET_STATE_DATA) { ++ msg->msg_controllen = _msg_controllen; ++ break; ++ } + } while (true); ++ ++#ifndef MSG_CMSG_CLOEXEC ++ int fd_count; ++ int ret = 0; ++ const int *fds = get_received_fds(msg, &fd_count); ++ for (int i = 0; !ret && i < fd_count; i++) { ++ ret = proxy_socket_set_cloexec(fds[i]); ++ } ++ if (ret) { ++ for (int i = 0; i < fd_count; i++) { ++ close(fds[i]); ++ } ++ return false; ++ } ++#endif ++ ++ return true; + } + + static bool +@@ -192,8 +298,32 @@ + static bool + proxy_socket_sendmsg(struct proxy_socket *socket, const struct msghdr *msg) + { ++ enum socket_state state = SOCKET_STATE_FIRST_MSG; ++ struct render_context_socket_header hdr = { ++ .length = htonl(msg->msg_iov[0].iov_len), ++ }; ++ ssize_t want = sizeof(hdr); ++ struct msghdr _msg = { ++ .msg_iov = ++ &(struct iovec){ ++ .iov_base = &hdr, ++ .iov_len = want, ++ }, ++ .msg_iovlen = 1, ++ .msg_control = msg->msg_control, ++ .msg_controllen = msg->msg_controllen, ++ }; ++ ++ assert(msg->msg_iovlen == 1); ++ ++ if (socket->is_seqpacket) { ++ _msg.msg_iov[0].iov_base = msg->msg_iov[0].iov_base; ++ _msg.msg_iov[0].iov_len = msg->msg_iov[0].iov_len; ++ want = 0; ++ } ++ + do { +- const ssize_t s = sendmsg(socket->fd, msg, MSG_NOSIGNAL); ++ const ssize_t s = sendmsg(socket->fd, &_msg, MSG_NOSIGNAL); + if (unlikely(s < 0)) { + if (errno == EAGAIN || errno == EINTR) + continue; +@@ -202,9 +332,30 @@ + return false; + } + +- /* no partial send since the socket type is SOCK_SEQPACKET */ +- assert(msg->msg_iovlen == 1 && msg->msg_iov[0].iov_len == (size_t)s); +- return true; ++ if (socket->is_seqpacket) { ++ /* no partial send since the socket type is SOCK_SEQPACKET */ ++ assert(_msg.msg_iovlen == 1 && _msg.msg_iov[0].iov_len == (size_t)s); ++ state = SOCKET_STATE_DATA; ++ } else if (state == SOCKET_STATE_FIRST_MSG) { ++ _msg.msg_controllen = 0; ++ _msg.msg_control = NULL; ++ state = SOCKET_STATE_HEADER; ++ } ++ ++ if (s <= want) { ++ _msg.msg_iov[0].iov_base = (char *)_msg.msg_iov[0].iov_base + s; ++ _msg.msg_iov[0].iov_len -= s; ++ want -= s; ++ } ++ ++ if (!want && state == SOCKET_STATE_HEADER) { ++ want = ntohl(hdr.length); ++ _msg.msg_iov[0].iov_base = msg->msg_iov[0].iov_base; ++ _msg.msg_iov[0].iov_len = want; ++ state = SOCKET_STATE_DATA; ++ } else if (!want && state == SOCKET_STATE_DATA) { ++ return true; ++ } + } while (true); + } + +diff --git a/src/proxy/proxy_socket.h b/src/proxy/proxy_socket.h +--- a/src/proxy/proxy_socket.h 2026-01-01 08:05:29 ++++ b/src/proxy/proxy_socket.h 2026-01-11 23:47:51 +@@ -10,6 +10,7 @@ + + struct proxy_socket { + int fd; ++ bool is_seqpacket; + }; + + bool +diff --git a/src/venus/venus-protocol/vn_protocol_renderer.h b/src/venus/venus-protocol/vn_protocol_renderer.h +--- a/src/venus/venus-protocol/vn_protocol_renderer.h 2026-01-01 08:05:29 ++++ b/src/venus/venus-protocol/vn_protocol_renderer.h 2026-01-11 23:47:51 +@@ -1,4 +1,4 @@ +-/* This file is generated by venus-protocol git-9fa07f3c. */ ++/* This file is generated by venus-protocol git-ce1b3c7c. */ + + /* + * Copyright 2020 Google LLC +diff --git a/src/venus/venus-protocol/vn_protocol_renderer_defines.h b/src/venus/venus-protocol/vn_protocol_renderer_defines.h +--- a/src/venus/venus-protocol/vn_protocol_renderer_defines.h 2026-01-01 08:05:29 ++++ b/src/venus/venus-protocol/vn_protocol_renderer_defines.h 2026-01-11 23:47:51 +@@ -465,7 +465,9 @@ + VK_COMMAND_TYPE_vkCmdSetAttachmentFeedbackLoopEnableEXT_EXT = 329, + VK_COMMAND_TYPE_vkCmdSetDepthClampRangeEXT_EXT = 330, + VK_COMMAND_TYPE_vkGetPhysicalDeviceCooperativeMatrixPropertiesKHR_EXT = 331, ++ VK_COMMAND_TYPE_vkGetMemoryMetalHandleEXT_EXT = 331, + VK_COMMAND_TYPE_vkCmdDrawMeshTasksEXT_EXT = 332, ++ VK_COMMAND_TYPE_vkGetMemoryMetalHandlePropertiesEXT_EXT = 332, + VK_COMMAND_TYPE_vkCmdDrawMeshTasksIndirectEXT_EXT = 333, + VK_COMMAND_TYPE_vkCmdDrawMeshTasksIndirectCountEXT_EXT = 334, + } VkCommandTypeEXT; +@@ -2869,6 +2871,23 @@ + const VkDepthClampRangeEXT* pDepthClampRange; + }; + ++struct vn_command_vkGetMemoryMetalHandleEXT { ++ VkDevice device; ++ const VkMemoryGetMetalHandleInfoEXT* pGetMetalHandleInfo; ++ void** pHandle; ++ ++ VkResult ret; ++}; ++ ++struct vn_command_vkGetMemoryMetalHandlePropertiesEXT { ++ VkDevice device; ++ VkExternalMemoryHandleTypeFlagBits handleType; ++ const void* pHandle; ++ VkMemoryMetalHandlePropertiesEXT* pMemoryMetalHandleProperties; ++ ++ VkResult ret; ++}; ++ + struct vn_command_vkSetReplyCommandStreamMESA { + const VkCommandStreamDescriptionMESA* pStream; + }; +@@ -3285,6 +3304,8 @@ + void (*dispatch_vkCmdSetRenderingAttachmentLocations)(struct vn_dispatch_context *ctx, struct vn_command_vkCmdSetRenderingAttachmentLocations *args); + void (*dispatch_vkCmdSetRenderingInputAttachmentIndices)(struct vn_dispatch_context *ctx, struct vn_command_vkCmdSetRenderingInputAttachmentIndices *args); + void (*dispatch_vkCmdSetDepthClampRangeEXT)(struct vn_dispatch_context *ctx, struct vn_command_vkCmdSetDepthClampRangeEXT *args); ++ void (*dispatch_vkGetMemoryMetalHandleEXT)(struct vn_dispatch_context *ctx, struct vn_command_vkGetMemoryMetalHandleEXT *args); ++ void (*dispatch_vkGetMemoryMetalHandlePropertiesEXT)(struct vn_dispatch_context *ctx, struct vn_command_vkGetMemoryMetalHandlePropertiesEXT *args); + void (*dispatch_vkSetReplyCommandStreamMESA)(struct vn_dispatch_context *ctx, struct vn_command_vkSetReplyCommandStreamMESA *args); + void (*dispatch_vkSeekReplyCommandStreamMESA)(struct vn_dispatch_context *ctx, struct vn_command_vkSeekReplyCommandStreamMESA *args); + void (*dispatch_vkExecuteCommandStreamsMESA)(struct vn_dispatch_context *ctx, struct vn_command_vkExecuteCommandStreamsMESA *args); +diff --git a/src/venus/venus-protocol/vn_protocol_renderer_device_memory.h b/src/venus/venus-protocol/vn_protocol_renderer_device_memory.h +--- a/src/venus/venus-protocol/vn_protocol_renderer_device_memory.h 2026-01-01 08:05:29 ++++ b/src/venus/venus-protocol/vn_protocol_renderer_device_memory.h 2026-01-11 23:47:51 +@@ -25,6 +25,8 @@ + * vkGetMemoryFdKHR + * vkGetMemoryFdPropertiesKHR + * vkMapMemory2 ++ * vkGetMemoryMetalHandleEXT ++ * vkGetMemoryMetalHandlePropertiesEXT + */ + + /* struct VkExportMemoryAllocateInfo chain */ +@@ -84,6 +86,69 @@ + } while (pnext); + } + ++/* struct VkImportMemoryMetalHandleInfoEXT chain */ ++ ++static inline void * ++vn_decode_VkImportMemoryMetalHandleInfoEXT_pnext_temp(struct vn_cs_decoder *dec) ++{ ++ /* no known/supported struct */ ++ if (vn_decode_simple_pointer(dec)) ++ vn_cs_decoder_set_fatal(dec); ++ return NULL; ++} ++ ++static inline void ++vn_decode_VkImportMemoryMetalHandleInfoEXT_self_temp(struct vn_cs_decoder *dec, VkImportMemoryMetalHandleInfoEXT *val) ++{ ++ /* skip val->{sType,pNext} */ ++ vn_decode_VkExternalMemoryHandleTypeFlagBits(dec, &val->handleType); ++ if (vn_decode_simple_pointer(dec)) { ++ vn_cs_decoder_set_fatal(dec); ++ } else { ++ val->handle = NULL; ++ } ++} ++ ++static inline void ++vn_decode_VkImportMemoryMetalHandleInfoEXT_temp(struct vn_cs_decoder *dec, VkImportMemoryMetalHandleInfoEXT *val) ++{ ++ VkStructureType stype; ++ vn_decode_VkStructureType(dec, &stype); ++ if (stype != VK_STRUCTURE_TYPE_IMPORT_MEMORY_METAL_HANDLE_INFO_EXT) ++ vn_cs_decoder_set_fatal(dec); ++ ++ val->sType = stype; ++ val->pNext = vn_decode_VkImportMemoryMetalHandleInfoEXT_pnext_temp(dec); ++ vn_decode_VkImportMemoryMetalHandleInfoEXT_self_temp(dec, val); ++} ++ ++static inline void ++vn_replace_VkImportMemoryMetalHandleInfoEXT_handle_self(VkImportMemoryMetalHandleInfoEXT *val) ++{ ++ /* skip val->sType */ ++ /* skip val->pNext */ ++ /* skip val->handleType */ ++ /* skip val->handle */ ++} ++ ++static inline void ++vn_replace_VkImportMemoryMetalHandleInfoEXT_handle(VkImportMemoryMetalHandleInfoEXT *val) ++{ ++ struct VkBaseOutStructure *pnext = (struct VkBaseOutStructure *)val; ++ ++ do { ++ switch ((int32_t)pnext->sType) { ++ case VK_STRUCTURE_TYPE_IMPORT_MEMORY_METAL_HANDLE_INFO_EXT: ++ vn_replace_VkImportMemoryMetalHandleInfoEXT_handle_self((VkImportMemoryMetalHandleInfoEXT *)pnext); ++ break; ++ default: ++ /* ignore unknown/unsupported struct */ ++ break; ++ } ++ pnext = pnext->pNext; ++ } while (pnext); ++} ++ + /* struct VkMemoryAllocateFlagsInfo chain */ + + static inline void * +@@ -337,6 +402,14 @@ + vn_decode_VkExportMemoryAllocateInfo_self_temp(dec, (VkExportMemoryAllocateInfo *)pnext); + } + break; ++ case VK_STRUCTURE_TYPE_IMPORT_MEMORY_METAL_HANDLE_INFO_EXT: ++ pnext = vn_cs_decoder_alloc_temp(dec, sizeof(VkImportMemoryMetalHandleInfoEXT)); ++ if (pnext) { ++ pnext->sType = stype; ++ pnext->pNext = vn_decode_VkMemoryAllocateInfo_pnext_temp(dec); ++ vn_decode_VkImportMemoryMetalHandleInfoEXT_self_temp(dec, (VkImportMemoryMetalHandleInfoEXT *)pnext); ++ } ++ break; + case VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_FLAGS_INFO: + pnext = vn_cs_decoder_alloc_temp(dec, sizeof(VkMemoryAllocateFlagsInfo)); + if (pnext) { +@@ -422,6 +495,9 @@ + break; + case VK_STRUCTURE_TYPE_EXPORT_MEMORY_ALLOCATE_INFO: + vn_replace_VkExportMemoryAllocateInfo_handle_self((VkExportMemoryAllocateInfo *)pnext); ++ break; ++ case VK_STRUCTURE_TYPE_IMPORT_MEMORY_METAL_HANDLE_INFO_EXT: ++ vn_replace_VkImportMemoryMetalHandleInfoEXT_handle_self((VkImportMemoryMetalHandleInfoEXT *)pnext); + break; + case VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_FLAGS_INFO: + vn_replace_VkMemoryAllocateFlagsInfo_handle_self((VkMemoryAllocateFlagsInfo *)pnext); +diff --git a/src/venus/venus-protocol/vn_protocol_renderer_info.h b/src/venus/venus-protocol/vn_protocol_renderer_info.h +--- a/src/venus/venus-protocol/vn_protocol_renderer_info.h 2026-01-01 08:05:29 ++++ b/src/venus/venus-protocol/vn_protocol_renderer_info.h 2026-01-11 23:47:51 +@@ -12,7 +12,7 @@ + + struct vn_info_extension_table { + union { +- bool enabled[185]; ++ bool enabled[186]; + struct { + bool ARM_rasterization_order_attachment_access; + bool EXT_4444_formats; +@@ -40,6 +40,7 @@ + bool EXT_extended_dynamic_state3; + bool EXT_external_memory_acquire_unmodified; + bool EXT_external_memory_dma_buf; ++ bool EXT_external_memory_metal; + bool EXT_filter_cubic; + bool EXT_fragment_shader_interlock; + bool EXT_global_priority; +@@ -212,8 +213,8 @@ + }; + + /* sorted by extension names for bsearch */ +-static const uint32_t _vn_info_extension_count = 185; +-static const struct vn_info_extension _vn_info_extensions[185] = { ++static const uint32_t _vn_info_extension_count = 186; ++static const struct vn_info_extension _vn_info_extensions[186] = { + { "VK_ARM_rasterization_order_attachment_access", 343, 1 }, + { "VK_EXT_4444_formats", 341, 1 }, + { "VK_EXT_attachment_feedback_loop_dynamic_state", 525, 1 }, +@@ -240,6 +241,7 @@ + { "VK_EXT_extended_dynamic_state3", 456, 2 }, + { "VK_EXT_external_memory_acquire_unmodified", 454, 1 }, + { "VK_EXT_external_memory_dma_buf", 126, 1 }, ++ { "VK_EXT_external_memory_metal", 603, 1 }, + { "VK_EXT_filter_cubic", 171, 3 }, + { "VK_EXT_fragment_shader_interlock", 252, 1 }, + { "VK_EXT_global_priority", 175, 2 }, +diff --git a/src/venus/venus-protocol/vn_protocol_renderer_util.h b/src/venus/venus-protocol/vn_protocol_renderer_util.h +--- a/src/venus/venus-protocol/vn_protocol_renderer_util.h 2026-01-01 08:05:29 ++++ b/src/venus/venus-protocol/vn_protocol_renderer_util.h 2026-01-11 23:47:51 +@@ -300,6 +300,8 @@ + PFN_vkGetImageSubresourceLayout2 GetImageSubresourceLayout2; + PFN_vkGetMemoryFdKHR GetMemoryFdKHR; + PFN_vkGetMemoryFdPropertiesKHR GetMemoryFdPropertiesKHR; ++ PFN_vkGetMemoryMetalHandleEXT GetMemoryMetalHandleEXT; ++ PFN_vkGetMemoryMetalHandlePropertiesEXT GetMemoryMetalHandlePropertiesEXT; + PFN_vkGetPipelineCacheData GetPipelineCacheData; + PFN_vkGetPrivateData GetPrivateData; + PFN_vkGetQueryPoolResults GetQueryPoolResults; +@@ -1042,6 +1044,12 @@ + NULL; + proc_table->GetMemoryFdPropertiesKHR = + ext_table->KHR_external_memory_fd ? VN_GDPA(dev, vkGetMemoryFdPropertiesKHR) : ++ NULL; ++ proc_table->GetMemoryMetalHandleEXT = ++ ext_table->EXT_external_memory_metal ? VN_GDPA(dev, vkGetMemoryMetalHandleEXT) : ++ NULL; ++ proc_table->GetMemoryMetalHandlePropertiesEXT = ++ ext_table->EXT_external_memory_metal ? VN_GDPA(dev, vkGetMemoryMetalHandlePropertiesEXT) : + NULL; + proc_table->GetPipelineCacheData = VN_GDPA(dev, vkGetPipelineCacheData); + proc_table->GetPrivateData = +diff --git a/src/venus/venus-protocol/vulkan.h b/src/venus/venus-protocol/vulkan.h +--- a/src/venus/venus-protocol/vulkan.h 2026-01-01 08:05:29 ++++ b/src/venus/venus-protocol/vulkan.h 2026-01-11 23:47:51 +@@ -28,9 +28,9 @@ + #include "vulkan_macos.h" + #endif + +-#ifdef VK_USE_PLATFORM_METAL_EXT ++//#ifdef VK_USE_PLATFORM_METAL_EXT + #include "vulkan_metal.h" +-#endif ++//#endif + + #ifdef VK_USE_PLATFORM_VI_NN + #include "vulkan_vi.h" +@@ -92,9 +92,9 @@ + #endif + + +-#ifdef VK_ENABLE_BETA_EXTENSIONS ++//#ifdef VK_ENABLE_BETA_EXTENSIONS + #include "vulkan_beta.h" +-#endif ++//#endif + + #ifdef VK_USE_PLATFORM_OHOS + #include "vulkan_ohos.h" +diff --git a/src/venus/venus-protocol/vulkan_beta.h b/src/venus/venus-protocol/vulkan_beta.h +--- a/src/venus/venus-protocol/vulkan_beta.h 1969-12-31 19:00:00 ++++ b/src/venus/venus-protocol/vulkan_beta.h 2026-01-11 23:47:51 +@@ -0,0 +1,226 @@ ++#ifndef VULKAN_BETA_H_ ++#define VULKAN_BETA_H_ 1 ++ ++/* ++** Copyright 2015-2025 The Khronos Group Inc. ++** ++** SPDX-License-Identifier: Apache-2.0 ++*/ ++ ++/* ++** This header is generated from the Khronos Vulkan XML API Registry. ++** ++*/ ++ ++ ++#ifdef __cplusplus ++extern "C" { ++#endif ++ ++ ++ ++// VK_KHR_portability_subset is a preprocessor guard. Do not pass it to API calls. ++#define VK_KHR_portability_subset 1 ++#define VK_KHR_PORTABILITY_SUBSET_SPEC_VERSION 1 ++#define VK_KHR_PORTABILITY_SUBSET_EXTENSION_NAME "VK_KHR_portability_subset" ++typedef struct VkPhysicalDevicePortabilitySubsetFeaturesKHR { ++ VkStructureType sType; ++ void* pNext; ++ VkBool32 constantAlphaColorBlendFactors; ++ VkBool32 events; ++ VkBool32 imageViewFormatReinterpretation; ++ VkBool32 imageViewFormatSwizzle; ++ VkBool32 imageView2DOn3DImage; ++ VkBool32 multisampleArrayImage; ++ VkBool32 mutableComparisonSamplers; ++ VkBool32 pointPolygons; ++ VkBool32 samplerMipLodBias; ++ VkBool32 separateStencilMaskRef; ++ VkBool32 shaderSampleRateInterpolationFunctions; ++ VkBool32 tessellationIsolines; ++ VkBool32 tessellationPointMode; ++ VkBool32 triangleFans; ++ VkBool32 vertexAttributeAccessBeyondStride; ++} VkPhysicalDevicePortabilitySubsetFeaturesKHR; ++ ++typedef struct VkPhysicalDevicePortabilitySubsetPropertiesKHR { ++ VkStructureType sType; ++ void* pNext; ++ uint32_t minVertexInputBindingStrideAlignment; ++} VkPhysicalDevicePortabilitySubsetPropertiesKHR; ++ ++ ++ ++// VK_AMDX_shader_enqueue is a preprocessor guard. Do not pass it to API calls. ++#define VK_AMDX_shader_enqueue 1 ++#define VK_AMDX_SHADER_ENQUEUE_SPEC_VERSION 2 ++#define VK_AMDX_SHADER_ENQUEUE_EXTENSION_NAME "VK_AMDX_shader_enqueue" ++#define VK_SHADER_INDEX_UNUSED_AMDX (~0U) ++typedef struct VkPhysicalDeviceShaderEnqueueFeaturesAMDX { ++ VkStructureType sType; ++ void* pNext; ++ VkBool32 shaderEnqueue; ++ VkBool32 shaderMeshEnqueue; ++} VkPhysicalDeviceShaderEnqueueFeaturesAMDX; ++ ++typedef struct VkPhysicalDeviceShaderEnqueuePropertiesAMDX { ++ VkStructureType sType; ++ void* pNext; ++ uint32_t maxExecutionGraphDepth; ++ uint32_t maxExecutionGraphShaderOutputNodes; ++ uint32_t maxExecutionGraphShaderPayloadSize; ++ uint32_t maxExecutionGraphShaderPayloadCount; ++ uint32_t executionGraphDispatchAddressAlignment; ++ uint32_t maxExecutionGraphWorkgroupCount[3]; ++ uint32_t maxExecutionGraphWorkgroups; ++} VkPhysicalDeviceShaderEnqueuePropertiesAMDX; ++ ++typedef struct VkExecutionGraphPipelineScratchSizeAMDX { ++ VkStructureType sType; ++ void* pNext; ++ VkDeviceSize minSize; ++ VkDeviceSize maxSize; ++ VkDeviceSize sizeGranularity; ++} VkExecutionGraphPipelineScratchSizeAMDX; ++ ++typedef struct VkExecutionGraphPipelineCreateInfoAMDX { ++ VkStructureType sType; ++ const void* pNext; ++ VkPipelineCreateFlags flags; ++ uint32_t stageCount; ++ const VkPipelineShaderStageCreateInfo* pStages; ++ const VkPipelineLibraryCreateInfoKHR* pLibraryInfo; ++ VkPipelineLayout layout; ++ VkPipeline basePipelineHandle; ++ int32_t basePipelineIndex; ++} VkExecutionGraphPipelineCreateInfoAMDX; ++ ++typedef union VkDeviceOrHostAddressConstAMDX { ++ VkDeviceAddress deviceAddress; ++ const void* hostAddress; ++} VkDeviceOrHostAddressConstAMDX; ++ ++typedef struct VkDispatchGraphInfoAMDX { ++ uint32_t nodeIndex; ++ uint32_t payloadCount; ++ VkDeviceOrHostAddressConstAMDX payloads; ++ uint64_t payloadStride; ++} VkDispatchGraphInfoAMDX; ++ ++typedef struct VkDispatchGraphCountInfoAMDX { ++ uint32_t count; ++ VkDeviceOrHostAddressConstAMDX infos; ++ uint64_t stride; ++} VkDispatchGraphCountInfoAMDX; ++ ++typedef struct VkPipelineShaderStageNodeCreateInfoAMDX { ++ VkStructureType sType; ++ const void* pNext; ++ const char* pName; ++ uint32_t index; ++} VkPipelineShaderStageNodeCreateInfoAMDX; ++ ++typedef VkResult (VKAPI_PTR *PFN_vkCreateExecutionGraphPipelinesAMDX)(VkDevice device, VkPipelineCache pipelineCache, uint32_t createInfoCount, const VkExecutionGraphPipelineCreateInfoAMDX* pCreateInfos, const VkAllocationCallbacks* pAllocator, VkPipeline* pPipelines); ++typedef VkResult (VKAPI_PTR *PFN_vkGetExecutionGraphPipelineScratchSizeAMDX)(VkDevice device, VkPipeline executionGraph, VkExecutionGraphPipelineScratchSizeAMDX* pSizeInfo); ++typedef VkResult (VKAPI_PTR *PFN_vkGetExecutionGraphPipelineNodeIndexAMDX)(VkDevice device, VkPipeline executionGraph, const VkPipelineShaderStageNodeCreateInfoAMDX* pNodeInfo, uint32_t* pNodeIndex); ++typedef void (VKAPI_PTR *PFN_vkCmdInitializeGraphScratchMemoryAMDX)(VkCommandBuffer commandBuffer, VkPipeline executionGraph, VkDeviceAddress scratch, VkDeviceSize scratchSize); ++typedef void (VKAPI_PTR *PFN_vkCmdDispatchGraphAMDX)(VkCommandBuffer commandBuffer, VkDeviceAddress scratch, VkDeviceSize scratchSize, const VkDispatchGraphCountInfoAMDX* pCountInfo); ++typedef void (VKAPI_PTR *PFN_vkCmdDispatchGraphIndirectAMDX)(VkCommandBuffer commandBuffer, VkDeviceAddress scratch, VkDeviceSize scratchSize, const VkDispatchGraphCountInfoAMDX* pCountInfo); ++typedef void (VKAPI_PTR *PFN_vkCmdDispatchGraphIndirectCountAMDX)(VkCommandBuffer commandBuffer, VkDeviceAddress scratch, VkDeviceSize scratchSize, VkDeviceAddress countInfo); ++ ++#ifndef VK_NO_PROTOTYPES ++VKAPI_ATTR VkResult VKAPI_CALL vkCreateExecutionGraphPipelinesAMDX( ++ VkDevice device, ++ VkPipelineCache pipelineCache, ++ uint32_t createInfoCount, ++ const VkExecutionGraphPipelineCreateInfoAMDX* pCreateInfos, ++ const VkAllocationCallbacks* pAllocator, ++ VkPipeline* pPipelines); ++ ++VKAPI_ATTR VkResult VKAPI_CALL vkGetExecutionGraphPipelineScratchSizeAMDX( ++ VkDevice device, ++ VkPipeline executionGraph, ++ VkExecutionGraphPipelineScratchSizeAMDX* pSizeInfo); ++ ++VKAPI_ATTR VkResult VKAPI_CALL vkGetExecutionGraphPipelineNodeIndexAMDX( ++ VkDevice device, ++ VkPipeline executionGraph, ++ const VkPipelineShaderStageNodeCreateInfoAMDX* pNodeInfo, ++ uint32_t* pNodeIndex); ++ ++VKAPI_ATTR void VKAPI_CALL vkCmdInitializeGraphScratchMemoryAMDX( ++ VkCommandBuffer commandBuffer, ++ VkPipeline executionGraph, ++ VkDeviceAddress scratch, ++ VkDeviceSize scratchSize); ++ ++VKAPI_ATTR void VKAPI_CALL vkCmdDispatchGraphAMDX( ++ VkCommandBuffer commandBuffer, ++ VkDeviceAddress scratch, ++ VkDeviceSize scratchSize, ++ const VkDispatchGraphCountInfoAMDX* pCountInfo); ++ ++VKAPI_ATTR void VKAPI_CALL vkCmdDispatchGraphIndirectAMDX( ++ VkCommandBuffer commandBuffer, ++ VkDeviceAddress scratch, ++ VkDeviceSize scratchSize, ++ const VkDispatchGraphCountInfoAMDX* pCountInfo); ++ ++VKAPI_ATTR void VKAPI_CALL vkCmdDispatchGraphIndirectCountAMDX( ++ VkCommandBuffer commandBuffer, ++ VkDeviceAddress scratch, ++ VkDeviceSize scratchSize, ++ VkDeviceAddress countInfo); ++#endif ++ ++ ++// VK_NV_displacement_micromap is a preprocessor guard. Do not pass it to API calls. ++#define VK_NV_displacement_micromap 1 ++#define VK_NV_DISPLACEMENT_MICROMAP_SPEC_VERSION 2 ++#define VK_NV_DISPLACEMENT_MICROMAP_EXTENSION_NAME "VK_NV_displacement_micromap" ++ ++typedef enum VkDisplacementMicromapFormatNV { ++ VK_DISPLACEMENT_MICROMAP_FORMAT_64_TRIANGLES_64_BYTES_NV = 1, ++ VK_DISPLACEMENT_MICROMAP_FORMAT_256_TRIANGLES_128_BYTES_NV = 2, ++ VK_DISPLACEMENT_MICROMAP_FORMAT_1024_TRIANGLES_128_BYTES_NV = 3, ++ VK_DISPLACEMENT_MICROMAP_FORMAT_MAX_ENUM_NV = 0x7FFFFFFF ++} VkDisplacementMicromapFormatNV; ++typedef struct VkPhysicalDeviceDisplacementMicromapFeaturesNV { ++ VkStructureType sType; ++ void* pNext; ++ VkBool32 displacementMicromap; ++} VkPhysicalDeviceDisplacementMicromapFeaturesNV; ++ ++typedef struct VkPhysicalDeviceDisplacementMicromapPropertiesNV { ++ VkStructureType sType; ++ void* pNext; ++ uint32_t maxDisplacementMicromapSubdivisionLevel; ++} VkPhysicalDeviceDisplacementMicromapPropertiesNV; ++ ++typedef struct VkAccelerationStructureTrianglesDisplacementMicromapNV { ++ VkStructureType sType; ++ void* pNext; ++ VkFormat displacementBiasAndScaleFormat; ++ VkFormat displacementVectorFormat; ++ VkDeviceOrHostAddressConstKHR displacementBiasAndScaleBuffer; ++ VkDeviceSize displacementBiasAndScaleStride; ++ VkDeviceOrHostAddressConstKHR displacementVectorBuffer; ++ VkDeviceSize displacementVectorStride; ++ VkDeviceOrHostAddressConstKHR displacedMicromapPrimitiveFlags; ++ VkDeviceSize displacedMicromapPrimitiveFlagsStride; ++ VkIndexType indexType; ++ VkDeviceOrHostAddressConstKHR indexBuffer; ++ VkDeviceSize indexStride; ++ uint32_t baseTriangle; ++ uint32_t usageCountsCount; ++ const VkMicromapUsageEXT* pUsageCounts; ++ const VkMicromapUsageEXT* const* ppUsageCounts; ++ VkMicromapEXT micromap; ++} VkAccelerationStructureTrianglesDisplacementMicromapNV; ++ ++ ++#ifdef __cplusplus ++} ++#endif ++ ++#endif +diff --git a/src/venus/venus-protocol/vulkan_metal.h b/src/venus/venus-protocol/vulkan_metal.h +--- a/src/venus/venus-protocol/vulkan_metal.h 1969-12-31 19:00:00 ++++ b/src/venus/venus-protocol/vulkan_metal.h 2026-01-11 23:47:51 +@@ -0,0 +1,238 @@ ++#ifndef VULKAN_METAL_H_ ++#define VULKAN_METAL_H_ 1 ++ ++/* ++** Copyright 2015-2025 The Khronos Group Inc. ++** ++** SPDX-License-Identifier: Apache-2.0 ++*/ ++ ++/* ++** This header is generated from the Khronos Vulkan XML API Registry. ++** ++*/ ++ ++ ++#ifdef __cplusplus ++extern "C" { ++#endif ++ ++ ++ ++// VK_EXT_metal_surface is a preprocessor guard. Do not pass it to API calls. ++#define VK_EXT_metal_surface 1 ++#ifdef __OBJC__ ++@class CAMetalLayer; ++#else ++typedef void CAMetalLayer; ++#endif ++ ++#define VK_EXT_METAL_SURFACE_SPEC_VERSION 1 ++#define VK_EXT_METAL_SURFACE_EXTENSION_NAME "VK_EXT_metal_surface" ++typedef VkFlags VkMetalSurfaceCreateFlagsEXT; ++typedef struct VkMetalSurfaceCreateInfoEXT { ++ VkStructureType sType; ++ const void* pNext; ++ VkMetalSurfaceCreateFlagsEXT flags; ++ const CAMetalLayer* pLayer; ++} VkMetalSurfaceCreateInfoEXT; ++ ++typedef VkResult (VKAPI_PTR *PFN_vkCreateMetalSurfaceEXT)(VkInstance instance, const VkMetalSurfaceCreateInfoEXT* pCreateInfo, const VkAllocationCallbacks* pAllocator, VkSurfaceKHR* pSurface); ++ ++#ifndef VK_NO_PROTOTYPES ++VKAPI_ATTR VkResult VKAPI_CALL vkCreateMetalSurfaceEXT( ++ VkInstance instance, ++ const VkMetalSurfaceCreateInfoEXT* pCreateInfo, ++ const VkAllocationCallbacks* pAllocator, ++ VkSurfaceKHR* pSurface); ++#endif ++ ++ ++// VK_EXT_metal_objects is a preprocessor guard. Do not pass it to API calls. ++#define VK_EXT_metal_objects 1 ++#ifdef __OBJC__ ++@protocol MTLDevice; ++typedef __unsafe_unretained id MTLDevice_id; ++#else ++typedef void* MTLDevice_id; ++#endif ++ ++#ifdef __OBJC__ ++@protocol MTLCommandQueue; ++typedef __unsafe_unretained id MTLCommandQueue_id; ++#else ++typedef void* MTLCommandQueue_id; ++#endif ++ ++#ifdef __OBJC__ ++@protocol MTLBuffer; ++typedef __unsafe_unretained id MTLBuffer_id; ++#else ++typedef void* MTLBuffer_id; ++#endif ++ ++#ifdef __OBJC__ ++@protocol MTLTexture; ++typedef __unsafe_unretained id MTLTexture_id; ++#else ++typedef void* MTLTexture_id; ++#endif ++ ++typedef struct __IOSurface* IOSurfaceRef; ++#ifdef __OBJC__ ++@protocol MTLSharedEvent; ++typedef __unsafe_unretained id MTLSharedEvent_id; ++#else ++typedef void* MTLSharedEvent_id; ++#endif ++ ++#define VK_EXT_METAL_OBJECTS_SPEC_VERSION 2 ++#define VK_EXT_METAL_OBJECTS_EXTENSION_NAME "VK_EXT_metal_objects" ++ ++typedef enum VkExportMetalObjectTypeFlagBitsEXT { ++ VK_EXPORT_METAL_OBJECT_TYPE_METAL_DEVICE_BIT_EXT = 0x00000001, ++ VK_EXPORT_METAL_OBJECT_TYPE_METAL_COMMAND_QUEUE_BIT_EXT = 0x00000002, ++ VK_EXPORT_METAL_OBJECT_TYPE_METAL_BUFFER_BIT_EXT = 0x00000004, ++ VK_EXPORT_METAL_OBJECT_TYPE_METAL_TEXTURE_BIT_EXT = 0x00000008, ++ VK_EXPORT_METAL_OBJECT_TYPE_METAL_IOSURFACE_BIT_EXT = 0x00000010, ++ VK_EXPORT_METAL_OBJECT_TYPE_METAL_SHARED_EVENT_BIT_EXT = 0x00000020, ++ VK_EXPORT_METAL_OBJECT_TYPE_FLAG_BITS_MAX_ENUM_EXT = 0x7FFFFFFF ++} VkExportMetalObjectTypeFlagBitsEXT; ++typedef VkFlags VkExportMetalObjectTypeFlagsEXT; ++typedef struct VkExportMetalObjectCreateInfoEXT { ++ VkStructureType sType; ++ const void* pNext; ++ VkExportMetalObjectTypeFlagBitsEXT exportObjectType; ++} VkExportMetalObjectCreateInfoEXT; ++ ++typedef struct VkExportMetalObjectsInfoEXT { ++ VkStructureType sType; ++ const void* pNext; ++} VkExportMetalObjectsInfoEXT; ++ ++typedef struct VkExportMetalDeviceInfoEXT { ++ VkStructureType sType; ++ const void* pNext; ++ MTLDevice_id mtlDevice; ++} VkExportMetalDeviceInfoEXT; ++ ++typedef struct VkExportMetalCommandQueueInfoEXT { ++ VkStructureType sType; ++ const void* pNext; ++ VkQueue queue; ++ MTLCommandQueue_id mtlCommandQueue; ++} VkExportMetalCommandQueueInfoEXT; ++ ++typedef struct VkExportMetalBufferInfoEXT { ++ VkStructureType sType; ++ const void* pNext; ++ VkDeviceMemory memory; ++ MTLBuffer_id mtlBuffer; ++} VkExportMetalBufferInfoEXT; ++ ++typedef struct VkImportMetalBufferInfoEXT { ++ VkStructureType sType; ++ const void* pNext; ++ MTLBuffer_id mtlBuffer; ++} VkImportMetalBufferInfoEXT; ++ ++typedef struct VkExportMetalTextureInfoEXT { ++ VkStructureType sType; ++ const void* pNext; ++ VkImage image; ++ VkImageView imageView; ++ VkBufferView bufferView; ++ VkImageAspectFlagBits plane; ++ MTLTexture_id mtlTexture; ++} VkExportMetalTextureInfoEXT; ++ ++typedef struct VkImportMetalTextureInfoEXT { ++ VkStructureType sType; ++ const void* pNext; ++ VkImageAspectFlagBits plane; ++ MTLTexture_id mtlTexture; ++} VkImportMetalTextureInfoEXT; ++ ++typedef struct VkExportMetalIOSurfaceInfoEXT { ++ VkStructureType sType; ++ const void* pNext; ++ VkImage image; ++ IOSurfaceRef ioSurface; ++} VkExportMetalIOSurfaceInfoEXT; ++ ++typedef struct VkImportMetalIOSurfaceInfoEXT { ++ VkStructureType sType; ++ const void* pNext; ++ IOSurfaceRef ioSurface; ++} VkImportMetalIOSurfaceInfoEXT; ++ ++typedef struct VkExportMetalSharedEventInfoEXT { ++ VkStructureType sType; ++ const void* pNext; ++ VkSemaphore semaphore; ++ VkEvent event; ++ MTLSharedEvent_id mtlSharedEvent; ++} VkExportMetalSharedEventInfoEXT; ++ ++typedef struct VkImportMetalSharedEventInfoEXT { ++ VkStructureType sType; ++ const void* pNext; ++ MTLSharedEvent_id mtlSharedEvent; ++} VkImportMetalSharedEventInfoEXT; ++ ++typedef void (VKAPI_PTR *PFN_vkExportMetalObjectsEXT)(VkDevice device, VkExportMetalObjectsInfoEXT* pMetalObjectsInfo); ++ ++#ifndef VK_NO_PROTOTYPES ++VKAPI_ATTR void VKAPI_CALL vkExportMetalObjectsEXT( ++ VkDevice device, ++ VkExportMetalObjectsInfoEXT* pMetalObjectsInfo); ++#endif ++ ++// TODO(b/417176273): this didn't made it into the spec, start using void*? ++typedef void* MTLResource_id; ++ ++// VK_EXT_external_memory_metal is a preprocessor guard. Do not pass it to API calls. ++#define VK_EXT_external_memory_metal 1 ++#define VK_EXT_EXTERNAL_MEMORY_METAL_SPEC_VERSION 1 ++#define VK_EXT_EXTERNAL_MEMORY_METAL_EXTENSION_NAME "VK_EXT_external_memory_metal" ++typedef struct VkImportMemoryMetalHandleInfoEXT { ++ VkStructureType sType; ++ const void* pNext; ++ VkExternalMemoryHandleTypeFlagBits handleType; ++ MTLResource_id handle; ++} VkImportMemoryMetalHandleInfoEXT; ++ ++typedef struct VkMemoryMetalHandlePropertiesEXT { ++ VkStructureType sType; ++ void* pNext; ++ uint32_t memoryTypeBits; ++} VkMemoryMetalHandlePropertiesEXT; ++ ++typedef struct VkMemoryGetMetalHandleInfoEXT { ++ VkStructureType sType; ++ const void* pNext; ++ VkDeviceMemory memory; ++ VkExternalMemoryHandleTypeFlagBits handleType; ++} VkMemoryGetMetalHandleInfoEXT; ++ ++typedef VkResult (VKAPI_PTR *PFN_vkGetMemoryMetalHandleEXT)(VkDevice device, const VkMemoryGetMetalHandleInfoEXT* pGetMetalHandleInfo, MTLResource_id* pHandle); ++typedef VkResult (VKAPI_PTR *PFN_vkGetMemoryMetalHandlePropertiesEXT)(VkDevice device, VkExternalMemoryHandleTypeFlagBits handleType, const MTLResource_id pHandle, VkMemoryMetalHandlePropertiesEXT* pMemoryMetalHandleProperties); ++ ++#ifndef VK_NO_PROTOTYPES ++VKAPI_ATTR VkResult VKAPI_CALL vkGetMemoryMetalHandleEXT( ++ VkDevice device, ++ const VkMemoryGetMetalHandleInfoEXT* pGetMetalHandleInfo, ++ void** pHandle); ++ ++VKAPI_ATTR VkResult VKAPI_CALL vkGetMemoryMetalHandlePropertiesEXT( ++ VkDevice device, ++ VkExternalMemoryHandleTypeFlagBits handleType, ++ const void* pHandle, ++ VkMemoryMetalHandlePropertiesEXT* pMemoryMetalHandleProperties); ++#endif ++ ++#ifdef __cplusplus ++} ++#endif ++ ++#endif +diff --git a/src/venus/vkr_allocator.c b/src/venus/vkr_allocator.c +--- a/src/venus/vkr_allocator.c 2026-01-01 08:05:29 ++++ b/src/venus/vkr_allocator.c 2026-01-11 23:47:51 +@@ -52,6 +52,7 @@ + PFN_vkGetPhysicalDeviceProperties2 GetPhysicalDeviceProperties2; + PFN_vkCreateDevice CreateDevice; + PFN_vkGetDeviceProcAddr GetDeviceProcAddr; ++ PFN_vkEnumerateDeviceExtensionProperties EnumerateDeviceExtensionProperties; + }; + + struct vkr_dev_proc_table { +@@ -109,6 +110,44 @@ + return VKR_ALLOCATOR_MAX_DEVICE_COUNT; + } + ++static const char * ++vkr_allocator_get_external_mem_ext(struct vkr_inst_proc_table *vk, ++ VkPhysicalDevice handle) ++{ ++ VkExtensionProperties *exts; ++ uint32_t count; ++ VkResult result = vk->EnumerateDeviceExtensionProperties(handle, NULL, &count, NULL); ++ if (result != VK_SUCCESS) ++ return NULL; ++ ++ exts = malloc(sizeof(*exts) * count); ++ if (!exts) ++ return NULL; ++ ++ result = vk->EnumerateDeviceExtensionProperties(handle, NULL, &count, exts); ++ if (result != VK_SUCCESS) { ++ free(exts); ++ return NULL; ++ } ++ ++ const char *name = NULL; ++ for (uint32_t i = 0; i < count; i++) { ++ VkExtensionProperties *props = &exts[i]; ++ ++ if (!strcmp(props->extensionName, VK_EXT_EXTERNAL_MEMORY_DMA_BUF_EXTENSION_NAME)) { ++ name = VK_EXT_EXTERNAL_MEMORY_DMA_BUF_EXTENSION_NAME; ++ break; ++ } else if (!strcmp(props->extensionName, VK_EXT_EXTERNAL_MEMORY_METAL_EXTENSION_NAME)) { ++ name = VK_EXT_EXTERNAL_MEMORY_METAL_EXTENSION_NAME; ++ break; ++ } ++ } ++ ++ free(exts); ++ ++ return name; ++} ++ + static struct vkr_opaque_fd_mem_info * + vkr_allocator_allocate_memory(struct virgl_resource *res) + { +@@ -123,26 +162,37 @@ + struct vkr_dev_proc_table *vk = &vkr_allocator.proc_tables[idx]; + + int fd = -1; +- if (virgl_resource_export_fd(res, &fd) != VIRGL_RESOURCE_FD_OPAQUE) { +- if (fd >= 0) +- close(fd); +- return NULL; +- } +- ++ VkImportMemoryMetalHandleInfoEXT metal_info = { 0 }; ++ VkImportMemoryFdInfoKHR fd_info = { 0 }; + VkMemoryAllocateInfo alloc_info = { + .sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO, +- .pNext = +- &(VkImportMemoryFdInfoKHR){ .sType = VK_STRUCTURE_TYPE_IMPORT_MEMORY_FD_INFO_KHR, +- .handleType = +- VK_EXTERNAL_MEMORY_HANDLE_TYPE_OPAQUE_FD_BIT, +- .fd = fd }, + .allocationSize = res->vulkan_info.allocation_size, + .memoryTypeIndex = res->vulkan_info.memory_type_index + }; + ++ if (res->fd_type == VIRGL_RESOURCE_METAL_HEAP) { ++ metal_info = (VkImportMemoryMetalHandleInfoEXT){ .sType = VK_STRUCTURE_TYPE_IMPORT_MEMORY_METAL_HANDLE_INFO_EXT, ++ .handle = res->metal_heap, ++ .handleType = VK_EXTERNAL_MEMORY_HANDLE_TYPE_MTLHEAP_BIT_EXT }; ++ alloc_info.pNext = &metal_info; ++ } else { ++ if (virgl_resource_export_fd(res, &fd) != VIRGL_RESOURCE_FD_OPAQUE) { ++ if (fd >= 0) ++ close(fd); ++ return NULL; ++ } ++ ++ fd_info = (VkImportMemoryFdInfoKHR){ .sType = VK_STRUCTURE_TYPE_IMPORT_MEMORY_FD_INFO_KHR, ++ .handleType = ++ VK_EXTERNAL_MEMORY_HANDLE_TYPE_OPAQUE_FD_BIT, ++ .fd = fd }; ++ alloc_info.pNext = &fd_info; ++ } ++ + VkDeviceMemory mem_handle; + if (vk->AllocateMemory(dev_handle, &alloc_info, NULL, &mem_handle) != VK_SUCCESS) { +- close(fd); ++ if (fd >= 0) ++ close(fd); + return NULL; + } + +@@ -198,6 +248,7 @@ + vk->GetPhysicalDeviceProperties2 = VN_GIPA(vkGetPhysicalDeviceProperties2); + vk->CreateDevice = VN_GIPA(vkCreateDevice); + vk->GetDeviceProcAddr = VN_GIPA(vkGetDeviceProcAddr); ++ vk->EnumerateDeviceExtensionProperties = VN_GIPA(vkEnumerateDeviceExtensionProperties); + #undef VN_GIPA + } + +@@ -218,11 +269,13 @@ + int + vkr_allocator_init(void) + { +- static const char *required_extensions[] = { +- "VK_KHR_external_memory_fd", ++ static const char *required_portability_exts[] = { ++ VK_KHR_PORTABILITY_ENUMERATION_EXTENSION_NAME, + }; ++ const char *required_extension; + struct vkr_inst_proc_table *vk = &vkr_allocator.proc_table; + VkResult res; ++ bool has_portability_enumeration = false; + + bool ret = vkr_library_load(&vkr_allocator.vulkan_library); + if (!ret) { +@@ -232,6 +285,12 @@ + /* Get vkGetInstanceProcAddr from libvulkan */ + PFN_vkGetInstanceProcAddr get_proc_addr = vkr_allocator.vulkan_library.GetInstanceProcAddr; + ++ PFN_vkEnumerateInstanceExtensionProperties enum_inst_ext_props = ++ (PFN_vkEnumerateInstanceExtensionProperties)get_proc_addr(VK_NULL_HANDLE, ++ "vkEnumerateInstanceExtensionProperties"); ++ ++ has_portability_enumeration = vkr_library_has_portability_enumeration(enum_inst_ext_props); ++ + VkApplicationInfo app_info = { + .sType = VK_STRUCTURE_TYPE_APPLICATION_INFO, + .apiVersion = VK_API_VERSION_1_1, +@@ -240,13 +299,16 @@ + VkInstanceCreateInfo inst_info = { + .sType = VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO, + .pApplicationInfo = &app_info, ++ .flags = has_portability_enumeration ? VK_INSTANCE_CREATE_ENUMERATE_PORTABILITY_BIT_KHR : 0, ++ .enabledExtensionCount = has_portability_enumeration ? ARRAY_SIZE(required_portability_exts) : 0, ++ .ppEnabledExtensionNames = has_portability_enumeration ? required_portability_exts : NULL, + }; + + vk->CreateInstance = + (PFN_vkCreateInstance)get_proc_addr(VK_NULL_HANDLE, "vkCreateInstance"); + res = vk->CreateInstance(&inst_info, NULL, &vkr_allocator.instance); + if (res != VK_SUCCESS) +- goto fail; ++ goto early_fail; + + vkr_allocator_inst_proc_table_init(vkr_allocator.instance, get_proc_addr, vk); + +@@ -270,6 +332,11 @@ + + memcpy(vkr_allocator.device_uuids[i], id_props.deviceUUID, VK_UUID_SIZE); + ++ required_extension = vkr_allocator_get_external_mem_ext(vk, physical_dev_handle); ++ if (!required_extension) { ++ continue; ++ } ++ + float priority = 1.0; + VkDeviceQueueCreateInfo queue_info = { + .sType = VK_STRUCTURE_TYPE_DEVICE_QUEUE_CREATE_INFO, +@@ -284,8 +351,8 @@ + .sType = VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO, + .queueCreateInfoCount = 1, + .pQueueCreateInfos = &queue_info, +- .enabledExtensionCount = ARRAY_SIZE(required_extensions), +- .ppEnabledExtensionNames = required_extensions, ++ .enabledExtensionCount = 1, ++ .ppEnabledExtensionNames = &required_extension, + }; + + res = vk->CreateDevice(physical_dev_handle, &dev_info, NULL, +@@ -312,6 +379,7 @@ + } + vk->DestroyInstance(vkr_allocator.instance, NULL); + ++early_fail: + memset(&vkr_allocator, 0, sizeof(vkr_allocator)); + + vkr_library_unload(&vkr_allocator.vulkan_library); +diff --git a/src/venus/vkr_buffer.c b/src/venus/vkr_buffer.c +--- a/src/venus/vkr_buffer.c 2026-01-01 08:05:29 ++++ b/src/venus/vkr_buffer.c 2026-01-11 23:47:51 +@@ -9,9 +9,36 @@ + #include "vkr_physical_device.h" + + static void ++vkr_buffer_fix_create_info(struct vkr_device *dev, ++ VkBufferCreateInfo *pCreateInfo) ++{ ++ VkExternalMemoryBufferCreateInfo *ext_create_info; ++ ++ ext_create_info = vkr_find_struct( ++ pCreateInfo, VK_STRUCTURE_TYPE_EXTERNAL_MEMORY_BUFFER_CREATE_INFO); ++ if (ext_create_info) { ++ /* strip out dmabuf */ ++ if ((ext_create_info->handleTypes & VK_EXTERNAL_MEMORY_HANDLE_TYPE_DMA_BUF_BIT_EXT) != 0) { ++ ext_create_info->handleTypes &= ~VK_EXTERNAL_MEMORY_HANDLE_TYPE_DMA_BUF_BIT_EXT; ++ /* add in supported handles */ ++ if (dev->physical_device->is_metal_export_supported) { ++ ext_create_info->handleTypes |= VK_EXTERNAL_MEMORY_HANDLE_TYPE_MTLHEAP_BIT_EXT; ++ } ++ } ++ } ++} ++ ++static void + vkr_dispatch_vkCreateBuffer(struct vn_dispatch_context *dispatch, + struct vn_command_vkCreateBuffer *args) + { ++ struct vkr_device *dev = vkr_device_from_handle(args->device); ++ ++ /* if host does not natively support dmabuf we need to patch create info */ ++ if (dev->physical_device->is_dma_buf_emulated) { ++ vkr_buffer_fix_create_info(dev, (VkBufferCreateInfo *)args->pCreateInfo); ++ } ++ + /* XXX If VkExternalMemoryBufferCreateInfo is chained by the app, all is + * good. If it is not chained, we might still bind an external memory to + * the buffer, because vkr_dispatch_vkAllocateMemory makes any HOST_VISIBLE +@@ -138,6 +165,11 @@ + { + struct vkr_device *dev = vkr_device_from_handle(args->device); + struct vn_device_proc_table *vk = &dev->proc_table; ++ ++ /* if host does not natively support dmabuf we need to patch create info */ ++ if (dev->physical_device->is_dma_buf_emulated) { ++ vkr_buffer_fix_create_info(dev, (VkBufferCreateInfo *)args->pInfo->pCreateInfo); ++ } + + vn_replace_vkGetDeviceBufferMemoryRequirements_args_handle(args); + vk->GetDeviceBufferMemoryRequirements(args->device, args->pInfo, +diff --git a/src/venus/vkr_common.h b/src/venus/vkr_common.h +--- a/src/venus/vkr_common.h 2026-01-01 08:05:29 ++++ b/src/venus/vkr_common.h 2026-01-11 23:47:51 +@@ -79,6 +79,11 @@ + if (name != _stack_##name) \ + free(name) + ++/* Used for DRM format emulation when extension is not available */ ++#ifndef DRM_FORMAT_MOD_LINEAR ++#define DRM_FORMAT_MOD_LINEAR (0) ++#endif ++ + struct vn_info_extension_table; + struct vkr_context; + struct vkr_ring; +diff --git a/src/venus/vkr_context.c b/src/venus/vkr_context.c +--- a/src/venus/vkr_context.c 2026-01-01 08:05:29 ++++ b/src/venus/vkr_context.c 2026-01-11 23:47:51 +@@ -34,6 +34,13 @@ + #include "vkr_ring.h" + #include "vkr_transport.h" + ++#ifdef __APPLE__ ++#include ++#else ++#define CFRetain(x) (x) ++#define CFRelease(x) ++#endif ++ + void + vkr_context_add_instance(struct vkr_context *ctx, + struct vkr_instance *instance, +@@ -189,6 +196,8 @@ + struct vkr_resource *res = entry->data; + if (res->fd_type == VIRGL_RESOURCE_FD_SHM) + munmap(res->u.data, res->size); ++ else if (res->fd_type == VIRGL_RESOURCE_METAL_HEAP) ++ CFRelease(res->u.metal_heap); + else if (res->u.fd >= 0) + close(res->u.fd); + free(res); +@@ -307,6 +316,34 @@ + } + + static bool ++vkr_context_import_resource_metal(struct vkr_context *ctx, ++ uint32_t res_id, ++ uint64_t blob_size, ++ enum virgl_resource_fd_type fd_type, ++ void *metal_heap) ++{ ++ assert(!vkr_context_get_resource(ctx, res_id)); ++ assert(fd_type == VIRGL_RESOURCE_METAL_HEAP); ++ ++ struct vkr_resource *res = malloc(sizeof(*res)); ++ if (!res) ++ return false; ++ ++ res->res_id = res_id; ++ res->fd_type = fd_type; ++ res->size = blob_size; ++ res->u.metal_heap = (void *)CFRetain(metal_heap); ++ ++ if (!vkr_context_add_resource(ctx, res)) { ++ CFRelease(metal_heap); ++ free(res); ++ return false; ++ } ++ ++ return true; ++} ++ ++static bool + vkr_context_create_resource_from_device_memory(struct vkr_context *ctx, + uint32_t res_id, + uint64_t blob_id, +@@ -323,6 +360,11 @@ + struct virgl_context_blob blob; + if (!vkr_device_memory_export_blob(mem, blob_size, blob_flags, &blob)) + return false; ++ ++ if (blob.type == VIRGL_RESOURCE_METAL_HEAP) { ++ *out_blob = blob; ++ return vkr_context_import_resource_metal(ctx, res_id, blob_size, blob.type, blob.u.metal_heap); ++ } + + /* If memory might get exported, store a dup'ed fd in vkr_resource for: + * - vkAllocateMemory for dma_buf import +diff --git a/src/venus/vkr_context.h b/src/venus/vkr_context.h +--- a/src/venus/vkr_context.h 2026-01-01 08:05:29 ++++ b/src/venus/vkr_context.h 2026-01-11 23:47:51 +@@ -29,6 +29,8 @@ + int fd; + /* valid when fd_type is shm */ + uint8_t *data; ++ /* valid when fd_type is metal heap */ ++ MTLResource_id metal_heap; + } u; + + size_t size; +diff --git a/src/venus/vkr_device.c b/src/venus/vkr_device.c +--- a/src/venus/vkr_device.c 2026-01-01 08:05:29 ++++ b/src/venus/vkr_device.c 2026-01-11 23:47:51 +@@ -130,29 +130,49 @@ + /* append extensions for our own use */ + const char **exts = NULL; + uint32_t ext_count = args->pCreateInfo->enabledExtensionCount; +- ext_count += physical_dev->KHR_external_memory_fd; +- ext_count += physical_dev->EXT_external_memory_dma_buf; +- ext_count += physical_dev->KHR_external_fence_fd; +- if (ext_count > args->pCreateInfo->enabledExtensionCount) { +- exts = malloc(sizeof(*exts) * ext_count); +- if (!exts) { +- args->ret = VK_ERROR_OUT_OF_HOST_MEMORY; +- return; +- } +- for (uint32_t i = 0; i < args->pCreateInfo->enabledExtensionCount; i++) +- exts[i] = args->pCreateInfo->ppEnabledExtensionNames[i]; ++ uint32_t add_count = 0; ++ add_count += physical_dev->KHR_external_memory_fd; ++ add_count += physical_dev->EXT_external_memory_dma_buf; ++ add_count += physical_dev->KHR_external_fence_fd; ++ add_count += physical_dev->EXT_external_memory_metal; ++ add_count += physical_dev->KHR_portability_subset; ++ exts = malloc(sizeof(*exts) * (ext_count + add_count)); ++ if (!exts) { ++ args->ret = VK_ERROR_OUT_OF_HOST_MEMORY; ++ return; ++ } + +- ext_count = args->pCreateInfo->enabledExtensionCount; +- if (physical_dev->KHR_external_memory_fd) +- exts[ext_count++] = "VK_KHR_external_memory_fd"; +- if (physical_dev->EXT_external_memory_dma_buf) +- exts[ext_count++] = "VK_EXT_external_memory_dma_buf"; +- if (physical_dev->KHR_external_fence_fd) +- exts[ext_count++] = "VK_KHR_external_fence_fd"; +- +- ((VkDeviceCreateInfo *)args->pCreateInfo)->ppEnabledExtensionNames = exts; +- ((VkDeviceCreateInfo *)args->pCreateInfo)->enabledExtensionCount = ext_count; ++ /* skip any emulated extensions */ ++ ext_count = 0; ++ for (uint32_t i = 0; i < args->pCreateInfo->enabledExtensionCount; i++) { ++ if (physical_dev->is_dma_buf_emulated && ++ !strcmp(args->pCreateInfo->ppEnabledExtensionNames[i], VK_EXT_EXTERNAL_MEMORY_DMA_BUF_EXTENSION_NAME)) ++ continue; ++ if (physical_dev->is_dma_buf_emulated && ++ !strcmp(args->pCreateInfo->ppEnabledExtensionNames[i], VK_KHR_EXTERNAL_MEMORY_FD_EXTENSION_NAME)) ++ continue; ++ if (!physical_dev->EXT_image_drm_format_modifier && ++ !strcmp(args->pCreateInfo->ppEnabledExtensionNames[i], VK_EXT_IMAGE_DRM_FORMAT_MODIFIER_EXTENSION_NAME)) ++ continue; ++ if (!physical_dev->EXT_queue_family_foreign && ++ !strcmp(args->pCreateInfo->ppEnabledExtensionNames[i], VK_EXT_QUEUE_FAMILY_FOREIGN_EXTENSION_NAME)) ++ continue; ++ exts[ext_count++] = args->pCreateInfo->ppEnabledExtensionNames[i]; + } ++ ++ if (physical_dev->KHR_external_memory_fd) ++ exts[ext_count++] = VK_KHR_EXTERNAL_MEMORY_FD_EXTENSION_NAME; ++ if (physical_dev->EXT_external_memory_dma_buf) ++ exts[ext_count++] = VK_EXT_EXTERNAL_MEMORY_DMA_BUF_EXTENSION_NAME; ++ if (physical_dev->KHR_external_fence_fd) ++ exts[ext_count++] = VK_KHR_EXTERNAL_FENCE_FD_EXTENSION_NAME; ++ if (physical_dev->EXT_external_memory_metal) ++ exts[ext_count++] = VK_EXT_EXTERNAL_MEMORY_METAL_EXTENSION_NAME; ++ if (physical_dev->KHR_portability_subset) ++ exts[ext_count++] = VK_KHR_PORTABILITY_SUBSET_EXTENSION_NAME; ++ ++ ((VkDeviceCreateInfo *)args->pCreateInfo)->ppEnabledExtensionNames = exts; ++ ((VkDeviceCreateInfo *)args->pCreateInfo)->enabledExtensionCount = ext_count; + + struct vkr_device *dev = + vkr_context_alloc_object(ctx, sizeof(*dev), VK_OBJECT_TYPE_DEVICE, args->pDevice); +diff --git a/src/venus/vkr_device_memory.c b/src/venus/vkr_device_memory.c +--- a/src/venus/vkr_device_memory.c 2026-01-01 08:05:29 ++++ b/src/venus/vkr_device_memory.c 2026-01-11 23:47:51 +@@ -47,6 +47,31 @@ + return true; + } + ++static bool ++vkr_get_metal_info_from_resource_info(struct vkr_context *ctx, ++ const VkImportMemoryResourceInfoMESA *res_info, ++ VkImportMemoryMetalHandleInfoEXT *out) ++{ ++ struct vkr_resource *res = vkr_context_get_resource(ctx, res_info->resourceId); ++ if (!res) { ++ vkr_log("failed to import resource: invalid res_id %u", res_info->resourceId); ++ vkr_context_set_fatal(ctx); ++ return false; ++ } ++ ++ if (res->fd_type != VIRGL_RESOURCE_METAL_HEAP) { ++ return false; ++ } ++ ++ *out = (VkImportMemoryMetalHandleInfoEXT){ ++ .sType = VK_STRUCTURE_TYPE_IMPORT_MEMORY_METAL_HANDLE_INFO_EXT, ++ .pNext = res_info->pNext, ++ .handle = res->u.metal_heap, ++ .handleType = VK_EXTERNAL_MEMORY_HANDLE_TYPE_MTLHEAP_BIT_EXT, ++ }; ++ return true; ++} ++ + #if defined(HAVE_LINUX_UDMABUF_H) && defined(HAVE_MEMFD_CREATE) + #include + #include +@@ -245,17 +270,22 @@ + + /* translate VkImportMemoryResourceInfoMESA into VkImportMemoryFdInfoKHR in place */ + VkImportMemoryFdInfoKHR local_import_info = { .fd = -1 }; ++ VkImportMemoryMetalHandleInfoEXT local_metal_import_info = { 0 }; + VkImportMemoryResourceInfoMESA *res_info = NULL; + VkBaseInStructure *prev_of_res_info = vkr_find_prev_struct( + alloc_info, VK_STRUCTURE_TYPE_IMPORT_MEMORY_RESOURCE_INFO_MESA); + if (prev_of_res_info) { + res_info = (VkImportMemoryResourceInfoMESA *)prev_of_res_info->pNext; + if (!vkr_get_fd_info_from_resource_info(ctx, res_info, &local_import_info)) { +- args->ret = VK_ERROR_INVALID_EXTERNAL_HANDLE; +- return; ++ if (!vkr_get_metal_info_from_resource_info(ctx, res_info, &local_metal_import_info)) { ++ args->ret = VK_ERROR_INVALID_EXTERNAL_HANDLE; ++ return; ++ } else { ++ prev_of_res_info->pNext = (const struct VkBaseInStructure *)&local_metal_import_info; ++ } ++ } else { ++ prev_of_res_info->pNext = (const struct VkBaseInStructure *)&local_import_info; + } +- +- prev_of_res_info->pNext = (const struct VkBaseInStructure *)&local_import_info; + } + + VkExportMemoryAllocateInfo *export_info = +@@ -355,14 +385,33 @@ + + alloc_info->pNext = &local_import_info; + valid_fd_types = 1 << VIRGL_RESOURCE_FD_DMABUF; ++ } else if (physical_dev->is_metal_export_supported) { ++ assert(physical_dev->is_dma_buf_emulated); ++ /* Align to 4KiB, which is what Linux expects */ ++ alloc_info->allocationSize = align(alloc_info->allocationSize, 0x1000); ++ if (!export_info) { ++ local_export_info = (const VkExportMemoryAllocateInfo){ ++ .sType = VK_STRUCTURE_TYPE_EXPORT_MEMORY_ALLOCATE_INFO, ++ .pNext = alloc_info->pNext, ++ .handleTypes = VK_EXTERNAL_MEMORY_HANDLE_TYPE_MTLHEAP_BIT_EXT, ++ }; ++ export_info = &local_export_info; ++ alloc_info->pNext = &local_export_info; ++ } + } + } + + if (export_info) { ++ if (physical_dev->is_dma_buf_emulated && physical_dev->is_metal_export_supported) { ++ export_info->handleTypes &= ~VK_EXTERNAL_MEMORY_HANDLE_TYPE_DMA_BUF_BIT_EXT; ++ export_info->handleTypes |= VK_EXTERNAL_MEMORY_HANDLE_TYPE_MTLHEAP_BIT_EXT; ++ } + if (export_info->handleTypes & VK_EXTERNAL_MEMORY_HANDLE_TYPE_OPAQUE_FD_BIT) + valid_fd_types |= 1 << VIRGL_RESOURCE_FD_OPAQUE; + if (export_info->handleTypes & VK_EXTERNAL_MEMORY_HANDLE_TYPE_DMA_BUF_BIT_EXT) + valid_fd_types |= 1 << VIRGL_RESOURCE_FD_DMABUF; ++ if (export_info->handleTypes & VK_EXTERNAL_MEMORY_HANDLE_TYPE_MTLHEAP_BIT_EXT) ++ valid_fd_types |= 1 << VIRGL_RESOURCE_METAL_HEAP; + } + + struct vkr_device_memory *mem = vkr_device_memory_create_and_add(ctx, args); +@@ -438,26 +487,41 @@ + return; + } + +- if (res->fd_type != VIRGL_RESOURCE_FD_DMABUF) { ++ uint32_t memoryTypeBits; ++ vn_replace_vkGetMemoryResourcePropertiesMESA_args_handle(args); ++ if (res->fd_type == VIRGL_RESOURCE_FD_DMABUF) { ++ static const VkExternalMemoryHandleTypeFlagBits handle_type = ++ VK_EXTERNAL_MEMORY_HANDLE_TYPE_DMA_BUF_BIT_EXT; ++ VkMemoryFdPropertiesKHR mem_fd_props = { ++ .sType = VK_STRUCTURE_TYPE_MEMORY_FD_PROPERTIES_KHR, ++ .pNext = NULL, ++ .memoryTypeBits = 0, ++ }; ++ args->ret = ++ vk->GetMemoryFdPropertiesKHR(args->device, handle_type, res->u.fd, &mem_fd_props); ++ if (args->ret != VK_SUCCESS) ++ return; ++ memoryTypeBits = mem_fd_props.memoryTypeBits; ++ } else if (res->fd_type == VIRGL_RESOURCE_METAL_HEAP) { ++ static const VkExternalMemoryHandleTypeFlagBits handle_type = ++ VK_EXTERNAL_MEMORY_HANDLE_TYPE_MTLHEAP_BIT_EXT; ++ VkMemoryMetalHandlePropertiesEXT mem_metal_props = { ++ .sType = VK_STRUCTURE_TYPE_MEMORY_METAL_HANDLE_PROPERTIES_EXT, ++ .pNext = NULL, ++ .memoryTypeBits = 0, ++ }; ++ args->ret = ++ vk->GetMemoryMetalHandlePropertiesEXT(args->device, handle_type, res->u.metal_heap, &mem_metal_props); ++ if (args->ret != VK_SUCCESS) ++ return; ++ memoryTypeBits = mem_metal_props.memoryTypeBits; ++ } else { + args->ret = VK_ERROR_INVALID_EXTERNAL_HANDLE; + return; + } + +- static const VkExternalMemoryHandleTypeFlagBits handle_type = +- VK_EXTERNAL_MEMORY_HANDLE_TYPE_DMA_BUF_BIT_EXT; +- VkMemoryFdPropertiesKHR mem_fd_props = { +- .sType = VK_STRUCTURE_TYPE_MEMORY_FD_PROPERTIES_KHR, +- .pNext = NULL, +- .memoryTypeBits = 0, +- }; +- vn_replace_vkGetMemoryResourcePropertiesMESA_args_handle(args); +- args->ret = +- vk->GetMemoryFdPropertiesKHR(args->device, handle_type, res->u.fd, &mem_fd_props); +- if (args->ret != VK_SUCCESS) +- return; ++ args->pMemoryResourceProperties->memoryTypeBits = memoryTypeBits; + +- args->pMemoryResourceProperties->memoryTypeBits = mem_fd_props.memoryTypeBits; +- + VkMemoryResourceAllocationSizePropertiesMESA *alloc_size_props = + vkr_find_struct(args->pMemoryResourceProperties->pNext, + VK_STRUCTURE_TYPE_MEMORY_RESOURCE_ALLOCATION_SIZE_PROPERTIES_MESA); +@@ -527,6 +591,7 @@ + + const bool can_export_dma_buf = mem->valid_fd_types & (1 << VIRGL_RESOURCE_FD_DMABUF); + const bool can_export_opaque = mem->valid_fd_types & (1 << VIRGL_RESOURCE_FD_OPAQUE); ++ const bool can_export_metal = mem->valid_fd_types & (1 << VIRGL_RESOURCE_METAL_HEAP); + enum virgl_resource_fd_type fd_type; + VkExternalMemoryHandleTypeFlagBits handle_type; + struct virgl_resource_vulkan_info vulkan_info; +@@ -541,10 +606,16 @@ + /* prefer dmabuf for easier mapping? */ + fd_type = VIRGL_RESOURCE_FD_DMABUF; + handle_type = VK_EXTERNAL_MEMORY_HANDLE_TYPE_DMA_BUF_BIT_EXT; +- } else if (can_export_opaque) { ++ } else if (can_export_opaque || can_export_metal) { + /* prefer opaque for performance? */ +- fd_type = VIRGL_RESOURCE_FD_OPAQUE; +- handle_type = VK_EXTERNAL_MEMORY_HANDLE_TYPE_OPAQUE_FD_BIT; ++ if (can_export_opaque) { ++ fd_type = VIRGL_RESOURCE_FD_OPAQUE; ++ handle_type = VK_EXTERNAL_MEMORY_HANDLE_TYPE_OPAQUE_FD_BIT; ++ } else { ++ assert(can_export_metal); ++ fd_type = VIRGL_RESOURCE_METAL_HEAP; ++ handle_type = VK_EXTERNAL_MEMORY_HANDLE_TYPE_MTLHEAP_BIT_EXT; ++ } + + STATIC_ASSERT(sizeof(vulkan_info.device_uuid) == VK_UUID_SIZE); + STATIC_ASSERT(sizeof(vulkan_info.driver_uuid) == VK_UUID_SIZE); +@@ -562,6 +633,7 @@ + } + + int fd; ++ MTLResource_id metal_heap; + if (mem->udmabuf_fd >= 0) { + fd = os_dupfd_cloexec(mem->udmabuf_fd); + if (fd < 0) { +@@ -577,6 +649,19 @@ + vkr_log("mem gbm bo export failed (ret %d)", fd); + return false; + } ++ } else if (can_export_metal) { ++ assert(handle_type == VK_EXTERNAL_MEMORY_HANDLE_TYPE_MTLHEAP_BIT_EXT); ++ struct vn_device_proc_table *vk = &mem->device->proc_table; ++ const VkMemoryGetMetalHandleInfoEXT metal_info = { ++ .sType = VK_STRUCTURE_TYPE_MEMORY_GET_METAL_HANDLE_INFO_EXT, ++ .memory = mem->base.handle.device_memory, ++ .handleType = handle_type, ++ }; ++ VkResult ret = vk->GetMemoryMetalHandleEXT(mem->device->base.handle.device, &metal_info, &metal_heap); ++ if (ret != VK_SUCCESS) { ++ vkr_log("metal export failed (vk ret %d)", ret); ++ return false; ++ } + } else { + struct vn_device_proc_table *vk = &mem->device->proc_table; + const VkMemoryGetFdInfoKHR fd_info = { +@@ -605,10 +690,15 @@ + + *out_blob = (struct virgl_context_blob){ + .type = fd_type, +- .u.fd = fd, + .map_info = map_info, + .vulkan_info = vulkan_info, + }; ++ ++ if (fd_type == VIRGL_RESOURCE_METAL_HEAP) { ++ out_blob->u.metal_heap = metal_heap; ++ } else { ++ out_blob->u.fd = fd; ++ } + + return true; + } +diff --git a/src/venus/vkr_image.c b/src/venus/vkr_image.c +--- a/src/venus/vkr_image.c 2026-01-01 08:05:29 ++++ b/src/venus/vkr_image.c 2026-01-11 23:47:51 +@@ -9,9 +9,81 @@ + #include "vkr_physical_device.h" + + static void ++vkr_image_fix_create_info(struct vkr_device *dev, ++ VkImageCreateInfo *pCreateInfo) ++{ ++ VkExternalMemoryImageCreateInfo *ext_create_info; ++ ++ ext_create_info = vkr_find_struct( ++ pCreateInfo, VK_STRUCTURE_TYPE_EXTERNAL_MEMORY_IMAGE_CREATE_INFO); ++ if (ext_create_info) { ++ /* strip out dmabuf */ ++ if ((ext_create_info->handleTypes & VK_EXTERNAL_MEMORY_HANDLE_TYPE_DMA_BUF_BIT_EXT) != 0) { ++ ext_create_info->handleTypes &= ~VK_EXTERNAL_MEMORY_HANDLE_TYPE_DMA_BUF_BIT_EXT; ++ /* add in supported handles */ ++ if (dev->physical_device->is_metal_export_supported) { ++ ext_create_info->handleTypes |= VK_EXTERNAL_MEMORY_HANDLE_TYPE_MTLTEXTURE_BIT_EXT; ++ } ++ } ++ } ++} ++ ++static VkResult ++vkr_image_fix_drm_format(struct vkr_device *dev, ++ VkImageCreateInfo *pCreateInfo) ++{ ++ const VkImageDrmFormatModifierExplicitCreateInfoEXT* drm_format_info = ++ vkr_find_struct(pCreateInfo, VK_STRUCTURE_TYPE_IMAGE_DRM_FORMAT_MODIFIER_EXPLICIT_CREATE_INFO_EXT); ++ const VkImageDrmFormatModifierListCreateInfoEXT* drm_format_list = ++ vkr_find_struct(pCreateInfo, VK_STRUCTURE_TYPE_IMAGE_DRM_FORMAT_MODIFIER_LIST_CREATE_INFO_EXT); ++ ++ if (pCreateInfo->tiling != VK_IMAGE_TILING_DRM_FORMAT_MODIFIER_EXT || (!drm_format_info && !drm_format_list)) { ++ return VK_SUCCESS; ++ } ++ ++ if (drm_format_info && drm_format_info->drmFormatModifier == DRM_FORMAT_MOD_LINEAR) { ++ pCreateInfo->tiling = VK_IMAGE_TILING_LINEAR; ++ return VK_SUCCESS; ++ } ++ ++ for (int i = 0; drm_format_list && i < drm_format_list->drmFormatModifierCount; i++) { ++ if (drm_format_list->pDrmFormatModifiers[i] == DRM_FORMAT_MOD_LINEAR) { ++ pCreateInfo->tiling = VK_IMAGE_TILING_LINEAR; ++ return VK_SUCCESS; ++ } ++ } ++ ++ vkr_log("only DRM_FORMAT_MOD_LINEAR is supported"); ++ return VK_ERROR_FORMAT_NOT_SUPPORTED; ++} ++ ++static VkResult ++vkr_image_emulate_drm_format_modifier_properties(UNUSED struct vkr_device *dev, ++ UNUSED VkImage image, ++ VkImageDrmFormatModifierPropertiesEXT* pProperties) ++{ ++ pProperties->drmFormatModifier = DRM_FORMAT_MOD_LINEAR; ++ return VK_SUCCESS; ++} ++ ++static void + vkr_dispatch_vkCreateImage(struct vn_dispatch_context *dispatch, + struct vn_command_vkCreateImage *args) + { ++ struct vkr_device *dev = vkr_device_from_handle(args->device); ++ ++ /* if host does not natively support dmabuf we need to patch create info */ ++ if (dev->physical_device->is_dma_buf_emulated) { ++ vkr_image_fix_create_info(dev, (VkImageCreateInfo *)args->pCreateInfo); ++ } ++ ++ if (!dev->physical_device->EXT_image_drm_format_modifier) { ++ args->ret = vkr_image_fix_drm_format(dev, (VkImageCreateInfo *)args->pCreateInfo); ++ if (args->ret != VK_SUCCESS) { ++ return; ++ } ++ } ++ + /* XXX If VkExternalMemoryImageCreateInfo is chained by the app, all is + * good. If it is not chained, we might still bind an external memory to + * the image, because vkr_dispatch_vkAllocateMemory makes any HOST_VISIBLE +@@ -150,6 +222,11 @@ + struct vkr_device *dev = vkr_device_from_handle(args->device); + struct vn_device_proc_table *vk = &dev->proc_table; + ++ /* if host does not natively support dmabuf we need to patch create info */ ++ if (dev->physical_device->is_dma_buf_emulated) { ++ vkr_image_fix_create_info(dev, (VkImageCreateInfo *)args->pInfo->pCreateInfo); ++ } ++ + vn_replace_vkGetDeviceImageSubresourceLayout_args_handle(args); + vk->GetDeviceImageSubresourceLayout(args->device, args->pInfo, args->pLayout); + } +@@ -163,8 +240,14 @@ + struct vn_device_proc_table *vk = &dev->proc_table; + + vn_replace_vkGetImageDrmFormatModifierPropertiesEXT_args_handle(args); +- args->ret = vk->GetImageDrmFormatModifierPropertiesEXT(args->device, args->image, +- args->pProperties); ++ ++ if (dev->physical_device->EXT_image_drm_format_modifier) { ++ args->ret = vk->GetImageDrmFormatModifierPropertiesEXT(args->device, args->image, ++ args->pProperties); ++ } else { ++ args->ret = vkr_image_emulate_drm_format_modifier_properties(dev, args->image, ++ args->pProperties); ++ } + } + + static void +@@ -219,6 +302,11 @@ + struct vkr_device *dev = vkr_device_from_handle(args->device); + struct vn_device_proc_table *vk = &dev->proc_table; + ++ /* if host does not natively support dmabuf we need to patch create info */ ++ if (dev->physical_device->is_dma_buf_emulated) { ++ vkr_image_fix_create_info(dev, (VkImageCreateInfo *)args->pInfo->pCreateInfo); ++ } ++ + vn_replace_vkGetDeviceImageMemoryRequirements_args_handle(args); + vk->GetDeviceImageMemoryRequirements(args->device, args->pInfo, + args->pMemoryRequirements); +@@ -231,6 +319,11 @@ + { + struct vkr_device *dev = vkr_device_from_handle(args->device); + struct vn_device_proc_table *vk = &dev->proc_table; ++ ++ /* if host does not natively support dmabuf we need to patch create info */ ++ if (dev->physical_device->is_dma_buf_emulated) { ++ vkr_image_fix_create_info(dev, (VkImageCreateInfo *)args->pInfo->pCreateInfo); ++ } + + vn_replace_vkGetDeviceImageSparseMemoryRequirements_args_handle(args); + vk->GetDeviceImageSparseMemoryRequirements(args->device, args->pInfo, +diff --git a/src/venus/vkr_instance.c b/src/venus/vkr_instance.c +--- a/src/venus/vkr_instance.c 2026-01-01 08:05:29 ++++ b/src/venus/vkr_instance.c 2026-01-11 23:47:51 +@@ -177,6 +177,11 @@ + create_info->pNext = &messenger_create_info; + } + ++ if (vkr_library_has_portability_enumeration(vk->EnumerateInstanceExtensionProperties)) { ++ ext_names[ext_count++] = VK_KHR_PORTABILITY_ENUMERATION_EXTENSION_NAME; ++ create_info->flags = VK_INSTANCE_CREATE_ENUMERATE_PORTABILITY_BIT_KHR; ++ } ++ + assert(layer_count <= ARRAY_SIZE(layer_names)); + create_info->enabledLayerCount = layer_count; + create_info->ppEnabledLayerNames = layer_names; +diff --git a/src/venus/vkr_library.c b/src/venus/vkr_library.c +--- a/src/venus/vkr_library.c 2026-01-01 08:05:29 ++++ b/src/venus/vkr_library.c 2026-01-11 23:47:51 +@@ -35,15 +35,23 @@ + + #if defined(ENABLE_VULKAN_DLOAD) + ++#ifdef __APPLE__ ++#define LIBVULKAN1 "libvulkan.1.dylib" ++#define LIBVULKAN "libvulkan.dylib" ++#else ++#define LIBVULKAN1 "libvulkan.so.1" ++#define LIBVULKAN "libvulkan.so" ++#endif ++ + bool + vkr_library_load(struct vulkan_library *lib) + { + if (lib->handle) + return true; + +- lib->handle = dlopen("libvulkan.so.1", RTLD_NOW | RTLD_LOCAL); ++ lib->handle = dlopen(LIBVULKAN1, RTLD_NOW | RTLD_LOCAL); + if (lib->handle == NULL) +- lib->handle = dlopen("libvulkan.so", RTLD_NOW | RTLD_LOCAL); ++ lib->handle = dlopen(LIBVULKAN, RTLD_NOW | RTLD_LOCAL); + if (lib->handle == NULL) { + vkr_log("failed to open libvulkan: %s", dlerror()); + return false; +@@ -89,3 +97,27 @@ + } + + #endif /* ENABLE_VULKAN_DLOAD */ ++ ++bool ++vkr_library_has_portability_enumeration(PFN_vkEnumerateInstanceExtensionProperties enum_inst_ext_props) ++{ ++ uint32_t propertyCount = 0; ++ VkExtensionProperties *properties; ++ VkResult ret; ++ bool has_portability_enumeration = false; ++ ++ ret = enum_inst_ext_props(NULL, &propertyCount, NULL); ++ if (ret != VK_SUCCESS) { ++ return false; ++ } ++ properties = calloc(propertyCount, sizeof(*properties)); ++ ret = enum_inst_ext_props(NULL, &propertyCount, properties); ++ for (int i = 0; ret == VK_SUCCESS && i < propertyCount; i++) { ++ if (!strcmp(properties[i].extensionName, VK_KHR_PORTABILITY_ENUMERATION_EXTENSION_NAME)) { ++ has_portability_enumeration = true; ++ break; ++ } ++ } ++ free(properties); ++ return has_portability_enumeration; ++} +diff --git a/src/venus/vkr_library.h b/src/venus/vkr_library.h +--- a/src/venus/vkr_library.h 2026-01-01 08:05:29 ++++ b/src/venus/vkr_library.h 2026-01-11 23:47:51 +@@ -44,4 +44,7 @@ + + #endif /* ENABLE_VULKAN_DLOAD */ + ++bool ++vkr_library_has_portability_enumeration(PFN_vkEnumerateInstanceExtensionProperties enum_inst_ext_props); ++ + #endif /* VKR_LIBRARY_H */ +diff --git a/src/venus/vkr_physical_device.c b/src/venus/vkr_physical_device.c +--- a/src/venus/vkr_physical_device.c 2026-01-01 08:05:29 ++++ b/src/venus/vkr_physical_device.c 2026-01-11 23:47:51 +@@ -227,6 +227,16 @@ + VK_EXTERNAL_MEMORY_HANDLE_TYPE_OPAQUE_FD_BIT); + } + ++ if (physical_dev->EXT_external_memory_metal) { ++ info.handleType = VK_EXTERNAL_MEMORY_HANDLE_TYPE_MTLHEAP_BIT_EXT, ++ vk->GetPhysicalDeviceExternalBufferProperties(handle, &info, &props); ++ physical_dev->is_metal_export_supported = ++ (props.externalMemoryProperties.externalMemoryFeatures & ++ VK_EXTERNAL_MEMORY_FEATURE_EXPORTABLE_BIT) && ++ (props.externalMemoryProperties.exportFromImportedHandleTypes & ++ VK_EXTERNAL_MEMORY_HANDLE_TYPE_MTLHEAP_BIT_EXT); ++ } ++ + /* fallback to gbm allocation with dma-buf import */ + if (!physical_dev->is_dma_buf_fd_export_supported && + !physical_dev->is_opaque_fd_export_supported && +@@ -269,12 +279,25 @@ + for (uint32_t i = 0; i < count; i++) { + VkExtensionProperties *props = &exts[i]; + +- if (!strcmp(props->extensionName, "VK_KHR_external_memory_fd")) ++ if (!strcmp(props->extensionName, VK_KHR_EXTERNAL_MEMORY_FD_EXTENSION_NAME)) { + physical_dev->KHR_external_memory_fd = true; +- else if (!strcmp(props->extensionName, "VK_EXT_external_memory_dma_buf")) ++ } else if (!strcmp(props->extensionName, VK_EXT_EXTERNAL_MEMORY_DMA_BUF_EXTENSION_NAME)) { + physical_dev->EXT_external_memory_dma_buf = true; +- else if (!strcmp(props->extensionName, "VK_KHR_external_fence_fd")) ++ } else if (!strcmp(props->extensionName, VK_KHR_EXTERNAL_FENCE_FD_EXTENSION_NAME)) { + physical_dev->KHR_external_fence_fd = true; ++ } else if (!strcmp(props->extensionName, VK_EXT_IMAGE_DRM_FORMAT_MODIFIER_EXTENSION_NAME)) { ++ physical_dev->EXT_image_drm_format_modifier = true; ++ } else if (!strcmp(props->extensionName, VK_EXT_QUEUE_FAMILY_FOREIGN_EXTENSION_NAME)) { ++ physical_dev->EXT_queue_family_foreign = true; ++ } else if (!strcmp(props->extensionName, VK_EXT_EXTERNAL_MEMORY_METAL_EXTENSION_NAME)) { ++ physical_dev->EXT_external_memory_metal = true; ++ /* hide from guest */ ++ continue; ++ } else if (!strcmp(props->extensionName, VK_KHR_PORTABILITY_SUBSET_EXTENSION_NAME)) { ++ physical_dev->KHR_portability_subset = true; ++ /* hide from guest */ ++ continue; ++ } + + const uint32_t spec_ver = vkr_extension_get_spec_version(props->extensionName); + if (spec_ver) { +@@ -284,6 +307,34 @@ + } + } + ++ /* add any emulated properties to show to the guest */ ++ VkExtensionProperties prop; ++ uint32_t emulated_count = 0; ++ physical_dev->is_dma_buf_emulated = !physical_dev->EXT_external_memory_dma_buf && physical_dev->EXT_external_memory_metal; ++ emulated_count += 2*physical_dev->is_dma_buf_emulated; ++ emulated_count += !physical_dev->EXT_image_drm_format_modifier; ++ emulated_count += !physical_dev->EXT_queue_family_foreign; ++ exts = realloc(exts, sizeof(*exts) * (advertised_count + emulated_count)); ++ if (physical_dev->is_dma_buf_emulated) { ++ strcpy(prop.extensionName, VK_EXT_EXTERNAL_MEMORY_DMA_BUF_EXTENSION_NAME); ++ prop.specVersion = vkr_extension_get_spec_version(prop.extensionName); ++ exts[advertised_count++] = prop; ++ strcpy(prop.extensionName, VK_KHR_EXTERNAL_MEMORY_FD_EXTENSION_NAME); ++ prop.specVersion = vkr_extension_get_spec_version(prop.extensionName); ++ exts[advertised_count++] = prop; ++ } ++ if (!physical_dev->EXT_image_drm_format_modifier) { ++ strcpy(prop.extensionName, VK_EXT_IMAGE_DRM_FORMAT_MODIFIER_EXTENSION_NAME); ++ prop.specVersion = vkr_extension_get_spec_version(prop.extensionName); ++ exts[advertised_count++] = prop; ++ } ++ if (!physical_dev->EXT_queue_family_foreign) { ++ /* FIXME: we don't actually emulate this yet as MoltenVK ignores queue family transfers... */ ++ strcpy(prop.extensionName, VK_EXT_QUEUE_FAMILY_FOREIGN_EXTENSION_NAME); ++ prop.specVersion = vkr_extension_get_spec_version(prop.extensionName); ++ exts[advertised_count++] = prop; ++ } ++ + if (physical_dev->KHR_external_fence_fd) { + const VkPhysicalDeviceExternalFenceInfo fence_info = { + .sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_EXTERNAL_FENCE_INFO, +@@ -344,6 +395,21 @@ + } + + static void ++vkr_physical_device_emulate_drm_props(VkDrmFormatModifierPropertiesListEXT *drm_props_list) ++{ ++ drm_props_list->drmFormatModifierCount = 1; ++ if (drm_props_list->pDrmFormatModifierProperties) { ++ drm_props_list->pDrmFormatModifierProperties[0] = (VkDrmFormatModifierPropertiesEXT){ ++ .drmFormatModifier = DRM_FORMAT_MOD_LINEAR, ++ .drmFormatModifierPlaneCount = 1, ++ .drmFormatModifierTilingFeatures = VK_FORMAT_FEATURE_SAMPLED_IMAGE_BIT | ++ VK_FORMAT_FEATURE_SAMPLED_IMAGE_FILTER_LINEAR_BIT | ++ VK_FORMAT_FEATURE_COLOR_ATTACHMENT_BIT, ++ }; ++ }; ++} ++ ++static void + vkr_dispatch_vkEnumeratePhysicalDevices(struct vn_dispatch_context *dispatch, + struct vn_command_vkEnumeratePhysicalDevices *args) + { +@@ -701,6 +767,15 @@ + vn_replace_vkGetPhysicalDeviceFormatProperties2_args_handle(args); + vk->GetPhysicalDeviceFormatProperties2(args->physicalDevice, args->format, + args->pFormatProperties); ++ ++ /* emulate support for drm format modifiers */ ++ if (!physical_dev->EXT_image_drm_format_modifier) { ++ VkDrmFormatModifierPropertiesListEXT* drm_props_list = ++ vkr_find_struct(args->pFormatProperties, VK_STRUCTURE_TYPE_DRM_FORMAT_MODIFIER_PROPERTIES_LIST_EXT); ++ if (drm_props_list) { ++ vkr_physical_device_emulate_drm_props(drm_props_list); ++ } ++ } + } + + static void +@@ -712,9 +787,71 @@ + vkr_physical_device_from_handle(args->physicalDevice); + struct vn_physical_device_proc_table *vk = &physical_dev->proc_table; + ++ /* filter unsupported drm format modifiers */ ++ if (!physical_dev->EXT_image_drm_format_modifier) { ++ VkPhysicalDeviceImageFormatInfo2 *pImageFormatInfo = ++ (VkPhysicalDeviceImageFormatInfo2 *)args->pImageFormatInfo; ++ VkBaseInStructure *prev_struct = ++ vkr_find_prev_struct(pImageFormatInfo, VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_IMAGE_DRM_FORMAT_MODIFIER_INFO_EXT); ++ if (prev_struct) { ++ const VkPhysicalDeviceImageDrmFormatModifierInfoEXT *drm_format_mod = ++ (const VkPhysicalDeviceImageDrmFormatModifierInfoEXT *)prev_struct->pNext; ++ if (drm_format_mod->drmFormatModifier == DRM_FORMAT_MOD_LINEAR) { ++ /* Remove the struct from the list */ ++ prev_struct->pNext = drm_format_mod->pNext; ++ vkr_log("emulating DRM_FORMAT_MOD_LINEAR with VK_IMAGE_TILING_LINEAR"); ++ pImageFormatInfo->tiling = VK_IMAGE_TILING_LINEAR; ++ pImageFormatInfo->usage &= ++ ~(VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT | VK_IMAGE_USAGE_INPUT_ATTACHMENT_BIT); ++ } else { ++ vkr_log("only DRM_FORMAT_MOD_LINEAR is supported"); ++ args->ret = VK_ERROR_FORMAT_NOT_SUPPORTED; ++ return; ++ } ++ } ++ } ++ ++ /* emulate handle for dmabuf */ ++ if (physical_dev->is_dma_buf_emulated && physical_dev->is_metal_export_supported) { ++ VkPhysicalDeviceExternalImageFormatInfo *info = ++ vkr_find_struct(args->pImageFormatInfo, VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_EXTERNAL_IMAGE_FORMAT_INFO); ++ if (info && info->handleType & VK_EXTERNAL_MEMORY_HANDLE_TYPE_DMA_BUF_BIT_EXT) { ++ info->handleType &= ~VK_EXTERNAL_MEMORY_HANDLE_TYPE_DMA_BUF_BIT_EXT; ++ info->handleType |= VK_EXTERNAL_MEMORY_HANDLE_TYPE_MTLTEXTURE_BIT_EXT; ++ } ++ if (info && info->handleType & VK_EXTERNAL_MEMORY_HANDLE_TYPE_DMA_BUF_BIT_EXT) { ++ info->handleType &= ~VK_EXTERNAL_MEMORY_HANDLE_TYPE_DMA_BUF_BIT_EXT; ++ info->handleType |= VK_EXTERNAL_MEMORY_HANDLE_TYPE_MTLTEXTURE_BIT_EXT; ++ } ++ } ++ + vn_replace_vkGetPhysicalDeviceImageFormatProperties2_args_handle(args); + args->ret = vk->GetPhysicalDeviceImageFormatProperties2( + args->physicalDevice, args->pImageFormatInfo, args->pImageFormatProperties); ++ ++ /* emulate handle for dmabuf */ ++ if (physical_dev->is_dma_buf_emulated && physical_dev->is_metal_export_supported) { ++ VkExternalImageFormatProperties *img_props = vkr_find_struct( ++ args->pImageFormatProperties->pNext, VK_STRUCTURE_TYPE_EXTERNAL_IMAGE_FORMAT_PROPERTIES); ++ VkExternalMemoryProperties *props = &img_props->externalMemoryProperties; ++ if (img_props && (props->exportFromImportedHandleTypes & VK_EXTERNAL_MEMORY_HANDLE_TYPE_MTLTEXTURE_BIT_EXT)) { ++ props->exportFromImportedHandleTypes &= ~VK_EXTERNAL_MEMORY_HANDLE_TYPE_MTLTEXTURE_BIT_EXT; ++ props->exportFromImportedHandleTypes |= VK_EXTERNAL_MEMORY_HANDLE_TYPE_DMA_BUF_BIT_EXT; ++ } ++ if (img_props && (props->compatibleHandleTypes & VK_EXTERNAL_MEMORY_HANDLE_TYPE_MTLTEXTURE_BIT_EXT)) { ++ props->compatibleHandleTypes &= ~VK_EXTERNAL_MEMORY_HANDLE_TYPE_MTLTEXTURE_BIT_EXT; ++ props->compatibleHandleTypes |= VK_EXTERNAL_MEMORY_HANDLE_TYPE_DMA_BUF_BIT_EXT; ++ } ++ } ++ ++ /* emulate support for drm format modifiers */ ++ if (!physical_dev->EXT_image_drm_format_modifier) { ++ VkDrmFormatModifierPropertiesListEXT* drm_props_list = ++ vkr_find_struct(args->pImageFormatProperties, VK_STRUCTURE_TYPE_DRM_FORMAT_MODIFIER_PROPERTIES_LIST_EXT); ++ if (drm_props_list) { ++ vkr_physical_device_emulate_drm_props(drm_props_list); ++ } ++ } + } + + static void +@@ -740,9 +877,35 @@ + vkr_physical_device_from_handle(args->physicalDevice); + struct vn_physical_device_proc_table *vk = &physical_dev->proc_table; + ++ /* emulate handle for dmabuf */ ++ if (physical_dev->is_dma_buf_emulated && physical_dev->is_metal_export_supported) { ++ VkPhysicalDeviceExternalBufferInfo *info = (VkPhysicalDeviceExternalBufferInfo *)&args->pExternalBufferInfo; ++ if (info->handleType & VK_EXTERNAL_MEMORY_HANDLE_TYPE_DMA_BUF_BIT_EXT) { ++ info->handleType &= ~VK_EXTERNAL_MEMORY_HANDLE_TYPE_DMA_BUF_BIT_EXT; ++ info->handleType |= VK_EXTERNAL_MEMORY_HANDLE_TYPE_MTLHEAP_BIT_EXT; ++ } ++ if (info->handleType & VK_EXTERNAL_MEMORY_HANDLE_TYPE_DMA_BUF_BIT_EXT) { ++ info->handleType &= ~VK_EXTERNAL_MEMORY_HANDLE_TYPE_DMA_BUF_BIT_EXT; ++ info->handleType |= VK_EXTERNAL_MEMORY_HANDLE_TYPE_MTLHEAP_BIT_EXT; ++ } ++ } ++ + vn_replace_vkGetPhysicalDeviceExternalBufferProperties_args_handle(args); + vk->GetPhysicalDeviceExternalBufferProperties( + args->physicalDevice, args->pExternalBufferInfo, args->pExternalBufferProperties); ++ ++ /* emulate handle for dmabuf */ ++ if (physical_dev->is_dma_buf_emulated && physical_dev->is_metal_export_supported) { ++ VkExternalMemoryProperties *props = &args->pExternalBufferProperties->externalMemoryProperties; ++ if (props->exportFromImportedHandleTypes & VK_EXTERNAL_MEMORY_HANDLE_TYPE_MTLHEAP_BIT_EXT) { ++ props->exportFromImportedHandleTypes &= ~VK_EXTERNAL_MEMORY_HANDLE_TYPE_MTLHEAP_BIT_EXT; ++ props->exportFromImportedHandleTypes |= VK_EXTERNAL_MEMORY_HANDLE_TYPE_DMA_BUF_BIT_EXT; ++ } ++ if (props->compatibleHandleTypes & VK_EXTERNAL_MEMORY_HANDLE_TYPE_MTLHEAP_BIT_EXT) { ++ props->compatibleHandleTypes &= ~VK_EXTERNAL_MEMORY_HANDLE_TYPE_MTLHEAP_BIT_EXT; ++ props->compatibleHandleTypes |= VK_EXTERNAL_MEMORY_HANDLE_TYPE_DMA_BUF_BIT_EXT; ++ } ++ } + } + + static void +diff --git a/src/venus/vkr_physical_device.h b/src/venus/vkr_physical_device.h +--- a/src/venus/vkr_physical_device.h 2026-01-01 08:05:29 ++++ b/src/venus/vkr_physical_device.h 2026-01-11 23:47:51 +@@ -23,14 +23,19 @@ + + bool KHR_external_memory_fd; + bool EXT_external_memory_dma_buf; ++ bool EXT_external_memory_metal; ++ bool KHR_portability_subset; ++ bool EXT_image_drm_format_modifier; ++ bool EXT_queue_family_foreign; + + bool KHR_external_fence_fd; +- bool KHR_external_semaphore_fd; + + VkPhysicalDeviceMemoryProperties memory_properties; + VkPhysicalDeviceIDProperties id_properties; + bool is_dma_buf_fd_export_supported; + bool is_opaque_fd_export_supported; ++ bool is_metal_export_supported; ++ bool is_dma_buf_emulated; + void *gbm_device; + int udmabuf_dev_fd; + +diff --git a/src/venus/vkr_queue.c b/src/venus/vkr_queue.c +--- a/src/venus/vkr_queue.c 2026-01-01 08:05:29 ++++ b/src/venus/vkr_queue.c 2026-01-11 23:47:51 +@@ -481,19 +481,23 @@ + + vn_replace_vkResetFenceResourceMESA_args_handle(args); + +- const VkFenceGetFdInfoKHR info = { +- .sType = VK_STRUCTURE_TYPE_FENCE_GET_FD_INFO_KHR, +- .fence = args->fence, +- .handleType = VK_EXTERNAL_FENCE_HANDLE_TYPE_SYNC_FD_BIT, +- }; +- VkResult result = vk->GetFenceFdKHR(args->device, &info, &fd); +- if (result != VK_SUCCESS) { +- vkr_context_set_fatal(ctx); +- return; +- } ++ if (dev->physical_device->KHR_external_fence_fd) { ++ const VkFenceGetFdInfoKHR info = { ++ .sType = VK_STRUCTURE_TYPE_FENCE_GET_FD_INFO_KHR, ++ .fence = args->fence, ++ .handleType = VK_EXTERNAL_FENCE_HANDLE_TYPE_SYNC_FD_BIT, ++ }; ++ VkResult result = vk->GetFenceFdKHR(args->device, &info, &fd); ++ if (result != VK_SUCCESS) { ++ vkr_context_set_fatal(ctx); ++ return; ++ } + +- if (fd >= 0) +- close(fd); ++ if (fd >= 0) ++ close(fd); ++ } else { ++ vk->ResetFences(args->device, 1, &args->fence); ++ } + } + + static void +diff --git a/src/venus/vkr_renderer.c b/src/venus/vkr_renderer.c +--- a/src/venus/vkr_renderer.c 2026-01-01 08:05:29 ++++ b/src/venus/vkr_renderer.c 2026-01-11 23:47:51 +@@ -69,8 +69,10 @@ + return false; + + vkr_debug_init(); +- virgl_log_set_handler(cbs->debug_logger, NULL, NULL); + ++ if (cbs->debug_logger) ++ virgl_log_set_handler(cbs->debug_logger, NULL, NULL); ++ + vkr_state.cbs = cbs; + list_inithead(&vkr_state.contexts); + +@@ -177,6 +179,7 @@ + uint32_t blob_flags, + enum virgl_resource_fd_type *out_fd_type, + int *out_res_fd, ++ void **out_res_ptr, + uint32_t *out_map_info, + struct virgl_resource_vulkan_info *out_vulkan_info) + { +@@ -194,13 +197,17 @@ + return false; + + assert(blob.type == VIRGL_RESOURCE_FD_SHM || blob.type == VIRGL_RESOURCE_FD_DMABUF || +- blob.type == VIRGL_RESOURCE_FD_OPAQUE); ++ blob.type == VIRGL_RESOURCE_FD_OPAQUE || blob.type == VIRGL_RESOURCE_METAL_HEAP); + + *out_fd_type = blob.type; +- *out_res_fd = blob.u.fd; ++ if (blob.type == VIRGL_RESOURCE_METAL_HEAP) { ++ *out_res_ptr = blob.u.metal_heap; ++ } else { ++ *out_res_fd = blob.u.fd; ++ } + *out_map_info = blob.map_info; + +- if (blob.type == VIRGL_RESOURCE_FD_OPAQUE) { ++ if (blob.type == VIRGL_RESOURCE_FD_OPAQUE || blob.type == VIRGL_RESOURCE_METAL_HEAP) { + assert(out_vulkan_info); + *out_vulkan_info = blob.vulkan_info; + } +diff --git a/src/venus/vkr_renderer.h b/src/venus/vkr_renderer.h +--- a/src/venus/vkr_renderer.h 2026-01-01 08:05:29 ++++ b/src/venus/vkr_renderer.h 2026-01-11 23:47:51 +@@ -61,6 +61,7 @@ + uint32_t blob_flags, + enum virgl_resource_fd_type *out_fd_type, + int *out_res_fd, ++ void **out_res_ptr, + uint32_t *out_map_info, + struct virgl_resource_vulkan_info *out_vulkan_info); + +diff --git a/src/venus/vkr_ring.c b/src/venus/vkr_ring.c +--- a/src/venus/vkr_ring.c 2026-01-01 08:05:29 ++++ b/src/venus/vkr_ring.c 2026-01-11 23:47:51 +@@ -204,7 +204,11 @@ + .tv_sec = us / 1000000, + .tv_nsec = (us % 1000000) * 1000, + }; ++#ifdef __APPLE__ ++ nanosleep(&ts, NULL); ++#else + clock_nanosleep(CLOCK_MONOTONIC, 0, &ts, NULL); ++#endif + } + + static bool +diff --git a/src/virgl_context.h b/src/virgl_context.h +--- a/src/virgl_context.h 2026-01-01 08:05:29 ++++ b/src/virgl_context.h 2026-01-11 23:47:51 +@@ -44,6 +44,7 @@ + int fd; + uint32_t opaque_handle; + struct pipe_resource *pipe_resource; ++ void *metal_heap; + } u; + + uint32_t map_info; +diff --git a/src/virgl_resource.c b/src/virgl_resource.c +--- a/src/virgl_resource.c 2026-01-01 08:05:29 ++++ b/src/virgl_resource.c 2026-01-11 23:47:51 +@@ -36,6 +36,13 @@ + #include "virgl_util.h" + #include "virgl_context.h" + ++#ifdef __APPLE__ ++#include ++#else ++#define CFRetain(x) (x) ++#define CFRelease(x) ++#endif ++ + static struct util_hash_table *virgl_resource_table; + static struct virgl_resource_pipe_callbacks pipe_callbacks; + +@@ -46,7 +53,9 @@ + + if (res->pipe_resource) + pipe_callbacks.unref(res->pipe_resource, pipe_callbacks.data); +- if ((res->fd_type != VIRGL_RESOURCE_FD_INVALID) && ++ if (res->fd_type == VIRGL_RESOURCE_METAL_HEAP) ++ CFRelease(res->metal_heap); ++ else if ((res->fd_type != VIRGL_RESOURCE_FD_INVALID) && + (res->fd_type != VIRGL_RESOURCE_OPAQUE_HANDLE)) + close(res->fd); + +@@ -206,6 +215,25 @@ + return res; + } + ++struct virgl_resource * ++virgl_resource_create_from_metal_heap(UNUSED struct virgl_context *ctx, ++ uint32_t res_id, ++ void *metal_heap, ++ const struct virgl_resource_vulkan_info *vulkan_info) ++{ ++ struct virgl_resource *res; ++ ++ res = virgl_resource_create(res_id); ++ if (!res) ++ return NULL; ++ ++ res->fd_type = VIRGL_RESOURCE_METAL_HEAP; ++ res->metal_heap = (void *)CFRetain(metal_heap); ++ res->vulkan_info = *vulkan_info; ++ ++ return res; ++} ++ + void + virgl_resource_remove(uint32_t res_id) + { +@@ -263,6 +291,8 @@ + return VIRGL_RESOURCE_FD_INVALID; + + return ctx->export_opaque_handle(ctx, res, fd); ++ } else if (res->fd_type == VIRGL_RESOURCE_METAL_HEAP) { ++ return VIRGL_RESOURCE_FD_INVALID; + } else if (res->fd_type != VIRGL_RESOURCE_FD_INVALID) { + *fd = os_dupfd_cloexec(res->fd); + return *fd >= 0 ? res->fd_type : VIRGL_RESOURCE_FD_INVALID; +diff --git a/src/virgl_resource.h b/src/virgl_resource.h +--- a/src/virgl_resource.h 2026-01-01 08:05:29 ++++ b/src/virgl_resource.h 2026-01-11 23:47:51 +@@ -50,6 +50,12 @@ + */ + VIRGL_RESOURCE_OPAQUE_HANDLE, + ++ /** ++ * A MTLHeap resource represents an opaque buffer of memory that can ++ * be shared across CPU and GPU. It can be the backing storage for textures. ++ */ ++ VIRGL_RESOURCE_METAL_HEAP, ++ + VIRGL_RESOURCE_FD_INVALID = -1, + }; + +@@ -94,6 +100,9 @@ + uint32_t opaque_handle_context_id; + uint32_t opaque_handle; + ++ /* When fd_type == VIRGL_RESOURCE_METAL_HEAP */ ++ void *metal_heap; ++ + const struct iovec *iov; + int iov_count; + +@@ -156,6 +165,12 @@ + virgl_resource_create_from_iov(uint32_t res_id, + const struct iovec *iov, + int iov_count); ++ ++struct virgl_resource * ++virgl_resource_create_from_metal_heap(struct virgl_context *ctx, ++ uint32_t res_id, ++ void *metal_heap, ++ const struct virgl_resource_vulkan_info *vulkan_info); + + void + virgl_resource_remove(uint32_t res_id); +diff --git a/src/virgl_util.c b/src/virgl_util.c +--- a/src/virgl_util.c 2026-01-01 08:05:29 ++++ b/src/virgl_util.c 2026-01-11 23:47:51 +@@ -257,10 +257,15 @@ + va_list va) + { + char *prefixed_fmt = NULL; ++ char line_end = '\0'; + + assert(strchr(domain,'%') == NULL); + +- if (asprintf(&prefixed_fmt, "%s: %s", domain, fmt) < 0) ++ /* add new line if needed */ ++ if (fmt[strlen(fmt)-1] != '\n') ++ line_end = '\n'; ++ ++ if (asprintf(&prefixed_fmt, "%s: %s%c", domain, fmt, line_end) < 0) + return; + + virgl_logv(log_level, prefixed_fmt, va); +diff --git a/src/virglrenderer.c b/src/virglrenderer.c +--- a/src/virglrenderer.c 2026-01-01 08:05:29 ++++ b/src/virglrenderer.c 2026-01-12 00:02:51 +@@ -46,6 +46,10 @@ + #include "vrend/vrend_renderer.h" + #include "vrend/vrend_winsys.h" + ++#ifdef ENABLE_METAL ++#include "vrend/vrend_metal.h" ++#endif ++ + #ifndef WIN32 + #include "util/libsync.h" + #endif +@@ -178,11 +182,37 @@ + void virgl_renderer_fill_caps(uint32_t set, uint32_t version, + void *caps) + { ++ if (getenv("VIRGL_DEBUG_CAPS")) { ++ fprintf(stderr, "DEBUG virgl_renderer_fill_caps: set=%u version=%u\n", set, version); ++ fflush(stderr); ++ } ++ + switch (set) { + case VIRTGPU_DRM_CAPSET_VIRGL: + case VIRTGPU_DRM_CAPSET_VIRGL2: +- if (state.vrend_initialized) ++ if (state.vrend_initialized) { + vrend_renderer_fill_caps(set, version, (union virgl_caps *)caps); ++ if (getenv("VIRGL_DEBUG_CAPS")) { ++ union virgl_caps *vcaps = (union virgl_caps *)caps; ++ fprintf(stderr, "DEBUG virgl_renderer_fill_caps returning to QEMU:\n"); ++ fprintf(stderr, " max_version=%u\n", vcaps->max_version); ++ fprintf(stderr, " v1.glsl_level=%u at address %p\n", vcaps->v1.glsl_level, (void*)&vcaps->v1.glsl_level); ++ fprintf(stderr, " v2.v1.glsl_level=%u at address %p\n", vcaps->v2.v1.glsl_level, (void*)&vcaps->v2.v1.glsl_level); ++ fprintf(stderr, " caps base address=%p\n", (void*)caps); ++ ++ /* Calculate actual offset */ ++ size_t glsl_offset = (uint8_t*)&vcaps->v1.glsl_level - (uint8_t*)caps; ++ fprintf(stderr, " glsl_level offset from caps base: %zu bytes\n", glsl_offset); ++ ++ /* Dump bytes at that offset */ ++ uint8_t *bytes = (uint8_t *)caps; ++ fprintf(stderr, " Bytes at glsl_level offset (%zu-%zu): ", glsl_offset, glsl_offset+7); ++ for (size_t i = glsl_offset; i < glsl_offset + 8 && i < 1024; i++) ++ fprintf(stderr, "%02x ", bytes[i]); ++ fprintf(stderr, "\n"); ++ fflush(stderr); ++ } ++ } + break; + case VIRTGPU_DRM_CAPSET_VENUS: + if (state.proxy_initialized) +@@ -482,7 +512,8 @@ + + static int virgl_renderer_resource_get_info_common(int res_handle, + struct virgl_renderer_resource_info *info, +- UNUSED void **d3d_tex2d) ++ enum virgl_renderer_native_handle_type *type, ++ virgl_renderer_native_handle *handle) + { + int ret = 0; + +@@ -504,8 +535,19 @@ + (struct vrend_renderer_resource_info *)info); + + #ifdef WIN32 +- if (d3d_tex2d) +- ret = vrend_renderer_resource_d3d11_texture2d(res->pipe_resource, d3d_tex2d); ++ if (type && handle) { ++ *handle = vrend_renderer_resource_d3d11_texture2d(res->pipe_resource); ++ if (*handle) { ++ *type = VIRGL_NATIVE_HANDLE_D3D_TEX2D; ++ } ++ } ++#elif defined(ENABLE_METAL) ++ if (type && handle) { ++ *handle = vrend_renderer_resource_metal_texture(res->pipe_resource); ++ if (*handle) { ++ *type = VIRGL_NATIVE_HANDLE_METAL_TEXTURE; ++ } ++ } + #endif + + return ret; +@@ -517,7 +559,7 @@ + TRACE_FUNC(); + int ret; + +- if ((ret = virgl_renderer_resource_get_info_common(res_handle, info, NULL)) != 0) ++ if ((ret = virgl_renderer_resource_get_info_common(res_handle, info, NULL, NULL)) != 0) + return ret; + + if (state.winsys_initialized) { +@@ -540,7 +582,8 @@ + + if ((ret = virgl_renderer_resource_get_info_common(res_handle, + &info_ext->base, +- &info_ext->d3d_tex2d)) != 0) ++ &info_ext->native_type, ++ &info_ext->native_handle)) != 0) + return ret; + + info_ext->version = VIRGL_RENDERER_RESOURCE_INFO_EXT_VERSION; +@@ -556,6 +599,25 @@ + + return 0; + } ++ ++int virgl_renderer_borrow_texture_for_scanout(int res_handle, ++ struct virgl_renderer_resource_info_ext *info) ++{ ++ TRACE_FUNC(); ++ struct virgl_resource *res = virgl_resource_lookup(res_handle); ++ ++ if (!res) ++ return EINVAL; ++ if (!info) ++ return EINVAL; ++ ++ if (!res->pipe_resource) ++ return 0; ++ ++ vrend_renderer_borrow_texture_for_scanout(res->pipe_resource); ++ ++ return virgl_renderer_resource_get_info_ext(res_handle, info); ++} + + void virgl_renderer_get_cap_set(uint32_t cap_set, uint32_t *max_ver, + uint32_t *max_size) +@@ -911,8 +972,8 @@ + renderer_flags |= VREND_USE_EXTERNAL_BLOB; + if (flags & VIRGL_RENDERER_USE_VIDEO) + renderer_flags |= VREND_USE_VIDEO; +- if (flags & VIRGL_RENDERER_D3D11_SHARE_TEXTURE) +- renderer_flags |= VREND_D3D11_SHARE_TEXTURE; ++ if (flags & VIRGL_RENDERER_NATIVE_SHARE_TEXTURE) ++ renderer_flags |= VREND_NATIVE_SHARE_TEXTURE; + if (flags & VIRGL_RENDERER_COMPAT_PROFILE) + renderer_flags |= VREND_USE_COMPAT_CONTEXT; + if (flags & VIRGL_RENDERER_USE_GLES) +@@ -1203,6 +1264,10 @@ + res = virgl_resource_create_from_opaque_handle(ctx, args->res_handle, blob.u.opaque_handle); + if (!res) + return -ENOMEM; ++ } else if (blob.type == VIRGL_RESOURCE_METAL_HEAP) { ++ res = virgl_resource_create_from_metal_heap(ctx, args->res_handle, blob.u.metal_heap, &blob.vulkan_info); ++ if (!res) ++ return -ENOMEM; + } else if (blob.type != VIRGL_RESOURCE_FD_INVALID) { + res = virgl_resource_create_from_fd(args->res_handle, + blob.type, +@@ -1267,6 +1332,7 @@ + map_size = res->map_size; + break; + case VIRGL_RESOURCE_FD_OPAQUE: ++ case VIRGL_RESOURCE_METAL_HEAP: + ret = vkr_allocator_resource_map(res, &map, &map_size); + break; + case VIRGL_RESOURCE_OPAQUE_HANDLE: +@@ -1327,6 +1393,7 @@ + MAP_FIXED | MAP_SHARED); + break; + case VIRGL_RESOURCE_FD_OPAQUE: ++ case VIRGL_RESOURCE_METAL_HEAP: + case VIRGL_RESOURCE_FD_INVALID: + /* Avoid a default case so that -Wswitch will tell us at compile time + * if a new virgl resource type is added without being handled here. +@@ -1365,6 +1432,7 @@ + ret = munmap(res->mapped, res->map_size); + break; + case VIRGL_RESOURCE_FD_OPAQUE: ++ case VIRGL_RESOURCE_METAL_HEAP: + ret = vkr_allocator_resource_unmap(res); + break; + case VIRGL_RESOURCE_FD_INVALID: +@@ -1415,6 +1483,7 @@ + *fd_type = VIRGL_RENDERER_BLOB_FD_TYPE_SHM; + break; + case VIRGL_RESOURCE_OPAQUE_HANDLE: ++ case VIRGL_RESOURCE_METAL_HEAP: + case VIRGL_RESOURCE_FD_INVALID: + /* Avoid a default case so that -Wswitch will tell us at compile time if a + * new virgl resource type is added without being handled here. +@@ -1480,6 +1549,60 @@ + res->map_size = args->size; + + return 0; ++} ++ ++enum virgl_renderer_native_handle_type ++virgl_renderer_create_handle_for_scanout(uint32_t res_id, ++ uint32_t width, ++ uint32_t height, ++ uint32_t virgl_format, ++ uint32_t padding, ++ uint32_t stride, ++ uint32_t offset, ++ virgl_renderer_native_handle *handle) ++{ ++ TRACE_FUNC(); ++#ifdef ENABLE_METAL ++ struct virgl_resource *res = virgl_resource_lookup(res_id); ++ ++ if (!res) ++ return VIRGL_NATIVE_HANDLE_NONE; ++ ++ if (res->fd_type != VIRGL_RESOURCE_METAL_HEAP) ++ return VIRGL_NATIVE_HANDLE_NONE; ++ ++ struct vrend_metal_texture_description desc = { ++ .width = width, ++ .height = height, ++ .stride = stride, ++ .offset = offset, ++ .usage = PIPE_USAGE_IMMUTABLE, ++ .format = virgl_format, ++ }; ++ MTLTexture_id tex; ++ ++ if (!virgl_metal_create_texture_from_heap(res->metal_heap, ++ &desc, ++ &tex)) ++ return VIRGL_NATIVE_HANDLE_NONE; ++ ++ *handle = tex; ++ return VIRGL_NATIVE_HANDLE_METAL_TEXTURE; ++#else /* !ENABLE_METAL */ ++ return VIRGL_NATIVE_HANDLE_NONE; ++#endif ++} ++ ++void ++virgl_renderer_release_handle_for_scanout(enum virgl_renderer_native_handle_type type, ++ virgl_renderer_native_handle handle) ++{ ++ TRACE_FUNC(); ++#ifdef ENABLE_METAL ++ if (type == VIRGL_NATIVE_HANDLE_METAL_TEXTURE) { ++ virgl_metal_release_texture(handle); ++ } ++#endif + } + + int +diff --git a/src/virglrenderer.h b/src/virglrenderer.h +--- a/src/virglrenderer.h 2026-01-01 08:05:29 ++++ b/src/virglrenderer.h 2026-01-11 23:47:51 +@@ -163,7 +163,9 @@ + #endif /* VIRGL_RENDERER_UNSTABLE_APIS */ + + +-#define VIRGL_RENDERER_D3D11_SHARE_TEXTURE (1 << 12) ++#define VIRGL_RENDERER_NATIVE_SHARE_TEXTURE (1 << 12) ++/* Compatibility with older versions */ ++#define VIRGL_RENDERER_D3D11_SHARE_TEXTURE (VIRGL_RENDERER_NATIVE_SHARE_TEXTURE) + #define VIRGL_RENDERER_COMPAT_PROFILE (1 << 13) + + /* Blob allocations must be done by guest from dedicated heap (Host visible memory). */ +@@ -363,15 +365,36 @@ + int fd; + }; + +-#define VIRGL_RENDERER_RESOURCE_INFO_EXT_VERSION 0 ++#define VIRGL_RENDERER_RESOURCE_INFO_EXT_VERSION 1 + ++/** ++ * Describes a handle type used in the native graphics API ++ */ ++enum virgl_renderer_native_handle_type { ++ VIRGL_NATIVE_HANDLE_NONE, ++ /* handle is a valid pointer to a ID3D11Texture2D */ ++ VIRGL_NATIVE_HANDLE_D3D_TEX2D, ++ /* handle is a valid pointer to a MTLTexture */ ++ VIRGL_NATIVE_HANDLE_METAL_TEXTURE, ++}; ++ ++/** ++ * The actual type is determined by `virgl_renderer_native_handle_type` ++ */ ++typedef void *virgl_renderer_native_handle; ++ + struct virgl_renderer_resource_info_ext { + int version; + struct virgl_renderer_resource_info base; + bool has_dmabuf_export; + int planes; + uint64_t modifiers; +- void *d3d_tex2d; ++ union { ++ /* this is for backwards compatibility */ ++ void *d3d_tex2d; ++ virgl_renderer_native_handle native_handle; ++ }; ++ enum virgl_renderer_native_handle_type native_type; + }; + + VIRGL_EXPORT int virgl_renderer_resource_get_info(int res_handle, +@@ -380,6 +403,9 @@ + VIRGL_EXPORT int virgl_renderer_resource_get_info_ext(int res_handle, + struct virgl_renderer_resource_info_ext *info); + ++VIRGL_EXPORT int virgl_renderer_borrow_texture_for_scanout(int res_handle, ++ struct virgl_renderer_resource_info_ext *info); ++ + VIRGL_EXPORT void virgl_renderer_cleanup(void *cookie); + + /* reset the rendererer - destroy all contexts and resource */ +@@ -509,6 +535,38 @@ + int ndw, + uint64_t *in_fence_ids, + uint32_t num_in_fences); ++ ++/** ++ * Blob resources are untyped but we may wish to create a native texture handle ++ * for scanout. Not all blobs support exporting to a file-descriptor so this ++ * can be used even in cases where `virgl_renderer_resource_export_blob` is not ++ * supported. ++ * ++ * The user should assume the returned handle is immutable. ++ * ++ * If a handle cannot be created, `VIRGL_RESOURCE_NATIVE_TYPE_NONE` will be ++ * returned. ++ * ++ * If the return value is not `VIRGL_RESOURCE_NATIVE_TYPE_NONE`, the user MUST ++ * call `virgl_renderer_release_handle_for_scanout` with the returned handle ++ * and type when they are done using it. Otherwise, memory will be leaked. ++ */ ++VIRGL_EXPORT enum virgl_renderer_native_handle_type ++virgl_renderer_create_handle_for_scanout(uint32_t res_id, ++ uint32_t width, ++ uint32_t height, ++ uint32_t virgl_format, ++ uint32_t padding, ++ uint32_t stride, ++ uint32_t offset, ++ virgl_renderer_native_handle *handle); ++ ++/** ++ * This frees a handle acquired from `virgl_renderer_create_handle_for_scanout` ++ */ ++VIRGL_EXPORT void ++virgl_renderer_release_handle_for_scanout(enum virgl_renderer_native_handle_type type, ++ virgl_renderer_native_handle handle); + + /* vtest semi-private APIs: */ + VIRGL_EXPORT int virgl_renderer_attach_fence(int ctx_id, int fence_fd); +diff --git a/src/vrend/vrend_blitter.h b/src/vrend/vrend_blitter.h +--- a/src/vrend/vrend_blitter.h 2026-01-01 08:05:29 ++++ b/src/vrend/vrend_blitter.h 2026-01-11 23:47:51 +@@ -35,6 +35,12 @@ + "%s" \ + + #define FS_HEADER_GLES \ ++ "#version 300 es\n" \ ++ "// Blitter\n" \ ++ "%s" \ ++ "precision mediump float;\n" \ ++ ++#define FS_HEADER_GLES_MS \ + "#version 310 es\n" \ + "// Blitter\n" \ + "%s" \ +@@ -52,6 +58,11 @@ + "// Blitter\n" \ + + #define HEADER_GLES \ ++ "#version 300 es\n" \ ++ "// Blitter\n" \ ++ "precision mediump float;\n" \ ++ ++#define HEADER_GLES_MS \ + "#version 310 es\n" \ + "// Blitter\n" \ + "precision mediump float;\n" \ +@@ -145,7 +156,7 @@ + "}\n" + + #define FS_TEXFETCH_COL_MSAA_GL FS_HEADER_GL FS_TEXFETCH_COL_MSAA_BODY +-#define FS_TEXFETCH_COL_MSAA_GLES FS_HEADER_GLES FS_TEXFETCH_COL_MSAA_BODY ++#define FS_TEXFETCH_COL_MSAA_GLES FS_HEADER_GLES_MS FS_TEXFETCH_COL_MSAA_BODY + #define FS_TEXFETCH_COL_MSAA_ARRAY_GLES FS_HEADER_GLES_MS_ARRAY FS_TEXFETCH_COL_MSAA_BODY + + #define FS_TEXFETCH_DS_BODY \ +@@ -178,7 +189,7 @@ + struct vrend_resource; + struct vrend_blit_info; + #define FS_TEXFETCH_DS_MSAA_GL HEADER_GL FS_TEXFETCH_DS_MSAA_BODY +-#define FS_TEXFETCH_DS_MSAA_GLES HEADER_GLES FS_TEXFETCH_DS_MSAA_BODY_GLES ++#define FS_TEXFETCH_DS_MSAA_GLES HEADER_GLES_MS FS_TEXFETCH_DS_MSAA_BODY_GLES + #define FS_TEXFETCH_DS_MSAA_ARRAY_GLES HEADER_GLES_MS_ARRAY FS_TEXFETCH_DS_MSAA_BODY_GLES + + /* implement blitting using OpenGL. */ +diff --git a/src/vrend/vrend_decode.c b/src/vrend/vrend_decode.c +--- a/src/vrend/vrend_decode.c 2026-01-01 08:05:29 ++++ b/src/vrend/vrend_decode.c 2026-01-11 23:47:51 +@@ -888,6 +888,17 @@ + return EINVAL; + } + ++ /* Surface GL errors with object metadata to pinpoint bad creations. */ ++ if (!vrend_check_no_error(ctx) && ret == 0) { ++ virgl_error("GL error during CREATE_OBJECT type=%s handle=0x%x len=%u\n", ++ vrend_get_object_type_name(obj_type), handle, length); ++ /* Dump a small slice of the payload for quick diagnosis. */ ++ for (uint32_t i = 0; i < length && i < 12; i++) { ++ virgl_error(" dword[%u]=0x%x\n", i, buf[i]); ++ } ++ ret = EINVAL; ++ } ++ + return ret; + } + +@@ -2095,9 +2106,22 @@ + + TRACE_SCOPE_SLOW(vrend_get_comand_name(cmd)); + ++ /* If video is disabled at runtime, drop video commands quietly to avoid ++ * noisy errors from guest probes (e.g., gst-plugin-scan). ++ */ ++ if (!vrend_renderer_video_available() && ++ cmd >= VIRGL_CCMD_CREATE_VIDEO_CODEC && cmd <= VIRGL_CCMD_END_FRAME) { ++ continue; ++ } ++ + ret = decode_table[cmd](gdctx->grctx, buf, len); +- if (!vrend_check_no_error(gdctx->grctx) && !ret) ++ if (!vrend_check_no_error(gdctx->grctx) && !ret) { ++ /* Surface the offending command when a GL error is observed. */ ++ virgl_error("GL error after %s (ctx %d cmd=0x%x len=%u offset=%u)\n", ++ vrend_get_comand_name(cmd), gdctx->base.ctx_id, ++ cmd, len, cur_offset); + ret = EINVAL; ++ } + if (ret) { + virgl_error("context %d failed to dispatch %s: %d\n", + gdctx->base.ctx_id, vrend_get_comand_name(cmd), ret); +diff --git a/src/vrend/vrend_formats.c b/src/vrend/vrend_formats.c +--- a/src/vrend/vrend_formats.c 2026-01-01 08:05:29 ++++ b/src/vrend/vrend_formats.c 2026-01-11 23:47:51 +@@ -357,6 +357,19 @@ + { VIRGL_FORMAT_B8G8R8A8_SRGB, GL_SRGB8_ALPHA8, GL_BGRA, GL_UNSIGNED_BYTE, NO_SWIZZLE, view_class_32 }, + }; + ++#ifdef __APPLE__ ++/* ++ * macOS with Metal backend has broken GL_BGRA format as a render target. ++ * Use GL_RGBA format (which works) with the same swizzle as GLES. ++ */ ++static struct vrend_format_table macos_bgra_formats[] = { ++ { VIRGL_FORMAT_B8G8R8X8_UNORM, GL_RGBA8, GL_BGRA, GL_UNSIGNED_BYTE, RGB1_SWIZZLE, view_class_32 }, ++ { VIRGL_FORMAT_B8G8R8A8_UNORM, GL_RGBA8, GL_BGRA, GL_UNSIGNED_BYTE, NO_SWIZZLE, view_class_32 }, ++ { VIRGL_FORMAT_B8G8R8X8_SRGB, GL_SRGB8_ALPHA8, GL_BGRA, GL_UNSIGNED_BYTE, RGB1_SWIZZLE, view_class_32 }, ++ { VIRGL_FORMAT_B8G8R8A8_SRGB, GL_SRGB8_ALPHA8, GL_BGRA, GL_UNSIGNED_BYTE, NO_SWIZZLE, view_class_32 } ++}; ++#endif ++ + static struct vrend_format_table gles_bgra_formats[] = { + { VIRGL_FORMAT_B8G8R8X8_UNORM, GL_RGBA8, GL_RGBA, GL_UNSIGNED_BYTE, RGB1_SWIZZLE, view_class_32 }, + { VIRGL_FORMAT_B8G8R8A8_UNORM, GL_RGBA8, GL_RGBA, GL_UNSIGNED_BYTE, NO_SWIZZLE, view_class_32 }, +@@ -593,7 +606,12 @@ + * transfer operations. So we only register support for it in GL. + */ + add_formats(gl_base_rgba_formats); ++ /* Use gl_bgra_formats directly for all platforms */ ++#if 0 ++ add_formats(macos_bgra_formats); ++#else + add_formats(gl_bgra_formats); ++#endif + add_formats(gl_bit10_formats); + } + +@@ -716,48 +734,183 @@ + assert(glGetError() == GL_NO_ERROR && + "Stale error state detected, please check for failures in initialization"); + ++ /* glTexStorage2DMultisample availability check with graceful downgrade: ++ * ++ * glTexStorage2DMultisample requires: ++ * - OpenGL 4.3+ or GL_ARB_texture_storage_multisample (desktop GL) ++ * - OpenGL ES 3.1+ (mobile/ANGLE) ++ * ++ * Fallback alternatives available on older versions: ++ * - glTexImage2DMultisample: GL 3.2+ / ES 3.1+ (works on GL 4.1 Core) ++ * - glRenderbufferStorageMultisample: GL 3.0+ / ES 3.0+ (works on ANGLE) ++ * ++ * We'll use glTexStorage2DMultisample if available, otherwise fall back to ++ * glTexImage2DMultisample for proper MSAA capability testing. */ ++ ++ const char *renderer = (const char *)glGetString(GL_RENDERER); ++ const char *version_str = (const char *)glGetString(GL_VERSION); ++ ++ /* Check multisample function availability with multiple fallback options */ ++ bool has_tex_storage_ms = false; ++ bool has_tex_image_ms = false; ++ bool has_rbo_storage_ms = false; ++ ++ if (epoxy_is_desktop_gl()) { ++ /* Desktop OpenGL path */ ++ if (epoxy_gl_version() >= 43) { ++ has_tex_storage_ms = true; ++ } else if (epoxy_has_gl_extension("GL_ARB_texture_storage_multisample")) { ++ has_tex_storage_ms = true; ++ } ++ /* glTexImage2DMultisample available since GL 3.2 */ ++ if (epoxy_gl_version() >= 32) { ++ has_tex_image_ms = true; ++ } ++ /* glRenderbufferStorageMultisample available since GL 3.0 */ ++ if (epoxy_gl_version() >= 30) { ++ has_rbo_storage_ms = true; ++ } ++ } else { ++ /* OpenGL ES path */ ++ if (epoxy_gl_version() >= 31) { ++ has_tex_storage_ms = true; ++ has_tex_image_ms = true; ++ } ++ /* glRenderbufferStorageMultisample available since ES 3.0 (ANGLE/Metal) */ ++ if (epoxy_gl_version() >= 30) { ++ has_rbo_storage_ms = true; ++ } ++ } ++ ++ /* If no multisample functions available at all, disable MSAA */ ++ if (!has_tex_storage_ms && !has_tex_image_ms && !has_rbo_storage_ms) { ++ virgl_debug("[VREND FORMATS] No multisample functions available " ++ "(GL version: %s, renderer: %s, is_desktop: %d). " ++ "Disabling MSAA support.\n", ++ version_str ? version_str : "unknown", ++ renderer ? renderer : "unknown", ++ epoxy_is_desktop_gl()); ++ memset(caps->sample_locations, 0, 8 * sizeof(uint32_t)); ++ return 0; /* Return 0 to indicate MSAA not supported */ ++ } ++ ++ /* Log which multisample method we're using */ ++ if (has_tex_storage_ms) { ++ virgl_debug("[VREND FORMATS] Testing MSAA with glTexStorage2DMultisample\n"); ++ } else if (has_tex_image_ms) { ++ virgl_debug("[VREND FORMATS] Testing MSAA with glTexImage2DMultisample fallback\n"); ++ } else if (has_rbo_storage_ms) { ++ virgl_debug("[VREND FORMATS] Testing MSAA with glRenderbufferStorageMultisample fallback " ++ "(GL version: %s, renderer: %s)\n", ++ version_str ? version_str : "unknown", ++ renderer ? renderer : "unknown"); ++ } ++ ++ virgl_debug("[VREND FORMATS] Starting MSAA capability test with max_samples=%u\n", max_samples); ++ + glGenFramebuffers( 1, &fbo ); + memset(caps->sample_locations, 0, 8 * sizeof(uint32_t)); + + for (int i = 3; i >= 0; i--) { +- if (test_num_samples[i] > max_samples) ++ if (test_num_samples[i] > max_samples) { ++ virgl_debug("[VREND FORMATS] Skipping %u samples (exceeds max %u)\n", ++ test_num_samples[i], max_samples); + continue; +- glGenTextures(1, &tex); +- glBindTexture(GL_TEXTURE_2D_MULTISAMPLE, tex); +- glTexStorage2DMultisample(GL_TEXTURE_2D_MULTISAMPLE, test_num_samples[i], GL_RGBA32F, 64, 64, GL_TRUE); ++ } ++ ++ virgl_debug("[VREND FORMATS] Testing %u samples...\n", test_num_samples[i]); ++ ++ /* Clear any stale errors before testing */ ++ while (glGetError() != GL_NO_ERROR); ++ ++ if (has_tex_storage_ms || has_tex_image_ms) { ++ /* Texture-based MSAA testing - use GL_RGBA8 for better compatibility */ ++ glGenTextures(1, &tex); ++ GLenum err1 = glGetError(); ++ ++ glBindTexture(GL_TEXTURE_2D_MULTISAMPLE, tex); ++ GLenum err2 = glGetError(); ++ ++ if (has_tex_storage_ms) { ++ glTexStorage2DMultisample(GL_TEXTURE_2D_MULTISAMPLE, test_num_samples[i], GL_RGBA8, 64, 64, GL_TRUE); ++ } else { ++ glTexImage2DMultisample(GL_TEXTURE_2D_MULTISAMPLE, test_num_samples[i], GL_RGBA8, 64, 64, GL_TRUE); ++ } ++ GLenum err3 = glGetError(); ++ ++ if (err1 != GL_NO_ERROR || err2 != GL_NO_ERROR || err3 != GL_NO_ERROR) { ++ virgl_debug("[VREND FORMATS] glGenTextures err=0x%x, glBindTexture err=0x%x, glTex*Multisample err=0x%x\n", ++ err1, err2, err3); ++ } ++ } else { ++ /* Renderbuffer-based MSAA testing (fallback for ES 3.0 / ANGLE) */ ++ GLuint rbo; ++ glGenRenderbuffers(1, &rbo); ++ glBindRenderbuffer(GL_RENDERBUFFER, rbo); ++ glRenderbufferStorageMultisample(GL_RENDERBUFFER, test_num_samples[i], GL_RGBA8, 64, 64); ++ tex = rbo; /* Store RBO handle in tex variable for cleanup */ ++ } ++ + status = glGetError(); + if (status == GL_NO_ERROR) { + glBindFramebuffer(GL_FRAMEBUFFER, fbo); +- glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D_MULTISAMPLE, tex, 0); ++ ++ if (has_tex_storage_ms || has_tex_image_ms) { ++ glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D_MULTISAMPLE, tex, 0); ++ } else { ++ /* For renderbuffer fallback */ ++ glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER, tex); ++ } ++ + status = glCheckFramebufferStatus(GL_FRAMEBUFFER); + if (status == GL_FRAMEBUFFER_COMPLETE) { ++ virgl_debug("[VREND FORMATS] ✓ %u samples COMPLETE\n", test_num_samples[i]); + if (max_samples_confirmed < test_num_samples[i]) + max_samples_confirmed = test_num_samples[i]; + +- for (unsigned k = 0; k < test_num_samples[i]; ++k) { +- float msp[2]; +- uint32_t compressed; +- glGetMultisamplefv(GL_SAMPLE_POSITION, k, msp); +- compressed = ((unsigned)(floor(msp[0] * 16.0f)) & 0xf) << 4; +- compressed |= ((unsigned)(floor(msp[1] * 16.0f)) & 0xf); +- caps->sample_locations[out_buf_offsets[i] + (k >> 2)] |= compressed << (8 * (k & 3)); ++ /* glGetMultisamplefv only available in desktop GL (since 3.2), not in GL ES */ ++ if (epoxy_is_desktop_gl()) { ++ for (unsigned k = 0; k < test_num_samples[i]; ++k) { ++ float msp[2]; ++ uint32_t compressed; ++ glGetMultisamplefv(GL_SAMPLE_POSITION, k, msp); ++ compressed = ((unsigned)(floor(msp[0] * 16.0f)) & 0xf) << 4; ++ compressed |= ((unsigned)(floor(msp[1] * 16.0f)) & 0xf); ++ caps->sample_locations[out_buf_offsets[i] + (k >> 2)] |= compressed << (8 * (k & 3)); ++ } ++ } else { ++ /* OpenGL ES: sample locations not available, leave them zero-initialized */ ++ virgl_debug("[VREND FORMATS] (OpenGL ES: sample locations not available)\n"); + } + lowest_working_ms_count_idx = i; + } else { ++ virgl_debug("[VREND FORMATS] ✗ %u samples INCOMPLETE (status=0x%x)\n", ++ test_num_samples[i], status); + /* If a framebuffer doesn't support low sample counts, + * use the sample position from the last working larger count. */ + if (lowest_working_ms_count_idx > 0) { + for (unsigned k = 0; k < test_num_samples[i]; ++k) { + caps->sample_locations[out_buf_offsets[i] + (k >> 2)] = +- caps->sample_locations[out_buf_offsets[lowest_working_ms_count_idx] + (k >> 2)]; ++ caps->sample_locations[out_buf_offsets[lowest_working_ms_count_idx] + (k >> 2)]; + } + } + } + glBindFramebuffer(GL_FRAMEBUFFER, 0); ++ } else { ++ virgl_debug("[VREND FORMATS] ✗ %u samples GL_ERROR=0x%x\n", test_num_samples[i], status); + } +- glDeleteTextures(1, &tex); ++ ++ /* Cleanup - delete texture or renderbuffer */ ++ if (has_tex_storage_ms || has_tex_image_ms) { ++ glDeleteTextures(1, &tex); ++ } else { ++ glDeleteRenderbuffers(1, &tex); ++ } + } + glDeleteFramebuffers(1, &fbo); ++ ++ virgl_debug("[VREND FORMATS] MSAA test complete: returning max_samples_confirmed=%u\n", ++ max_samples_confirmed); + return max_samples_confirmed; + } + +diff --git a/src/vrend/vrend_metal.h b/src/vrend/vrend_metal.h +--- a/src/vrend/vrend_metal.h 1969-12-31 19:00:00 ++++ b/src/vrend/vrend_metal.h 2026-01-11 23:47:51 +@@ -0,0 +1,34 @@ ++/* ++ * Copyright 2025 Turing Software, LLC ++ * SPDX-License-Identifier: MIT ++ */ ++#ifndef VIRGL_METAL_H ++#define VIRGL_METAL_H ++ ++#include "virglrenderer.h" ++ ++typedef void *MTLDevice_id; ++typedef void *MTLTexture_id; ++typedef void *MTLHeap_id; ++ ++struct vrend_metal_texture_description { ++ unsigned width; ++ unsigned height; ++ unsigned stride; ++ unsigned offset; ++ unsigned bind; ++ unsigned usage; ++ uint32_t format; ++}; ++ ++bool virgl_metal_create_texture(MTLDevice_id device, ++ const struct vrend_metal_texture_description *desc, ++ MTLTexture_id *tex); ++ ++bool virgl_metal_create_texture_from_heap(MTLHeap_id heap, ++ const struct vrend_metal_texture_description *desc, ++ MTLTexture_id *tex); ++ ++void virgl_metal_release_texture(MTLTexture_id tex); ++ ++#endif +diff --git a/src/vrend/vrend_metal.m b/src/vrend/vrend_metal.m +--- a/src/vrend/vrend_metal.m 1969-12-31 19:00:00 ++++ b/src/vrend/vrend_metal.m 2026-01-11 23:47:51 +@@ -0,0 +1,149 @@ ++/* ++ * Copyright 2025 Turing Software, LLC ++ * SPDX-License-Identifier: MIT ++ */ ++#ifdef HAVE_CONFIG_H ++#include "config.h" ++#endif ++#include "virglrenderer.h" ++#include "vrend_metal.h" ++#include "pipe/p_state.h" ++#include "util/u_math.h" ++#include ++ ++struct metal_format_conversion { ++ uint32_t virgl_format; ++ MTLPixelFormat metal_format; ++}; ++ ++static bool virgl_format_to_metal_format(uint32_t format, MTLPixelFormat *metal_format) ++{ ++ static const struct metal_format_conversion conversions[] = { ++ { VIRGL_FORMAT_R8G8B8A8_UNORM, MTLPixelFormatRGBA8Unorm }, ++ { VIRGL_FORMAT_R8G8B8A8_SRGB, MTLPixelFormatRGBA8Unorm_sRGB }, ++ { VIRGL_FORMAT_B8G8R8X8_UNORM, MTLPixelFormatBGRA8Unorm }, ++ { VIRGL_FORMAT_B8G8R8A8_UNORM, MTLPixelFormatBGRA8Unorm }, ++ { VIRGL_FORMAT_B8G8R8A8_SRGB, MTLPixelFormatBGRA8Unorm_sRGB }, ++ { VIRGL_FORMAT_R16G16B16A16_FLOAT, MTLPixelFormatRGBA16Float }, ++ { VIRGL_FORMAT_R32G32B32A32_FLOAT, MTLPixelFormatRGBA32Float }, ++ { VIRGL_FORMAT_R10G10B10A2_UNORM, MTLPixelFormatRGB10A2Unorm }, ++ { VIRGL_FORMAT_R8_UNORM, MTLPixelFormatR8Unorm }, ++ { VIRGL_FORMAT_R16_UNORM, MTLPixelFormatR16Unorm }, ++ { VIRGL_FORMAT_R8G8_UNORM, MTLPixelFormatRG8Unorm }, ++ { VIRGL_FORMAT_R16G16_UNORM, MTLPixelFormatRG16Unorm }, ++ }; ++ ++ for (uint32_t i = 0; i < ARRAY_SIZE(conversions); i++) { ++ if (conversions[i].virgl_format == format) { ++ *metal_format = conversions[i].metal_format; ++ return true; ++ } ++ } ++ ++ return false; ++} ++ ++static MTLTextureUsage virgl_bind_to_metal_usage_flags(uint32_t flags) ++{ ++ MTLTextureUsage ret = MTLTextureUsageShaderRead | MTLTextureUsageShaderWrite; ++ ++ if (flags & PIPE_BIND_RENDER_TARGET) ++ ret |= MTLTextureUsageRenderTarget; ++ if (flags & PIPE_BIND_DEPTH_STENCIL) ++ ret |= MTLTextureUsageRenderTarget; ++ ++ return ret; ++} ++ ++static MTLResourceOptions virgl_usage_to_metal_resource_options(uint32_t usage) ++{ ++ switch (usage) { ++ case PIPE_USAGE_DEFAULT: ++ case PIPE_USAGE_STAGING: ++ default: ++ return MTLResourceStorageModeShared | MTLResourceCPUCacheModeDefaultCache; ++ case PIPE_USAGE_IMMUTABLE: ++ return MTLResourceStorageModeShared | MTLResourceCPUCacheModeWriteCombined | MTLHazardTrackingModeUntracked; ++ case PIPE_USAGE_DYNAMIC: ++ case PIPE_USAGE_STREAM: ++ return MTLResourceStorageModeShared | MTLResourceCPUCacheModeWriteCombined; ++ } ++} ++ ++static MTLTextureDescriptor *new_descriptor(const struct vrend_metal_texture_description *desc) ++{ ++ MTLPixelFormat pixel_format; ++ ++ if (!virgl_format_to_metal_format(desc->format, &pixel_format)) { ++ return NULL; ++ } ++ ++ MTLTextureDescriptor *descriptor = [MTLTextureDescriptor new]; ++ descriptor.textureType = MTLTextureType2D; ++ descriptor.pixelFormat = pixel_format; ++ descriptor.width = desc->width; ++ descriptor.height = desc->height; ++ descriptor.resourceOptions = virgl_usage_to_metal_resource_options(desc->usage); ++ descriptor.usage = virgl_bind_to_metal_usage_flags(desc->bind); ++ if (desc->usage == PIPE_USAGE_IMMUTABLE) { ++ descriptor.usage &= ~MTLTextureUsageShaderWrite; ++ } ++ ++ return descriptor; ++} ++ ++bool virgl_metal_create_texture(MTLDevice_id device, ++ const struct vrend_metal_texture_description *desc, ++ MTLTexture_id *tex) ++{ ++ id mtl_device = (id)device; ++ MTLTextureDescriptor *descriptor = new_descriptor(desc); ++ if (descriptor) { ++ *tex = [mtl_device newTextureWithDescriptor:descriptor]; ++ [descriptor release]; ++ return true; ++ } ++ ++ return false; ++} ++ ++bool virgl_metal_create_texture_from_heap(MTLHeap_id heap, ++ const struct vrend_metal_texture_description *desc, ++ MTLTexture_id *tex) ++{ ++ id mtl_heap = (id)heap; ++ id mtl_device = mtl_heap.device; ++ MTLTextureDescriptor *descriptor = new_descriptor(desc); ++ *tex = nil; ++ if (descriptor) { ++ NSUInteger deviceAlignment, bytesPerRow; ++ /* swap B/R for existing texture */ ++ if (desc->format == VIRGL_FORMAT_B8G8R8X8_UNORM || desc->format == VIRGL_FORMAT_B8G8R8A8_UNORM) { ++ descriptor.pixelFormat = MTLPixelFormatRGBA8Unorm; ++ } ++ /* Regardless of what we want, we have to respect the heap's options */ ++ descriptor.resourceOptions = mtl_heap.resourceOptions; ++ deviceAlignment = [mtl_device minimumLinearTextureAlignmentForPixelFormat:descriptor.pixelFormat]; ++ bytesPerRow = align(desc->stride, deviceAlignment); ++ id mtl_buffer = [mtl_heap newBufferWithLength:bytesPerRow * desc->height ++ options:mtl_heap.resourceOptions ++ offset:0]; ++ if (mtl_buffer) { ++ *tex = [mtl_buffer newTextureWithDescriptor:descriptor ++ offset:0 ++ bytesPerRow:bytesPerRow]; ++ [mtl_buffer release]; ++ } ++ [descriptor release]; ++ return !!*tex; ++ } ++ ++ return false; ++} ++ ++void virgl_metal_release_texture(MTLTexture_id tex) ++{ ++ id mtl_texture = (id)tex; ++ ++ [mtl_texture release]; ++} +diff --git a/src/vrend/vrend_renderer.c b/src/vrend/vrend_renderer.c +--- a/src/vrend/vrend_renderer.c 2026-01-01 08:05:29 ++++ b/src/vrend/vrend_renderer.c 2026-01-11 23:58:50 +@@ -28,6 +28,7 @@ + #include + #include + #include ++#include + #include + #include "pipe/p_shader_tokens.h" + +@@ -323,7 +324,7 @@ + FEAT(texture_mirror_clamp_to_edge, UNAVAIL, UNAVAIL, "GL_ATI_texture_mirror_once", "GL_EXT_texture_mirror_clamp", "GL_ARB_texture_mirror_clamp_to_edge", "GL_EXT_texture_mirror_clamp_to_edge"), + FEAT(texture_mirror_clamp, UNAVAIL, UNAVAIL, "GL_ATI_texture_mirror_once", "GL_EXT_texture_mirror_clamp"), + FEAT(texture_mirror_clamp_to_border, UNAVAIL, UNAVAIL, "GL_EXT_texture_mirror_clamp"), +- FEAT(texture_multisample, 32, 31, "GL_ARB_texture_multisample" ), ++ FEAT(texture_multisample, 32, 30, "GL_ARB_texture_multisample" ), + FEAT(texture_query_lod, 40, UNAVAIL, "GL_ARB_texture_query_lod", "GL_EXT_texture_query_lod"), + FEAT(texture_shadow_lod, UNAVAIL, UNAVAIL, "GL_EXT_texture_shadow_lod"), + FEAT(texture_srgb_decode, UNAVAIL, UNAVAIL, "GL_EXT_texture_sRGB_decode" ), +@@ -401,8 +402,11 @@ + #ifdef HAVE_EPOXY_EGL_H + bool use_egl_fence : 1; + #endif +- bool d3d_share_texture : 1; ++ bool native_share_texture : 1; + bool gbm_layout_feat : 1; ++ ++ /* host-side video acceleration availability */ ++ bool video_available; + }; + + struct sysval_uniform_block { +@@ -416,6 +420,11 @@ + + static struct global_renderer_state vrend_state; + ++bool vrend_renderer_video_available(void) ++{ ++ return vrend_state.video_available; ++} ++ + static inline bool has_feature(enum features_id feature_id) + { + int slot = feature_id / 64; +@@ -1389,12 +1398,56 @@ + { + GLint param; + const char *shader_parts[SHADER_MAX_STRINGS]; ++ char *modified_shaders[SHADER_MAX_STRINGS] = {NULL}; + +- for (int i = 0; i < shader->glsl_strings.num_strings; i++) +- shader_parts[i] = shader->glsl_strings.strings[i].buf; ++ /* Firefox uses GL_EXT_shader_texture_lod (GLES), but we have GL_ARB_shader_texture_lod (desktop GL). ++ * Rewrite extension directives to use the ARB version. */ ++ for (int i = 0; i < shader->glsl_strings.num_strings; i++) { ++ const char *src = shader->glsl_strings.strings[i].buf; ++ const char *ext_check = strstr(src, "GL_EXT_shader_texture_lod"); ++ ++ if (ext_check) { ++ /* Found GL_EXT_shader_texture_lod - replace with GL_ARB_shader_texture_lod */ ++ size_t src_len = strlen(src); ++ modified_shaders[i] = malloc(src_len + 16); /* Extra space for ARB vs EXT */ ++ if (modified_shaders[i]) { ++ char *dst = modified_shaders[i]; ++ const char *read_pos = src; ++ ++ while ((ext_check = strstr(read_pos, "GL_EXT_shader_texture_lod")) != NULL) { ++ /* Copy up to the extension name */ ++ size_t prefix_len = ext_check - read_pos; ++ memcpy(dst, read_pos, prefix_len); ++ dst += prefix_len; ++ ++ /* Write ARB version instead */ ++ memcpy(dst, "GL_ARB_shader_texture_lod", 25); ++ dst += 25; ++ ++ /* Skip past the EXT version */ ++ read_pos = ext_check + 25; ++ } ++ ++ /* Copy remaining string */ ++ strcpy(dst, read_pos); ++ shader_parts[i] = modified_shaders[i]; ++ } else { ++ shader_parts[i] = src; ++ } ++ } else { ++ shader_parts[i] = src; ++ } ++ } + + shader->id = glCreateShader(conv_shader_type(shader->sel->type)); + glShaderSource(shader->id, shader->glsl_strings.num_strings, shader_parts, NULL); ++ ++ /* Free temporary modified shader strings */ ++ for (int i = 0; i < shader->glsl_strings.num_strings; i++) { ++ if (modified_shaders[i]) ++ free(modified_shaders[i]); ++ } ++ + glCompileShader(shader->id); + glGetShaderiv(shader->id, GL_COMPILE_STATUS, ¶m); + if (param == GL_FALSE) { +@@ -2519,30 +2572,32 @@ + case PIPE_TEX_WRAP_CLAMP: if (vrend_state.use_core_profile == false) return GL_CLAMP; else return GL_CLAMP_TO_EDGE; + + case PIPE_TEX_WRAP_CLAMP_TO_EDGE: return GL_CLAMP_TO_EDGE; +- case PIPE_TEX_WRAP_CLAMP_TO_BORDER: return GL_CLAMP_TO_BORDER; ++ case PIPE_TEX_WRAP_CLAMP_TO_BORDER: ++ /* GLES (ANGLE/Metal) does not support clamp-to-border; fall back to edge ++ * to avoid GL_INVALID_ENUM on sampler parameter calls. ++ */ ++ if (vrend_state.use_gles || !has_feature(feat_sampler_border_colors)) { ++ return GL_CLAMP_TO_EDGE; ++ } ++ return GL_CLAMP_TO_BORDER; + + case PIPE_TEX_WRAP_MIRROR_REPEAT: return GL_MIRRORED_REPEAT; + case PIPE_TEX_WRAP_MIRROR_CLAMP: ++ /* Not available on GLES; fall back to mirrored repeat without error. */ + if (has_feature(feat_texture_mirror_clamp)) + return GL_MIRROR_CLAMP_EXT; +- else { +- vrend_report_context_error(ctx, VIRGL_ERROR_CTX_UNSUPPORTED_TEX_WRAP, wrap); +- return GL_MIRRORED_REPEAT; +- } ++ return GL_MIRRORED_REPEAT; + case PIPE_TEX_WRAP_MIRROR_CLAMP_TO_EDGE: ++ /* ANGLE/Metal lacks this; fall back silently to clamp-to-edge. */ + if (has_feature(feat_texture_mirror_clamp_to_edge)) + return GL_MIRROR_CLAMP_TO_EDGE_EXT; +- else { +- vrend_report_context_error(ctx, VIRGL_ERROR_CTX_UNSUPPORTED_TEX_WRAP, wrap); +- return GL_MIRRORED_REPEAT; +- } ++ return GL_CLAMP_TO_EDGE; + case PIPE_TEX_WRAP_MIRROR_CLAMP_TO_BORDER: + if (has_feature(feat_texture_mirror_clamp_to_border)) { + return GL_MIRROR_CLAMP_TO_BORDER_EXT; +- } else { +- vrend_report_context_error(ctx, VIRGL_ERROR_CTX_UNSUPPORTED_TEX_WRAP, wrap); +- return GL_MIRRORED_REPEAT; + } ++ /* No host support: clamp to edge to avoid GL errors. */ ++ return GL_CLAMP_TO_EDGE; + default: + assert(0); + return -1; +@@ -3094,6 +3149,11 @@ + + if (sub_ctx->nr_cbufs == 0) { + glReadBuffer(GL_NONE); ++ /* In core profile, must explicitly disable draw buffers when no color attachments */ ++ if (vrend_state.use_core_profile) { ++ GLenum none_buf = GL_NONE; ++ glDrawBuffers(1, &none_buf); ++ } + if (has_feature(feat_srgb_write_control)) { + glDisable(GL_FRAMEBUFFER_SRGB_EXT); + sub_ctx->framebuffer_srgb_enabled = false; +@@ -3455,7 +3515,8 @@ + return; + } + +- if (has_feature(feat_gles31_vertex_attrib_binding) && v->id == 0) { ++ if (has_feature(feat_gles31_vertex_attrib_binding)) { ++ if (v->id == 0) { + glGenVertexArrays(1, &v->id); + glBindVertexArray(v->id); + for (uint32_t i = 0; i < v->count; i++) { +@@ -3473,7 +3534,18 @@ + glVertexAttribBinding(i, ve->base.vertex_buffer_index); + glVertexBindingDivisor(i, ve->base.instance_divisor); + glEnableVertexAttribArray(i); ++ } + } ++ } else { ++ for (uint32_t i = 0; i < v->count; i++) { ++ struct vrend_vertex_element *ve = &v->elements[i]; ++ ++ if (util_format_is_pure_integer(ve->base.src_format)) { ++ UPDATE_INT_SIGN_MASK(ve->base.src_format, i, ++ v->signed_int_bitmask, ++ v->unsigned_int_bitmask); ++ } ++ } + } + } + +@@ -3716,7 +3788,7 @@ + glTexParameteri(view->texture->target, GL_TEXTURE_BASE_LEVEL, view->u.tex.first_level); + tex->cur_base = view->u.tex.first_level; + } +- if (tex->cur_max != view->u.tex.last_level) { ++ if (view->u.tex.last_level && tex->cur_max != view->u.tex.last_level) { + glTexParameteri(view->texture->target, GL_TEXTURE_MAX_LEVEL, view->u.tex.last_level); + tex->cur_max = view->u.tex.last_level; + } +@@ -4871,6 +4943,10 @@ + format = tex_conv_table[fmt].glformat; + type = tex_conv_table[fmt].gltype; + ++ if (!has_feature(feat_clear_texture)) { ++ return EINVAL; ++ } ++ + /* 32-bit BGRA resources are always reordered to RGBA ordering before + * submission to the host driver. Reorder red/blue color bytes in + * the clear color to match. */ +@@ -7556,11 +7632,7 @@ + return true; + + const char * a = (const char *) glGetString(GL_VENDOR); +- if (!a) +- return false; +- if (strcmp(a, "ARM") == 0) +- return true; +- return false; ++ return a && !(strcmp(a, "ARM") && strcmp(a, "Google Inc. (Apple)")); + } + + static bool vrend_use_gbm_layout_feature(UNUSED uint32_t flags) +@@ -7630,6 +7702,30 @@ + vrend_clicbs->make_current(gl_context); + gl_ver = epoxy_gl_version(); + ++ /* Surface the full GL strings early for debugging/profile confirmation. */ ++ const GLubyte *gl_ver_str = glGetString(GL_VERSION); ++ const GLubyte *gl_renderer_str = glGetString(GL_RENDERER); ++ const GLubyte *glsl_ver_str = glGetString(GL_SHADING_LANGUAGE_VERSION); ++ ++ /* On macOS+Metal the GL_VERSION string can start with just the numeric ++ * version and "Metal"; reshape it to a clearer OpenGL 4.x label for logs. ++ */ ++ char gl_ver_buf[128]; ++ const char *gl_ver_display = gl_ver_str ? (const char *)gl_ver_str : "(null)"; ++#ifdef __APPLE__ ++ int gl_major_num = gl_ver / 10; ++ int gl_minor_num = gl_ver % 10; ++ if (gl_ver_str && strstr((const char *)gl_ver_str, "Metal")) { ++ snprintf(gl_ver_buf, sizeof(gl_ver_buf), "OpenGL %d.%d (Metal)", gl_major_num, gl_minor_num); ++ gl_ver_display = gl_ver_buf; ++ } ++#endif ++ ++ virgl_info("GL strings: version='%s' renderer='%s' glsl='%s'\n", ++ gl_ver_display, ++ gl_renderer_str ? (const char *)gl_renderer_str : "(null)", ++ glsl_ver_str ? (const char *)glsl_ver_str : "(null)"); ++ + /* enable error output as early as possible */ + if (vrend_debug(NULL, dbg_khr) && epoxy_has_gl_extension("GL_KHR_debug")) { + glDebugMessageCallback(vrend_debug_cb, NULL); +@@ -7661,6 +7757,18 @@ + init_features(gles ? 0 : gl_ver, + gles ? gl_ver : 0); + ++#ifdef __APPLE__ ++ /* macOS core GL 4.1 lacks GL_ARB_copy_image; force fallback paths. */ ++ clear_feature(feat_copy_image); ++#endif ++ ++ /* Disable host video decode/encode paths entirely in this build to avoid ++ * guest CREATE_VIDEO_BUFFER commands on configurations that cannot service ++ * them (e.g., macOS ANGLE/Metal). This also keeps caps consistent with ++ * the advertised zero video caps below. ++ */ ++ vrend_state.video_available = false; ++ + if (!vrend_winsys_has_gl_colorspace()) + clear_feature(feat_srgb_write_control) ; + +@@ -7764,7 +7872,7 @@ + } + #endif + +- vrend_state.d3d_share_texture = flags & VREND_D3D11_SHARE_TEXTURE; ++ vrend_state.native_share_texture = flags & VREND_NATIVE_SHARE_TEXTURE; + + vrend_state.gbm_layout_feat = vrend_use_gbm_layout_feature(flags); + +@@ -8541,7 +8649,7 @@ + }; + ID3D11Texture2D* d3d_tex2d = NULL; + +- if (!vrend_state.d3d_share_texture) ++ if (!vrend_state.native_share_texture) + return; + + if ((gr->base.bind & VIRGL_RES_BIND_SCANOUT) == 0) +@@ -8570,7 +8678,7 @@ + + gr->d3d_tex2d = d3d_tex2d; + +- gr->storage_bits |= VREND_STORAGE_D3D_TEXTURE; ++ gr->storage_bits |= VREND_STORAGE_NATIVE_TEXTURE; + gr->storage_bits |= VREND_STORAGE_EGL_IMAGE; + return; + +@@ -8582,6 +8690,44 @@ + } + + /* ++ * When using ANGLE/Metal, this function creates a Metal Texture and ++ * EGL image given certain flags. ++ */ ++static void vrend_resource_metal_init(UNUSED struct vrend_resource *gr, UNUSED uint32_t format) ++{ ++#if defined(ENABLE_METAL) && defined(HAVE_EPOXY_EGL_H) ++ MTLTexture_id tex = NULL; ++ ++ if (!vrend_state.native_share_texture) ++ return; ++ ++ if ((gr->base.bind & VIRGL_RES_BIND_SCANOUT) == 0) ++ return; ++ ++ if (gr->base.depth0 != 1 || gr->base.last_level != 0 || gr->base.nr_samples > 1) ++ return; ++ ++ if (!virgl_egl_metal_create_texture(egl, &gr->base, format, &tex)) ++ goto fail; ++ ++ gr->egl_image = virgl_egl_metal_image_from_texture(egl, tex); ++ if (!gr->egl_image) ++ goto fail; ++ ++ gr->metal_texture = tex; ++ ++ gr->storage_bits |= VREND_STORAGE_NATIVE_TEXTURE; ++ gr->storage_bits |= VREND_STORAGE_EGL_IMAGE; ++ return; ++ ++fail: ++ if (tex) ++ virgl_metal_release_texture(gr->metal_texture); ++ gr->metal_texture = NULL; ++#endif ++} ++ ++/* + * When GBM allocation is enabled, this function creates a GBM buffer and + * EGL image given certain flags. + */ +@@ -8670,6 +8816,7 @@ + + if (!image_oes) { + vrend_resource_d3d_init(gr, format); ++ vrend_resource_metal_init(gr, format); + vrend_resource_gbm_init(gr, format); + if (gr->gbm_bo && !has_bit(gr->storage_bits, VREND_STORAGE_EGL_IMAGE)) + return 0; +@@ -8737,7 +8884,19 @@ + } + + if (pr->nr_samples > 1) { +- if (format_can_texture_storage) { ++ /* Metal backend (macOS): Silently downgrade MSAA to non-MSAA when not supported. ++ * gl=es mode (ANGLE) handles MSAA correctly, so only apply this workaround ++ * for desktop GL where Metal backend doesn't support multisampled textures. */ ++ const char *renderer_str = (const char *)glGetString(GL_RENDERER); ++ bool is_metal_backend = (renderer_str && strstr(renderer_str, "Metal")); ++ ++ if (is_metal_backend && !vrend_state.use_gles && !has_feature(feat_storage_multisample)) { ++ /* Metal backend: No MSAA support, downgrade to regular texture */ ++ virgl_debug("[VREND] Metal backend: MSAA texture requested (samples=%d, target=0x%x), downgrading to non-MSAA\n", ++ pr->nr_samples, gr->target); ++ gr->target = (gr->target == GL_TEXTURE_2D_MULTISAMPLE) ? GL_TEXTURE_2D : GL_TEXTURE_2D_ARRAY; ++ pr->nr_samples = 0; ++ } else if (format_can_texture_storage) { + if (gr->target == GL_TEXTURE_2D_MULTISAMPLE) { + glTexStorage2DMultisample(gr->target, pr->nr_samples, + internalformat, pr->width0, pr->height0, +@@ -8758,7 +8917,9 @@ + GL_TRUE); + } + } +- } else if (gr->target == GL_TEXTURE_CUBE_MAP) { ++ } ++ ++ if (pr->nr_samples <= 1 && gr->target == GL_TEXTURE_CUBE_MAP) { + int i; + if (format_can_texture_storage) + glTexStorage2D(GL_TEXTURE_CUBE_MAP, pr->last_level + 1, internalformat, pr->width0, pr->height0); +@@ -8821,7 +8982,9 @@ + + if (!format_can_texture_storage) { + glTexParameteri(gr->target, GL_TEXTURE_BASE_LEVEL, 0); +- glTexParameteri(gr->target, GL_TEXTURE_MAX_LEVEL, pr->last_level); ++ if (pr->last_level) { ++ glTexParameteri(gr->target, GL_TEXTURE_MAX_LEVEL, pr->last_level); ++ } + } + + glBindTexture(gr->target, 0); +@@ -8919,7 +9082,7 @@ + glDeleteMemoryObjectsEXT(1, &res->memobj); + } + +-#ifdef ENABLE_GBM ++#if defined(ENABLE_GBM) || defined(ENABLE_METAL) + if (res->egl_image) { + virgl_egl_image_destroy(egl, res->egl_image); + for (unsigned i = 0; i < ARRAY_SIZE(res->aux_plane_egl_image); i++) { +@@ -8937,6 +9100,10 @@ + if (res->d3d_tex2d) + res->d3d_tex2d->lpVtbl->Release(res->d3d_tex2d); + #endif ++#ifdef ENABLE_METAL ++ if (res->metal_texture) ++ virgl_metal_release_texture(res->metal_texture); ++#endif + free(res); + } + +@@ -9975,6 +10142,12 @@ + return 0; + } + ++/* Forward declaration for MSAA staging path below. */ ++static void vrend_renderer_blit_int(struct vrend_context *ctx, ++ struct vrend_resource *src_res, ++ struct vrend_resource *dst_res, ++ const struct pipe_blit_info *info); ++ + static int vrend_renderer_transfer_internal(struct vrend_context *ctx, + struct vrend_resource *res, + const struct vrend_transfer_info *info, +@@ -10105,7 +10278,123 @@ + return vrend_renderer_transfer_write_iov(ctx, res, info->iovec, info->iovec_cnt, info); + + } ++static int vrend_renderer_copy_transfer3d_msaa(struct vrend_context *ctx, ++ struct vrend_resource *dst_res, ++ struct vrend_resource *src_res, ++ const struct vrend_transfer_info *info) ++{ ++ /* Multisample textures reject TexSubImage uploads; stage into single-sample ++ * texture and resolve via blit to avoid GL_INVALID_OPERATION on macOS core. ++ */ ++ if (dst_res->target != GL_TEXTURE_2D_MULTISAMPLE && ++ dst_res->target != GL_TEXTURE_2D_MULTISAMPLE_ARRAY) ++ return EINVAL; + ++ const GLenum staging_target = (dst_res->target == GL_TEXTURE_2D_MULTISAMPLE_ARRAY) ? ++ GL_TEXTURE_2D_ARRAY : GL_TEXTURE_2D; ++ ++ virgl_warn("copy_transfer3d msaa staging: dst target=%u samples=%u level=%u box=[%d,%d,%d %dx%dx%d]\n", ++ dst_res->target, dst_res->base.nr_samples, info->level, ++ info->box->x, info->box->y, info->box->z, ++ info->box->width, info->box->height, info->box->depth); ++ ++ struct vrend_resource staging = *dst_res; ++ staging.target = staging_target; ++ staging.base.nr_samples = 1; ++#ifdef PIPE_TEXTURE_2D_MULTISAMPLE_ARRAY ++ if (dst_res->base.target == PIPE_TEXTURE_2D_MULTISAMPLE_ARRAY) ++ staging.base.target = PIPE_TEXTURE_2D_ARRAY; ++ else ++#endif ++ staging.base.target = PIPE_TEXTURE_2D; ++ staging.storage_bits = VREND_STORAGE_GL_TEXTURE; ++ staging.gbm_bo = NULL; ++ staging.egl_image = 0; ++ staging.iov = NULL; ++ staging.num_iovs = 0; ++ ++ glGenTextures(1, &staging.gl_id); ++ glBindTexture(staging_target, staging.gl_id); ++ glTexParameteri(staging_target, GL_TEXTURE_MIN_FILTER, GL_NEAREST); ++ glTexParameteri(staging_target, GL_TEXTURE_MAG_FILTER, GL_NEAREST); ++ ++ const GLint internalformat = tex_conv_table[staging.base.format].internalformat; ++ const GLsizei level_w = u_minify(staging.base.width0, info->level); ++ const GLsizei level_h = u_minify(staging.base.height0, info->level); ++ const GLsizei level_d = (staging_target == GL_TEXTURE_2D_ARRAY) ? staging.base.array_size : 1; ++ ++ if (util_format_is_compressed(staging.base.format)) { ++ const GLsizei comp_size = util_format_get_2d_size(staging.base.format, ++ util_format_get_stride(staging.base.format, level_w), ++ level_h); ++ if (staging_target == GL_TEXTURE_2D_ARRAY) { ++ glCompressedTexImage3D(staging_target, info->level, internalformat, ++ level_w, level_h, level_d, 0, ++ comp_size * level_d, NULL); ++ } else { ++ glCompressedTexImage2D(staging_target, info->level, internalformat, ++ level_w, level_h, 0, comp_size, NULL); ++ } ++ } else { ++ if (staging_target == GL_TEXTURE_2D_ARRAY) { ++ glTexImage3D(staging_target, info->level, internalformat, ++ level_w, level_h, level_d, 0, ++ tex_conv_table[staging.base.format].glformat, ++ tex_conv_table[staging.base.format].gltype, ++ NULL); ++ } else { ++ glTexImage2D(staging_target, info->level, internalformat, ++ level_w, level_h, 0, ++ tex_conv_table[staging.base.format].glformat, ++ tex_conv_table[staging.base.format].gltype, ++ NULL); ++ } ++ } ++ ++ int ret = vrend_renderer_transfer_write_iov(ctx, &staging, src_res->iov, ++ src_res->num_iovs, info); ++ if (ret) { ++ glDeleteTextures(1, &staging.gl_id); ++ return ret; ++ } ++ ++ struct pipe_blit_info blit = { 0 }; ++ blit.src.resource = &staging.base; ++ blit.src.level = info->level; ++ blit.src.box.x = info->box->x; ++ blit.src.box.y = info->box->y; ++ blit.src.box.z = info->box->z; ++ blit.src.box.width = info->box->width; ++ blit.src.box.height = info->box->height; ++ blit.src.box.depth = info->box->depth; ++ blit.src.format = staging.base.format; ++ ++ blit.dst.resource = &dst_res->base; ++ blit.dst.level = info->level; ++ blit.dst.box = blit.src.box; ++ blit.dst.format = dst_res->base.format; ++ ++ blit.mask = PIPE_MASK_RGBA; ++ if (vrend_format_is_ds(dst_res->base.format)) { ++ blit.mask = PIPE_MASK_Z; ++ if (util_format_has_stencil(util_format_description(dst_res->base.format))) ++ blit.mask |= PIPE_MASK_S; ++ } ++ blit.filter = PIPE_TEX_FILTER_NEAREST; ++ blit.scissor_enable = false; ++ blit.render_condition_enable = false; ++ blit.alpha_blend = false; ++ ++ /* Force shader-based blit to MSAA target; GL forbids single->multi FBO blits. */ ++ vrend_renderer_blit_int(ctx, &staging, dst_res, &blit); ++ ++ virgl_warn("copy_transfer3d msaa staging: shader blit completed\n"); ++ ++ glDeleteTextures(1, &staging.gl_id); ++ return 0; ++} ++ ++ + int vrend_renderer_copy_transfer3d(struct vrend_context *ctx, + uint32_t dst_handle, + +@@ -10113,6 +10402,16 @@ + struct vrend_resource *src_res, + const struct vrend_transfer_info *info) + { ++ static int copy_log_budget = 8; ++ if (copy_log_budget > 0) { ++ virgl_warn("copy_transfer3d: dst target=%u base.target=%u samples=%u format=%s level=%u box=[%d,%d,%d %dx%dx%d]\n", ++ dst_res->target, dst_res->base.target, dst_res->base.nr_samples, ++ util_format_name(dst_res->base.format), info->level, ++ info->box->x, info->box->y, info->box->z, ++ info->box->width, info->box->height, info->box->depth); ++ copy_log_budget--; ++ } ++ + if (!resource_contains_box(dst_res, info->box, info->level)) { + vrend_report_context_error(ctx, VIRGL_ERROR_CTX_ILLEGAL_CMD_BUFFER, dst_handle); + return EINVAL; +@@ -10159,6 +10458,14 @@ + } + #endif + ++ if (dst_res->target == GL_TEXTURE_2D_MULTISAMPLE || ++ dst_res->target == GL_TEXTURE_2D_MULTISAMPLE_ARRAY) { ++ int ret = vrend_renderer_copy_transfer3d_msaa(ctx, dst_res, src_res, info); ++ if (!ret) ++ return 0; ++ virgl_warn("copy_transfer3d MSAA staging fallback failed (%d), trying direct upload\n", ret); ++ } ++ + return vrend_renderer_transfer_write_iov(ctx, dst_res, src_res->iov, + src_res->num_iovs, info); + } +@@ -10550,7 +10857,10 @@ + slice_offset = src_box->z * slice_size; + cube_slice = (src_res->target == GL_TEXTURE_CUBE_MAP) ? src_box->z + src_box->depth : cube_slice; + i = (src_res->target == GL_TEXTURE_CUBE_MAP) ? src_box->z : 0; +- if (slice_offset + src_box->width * src_box->height + cube_slice * slice_size > total_size) { ++ /* Allow depth==0 (treated as 1 slice) and avoid width/height product overflow. */ ++ uint32_t slices_to_copy = src_box->depth ? src_box->depth : 1; ++ uint64_t required_size = (uint64_t)slice_offset + (uint64_t)slices_to_copy * slice_size; ++ if (required_size > total_size) { + virgl_error("Offset out of bound: %d\n", src_box->z); + goto cleanup; + } +@@ -10714,9 +11024,32 @@ + if (dst_res->egl_image) + comp_flags ^= VREND_COPY_COMPAT_FLAG_ONE_IS_EGL_IMAGE; + +- if (has_feature(feat_copy_image) && +- format_is_copy_compatible(src_res->base.format,dst_res->base.format, comp_flags) && +- src_res->base.nr_samples == dst_res->base.nr_samples) { ++ bool allow_copy_image = has_feature(feat_copy_image) && ++ format_is_copy_compatible(src_res->base.format, ++ dst_res->base.format, ++ comp_flags) && ++ src_res->base.nr_samples == dst_res->base.nr_samples; ++ ++ /* ANGLE/Metal on macOS returns GL_INVALID_ENUM for multisample copy_image ++ * targets when running in GLES mode. Prefer the blit fallback instead. ++ */ ++ if (allow_copy_image && ++ (src_res->target == GL_TEXTURE_2D_MULTISAMPLE || ++ src_res->target == GL_TEXTURE_2D_MULTISAMPLE_ARRAY || ++ dst_res->target == GL_TEXTURE_2D_MULTISAMPLE || ++ dst_res->target == GL_TEXTURE_2D_MULTISAMPLE_ARRAY)) { ++ allow_copy_image = false; ++ } ++#ifdef __APPLE__ ++ /* ANGLE GLES on macOS can still advertise copy_image but fail at runtime; ++ * force shader blit when running GLES on Apple to avoid GL_INVALID_ENUM. ++ */ ++ if (allow_copy_image && vrend_state.use_gles) { ++ allow_copy_image = false; ++ } ++#endif ++ ++ if (allow_copy_image) { + VREND_DEBUG(dbg_copy_resource, ctx, "COPY_REGION: use glCopyImageSubData\n"); + vrend_copy_sub_image(src_res, dst_res, src_level, src_box, + dst_level, dstx, dsty, dstz); +@@ -11296,7 +11629,7 @@ + * to resource_copy_region, in this case and if no render states etx need + * to be applied, forward the call to glCopyImageSubData, otherwise do a + * normal blit. */ +- if (has_feature(feat_copy_image) && ++ bool allow_copy_image = has_feature(feat_copy_image) && + (!info->render_condition_enable || !ctx->sub->cond_render_gl_mode) && + format_is_copy_compatible(info->src.format,info->dst.format, comp_flags) && + eglimage_copy_compatible && +@@ -11309,7 +11642,26 @@ + info->dst.box.y + info->dst.box.height <= dst_height && + info->src.box.width == info->dst.box.width && + info->src.box.height == info->dst.box.height && +- info->src.box.depth == info->dst.box.depth) { ++ info->src.box.depth == info->dst.box.depth; ++ ++ /* ANGLE/Metal GLES path returns GL_INVALID_ENUM for copy_image on MSAA ++ * targets; avoid copy_image there. Also prefer shader blit when running ++ * GLES on macOS even for non-MSAA to steer clear of driver quirks. ++ */ ++ if (allow_copy_image && ++ (src_res->target == GL_TEXTURE_2D_MULTISAMPLE || ++ src_res->target == GL_TEXTURE_2D_MULTISAMPLE_ARRAY || ++ dst_res->target == GL_TEXTURE_2D_MULTISAMPLE || ++ dst_res->target == GL_TEXTURE_2D_MULTISAMPLE_ARRAY)) { ++ allow_copy_image = false; ++ } ++#ifdef __APPLE__ ++ if (allow_copy_image && vrend_state.use_gles) { ++ allow_copy_image = false; ++ } ++#endif ++ ++ if (allow_copy_image) { + VREND_DEBUG(dbg_blit, ctx, " Use glCopyImageSubData\n"); + vrend_copy_sub_image(src_res, dst_res, info->src.level, &info->src.box, + info->dst.level, info->dst.box.x, info->dst.box.y, +@@ -12100,6 +12452,16 @@ + static void vrend_fill_caps_glsl_version(int gl_ver, int gles_ver, + union virgl_caps *caps) + { ++#ifdef __APPLE__ ++ /* macOS Metal reports GL 4.1 core but Mesa needs explicit GLSL 4.10. ++ * Force this for any desktop GL context on macOS to avoid fallback to GL 2.1. ++ */ ++ if (gl_ver >= 30 && gles_ver == 0) { ++ caps->v1.glsl_level = 410; ++ return; ++ } ++#endif ++ + if (gles_ver > 0) { + caps->v1.glsl_level = 120; + +@@ -12166,6 +12528,10 @@ + { + int i; + GLint max; ++ const char *gl_version_str = (const char *)glGetString(GL_VERSION); ++ const char *gl_renderer_str = (const char *)glGetString(GL_RENDERER); ++ const bool is_angle = ((gl_version_str && strstr(gl_version_str, "ANGLE")) || ++ (gl_renderer_str && strstr(gl_renderer_str, "ANGLE"))); + + /* + * We can't fully support this feature on GLES, +@@ -12210,9 +12576,27 @@ + + if (has_feature(feat_ubo)) { + glGetIntegerv(GL_MAX_VERTEX_UNIFORM_BLOCKS, &max); ++ const char *version_str = (const char *)glGetString(GL_VERSION); ++ bool is_angle_local = (version_str && strstr(version_str, "ANGLE")); + /* GL_MAX_VERTEX_UNIFORM_BLOCKS is omitting the ordinary uniform block, add it +- * also reduce by 1 as we might generate a VirglBlock helper uniform block */ +- caps->v1.max_uniform_blocks = max + 1 - 1; ++ * also reduce by 1 as we might generate a VirglBlock helper uniform block. ++ * Mesa needs at least 12 per shader after its own adjustments, so report max+1. ++ * ++ * Special handling for ANGLE/Metal: ANGLE clamps reported values to ES spec minimums ++ * (12 for UBOs) even though Metal backend supports 14. Detect ANGLE and report a ++ * higher value to ensure Mesa gets enough after its adjustments. */ ++ if (is_angle_local && max <= 12) { ++ caps->v1.max_uniform_blocks = 14; // ANGLE Metal internal limit ++ virgl_debug("[VREND CAPS] ANGLE backend with UBO limit %d (ES spec minimum), " ++ "overriding to 14 for Mesa compatibility\n", max); ++ } else { ++ caps->v1.max_uniform_blocks = max + 1; ++ virgl_debug("[VREND CAPS] feat_ubo=YES, GL_MAX_VERTEX_UNIFORM_BLOCKS=%d, reporting max_uniform_blocks=%d\n", ++ max, caps->v1.max_uniform_blocks); ++ } ++ } else { ++ virgl_debug("[VREND CAPS] feat_ubo=NO (gl_ver=%d, gles_ver=%d, epoxy_gl_version=%d, epoxy_is_desktop_gl=%d)\n", ++ gl_ver, gles_ver, epoxy_gl_version(), epoxy_is_desktop_gl()); + } + + if (has_feature(feat_depth_clamp)) +@@ -12231,8 +12615,13 @@ + if (has_feature(feat_seamless_cubemap_per_texture)) + caps->v1.bset.seamless_cube_map_per_texture = 1; + +- if (has_feature(feat_texture_multisample)) ++ if (has_feature(feat_texture_multisample)) { + caps->v1.bset.texture_multisample = 1; ++ virgl_debug("[VREND CAPS] feat_texture_multisample enabled, setting caps->v1.bset.texture_multisample=1\n"); ++ } else { ++ virgl_debug("[VREND CAPS] feat_texture_multisample NOT enabled (gl_ver=%d, gles_ver=%d)\n", ++ vrend_state.use_gles ? 0 : gl_ver, vrend_state.use_gles ? gl_ver : 0); ++ } + + if (has_feature(feat_tessellation)) + caps->v1.bset.has_tessellation_shaders = 1; +@@ -12349,6 +12738,7 @@ + + glGetIntegerv(GL_MAX_SAMPLES, &max); + caps->v1.max_samples = max; ++ virgl_debug("[VREND] GL_MAX_SAMPLES from glGetIntegerv: %d\n", max); + + /* All of the formats are common. */ + for (i = 0; i < VIRGL_FORMAT_MAX; i++) { +@@ -12376,6 +12766,7 @@ + GLfloat range[2]; + uint32_t video_memory; + const char *renderer = (const char *)glGetString(GL_RENDERER); ++ const bool angle_in_renderer = (renderer && strstr(renderer, "ANGLE")); + + /* Count this up when you add a feature flag that is used to set a CAP in + * the guest that was set unconditionally before. Then check that flag and +@@ -12383,10 +12774,134 @@ + * run on an old virgl host. Use it also to indicate non-cap fixes on the + * host that help enable features in the guest. */ + caps->v2.host_feature_check_version = 23; ++ if (gles_ver > 0 && angle_in_renderer) ++ caps->v2.host_feature_check_version = 4; + +- /* Forward host GL_RENDERER to the guest. */ +- strncpy(caps->v2.renderer, renderer, sizeof(caps->v2.renderer) - 1); ++ /* Forward host GL_RENDERER to the guest. ++ * ++ * Firefox has an ANGLE-specific GL_RENDERER parser that triggers on the ++ * substring "ANGLE" and may fail hard if the string doesn't match its ++ * expected formats. ++ * ++ * Additionally, Firefox's WebGL renderer sanitizer recognizes a limited set ++ * of device names (e.g. strings starting with "Apple"). The plain "virgl" ++ * renderer name does not match those heuristics. ++ * ++ * For ANGLE-on-Metal, extract the Apple SoC / device name and forward that ++ * (without the "ANGLE" token) so the guest GL_RENDERER can be both parse- and ++ * sanitize-friendly. ++ */ ++ if (renderer && strstr(renderer, "ANGLE Metal Renderer:")) { ++ const char *metal_start = strstr(renderer, "ANGLE Metal Renderer:"); ++ const char *device_start = metal_start + strlen("ANGLE Metal Renderer:"); ++ while (*device_start == ' ') ++ device_start++; + ++ const char *device_end = device_start; ++ while (*device_end && *device_end != ',' && *device_end != ')') ++ device_end++; ++ ++ while (device_end > device_start && device_end[-1] == ' ') ++ device_end--; ++ ++ /* Forward the real Metal device name. ++ * If it isn't vendor-prefixed, try to prefix it with the ANGLE vendor ++ * field from the leading "ANGLE (vendor, ...)" without including the ++ * "ANGLE" token itself. ++ */ ++ if (device_end > device_start) { ++ const size_t device_len = (size_t)(device_end - device_start); ++ ++ /* If already vendor-prefixed (common on Apple), keep as-is. */ ++ if (device_len >= 5 && !strncmp(device_start, "Apple", 5)) { ++ snprintf(caps->v2.renderer, sizeof(caps->v2.renderer), "%.*s", ++ (int)device_len, device_start); ++ } else { ++ const char *angle_prefix = "ANGLE ("; ++ const char *vendor_start = strstr(renderer, angle_prefix); ++ if (vendor_start == renderer) { ++ vendor_start += strlen(angle_prefix); ++ const char *vendor_end = strchr(vendor_start, ','); ++ if (vendor_end && vendor_end > vendor_start) { ++ while (*vendor_start == ' ') ++ vendor_start++; ++ while (vendor_end > vendor_start && vendor_end[-1] == ' ') ++ vendor_end--; ++ } ++ ++ if (vendor_end && vendor_end > vendor_start) { ++ snprintf(caps->v2.renderer, sizeof(caps->v2.renderer), "%.*s %.*s", ++ (int)(vendor_end - vendor_start), vendor_start, ++ (int)device_len, device_start); ++ } else { ++ snprintf(caps->v2.renderer, sizeof(caps->v2.renderer), "%.*s", ++ (int)device_len, device_start); ++ } ++ } else { ++ snprintf(caps->v2.renderer, sizeof(caps->v2.renderer), "%.*s", ++ (int)device_len, device_start); ++ } ++ } ++ } else { ++ strncpy(caps->v2.renderer, "Generic Renderer", sizeof(caps->v2.renderer) - 1); ++ caps->v2.renderer[sizeof(caps->v2.renderer) - 1] = '\0'; ++ } ++ } else if (renderer && !strncmp(renderer, "ANGLE (", 7)) { ++ /* Common ANGLE format: ++ * "ANGLE (Apple, Apple M4 Pro, OpenGL ES 3.2 ... )" ++ * Extract the renderer field (second CSV field) and forward it without ++ * the "ANGLE" token. ++ */ ++ const char *p = renderer + 7; /* after "ANGLE (" */ ++ while (*p == ' ') ++ p++; ++ ++ const char *vendor_start = p; ++ const char *vendor_end = strchr(vendor_start, ','); ++ if (!vendor_end) ++ goto angle_generic; ++ ++ const char *device_start = vendor_end + 1; ++ while (*device_start == ' ') ++ device_start++; ++ ++ const char *device_end = strchr(device_start, ','); ++ if (!device_end) ++ goto angle_generic; ++ ++ while (device_end > device_start && device_end[-1] == ' ') ++ device_end--; ++ ++ const size_t device_len = (size_t)(device_end - device_start); ++ if (!device_len) ++ goto angle_generic; ++ ++ if (device_len >= 5 && !strncmp(device_start, "Apple", 5)) { ++ snprintf(caps->v2.renderer, sizeof(caps->v2.renderer), "%.*s", ++ (int)device_len, device_start); ++ } else { ++ /* If not Apple-prefixed, keep the renderer field as-is (still ++ * avoiding the "ANGLE" token). ++ */ ++ snprintf(caps->v2.renderer, sizeof(caps->v2.renderer), "%.*s", ++ (int)device_len, device_start); ++ } ++ } else { ++ if (renderer) ++ strncpy(caps->v2.renderer, renderer, sizeof(caps->v2.renderer) - 1); ++ else ++ strncpy(caps->v2.renderer, "(null)", sizeof(caps->v2.renderer) - 1); ++ caps->v2.renderer[sizeof(caps->v2.renderer) - 1] = '\0'; ++ } ++ ++ goto angle_done; ++ ++angle_generic: ++ strncpy(caps->v2.renderer, "Generic Renderer", sizeof(caps->v2.renderer) - 1); ++ caps->v2.renderer[sizeof(caps->v2.renderer) - 1] = '\0'; ++ ++angle_done: ++ + /* glamor reject llvmpipe, and since the renderer string is + * composed of "virgl" and this renderer string we have to + * hide the "llvmpipe" part */ +@@ -12507,8 +13022,25 @@ + glGetIntegerv(GL_MAX_IMAGE_SAMPLES, (GLint*)&caps->v2.max_image_samples); + } + +- if (has_feature(feat_storage_multisample)) +- caps->v1.max_samples = vrend_renderer_query_multisample_caps(caps->v1.max_samples, &caps->v2); ++ /* Always call query_multisample_caps - it will handle the case where ++ * GL_ARB_texture_storage_multisample isn't available by skipping the test ++ * and returning the faked max_samples value with graceful downgrade. */ ++ caps->v1.max_samples = vrend_renderer_query_multisample_caps(caps->v1.max_samples, &caps->v2); ++ virgl_debug("[VREND CAPS] After query_multisample_caps: max_samples=%u\n", caps->v1.max_samples); ++ ++ /* For macOS Metal backend: Override to max_samples=1 to trigger Mesa's fake_sw_msaa. ++ * Even though MSAA tests confirm 4 samples work, Mesa's format queries can't verify ++ * multisample support, causing MaxSamples=0. Reporting 1 triggers fake_sw_msaa workaround. ++ * Apply to both desktop GL and GLES (ANGLE) modes. */ ++ const char *version = (const char *)glGetString(GL_VERSION); ++ bool is_metal = (version && strstr(version, "Metal")); ++ bool is_angle = (version && strstr(version, "ANGLE")); ++ if ((is_metal || is_angle) && caps->v1.max_samples > 1) { ++ virgl_debug("[VREND CAPS] %s backend: Overriding max_samples %u -> 1 for fake_sw_msaa\n", ++ is_angle ? "ANGLE" : "Metal", ++ caps->v1.max_samples); ++ caps->v1.max_samples = 1; ++ } + + caps->v2.capability_bits |= VIRGL_CAP_TGSI_INVARIANT | VIRGL_CAP_SET_MIN_SAMPLES | + VIRGL_CAP_TGSI_PRECISE | VIRGL_CAP_APP_TWEAK_SUPPORT; +@@ -12670,6 +13202,10 @@ + readback_str = "readback"; + set_format_bit(&caps->v2.supported_readback_formats, fmt); + } ++ /* Only report MSAA support for formats that actually support it. ++ * Even though we fake max_samples=4 for GL 3.0 requirements, we don't ++ * advertise MSAA format support to prevent Mesa from trying to use MSAA ++ * (which would fail on Metal backend). */ + if (vrend_format_can_multisample(fmt)) { + log_texture_feature = true; + multisample_str = "multisample"; +@@ -12801,6 +13337,7 @@ + if (has_feature(feat_ubo)) { + glGetIntegerv(GL_MAX_UNIFORM_BLOCK_SIZE, &max); + caps->v2.max_uniform_block_size = max; ++ virgl_debug("[VREND CAPS] GL_MAX_UNIFORM_BLOCK_SIZE=%d (Mesa needs >=16384 for UBO)\n", max); + } + + /* Propagate the max of Uniform Components */ +@@ -12911,6 +13448,11 @@ + return; + + vrend_renderer_fill_caps_v2(gl_ver, gles_ver, caps); ++ ++ /* Final caps report */ ++ virgl_debug("[VREND CAPS] FINAL VALUES: glsl_level=%u, max_samples=%u\n", ++ caps->v1.glsl_level, caps->v1.max_samples); ++ + } + + GLint64 vrend_renderer_get_timestamp(void) +@@ -13109,27 +13651,35 @@ + info->stride = util_format_get_nblocksx(res->base.format, u_minify(res->base.width0, 0)) * elsize; + } + +-int +-vrend_renderer_resource_d3d11_texture2d(struct pipe_resource *pres, void **d3d_tex2d) ++void * ++vrend_renderer_resource_d3d11_texture2d(struct pipe_resource *pres) + { + #ifdef WIN32 + struct vrend_resource *res = (struct vrend_resource *)pres; + +- if (!vrend_state.d3d_share_texture) +- return 0; +- +- if (!res->d3d_tex2d) +- return EINVAL; +- +- *d3d_tex2d = res->d3d_tex2d; +- return 0; ++ if (!vrend_state.native_share_texture) ++ return NULL; ++ else ++ return res->d3d_tex2d; + #else + (void)pres; +- (void)d3d_tex2d; +- return ENOTSUP; ++ return NULL; + #endif + } + ++#ifdef ENABLE_METAL ++MTLTexture_id ++vrend_renderer_resource_metal_texture(struct pipe_resource *pres) ++{ ++ struct vrend_resource *res = (struct vrend_resource *)pres; ++ ++ if (!vrend_state.native_share_texture) ++ return NULL; ++ else ++ return res->metal_texture; ++} ++#endif ++ + void vrend_renderer_get_cap_set(uint32_t cap_set, uint32_t *max_ver, + uint32_t *max_size) + { +@@ -13389,9 +13939,6 @@ + }; + struct vrend_resource *gr; + +- if (res->fd_type != VIRGL_RESOURCE_FD_DMABUF) +- return EINVAL; +- + gr = vrend_resource_create(&create_args); + if (!gr) + return ENOMEM; +@@ -13399,6 +13946,10 @@ + #ifdef HAVE_EPOXY_EGL_H + if (egl) { + #ifdef ENABLE_GBM ++ if (res->fd_type != VIRGL_RESOURCE_FD_DMABUF) { ++ FREE(gr); ++ return EINVAL; ++ } + int plane_fds[VIRGL_GBM_MAX_PLANES]; + uint32_t virgl_format; + uint32_t drm_format; +@@ -13439,7 +13990,53 @@ + return ret; + } + +-#else /* ENABLE_GBM */ ++#elif defined(ENABLE_METAL) ++ int ret; ++ ++ if (res->fd_type != VIRGL_RESOURCE_METAL_HEAP) { ++ FREE(gr); ++ return EINVAL; ++ } ++ if (args->plane_count > 1) { ++ virgl_warn("%s: ignoring plane_count = %d and using the first one\n", ++ __func__, args->plane_count); ++ } ++ const struct vrend_metal_texture_description desc = { ++ .width = args->width, ++ .height = args->height, ++ .stride = args->plane_strides[0], ++ .offset = args->plane_offsets[0], ++ .bind = args->bind, ++ .usage = args->usage, ++ .format = args->format, ++ }; ++ MTLTexture_id texture; ++ ++ if (!virgl_metal_create_texture_from_heap(res->metal_heap, ++ &desc, ++ &texture)) { ++ FREE(gr); ++ virgl_error("%s: failed to create texture from MTLHeap\n", __func__); ++ return EINVAL; ++ } ++ gr->egl_image = virgl_egl_metal_image_from_texture(egl, texture); ++ virgl_metal_release_texture(texture); ++ if (!gr->egl_image) { ++ virgl_error("%s: failed to create egl image\n", __func__); ++ FREE(gr); ++ return EINVAL; ++ } ++ ++ gr->storage_bits |= VREND_STORAGE_EGL_IMAGE; ++ gr->is_imported = true; ++ ++ ret = vrend_resource_alloc_texture(gr, args->format, gr->egl_image); ++ if (ret) { ++ virgl_egl_image_destroy(egl, gr->egl_image); ++ FREE(gr); ++ return ret; ++ } ++#else /* !ENABLE_METAL && !ENABLE_GBM */ + FREE(gr); + virgl_error("%s: no EGL/GBM support \n", __func__); + return EINVAL; +diff --git a/src/vrend/vrend_renderer.h b/src/vrend/vrend_renderer.h +--- a/src/vrend/vrend_renderer.h 2026-01-01 08:05:29 ++++ b/src/vrend/vrend_renderer.h 2026-01-11 23:54:22 +@@ -42,6 +42,9 @@ + #ifdef WIN32 + #include + #endif ++#ifdef ENABLE_METAL ++#include "vrend_metal.h" ++#endif + + #ifdef ENABLE_TESTS + /* With this flag set, the transfer will not try to use GBM mappings. +@@ -77,7 +80,7 @@ + #define VREND_STORAGE_HOST_SYSTEM_MEMORY BIT(5) + #define VREND_STORAGE_GL_IMMUTABLE BIT(6) + #define VREND_STORAGE_GL_MEMOBJ BIT(7) +-#define VREND_STORAGE_D3D_TEXTURE BIT(8) ++#define VREND_STORAGE_NATIVE_TEXTURE BIT(8) + + struct vrend_resource { + struct pipe_resource base; +@@ -107,6 +110,9 @@ + #ifdef WIN32 + ID3D11Texture2D *d3d_tex2d; + #endif ++#ifdef ENABLE_METAL ++ MTLTexture_id metal_texture; ++#endif + + uint64_t size; + GLbitfield buffer_storage_flags; +@@ -194,7 +200,7 @@ + #define VREND_USE_EXTERNAL_BLOB (1 << 1) + #define VREND_USE_ASYNC_FENCE_CB (1 << 2) + #define VREND_USE_VIDEO (1 << 3) +-#define VREND_D3D11_SHARE_TEXTURE (1 << 4) ++#define VREND_NATIVE_SHARE_TEXTURE (1 << 4) + #define VREND_USE_COMPAT_CONTEXT (1 << 5) + #define VREND_USE_GLES (1 << 6) + #define VREND_USE_GBM_LAYOUT (1 << 7) +@@ -612,6 +618,8 @@ + + extern const struct vrend_if_cbs *vrend_clicbs; + ++bool vrend_renderer_video_available(void); ++ + int vrend_renderer_export_query(struct pipe_resource *pres, + struct virgl_renderer_export_query *export_query); + +@@ -640,11 +648,16 @@ + + struct vrend_video_context *vrend_context_get_video_ctx(struct vrend_context *ctx); + +-int +-vrend_renderer_resource_d3d11_texture2d(struct pipe_resource *res, void **handle); ++void * ++vrend_renderer_resource_d3d11_texture2d(struct pipe_resource *res); + + int + vrend_renderer_pipe_resource_get_layout(struct vrend_context *ctx, + uint32_t out_res_id, uint32_t res_id); ++ ++#ifdef ENABLE_METAL ++MTLTexture_id ++vrend_renderer_resource_metal_texture(struct pipe_resource *pres); ++#endif + + #endif +diff --git a/src/vrend/vrend_shader.c b/src/vrend/vrend_shader.c +--- a/src/vrend/vrend_shader.c 2026-01-01 08:05:29 ++++ b/src/vrend/vrend_shader.c 2026-01-11 23:47:51 +@@ -6301,10 +6301,12 @@ + + if (ctx->prog_type == TGSI_PROCESSOR_VERTEX && ctx->cfg->use_explicit_locations) + emit_ext(glsl_strbufs, "ARB_explicit_attrib_location", "require"); +- if (ctx->prog_type == TGSI_PROCESSOR_FRAGMENT && fs_emit_layout(ctx)) ++ /* Core GLSL 150 already includes fragment coord layouts; avoid extension require on hosts that omit the ARB string (e.g., macOS core GL). */ ++ if (ctx->prog_type == TGSI_PROCESSOR_FRAGMENT && fs_emit_layout(ctx) && ctx->glsl_ver_required < 150) + emit_ext(glsl_strbufs, "ARB_fragment_coord_conventions", "require"); + +- if (ctx->ubo_used_mask) ++ /* Uniform buffers are core in GLSL 1.40+; only request the ARB extension when targeting older versions. */ ++ if (ctx->ubo_used_mask && ctx->glsl_ver_required < 140) + emit_ext(glsl_strbufs, "ARB_uniform_buffer_object", "require"); + + if (ctx->num_cull_dist_prop || ctx->key->num_in_cull || ctx->key->num_out_cull) +@@ -8178,6 +8180,9 @@ + bret = tgsi_iterate_shader(tokens, &ctx.iter); + if (bret == false) + goto fail; ++ ++ if (ctx.shader_req_bits & SHADER_REQ_INTS) ++ ctx.glsl_ver_required = require_glsl_ver(&ctx, 150); + + if (ctx.shader_req_bits & SHADER_REQ_FP64) + ctx.glsl_ver_required = require_glsl_ver(&ctx, 150); +diff --git a/src/vrend/vrend_winsys_egl.c b/src/vrend/vrend_winsys_egl.c +--- a/src/vrend/vrend_winsys_egl.c 2026-01-01 08:05:29 ++++ b/src/vrend/vrend_winsys_egl.c 2026-01-11 23:47:51 +@@ -32,6 +32,22 @@ + + #define EGL_EGLEXT_PROTOTYPES + #include ++ ++/* ANGLE-specific EGL extension attributes for Metal. ++ * These are defined in ANGLE's eglext_angle.h but we can't include it ++ * directly because epoxy/egl.h sets __eglext_h_ which prevents the ++ * system eglext.h from being included. Define them here instead. ++ */ ++#ifdef ENABLE_METAL ++#ifndef EGL_METAL_DEVICE_ANGLE ++#define EGL_METAL_DEVICE_ANGLE 0x34A6 ++#endif ++ ++#ifndef EGL_METAL_TEXTURE_ANGLE ++#define EGL_METAL_TEXTURE_ANGLE 0x34A7 ++#endif ++#endif ++ + #include + #ifdef WIN32 + #include +@@ -110,6 +126,9 @@ + #ifdef WIN32 + ID3D11Device *d3d11_device; + #endif ++#ifdef ENABLE_METAL ++ MTLDevice_id metal_device; ++#endif + }; + + static bool virgl_egl_has_extension_in_string(const char *haystack, const char *needle) +@@ -575,6 +594,66 @@ + #endif + } + ++static void ++virgl_egl_metal_init(UNUSED struct virgl_egl *egl) ++{ ++#ifdef ENABLE_METAL ++ EGLDeviceEXT device; ++ const char* device_ext = NULL; ++ MTLDevice_id metal_device; ++ ++ if (!has_bits(egl->extension_bits, EGL_EXT_DEVICE_QUERY)) ++ return; ++ ++ if (!egl->funcs.eglQueryDisplayAttrib(egl->egl_display, EGL_DEVICE_EXT, (EGLAttrib*)&device)) ++ return; ++ ++ device_ext = egl->funcs.eglQueryDeviceString(device, EGL_EXTENSIONS); ++ if (!device_ext) ++ return; ++ ++ if (!virgl_egl_has_extension_in_string(device_ext, "EGL_ANGLE_device_metal")) ++ return; ++ ++ if (!egl->funcs.eglQueryDeviceAttrib(device, EGL_METAL_DEVICE_ANGLE, (EGLAttrib*)&metal_device)) ++ return; ++ ++ egl->metal_device = metal_device; ++#endif ++} ++ ++#ifdef ENABLE_METAL ++bool virgl_egl_metal_create_texture(struct virgl_egl *egl, struct pipe_resource *res, ++ uint32_t format, MTLTexture_id *tex) ++{ ++ MTLDevice_id device = egl->metal_device; ++ struct vrend_metal_texture_description desc = { ++ .width = res->width0, ++ .height = res->height0, ++ .bind = res->bind, ++ .usage = res->usage, ++ .format = format, ++ }; ++ ++ return virgl_metal_create_texture(device, &desc, tex); ++} ++ ++EGLImageKHR ++virgl_egl_metal_image_from_texture(struct virgl_egl *egl, MTLTexture_id tex) ++{ ++ const EGLint attribs[] = { ++ EGL_NONE ++ }; ++ ++ if (!egl) ++ return NULL; ++ ++ return eglCreateImageKHR(egl->egl_display, EGL_NO_CONTEXT, ++ EGL_METAL_TEXTURE_ANGLE, (EGLClientBuffer)tex, ++ attribs); ++} ++#endif ++ + struct virgl_egl *virgl_egl_init_external(EGLDisplay egl_display) + { + const char *extensions; +@@ -610,6 +689,7 @@ + #endif + + virgl_egl_win32_init(egl); ++ virgl_egl_metal_init(egl); + return egl; + fail: + free(egl); +@@ -838,7 +918,9 @@ + (EGLClientBuffer)NULL, + attrs); + } ++#endif + ++#if defined(ENABLE_GBM) || defined(ENABLE_METAL) + void virgl_egl_image_destroy(struct virgl_egl *egl, void *image) + { + eglDestroyImageKHR(egl->egl_display, image); +diff --git a/src/vrend/vrend_winsys_egl.h b/src/vrend/vrend_winsys_egl.h +--- a/src/vrend/vrend_winsys_egl.h 2026-01-01 08:05:29 ++++ b/src/vrend/vrend_winsys_egl.h 2026-01-11 23:47:51 +@@ -32,6 +32,9 @@ + #ifdef WIN32 + #include + #endif ++#ifdef ENABLE_METAL ++#include "vrend_metal.h" ++#endif + + struct virgl_egl; + +@@ -80,12 +83,15 @@ + const int *plane_fds, + const uint32_t *plane_strides, + const uint32_t *plane_offsets); +-void virgl_egl_image_destroy(struct virgl_egl *egl, void *image); + + void *virgl_egl_image_from_gbm_bo(struct virgl_egl *egl, struct gbm_bo *bo); + void *virgl_egl_aux_plane_image_from_gbm_bo(struct virgl_egl *egl, struct gbm_bo *bo, int plane); + #endif + ++#if defined(ENABLE_GBM) || defined(ENABLE_METAL) ++void virgl_egl_image_destroy(struct virgl_egl *egl, void *image); ++#endif ++ + bool virgl_egl_supports_fences(struct virgl_egl *egl); + EGLSyncKHR virgl_egl_fence_create(struct virgl_egl *egl); + void virgl_egl_fence_destroy(struct virgl_egl *egl, EGLSyncKHR fence); +@@ -99,6 +105,12 @@ + bool virgl_egl_win32_create_d3d11_texture2d(struct virgl_egl *egl, + const D3D11_TEXTURE2D_DESC *desc, ID3D11Texture2D **tex); + EGLImageKHR virgl_egl_win32_image_from_d3d11_texture2d(struct virgl_egl *egl, ID3D11Texture2D *tex); ++#endif ++ ++#ifdef ENABLE_METAL ++bool virgl_egl_metal_create_texture(struct virgl_egl *egl, struct pipe_resource *res, ++ uint32_t format, MTLTexture_id *tex); ++EGLImageKHR virgl_egl_metal_image_from_texture(struct virgl_egl *egl, MTLTexture_id tex); + #endif + + #endifdiff --git a/src/vrend/vrend_renderer.c b/src/vrend/vrend_renderer.c +--- a/src/vrend/vrend_renderer.c 2026-01-01 08:05:29 ++++ b/src/vrend/vrend_renderer.c 2026-01-13 23:00:00 +@@ -13109,6 +13109,35 @@ void vrend_renderer_resource_get_info(struct pipe_resource *pres, + info->stride = util_format_get_nblocksx(res->base.format, u_minify(res->base.width0, 0)) * elsize; + } + ++void vrend_renderer_borrow_texture_for_scanout(struct pipe_resource *pres) ++{ ++ struct vrend_texture *tex = (struct vrend_texture *)pres; ++ struct vrend_format_table *tex_conv = &tex_conv_table[tex->base.base.format]; ++ ++ assert(tex->base.target == GL_TEXTURE_2D); ++ assert(!util_format_is_depth_or_stencil(tex->base.base.format)); ++ ++ glBindTexture(GL_TEXTURE_2D, tex->base.gl_id); ++ ++ if (tex_conv->flags & VIRGL_TEXTURE_NEED_SWIZZLE) { ++ for (unsigned i = 0; i < ARRAY_SIZE(tex->cur_swizzle); ++i) { ++ GLint next_swizzle = to_gl_swizzle(tex_conv->swizzle[i]); ++ if (tex->cur_swizzle[i] != next_swizzle) { ++ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_SWIZZLE_R + i, next_swizzle); ++ tex->cur_swizzle[i] = next_swizzle; ++ } ++ } ++ } ++ ++ if (tex->cur_srgb_decode != GL_DECODE_EXT && util_format_is_srgb(tex->base.base.format)) { ++ if (has_feature(feat_texture_srgb_decode)) { ++ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_SRGB_DECODE_EXT, ++ GL_DECODE_EXT); ++ tex->cur_srgb_decode = GL_DECODE_EXT; ++ } ++ } ++} ++ + int + vrend_renderer_resource_d3d11_texture2d(struct pipe_resource *pres, void **d3d_tex2d) + { +diff --git a/src/vrend/vrend_renderer.h b/src/vrend/vrend_renderer.h +--- a/src/vrend/vrend_renderer.h 2026-01-01 08:05:29 ++++ b/src/vrend/vrend_renderer.h 2026-01-13 23:00:00 +@@ -564,6 +564,8 @@ struct vrend_blit_info { + void vrend_renderer_resource_get_info(struct pipe_resource *pres, + struct vrend_renderer_renderer_info *info); + ++void vrend_renderer_borrow_texture_for_scanout(struct pipe_resource *pres); ++ + void vrend_renderer_get_cap_set(uint32_t cap_set, uint32_t *max_ver, + uint32_t *max_size); + diff --git a/macos/prepare-qemu-gpu-runtime.sh b/macos/prepare-qemu-gpu-runtime.sh index 54a4a650..768e1304 100755 --- a/macos/prepare-qemu-gpu-runtime.sh +++ b/macos/prepare-qemu-gpu-runtime.sh @@ -4,7 +4,7 @@ set -euo pipefail usage() { cat <<'EOF' -Usage: macos/prepare-qemu-gpu-runtime.sh --source-qemu PATH [--archive-dir DIR] +Usage: macos/prepare-qemu-gpu-runtime.sh --source-qemu PATH [--source-virgl PATH] [--archive-dir DIR] Stage, relocate, validate, and ad-hoc sign the source-built QEMU runtime at: macos/.build/qemu-gpu-runtime @@ -16,6 +16,7 @@ EOF } source_qemu= +source_virgl= archive_cache= while (($#)); do case "$1" in @@ -25,6 +26,12 @@ while (($#)); do source_qemu=$2 shift 2 ;; + --source-virgl) + (($# >= 2)) || { usage >&2; exit 64; } + [[ -z $source_virgl ]] || { usage >&2; exit 64; } + source_virgl=$2 + shift 2 + ;; --archive-dir) (($# >= 2)) || { usage >&2; exit 64; } [[ -z $archive_cache ]] || { usage >&2; exit 64; } @@ -94,6 +101,10 @@ macos_major=$(sw_vers -productVersion | awk -F. '{ print $1 }') [[ $source_qemu == /* ]] || die "--source-qemu must be an absolute path" [[ -f $source_qemu && ! -L $source_qemu && -x $source_qemu ]] || \ die "--source-qemu must name a regular executable: $source_qemu" +if [[ -n $source_virgl ]]; then + [[ $source_virgl == /* && -f $source_virgl && ! -L $source_virgl ]] || \ + die "--source-virgl must name a regular absolute file" +fi [[ -f $entitlements && ! -L $entitlements ]] || \ die "missing QEMU signing entitlements: $entitlements" [[ -x $dependency_bundler && ! -L $dependency_bundler ]] || \ @@ -222,7 +233,7 @@ tar -xzf "$epoxy_archive" -C "$extract_dir" "$epoxy_member" tar -xzf "$angle_archive" -C "$extract_dir" "$egl_member" "$gles_member" install -m 0755 "$source_qemu" "$staged_runtime/bin/qemu-system-aarch64" -install -m 0755 "$extract_dir/$virgl_member" \ +install -m 0755 "${source_virgl:-$extract_dir/$virgl_member}" \ "$staged_runtime/lib/libvirglrenderer.1.dylib" install -m 0755 "$extract_dir/$epoxy_member" "$staged_runtime/lib/libepoxy.0.dylib" install -m 0755 "$extract_dir/$egl_member" "$staged_runtime/lib/libEGL.dylib" diff --git a/macos/run-qemu-gpu.sh b/macos/run-qemu-gpu.sh index 42fb86e0..622fe9e9 100755 --- a/macos/run-qemu-gpu.sh +++ b/macos/run-qemu-gpu.sh @@ -141,6 +141,7 @@ require_qemu_device() { for device in \ hda-micro \ intel-hda \ + omarchy-video-shmem \ virtconsole \ virtserialport \ virtio-balloon-pci \ @@ -386,6 +387,7 @@ runtime = exact_keys( "sharedFolder", "storage", "virtualMachineMonitor", + "video", }, "build spec runtime", ) @@ -403,6 +405,7 @@ expected_devices = [ "intel-hda", "hda-micro", "virtio-9p-pci", + "omarchy-video-shmem", ] clipboard = { "device": "virtserialport", @@ -476,6 +479,16 @@ camera = { "protocolVersion": 1, "width": 1280, } +video = { + "device": "virtserialport", + "port": "dev.tryomarchy.video", + "frameDevice": "omarchy-video-shmem", + "protocolVersion": 1, + "hostDecoder": "videotoolbox", + "guestDriver": "vaapi", + "codecs": ["av1", "hevc", "vp9"], + "maximumSessions": 8, +} storage = { "device": "virtio-blk-pci", "format": "raw", @@ -497,6 +510,7 @@ if ( or runtime.get("network") != network or runtime.get("audio") != audio or runtime.get("camera") != camera + or runtime.get("video") != video or runtime.get("storage") != storage or runtime.get("clipboard") != clipboard or runtime.get("authentication") != authentication @@ -530,6 +544,7 @@ supply_chain_keys = { "archLinuxArmPackagesRepository", "hyprland", "mise", + "nativeVideo", "omarchyPackagesCommit", "omarchyPackagesRepository", "ttfx", @@ -698,7 +713,7 @@ vivaldi = exact_keys( if vivaldi != { "version": "8.2.4133.33", "rpmRelease": 1, - "pkgrel": 2, + "pkgrel": 3, "repository": "https://repo.vivaldi.com/stable", "rpmUrl": "https://downloads.vivaldi.com/stable/vivaldi-stable-8.2.4133.33-1.aarch64.rpm", "rpmSha256": "99fe7542199ba11d16d9af02783540c8c03554c37d80597a219595751414503d", @@ -745,6 +760,17 @@ voxtype_identity = hashlib.sha256( if voxtype_identity != "906951dd6a221d39a63116af86dddf77c202bf8dfca59cccc73536c44cd22669": fail("factory Voxtype component is not the reviewed signed ARM64 release") +if supply_chain.get("nativeVideo") != { + "version": "1.0.0", + "ffmpegVersion": "9.0.1", + "ffmpegUrl": "https://ffmpeg.org/releases/ffmpeg-9.0.1.tar.xz", + "ffmpegSha256": "cf38e0e28c7e5605942c4a77755349b0145804a397af37eb1fb4c77cb237f635", + "patch": "video/ffmpeg-full-bitstream.patch", + "patchSha256": "57301544bb9fd26bf50b1cc07288b58257201993e73bd71d53513045815a325a", + "license": "GPL-3.0-or-later", +}: + fail("native video component is not pinned to the reviewed FFmpeg source") + command_line = runtime.get("kernelCommandLine") if not isinstance(command_line, str) or not command_line or any(character in command_line for character in "\x00\r\n\t"): fail("kernel command line is invalid") @@ -972,6 +998,8 @@ audio_bridge_pid="" authentication_bridge_pid="" camera_bridge_pid="" clipboard_bridge_pid="" +video_bridge_pid="" +video_shm_name="" terminate_child() { local pid=$1 @@ -1011,6 +1039,12 @@ cleanup() { if [[ $clipboard_bridge_pid =~ ^[0-9]+$ ]]; then terminate_child "$clipboard_bridge_pid" 20 fi + if [[ $video_bridge_pid =~ ^[0-9]+$ ]]; then + terminate_child "$video_bridge_pid" 20 + fi + if [[ -n $video_shm_name && ${OMARCHY_QEMU_GPU_DRY_RUN:-0} != 1 ]]; then + "$native_bridge" --remove-native-video-memory "$video_shm_name" 9>&- || true + fi qemu_persistent_storage_release_lock if [[ -n $work_dir && -n $owner_marker && -n $owner_token ]]; then case "$work_dir" in @@ -1262,6 +1296,9 @@ audio_bridge_socket="/tmp/${work_dir##*/}/audio.sock" authentication_bridge_socket="/tmp/${work_dir##*/}/authentication.sock" camera_bridge_socket="/tmp/${work_dir##*/}/camera.sock" clipboard_bridge_socket="/tmp/${work_dir##*/}/clipboard.sock" +video_bridge_socket="/tmp/${work_dir##*/}/video.sock" +video_gpu_socket="/tmp/${work_dir##*/}/video-gpu.sock" +video_shm_name="/tovd.$$.${RANDOM}${RANDOM}" audio_route_dir="/tmp/${work_dir##*/}/audio-routes" mkdir -m 700 "$work_dir/audio-routes" @@ -1431,6 +1468,10 @@ qemu_args=( -device 'virtserialport,bus=omarchy-serial.0,nr=3,chardev=omarchy-authentication-bridge,name=dev.tryomarchy.authentication' -chardev "socket,id=omarchy-camera-bridge,path=$camera_bridge_socket,server=on,wait=off" -device 'virtserialport,bus=omarchy-serial.0,nr=4,chardev=omarchy-camera-bridge,name=dev.tryomarchy.camera' + -chardev "socket,id=omarchy-video-bridge,path=$video_bridge_socket,server=on,wait=off" + -device 'virtserialport,bus=omarchy-serial.0,nr=5,chardev=omarchy-video-bridge,name=dev.tryomarchy.video' + -chardev "socket,id=omarchy-video-gpu,path=$video_gpu_socket,server=on,wait=off" + -device "omarchy-video-shmem,shm-name=$video_shm_name,shm-create=on,gpu-chardev=omarchy-video-gpu" ) if [[ -n $shared_folder ]]; then @@ -1467,6 +1508,8 @@ if [[ ${OMARCHY_QEMU_GPU_DRY_RUN:-0} == 1 ]]; then "$native_bridge" "$authentication_bridge_socket" >&2 printf '\n[qemu-gpu] camera bridge command: %q --bridge-native-camera QEMU_PID %q' \ "$native_bridge" "$camera_bridge_socket" >&2 + printf '\n[qemu-gpu] video bridge command: %q --bridge-native-video QEMU_PID %q %q %q' \ + "$native_bridge" "$video_bridge_socket" "$video_shm_name" "$video_gpu_socket" >&2 if [[ -n $shared_folder ]]; then printf '\n[qemu-gpu] shared folder: %q' "$shared_folder" >&2 else @@ -1496,7 +1539,7 @@ printf '%s\n' "$qemu_pid" >"$work_dir/.qemu.pid" chmod 600 "$work_dir/.qemu.pid" for ((attempt = 0; attempt < 100; attempt++)); do - if [[ -S $qmp_socket && -S $audio_bridge_socket && -S $authentication_bridge_socket && -S $camera_bridge_socket && -S $clipboard_bridge_socket ]]; then + if [[ -S $qmp_socket && -S $audio_bridge_socket && -S $authentication_bridge_socket && -S $camera_bridge_socket && -S $clipboard_bridge_socket && -S $video_bridge_socket && -S $video_gpu_socket ]]; then break fi kill -0 "$qemu_pid" 2>/dev/null || fail "QEMU exited before creating its private QMP socket" @@ -1507,6 +1550,8 @@ done [[ -S $authentication_bridge_socket ]] || fail "QEMU did not create its private authentication bridge socket" [[ -S $camera_bridge_socket ]] || fail "QEMU did not create its private camera bridge socket" [[ -S $clipboard_bridge_socket ]] || fail "QEMU did not create its private clipboard bridge socket" +[[ -S $video_bridge_socket ]] || fail "QEMU did not create its private video bridge socket" +[[ -S $video_gpu_socket ]] || fail "QEMU did not create its private video GPU socket" echo "[qemu-gpu] Ready. QMP: $qmp_socket" >&2 # FD 9 deliberately remains open only in QEMU. Letting the sibling audio @@ -1539,6 +1584,14 @@ start_camera_bridge() { start_camera_bridge camera_bridge_restarts=0 +start_video_bridge() { + "$native_bridge" --bridge-native-video \ + "$qemu_pid" "$video_bridge_socket" "$video_shm_name" "$video_gpu_socket" 9>&- & + video_bridge_pid=$! +} +start_video_bridge +video_bridge_restarts=0 + # Bash 3.2 has no `wait -n`. The native-audio bridge is required for the guest # transport, so watch it alongside QEMU and fail if it exits unexpectedly. while true; do @@ -1619,6 +1672,21 @@ while true; do fi fi fi + # Video failure can fall back in the application; keep the desktop alive. + if [[ $video_bridge_pid =~ ^[0-9]+$ ]]; then + video_bridge_state=$(ps -p "$video_bridge_pid" -o state= 2>/dev/null || true) + if [[ -z $video_bridge_state || $video_bridge_state == *Z* ]]; then + wait "$video_bridge_pid" || true + video_bridge_pid="" + if (( video_bridge_restarts < 5 )); then + video_bridge_restarts=$((video_bridge_restarts + 1)) + echo "[qemu-gpu] restarting native video bridge ($video_bridge_restarts/5)" >&2 + start_video_bridge + else + echo "[qemu-gpu] hardware video decoding is unavailable for this session" >&2 + fi + fi + fi sleep 0.1 done diff --git a/tests/native-video-benchmark.c b/tests/native-video-benchmark.c new file mode 100644 index 00000000..afea7dea --- /dev/null +++ b/tests/native-video-benchmark.c @@ -0,0 +1,218 @@ +/* Identical demux/hash work for software and the real Mac decoder bridge. + * Build in the guest: + * cc -O2 native-video-benchmark.c -lavformat -lavcodec -lavutil -lswscale -o video-benchmark + * Run: video-benchmark software|hardware INPUT.mp4 [PORT] + */ +#define _POSIX_C_SOURCE 200809L +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define MAX_PAYLOAD (64u * 1024 * 1024) +static unsigned frames; +static struct AVSHA *sha; +static uint8_t *pixels; +static size_t pixel_capacity; +static int width, height, hardware_confirmed; +static unsigned video_codec; +static enum AVPixelFormat output_format; +static struct SwsContext *scale; +static const char *frame_resource; +static uint8_t *shared_pixels; +static const size_t shared_size = 512u * 1024 * 1024; +static int frame_fd = -1; + +static void die(const char *message) { fprintf(stderr, "%s\n", message); exit(1); } +static void check(int result) { if (result < 0) { char error[AV_ERROR_MAX_STRING_SIZE]; av_strerror(result, error, sizeof(error)); die(error); } } +static void io_all(int fd, void *buffer, size_t length, int sending) { + uint8_t *cursor = buffer; + while (length) { + ssize_t count = sending ? write(fd, cursor, length) : read(fd, cursor, length); + if (count < 0 && errno == EINTR) continue; + if (count <= 0) die("video transport closed"); + cursor += count; length -= count; + } +} +static uint64_t get(const uint8_t *p, unsigned bytes) { + uint64_t value = 0; + for (unsigned i = 0; i < bytes; ++i) value |= (uint64_t)p[i] << (i * 8); + return value; +} +static void put(uint8_t *p, uint64_t value, unsigned bytes) { + for (unsigned i = 0; i < bytes; ++i) p[i] = value >> (i * 8); +} +static void reserve(size_t length) { + if (length > MAX_PAYLOAD) die("oversized video frame"); + if (length > pixel_capacity) { + uint8_t *new_pixels = realloc(pixels, length); + if (!new_pixels) die("out of memory"); + pixels = new_pixels; pixel_capacity = length; + } +} +static void hash(int64_t token, size_t length) { + if (getenv("OMARCHY_VIDEO_BENCH_NO_HASH")) { ++frames; return; } + uint8_t digest[32]; + av_sha_init(sha, 256); + av_sha_update(sha, pixels, length); + av_sha_final(sha, digest); + printf("%" PRId64 " ", token); + for (unsigned i = 0; i < 32; ++i) printf("%02x", digest[i]); + putchar('\n'); + ++frames; +} +static void request(int fd, unsigned operation, int64_t token, const void *payload, size_t length) { + if (length > MAX_PAYLOAD) die("oversized compressed packet"); + uint8_t header[40] = {0}; + memcpy(header, "TOVD", 4); put(header + 4, 1, 2); put(header + 6, operation, 2); + put(header + 8, 1, 4); put(header + 12, length, 4); put(header + 16, token, 8); + if (operation == 1) { + if (frame_resource) put(header + 32, 1, 4); + put(header + 24, video_codec, 4); + if (video_codec) put(header + 28, (unsigned)width | ((unsigned)height << 16), 4); + } + io_all(fd, header, sizeof(header), 1); + io_all(fd, (void *)payload, length, 1); + for (;;) { + io_all(fd, header, sizeof(header), 0); + size_t size = get(header + 12, 4); + unsigned op = get(header + 6, 2), flags = get(header + 32, 4); + if (memcmp(header, "TOVD", 4) || get(header + 4, 2) != 1 || get(header + 8, 4) != 1 || + get(header + 36, 4) || size > MAX_PAYLOAD) die("invalid bridge response"); + reserve(size + (size < MAX_PAYLOAD)); + io_all(fd, pixels, size, 0); + if (op == 0xffff) { fwrite(pixels, 1, size, stderr); die("\nbridge rejected request"); } + if (op == 0x8100) { + unsigned fmt = flags & 0xff; + if (flags & 0x200) { + if (!shared_pixels || size != 16) die("unexpected shared video frame"); + uint64_t offset = get(pixels, 8); + size = get(pixels + 8, 4); + if (get(pixels + 12, 4) || offset > shared_size || size > shared_size - offset) die("invalid shared frame bounds"); + reserve(size); + memcpy(pixels, shared_pixels + offset, size); + } + if (get(header + 24, 4) != (unsigned)width || get(header + 28, 4) != (unsigned)height || + (fmt != 1 && fmt != 2) || + size != (size_t)width * height * 3 / 2 * (fmt == 2 ? 2 : 1)) die("invalid frame layout"); + hash((int64_t)get(header + 16, 8), size); + if (flags & 0x200) { + put(header + 6, 5, 2); put(header + 12, 0, 4); + memset(header + 24, 0, 16); + io_all(fd, header, sizeof(header), 1); + } + } else if (op == (operation | 0x8000)) { + if (get(header + 16, 8) != (uint64_t)token || size) die("invalid bridge acknowledgement"); + if (op == 0x8001) { + width = get(header + 24, 4); height = get(header + 28, 4); + hardware_confirmed = !!(flags & 0x100); + if (!hardware_confirmed) die("decoder did not confirm hardware use"); + } + return; + } else die("unexpected bridge response"); + } +} +static void software_frames(AVCodecContext *decoder, AVFrame *frame) { + int status; + while ((status = avcodec_receive_frame(decoder, frame)) >= 0) { + width = frame->width; height = frame->height; + enum AVPixelFormat fmt = output_format; + int length = av_image_get_buffer_size(fmt, width, height, 1); + check(length); reserve(length); + uint8_t *planes[4]; int strides[4]; + check(av_image_fill_arrays(planes, strides, pixels, fmt, width, height, 1)); + scale = sws_getCachedContext(scale, width, height, frame->format, width, height, fmt, + SWS_POINT, NULL, NULL, NULL); + if (!scale) die("cannot create pixel converter"); + if (sws_scale(scale, (const uint8_t *const *)frame->data, frame->linesize, 0, height, + planes, strides) != height) die("incomplete pixel conversion"); + hash(frame->pts, length); + av_frame_unref(frame); + } + if (status != AVERROR(EAGAIN) && status != AVERROR_EOF) check(status); +} +int main(int argc, char **argv) { + if (argc < 3) die("usage: video-benchmark software|hardware INPUT [PORT]"); + int hardware = !strcmp(argv[1], "hardware"); + frame_resource = getenv("OMARCHY_VIDEO_RESOURCE"); + if (!hardware && strcmp(argv[1], "software")) die("invalid mode"); + AVFormatContext *input = NULL; + AVDictionary *demux_options = NULL; + av_dict_set(&demux_options, "ignore_editlist", "1", 0); + check(avformat_open_input(&input, argv[2], NULL, &demux_options)); + av_dict_free(&demux_options); + check(avformat_find_stream_info(input, NULL)); + int stream = av_find_best_stream(input, AVMEDIA_TYPE_VIDEO, -1, -1, NULL, 0); + check(stream); + AVCodecParameters *parameters = input->streams[stream]->codecpar; + width = parameters->width; height = parameters->height; + if (parameters->codec_id == AV_CODEC_ID_HEVC) { + if (parameters->extradata_size < 23 || parameters->extradata[0] != 1) die("HEVC requires hvcC"); + output_format = (parameters->extradata[17] & 7) ? AV_PIX_FMT_P010LE : AV_PIX_FMT_NV12; + } else if (parameters->codec_id == AV_CODEC_ID_AV1) { + video_codec = 1; + if (parameters->extradata_size < 4 || parameters->extradata[0] != 0x81) die("AV1 requires av1C"); + output_format = (parameters->extradata[2] & 0x40) ? AV_PIX_FMT_P010LE : AV_PIX_FMT_NV12; + } else die("benchmark requires HEVC or AV1"); + AVCodecContext *decoder = NULL; + AVFrame *frame = av_frame_alloc(); AVPacket *packet = av_packet_alloc(); + sha = av_sha_alloc(); if (!frame || !packet || !sha) die("out of memory"); + int fd = -1; + struct timespec cpu_start, cpu_end; + clock_gettime(CLOCK_PROCESS_CPUTIME_ID, &cpu_start); + int64_t start = av_gettime_relative(); + if (hardware) { + fd = open(argc > 3 ? argv[3] : "/dev/virtio-ports/dev.tryomarchy.video", O_RDWR | O_CLOEXEC); + if (fd < 0 || flock(fd, LOCK_EX | LOCK_NB) < 0) die("cannot acquire video transport"); + if (frame_resource) { + frame_fd = open(frame_resource, O_RDONLY | O_CLOEXEC); + if (frame_fd < 0) die("cannot open video memory PCI resource"); + shared_pixels = mmap(NULL, shared_size, PROT_READ, MAP_SHARED, frame_fd, 0); + if (shared_pixels == MAP_FAILED) die("cannot map video memory PCI resource"); + } + request(fd, 1, 0, parameters->extradata, parameters->extradata_size); + } else { + const AVCodec *codec = avcodec_find_decoder(parameters->codec_id); + decoder = avcodec_alloc_context3(codec); + if (!decoder) die("out of memory"); + check(avcodec_parameters_to_context(decoder, parameters)); + decoder->thread_count = 4; + check(avcodec_open2(decoder, codec, NULL)); + } + unsigned packets = 0; + int status; + while ((status = av_read_frame(input, packet)) >= 0) { + if (packet->stream_index == stream) { + if (hardware) request(fd, 2, packet->pts, packet->data, packet->size); + else { check(avcodec_send_packet(decoder, packet)); software_frames(decoder, frame); } + ++packets; + } + av_packet_unref(packet); + } + if (status != AVERROR_EOF) check(status); + if (hardware) { request(fd, 3, 0, NULL, 0); request(fd, 4, 0, NULL, 0); close(fd); } + else { check(avcodec_send_packet(decoder, NULL)); software_frames(decoder, frame); } + clock_gettime(CLOCK_PROCESS_CPUTIME_ID, &cpu_end); + double seconds = (av_gettime_relative() - start) / 1000000.0; + double cpu = cpu_end.tv_sec - cpu_start.tv_sec + (cpu_end.tv_nsec - cpu_start.tv_nsec) / 1e9; + fprintf(stderr, "{\"mode\":\"%s\",\"hardware\":%s,\"packets\":%u,\"frames\":%u," + "\"seconds\":%.6f,\"cpu_seconds\":%.6f,\"fps\":%.3f}\n", + argv[1], hardware_confirmed ? "true" : "false", packets, frames, seconds, cpu, frames / seconds); + avcodec_free_context(&decoder); av_frame_free(&frame); av_packet_free(&packet); + avformat_close_input(&input); sws_freeContext(scale); av_free(sha); free(pixels); + if (shared_pixels) munmap(shared_pixels, shared_size); + if (frame_fd >= 0) close(frame_fd); + return packets != frames; +} diff --git a/tests/native-video-firefox-pool.cpp b/tests/native-video-firefox-pool.cpp new file mode 100644 index 00000000..9ae72ea5 --- /dev/null +++ b/tests/native-video-firefox-pool.cpp @@ -0,0 +1,25 @@ +// Run with LD_PRELOAD=firefox-video-bootstrap.so and arguments -contentproc rdd +// against the test VM's broker. No hardware decoder is opened by this test. +#include +#include +#include +extern "C" int tovd_acquire_preopened_client(int *, const uint8_t **) __attribute__((weak)); +extern "C" void tovd_release_preopened_client(int, int) __attribute__((weak)); +int main() { + if (!tovd_acquire_preopened_client || !tovd_release_preopened_client) return 1; + int a = -1, b = -1, extra = -1; const uint8_t *pixels = nullptr; + int one = tovd_acquire_preopened_client(&a, &pixels); + int two = tovd_acquire_preopened_client(&b, &pixels); + if (!one || !two || one == two || a == b || !pixels || + tovd_acquire_preopened_client(&extra, &pixels)) return 2; + for (unsigned i = 0; i < 100; ++i) { + tovd_release_preopened_client(one, 1); + if (tovd_acquire_preopened_client(&extra, &pixels) != one || extra != a) return 3; + } + tovd_release_preopened_client(one, 0); + if (fcntl(a, F_GETFD) != -1 || tovd_acquire_preopened_client(&extra, &pixels)) return 4; + tovd_release_preopened_client(two, 1); + if (tovd_acquire_preopened_client(&extra, &pixels) != two || extra != b) return 5; + tovd_release_preopened_client(two, 0); + std::puts("PASS: bounded RDD capabilities, healthy reuse, and corrupt-connection retirement"); +} diff --git a/tests/native-video-gpu.cpp b/tests/native-video-gpu.cpp new file mode 100644 index 00000000..f3207cda --- /dev/null +++ b/tests/native-video-gpu.cpp @@ -0,0 +1,134 @@ +// Linux + running native-video VM integration test. Compare the host GPU path +// against the existing memory path for the same compressed HEVC/AV1 packets. +#include "../guest/video/driver.cpp" +extern "C" { +#include +#include +} +#include +#include +#include +#include +#include +#include +#include + +static void check(bool ok, const char *message) { + if (!ok) throw std::runtime_error(message); +} +int main(int argc, char **argv) try { + check(argc == 2, "usage: native-video-gpu INPUT.mp4"); + AVFormatContext *input = nullptr; + check(avformat_open_input(&input, argv[1], nullptr, nullptr) >= 0 && + avformat_find_stream_info(input, nullptr) >= 0, "cannot open video"); + int stream = av_find_best_stream(input, AVMEDIA_TYPE_VIDEO, -1, -1, nullptr, 0); + check(stream >= 0, "missing video stream"); + auto *codec = input->streams[stream]->codecpar; + check(codec->codec_id == AV_CODEC_ID_HEVC || codec->codec_id == AV_CODEC_ID_AV1, + "test supports HEVC and AV1"); + check(codec->extradata_size >= 23 || (codec->codec_id == AV_CODEC_ID_AV1 && codec->extradata_size >= 4), + "missing codec configuration"); + bool ten_bit = codec->codec_id == AV_CODEC_ID_HEVC ? (codec->extradata[17] & 7) == 2 : codec->extradata[2] & 0x40; + unsigned width = codec->width, height = codec->height, row_bytes = width * (ten_bit ? 2 : 1); + int fd = open("/dev/dri/renderD128", O_RDWR | O_CLOEXEC); + check(fd >= 0, "cannot open render device"); + auto *device = gbm_create_device(fd); + check(device != nullptr, "cannot create GBM device"); + gbm_bo *planes[2]{}; + Surface surface; + surface.width = width;surface.height = height; + surface.fourcc = ten_bit ? VA_FOURCC_P010 : VA_FOURCC_NV12; + std::shared_ptr retained_image; + uint32_t resources[2]{}; + for (unsigned p = 0; p < 2; ++p) { + planes[p] = gbm_bo_create(device, width >> p, height >> p, + ten_bit ? (p ? DRM_FORMAT_GR1616 : DRM_FORMAT_R16) : (p ? DRM_FORMAT_GR88 : DRM_FORMAT_R8), + GBM_BO_USE_RENDERING | GBM_BO_USE_LINEAR); + check(planes[p] != nullptr, "cannot allocate video texture"); + uint32_t pitch; void *cookie = nullptr; + void *data = gbm_bo_map(planes[p], 0, 0, width >> p, height >> p, GBM_BO_TRANSFER_WRITE, &pitch, &cookie); + check(data != nullptr, "cannot initialize video texture"); + memset(data, 0, size_t(pitch) * (height >> p));gbm_bo_unmap(planes[p], cookie); + drm_virtgpu_resource_info info{};info.bo_handle = gbm_bo_get_handle(planes[p]).u32; + check(drmIoctl(fd, DRM_IOCTL_VIRTGPU_RESOURCE_INFO, &info) == 0 && info.res_handle, + "cannot resolve VirGL resource"); + resources[p] = info.res_handle; + surface.planes[p] = planes[p]; + int exported = gbm_bo_get_fd(planes[p]); + check(exported >= 0, "cannot export video texture");close(exported); + drm_virtgpu_3d_wait wait{};wait.handle = info.bo_handle; + check(drmIoctl(fd, DRM_IOCTL_VIRTGPU_WAIT, &wait) == 0, "cannot wait for texture initialization"); + } + tovd::Client cpu, gpu; + tovd::Message open;open.op = 1;open.flags = 1; + open.arg0 = codec->codec_id == AV_CODEC_ID_HEVC ? 0 : 1; + open.arg1 = open.arg0 ? width | (height << 16) : 0; + open.payload.assign(codec->extradata, codec->extradata + codec->extradata_size); + cpu.exchange(open, {});gpu.exchange(open, {}); + check(gpu.supports_gpu(), "host did not advertise GPU transfer"); + std::map> reference; + unsigned compared = 0, copied_on_gpu = 0; + auto expected = [&](const tovd::Message& m, const uint8_t *pixels, size_t size) { + check(pixels && size == size_t(row_bytes) * height * 3 / 2, "invalid reference frame"); + reference[m.token] = {pixels, pixels + size}; + }; + auto actual = [&](const tovd::Message& m, const uint8_t *pixels, size_t size) { + auto found = reference.find(m.token);check(found != reference.end(), "missing reference token"); + store_frame(surface, m, pixels, size); + cache_surface(surface); + check(*surface.pixels == found->second, "VA surface CPU cache differs from decoded pixels"); + if (compared == 5) retained_image = surface.pixels; + if (retained_image) check(*retained_image == found->second, "retained VA image is stale"); + if (compared == 10) retained_image.reset(); + if (m.flags & tovd::gpu_flag) { + check(pixels == nullptr && size == 0, "GPU frame unexpectedly contains CPU pixels"); + ++copied_on_gpu; + for (unsigned p = 0; p < 2; ++p) { + drm_virtgpu_3d_transfer_from_host transfer{}; + transfer.bo_handle = gbm_bo_get_handle(planes[p]).u32; + transfer.box.w = width >> p; transfer.box.h = height >> p;transfer.box.d = 1; + if (drmIoctl(fd, DRM_IOCTL_VIRTGPU_TRANSFER_FROM_HOST, &transfer) != 0) { + std::cerr << "readback errno=" << errno << ' ' << strerror(errno) << '\n'; + throw std::runtime_error("cannot request GPU readback"); + } + drm_virtgpu_3d_wait wait{};wait.handle = transfer.bo_handle; + check(drmIoctl(fd, DRM_IOCTL_VIRTGPU_WAIT, &wait) == 0, "cannot wait for GPU readback"); + uint32_t pitch;void *cookie = nullptr; + auto *data = static_cast(gbm_bo_map(planes[p], 0, 0, width >> p, height >> p, + GBM_BO_TRANSFER_READ, &pitch, &cookie)); + check(data != nullptr, "cannot read back imported texture"); + bool match = true; + for (unsigned y = 0; y < (height >> p); ++y) + match &= !memcmp(data + size_t(y) * pitch, + found->second.data() + (p ? size_t(row_bytes) * height : 0) + size_t(y) * row_bytes, row_bytes); + if (!match) { + for (unsigned x = 0; x < 16; ++x) + std::cerr << unsigned(data[x]) << '/' << unsigned(found->second[(p ? size_t(row_bytes) * height : 0) + x]) << ' '; + std::cerr << " plane=" << p << " frame=" << compared << '\n'; + } + gbm_bo_unmap(planes[p], cookie);check(match, "GPU imported pixels differ from decoder output"); + } + } else check(pixels && size == found->second.size() && !memcmp(pixels, found->second.data(), size), + "fallback pixels differ from decoder output"); + reference.erase(found);++compared; + }; + AVPacket *packet = av_packet_alloc();uint64_t token = 0; + while (compared < 60 && av_read_frame(input, packet) >= 0) { + if (packet->stream_index == stream) { + tovd::Message decode;decode.op = 2;decode.token = ++token; + decode.payload.assign(packet->data, packet->data + packet->size); + cpu.exchange(decode, expected); + decode.flags = 4;decode.arg0 = resources[0];decode.arg1 = resources[1]; + gpu.exchange(decode, actual); + } + av_packet_unref(packet); + } + tovd::Message drain;drain.op = 3;cpu.exchange(drain, expected);gpu.exchange(drain, actual); + check(reference.empty() && compared >= 30 && copied_on_gpu >= compared - 2, "GPU path was not exercised consistently"); + av_packet_free(&packet);avformat_close_input(&input); + for (unsigned p = 0; p < 2; ++p) { surface.planes[p] = nullptr;gbm_bo_destroy(planes[p]); } + gbm_device_destroy(device);close(fd); + std::cout << "PASS: " << compared << " exact frames, " << copied_on_gpu << " via IOSurface GPU import, " + << (ten_bit ? "10" : "8") << " bit\n"; + return 0; +} catch (const std::exception& e) { std::cerr << e.what() << '\n';return 1; } diff --git a/tests/native-video-smoke.py b/tests/native-video-smoke.py new file mode 100644 index 00000000..c857d206 --- /dev/null +++ b/tests/native-video-smoke.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +"""Prepare a reproducible HEVC packet corpus, or decode it inside a Linux guest. + +prepare INPUT.mp4 OUTPUT.tovd: uses ffprobe without decoding the input. +decode INPUT.tovd OUTPUT.json [PORT]: exercises the real virtio/VideoToolbox path. +The report hashes every complete decoded frame and includes transport time. +""" + +import hashlib +import json +import os +import struct +import subprocess +import sys +import time + +HEADER = struct.Struct("<4sHHIIQIIII") +MAGIC = b"TOVD" +MAX_PAYLOAD = 64 * 1024 * 1024 + + +def unhex(data): + return bytes.fromhex("".join(line.split(": ", 1)[1].split(" ", 1)[0] + for line in data.splitlines() if ": " in line)) + + +def message(op, token=0, payload=b""): + return HEADER.pack(MAGIC, 1, op, 1, len(payload), token & ((1 << 64) - 1), 0, 0, 0, 0) + payload + + +def prepare(source, target): + result = json.loads(subprocess.check_output([ + "ffprobe", "-v", "error", "-select_streams", "v:0", "-show_streams", + "-show_packets", "-show_data", "-of", "json", source, + ])) + stream = result["streams"][0] + if stream["codec_name"] != "hevc": + raise ValueError("test corpus must contain HEVC") + config = unhex(stream["extradata"]) + with open(target, "wb") as output: + output.write(message(1, payload=config)) + for packet in result["packets"]: + output.write(message(2, int(packet["pts"]), unhex(packet["data"]))) + output.write(message(3)) + output.write(message(4)) + print(json.dumps({"packets": len(result["packets"]), "width": stream["width"], + "height": stream["height"], "time_base": stream["time_base"]})) + + +def exact(read, count): + parts = [] + remaining = count + while remaining: + part = read(remaining) + if not part: + raise EOFError("video channel closed during response") + parts.append(part) + remaining -= len(part) + return b"".join(parts) + + +def receive(read): + header = exact(read, HEADER.size) + fields = HEADER.unpack(header) + if fields[:2] != (MAGIC, 1) or fields[4] > MAX_PAYLOAD or fields[-1] != 0: + raise ValueError("invalid video response") + return fields, exact(read, fields[4]) + + +def decode(source, target, port): + descriptor = os.open(port, os.O_RDWR | os.O_CLOEXEC) + frames = [] + opened = None + packets = 0 + start = time.monotonic() + try: + with open(source, "rb") as corpus: + while True: + request = corpus.read(HEADER.size) + if not request: + break + fields = HEADER.unpack(request) + payload = exact(corpus.read, fields[4]) + data = request + payload + offset = 0 + while offset < len(data): + offset += os.write(descriptor, data[offset:]) + while True: + response, pixels = receive(lambda n: os.read(descriptor, n)) + op = response[2] + if op == 0xffff: + raise RuntimeError(pixels.decode(errors="replace")) + if op == 0x8100: + width, height, fmt = response[6:9] + expected = width * height * 3 // 2 * (2 if fmt == 2 else 1) + if len(pixels) != expected: + raise ValueError("invalid decoded frame length") + frames.append({"token": response[5], "sha256": hashlib.sha256(pixels).hexdigest(), + "bytes": len(pixels), "format": "p010le" if fmt == 2 else "nv12"}) + elif op == fields[2] | 0x8000: + if op == 0x8001: + opened = {"width": response[6], "height": response[7], + "hardware": bool(response[8] & 0x100)} + break + else: + raise ValueError("unexpected response operation") + packets += fields[2] == 2 + finally: + os.close(descriptor) + elapsed = time.monotonic() - start + if not opened or not opened["hardware"] or len(frames) != packets: + raise RuntimeError("not every packet produced a hardware-decoded frame") + report = {"opened": opened, "packets": packets, "frames": frames, + "elapsed_seconds": elapsed, "fps": len(frames) / elapsed} + with open(target, "w") as output: + json.dump(report, output, indent=2) + print(json.dumps({**opened, "frames": len(frames), "elapsed_seconds": elapsed, + "fps": len(frames) / elapsed})) + + +if __name__ == "__main__": + if sys.argv[1] == "prepare": + prepare(*sys.argv[2:]) + elif sys.argv[1] == "decode": + decode(sys.argv[2], sys.argv[3], sys.argv[4] if len(sys.argv) > 4 + else "/dev/virtio-ports/dev.tryomarchy.video") + else: + raise SystemExit("usage: native-video-smoke.py prepare|decode INPUT OUTPUT [PORT]") diff --git a/tests/native-video-surface.cpp b/tests/native-video-surface.cpp new file mode 100644 index 00000000..8dd73c44 --- /dev/null +++ b/tests/native-video-surface.cpp @@ -0,0 +1,79 @@ +// Linux/virgl integration test: retained DMA-BUF handles and VA CPU images must +// see every reused surface, including direct-upload frames and image aliases. +#include "../guest/video/driver.cpp" +#include +#include + +static void check(bool ok, const char *message) { + if (!ok) throw std::runtime_error(message); +} + +int main(int argc, char **argv) { + try { + int fd = open(argc > 1 ? argv[1] : "/dev/dri/renderD128", O_RDWR | O_CLOEXEC); + check(fd >= 0, "cannot open render device"); + Driver driver; + driver.gbm = gbm_create_device(fd); + check(driver.gbm != nullptr, "cannot create GBM device"); + VADriverContext ctx{}; + ctx.pDriverData = &driver; + for (unsigned fourcc : {VA_FOURCC_NV12, VA_FOURCC_P010}) { + auto s = std::make_unique(); + s->width = 256; s->height = 128; s->fourcc = fourcc; + auto id = driver.id(); + driver.surfaces.emplace(id, std::move(s)); + auto& target = *driver.surfaces.at(id); + VADRMPRIMESurfaceDescriptor dma{}; + check(export_surface(&ctx, id, VA_SURFACE_ATTRIB_MEM_TYPE_DRM_PRIME_2, + VA_EXPORT_SURFACE_READ_ONLY | VA_EXPORT_SURFACE_SEPARATE_LAYERS, + &dma) == VA_STATUS_SUCCESS, "DMA-BUF export failed"); + VAImage retained{}; + for (unsigned frame = 0; frame < 20; ++frame) { + Bytes expected(target.size()); + for (size_t i = 0; i < expected.size(); ++i) + expected[i] = uint8_t(i * 17 + frame * 31 + (i >> 8)); + tovd::Message message{}; + message.flags = fourcc == VA_FOURCC_P010 ? 2 : 1; + message.arg0 = target.width; message.arg1 = target.height; + store_frame(target, message, expected.data(), expected.size()); + check(sync_surface(&ctx, id) == VA_STATUS_SUCCESS, "surface synchronization failed"); + // Independently inspect the actual persistent GPU allocations. + for (unsigned plane = 0; plane < 2; ++plane) { + uint32_t pitch = 0; void *cookie = nullptr; + unsigned rows = plane ? target.height / 2 : target.height; + auto *mapped = static_cast(gbm_bo_map(target.planes[plane], 0, 0, + plane ? target.width / 2 : target.width, rows, + GBM_BO_TRANSFER_READ, &pitch, &cookie)); + check(mapped != nullptr, "GPU readback failed"); + for (unsigned row = 0; row < rows; ++row) + check(!memcmp(mapped + size_t(row) * pitch, + expected.data() + (plane ? size_t(target.stride()) * target.height : 0) + + size_t(row) * target.stride(), target.stride()), "stale DMA-BUF frame"); + gbm_bo_unmap(target.planes[plane], cookie); + } + VAImage image{}; + check(derive_image(&ctx, id, &image) == VA_STATUS_SUCCESS, "derive image failed"); + check(*driver.buffers.at(image.buf).bytes == expected, "stale derived CPU image"); + destroy_image(&ctx, image.image_id); + if (frame == 5) check(derive_image(&ctx, id, &retained) == VA_STATUS_SUCCESS, "alias failed"); + if (frame >= 5 && frame <= 10) + check(*driver.buffers.at(retained.buf).bytes == expected, "retained image alias is stale"); + if (frame == 10) destroy_image(&ctx, retained.image_id); + VAImage copy{}; + check(make_image(&ctx, fourcc, target.width, target.height, {}, ©) == VA_STATUS_SUCCESS, + "create image failed"); + check(get_image(&ctx, id, 0, 0, target.width, target.height, copy.image_id) == VA_STATUS_SUCCESS, + "get image failed"); + check(*driver.buffers.at(copy.buf).bytes == expected, "stale copied CPU image"); + destroy_image(&ctx, copy.image_id); + } + for (unsigned i = 0; i < dma.num_objects; ++i) close(dma.objects[i].fd); + driver.surfaces.erase(id); + } + driver.surfaces.clear(); + gbm_device_destroy(driver.gbm); driver.gbm = nullptr; close(fd); + std::cout << "PASS: NV12/P010 GPU reuse, CPU readback, and retained VA image aliases\n"; + } catch (const std::exception& e) { + std::cerr << e.what() << '\n'; return 1; + } +} diff --git a/tests/native-video-transport.cpp b/tests/native-video-transport.cpp new file mode 100644 index 00000000..d743f9ca --- /dev/null +++ b/tests/native-video-transport.cpp @@ -0,0 +1,58 @@ +// Linux integration checks for partial messages, backpressure and descriptor +// ownership. Run without a VM decoder: only private socket pairs are used. +#include "../guest/video/wire.hpp" +#include +#include +#include +#include + +using Clock = std::chrono::steady_clock; +static void require(bool value, const char *message) { + if (!value) throw std::runtime_error(message); +} +template static void rejects(F operation, const char *message) { + bool threw = false; + try { operation(); } catch (const std::exception&) { threw = true; } + require(threw, message); +} +int main() try { + int pair[2]; + require(!socketpair(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0, pair), "socketpair failed"); + tovd::FD sender(pair[0]), receiver(pair[1]); + tovd::Message original; + original.op = 2; original.token = UINT64_MAX; original.payload = {1, 2, 3, 4}; + original.send(sender.get()); + auto received = tovd::Message::receive(receiver.get()); + require(received.token == UINT64_MAX && received.payload == original.payload, "wire roundtrip failed"); + std::array malformed{}; + memcpy(malformed.data(), "TOVD", 4); tovd::put(malformed.data() + 4, 1, 2); + tovd::put(malformed.data() + 12, tovd::slot_size + 1, 4); + tovd::transfer(sender.get(), malformed.data(), malformed.size(), true); + rejects([&] { tovd::Message::receive(receiver.get()); }, "oversized payload accepted"); + + // A peer that supplies occasional bytes must not extend the whole deadline. + std::atomic done{false}; + std::thread trickle([&] { + uint8_t byte = 0; + while (!done) { + send(sender.get(), &byte, 1, MSG_NOSIGNAL); + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + } + }); + std::array bytes{}; + auto start = Clock::now(); + rejects([&] { tovd::transfer(receiver.get(), bytes.data(), bytes.size(), false, 40); }, "trickle bypassed deadline"); + done = true; trickle.join(); + require(Clock::now() - start < std::chrono::seconds(2), "read did not honor whole-message deadline"); + // Writable readiness is not a guarantee that a large write can complete. + int send_buffer = 1024; + setsockopt(sender.get(), SOL_SOCKET, SO_SNDBUF, &send_buffer, sizeof(send_buffer)); + std::vector large(4 * 1024 * 1024); + start = Clock::now(); + rejects([&] { tovd::transfer(sender.get(), large.data(), large.size(), true, 40); }, "backpressure bypassed deadline"); + require(Clock::now() - start < std::chrono::seconds(2), "send blocked past its deadline"); + std::cout << "PASS: bounded framing, partial reads, and send backpressure\n"; + return 0; +} catch (const std::exception& error) { + std::cerr << error.what() << '\n'; return 1; +} From abf515dc16d7892d502e5b31ab4b553e051adb75 Mon Sep 17 00:00:00 2001 From: Oleg Zuev Date: Tue, 8 Sep 2026 18:24:15 +0300 Subject: [PATCH 2/3] Verify FFmpeg pixels and prevent HDA audio dropouts --- docs/native-video.md | 83 ++++++++++++++ .../90-try-omarchy-quantum.conf | 12 +- guest/scripts/register-local-repository.sh | 2 +- guest/tests/verify.py | 2 +- macos/run-qemu-gpu.sh | 5 +- tests/audio-continuity.py | 63 ++++++++++ tests/native-video-ffmpeg.py | 108 ++++++++++++++++++ 7 files changed, 266 insertions(+), 9 deletions(-) create mode 100644 tests/audio-continuity.py create mode 100644 tests/native-video-ffmpeg.py diff --git a/docs/native-video.md b/docs/native-video.md index 0eaff391..37dce691 100644 --- a/docs/native-video.md +++ b/docs/native-video.md @@ -105,6 +105,45 @@ c++ -std=c++17 -O2 -pthread tests/native-video-gpu.cpp \ diagnostic, `OMARCHY_VIDEO_LOG=1 mpv video.mp4` reports the codec and hardware session; mpv must also report `Using hardware decoding (vaapi)`. +For FFmpeg commands, enable the same process-scoped environment as the player: + +```sh +. /usr/local/lib/omarchy-video/environment.sh +omarchy_video_environment +omarchy_video_private_ffmpeg /usr/bin/ffmpeg +ffmpeg -hwaccel vaapi -hwaccel_device /dev/dri/renderD128 \ + -hwaccel_output_format vaapi -i video.mp4 -an -f null - +``` + +To reproduce the pixel and decode-throughput comparison inside the VM: + +```sh +python3 tests/native-video-ffmpeg.py --output ffmpeg-results \ + --frames 120 --repeats 3 hevc-main.mp4 hevc-main10.mp4 av1-main10.mp4 vp9.webm +``` + +The harness compares decoded pixels, timestamps and frame counts after +`hwdownload` into NV12/P010, then measures decode-only runs separately. It +requires explicit hardware initialization, checks all requested frames, and +alternates hardware/software order across three benchmark repetitions. + +On 2026-09-08, using the built runtime and a regular guest user on the M5 Pro +with 18 vCPUs and 12 GiB RAM, all 480 frames across these four streams matched +software decoding exactly. The following are median decode-only measurements: + +| Stream | Hardware FPS | Software FPS | Hardware guest CPU seconds | Software guest CPU seconds | +| --- | ---: | ---: | ---: | ---: | +| HEVC 1920×1080, 8-bit | 107.91 | 189.27 | 0.157 | 2.253 | +| HEVC 3840×2160, 10-bit | 56.68 | 43.89 | 0.794 | 14.318 | +| AV1 3840×2160, 10-bit | 115.16 | 17.34 | 0.403 | 103.364 | +| VP9 3840×2160, 8-bit | 76.53 | 118.23 | 0.327 | 3.816 | + +CPU time is the FFmpeg process's user plus system time in the guest; it excludes +the host decoder/helper and is not a whole-system energy measurement. Hardware +decoding reduces guest CPU work in these cases but is not always faster in wall +time. Null-output throughput excludes CPU readback and presentation; the +playback measurements below establish the separate end-to-end result. + ## Playback verification On the M5 Pro test machine, 4K/10-bit HEVC and the downloaded AV1 YouTube sample @@ -138,6 +177,50 @@ or playback that slows the media clock likewise does not prove display cadence. These measurements establish 4K60 playback for this machine and tested streams; other Macs and streams need their own playback measurements. +## Audio continuity + +The launcher sets SDL's backend timer to 1 ms (`timer-period=1000`). With the +default 10 ms period, host scheduling delays can fill HDA's 8 KiB output ring +(42.7 ms of stereo 48 kHz S16 audio). QEMU's HDA output callback then discards +the entire ring. This produces phase jumps and missing audio even when the +guest PipeWire graph reports zero xruns. The existing guest quantum stays at +4096; increasing it further does not address this separate host buffer. + +The change was checked on 2026-09-08 with the same runtime, guest, Mac speaker +route, and a 45-second 997 Hz stereo tone while Vivaldi played the 4K60 YouTube +sample muted. Both QEMU's output capture and an SDL callback capture were +analyzed before and after the timer change: + +| Backend timer | Discontinuities per channel | Captured tone duration after SDL | +| --- | ---: | ---: | +| Default 10 ms | 37 | 43.3787 seconds | +| 1 ms | 0 | 45.0000 seconds | + +The SDL capture includes the actual buffers supplied to the host audio backend, +including any inserted silence. It does not measure the physical speaker or +Bluetooth transport. One-millisecond scheduling increases requested timer +wakeups while audio is active; it cannot guarantee continuity during arbitrary +host stalls, suspend, or output-device changes. + +To reproduce the deterministic tone check, generate and play it in the guest: + +```sh +ffmpeg -f lavfi -i sine=frequency=997:sample_rate=48000:duration=45 \ + -ac 2 tone.wav +pw-play tone.wav +``` + +Start a capture before playback with QEMU's human monitor command +`wavcapture /absolute/path/capture.wav omarchy-audio 48000 16 2`, then close it +with `stopcapture 0` after playback. Analyze the host WAV with: + +```sh +python3 tests/audio-continuity.py /absolute/path/capture.wav --duration 45 +``` + +The test checks both channels using a sine recurrence, detects phase jumps and +inserted silence, and requires the complete source duration within 10 ms. + The local upgrade used for these tests retained the application's existing factory image. **Reset Omarchy restores that earlier guest baseline.** A new factory image built from this branch includes the native-video package through diff --git a/guest/native-overlay/usr/share/pipewire/pipewire.conf.d/90-try-omarchy-quantum.conf b/guest/native-overlay/usr/share/pipewire/pipewire.conf.d/90-try-omarchy-quantum.conf index 338fec4c..11086b6f 100644 --- a/guest/native-overlay/usr/share/pipewire/pipewire.conf.d/90-try-omarchy-quantum.conf +++ b/guest/native-overlay/usr/share/pipewire/pipewire.conf.d/90-try-omarchy-quantum.conf @@ -1,18 +1,18 @@ # Try Omarchy: raise the graph quantum for the emulated HDA device. # -# QEMU advances the emulated Intel HDA DMA position from its audio timer, in -# 10 ms steps, so the reported position moves in coarse jumps rather than +# QEMU advances the emulated Intel HDA DMA position from its codec timer, so +# the reported position moves in scheduling-dependent jumps rather than # continuously. ALSA derives elapsed frames from that counter, and at the # default quantum the jumps are large enough relative to a period that the # driver reports xruns even though the 682 ms ring is nowhere near empty. # # A larger quantum means fewer, bigger position checks, which the emulated -# counter can satisfy. Audible dropouts stop; the cost is added latency, which -# is irrelevant for desktop playback. +# counter can satisfy. This addresses guest xruns at the cost of added playback +# latency; it cannot prevent the host backend from discarding audio. # # This is the guest-side half of the problem. The host-side knob is -# -audiodev sdl,timer-period=, which buys the same thing with CPU instead and -# is deliberately left at its default. +# -audiodev sdl,timer-period=1000. It drains the separate HDA output ring into +# SDL frequently enough to avoid full-ring drops under normal video playback. context.properties = { default.clock.quantum = 4096 default.clock.min-quantum = 4096 diff --git a/guest/scripts/register-local-repository.sh b/guest/scripts/register-local-repository.sh index 6e6002df..ab78199a 100755 --- a/guest/scripts/register-local-repository.sh +++ b/guest/scripts/register-local-repository.sh @@ -83,7 +83,7 @@ repo_dir="$root/usr/share/try-omarchy/repo" shopt -s nullglob archives=("$repo_dir"/*.pkg.tar.zst) shopt -u nullglob -expected_archive_count=6 +expected_archive_count=7 (( ${#archives[@]} == expected_archive_count )) || fail "local repository expected $expected_archive_count package archive(s), found ${#archives[@]}" [[ ${archives[*]} == *'/try-omarchy-runtime-'* ]] || fail "local repository is missing the Omarchy runtime" diff --git a/guest/tests/verify.py b/guest/tests/verify.py index e1ca3ebd..a4fd65ed 100755 --- a/guest/tests/verify.py +++ b/guest/tests/verify.py @@ -691,7 +691,7 @@ def main() -> None: "pacman recovery files snapshot the final local-repository configuration", ) check( - "expected_archive_count=6" in local_repository + "expected_archive_count=7" in local_repository and "factory repository is missing pinned ttfx" in local_repository and "factory repository is missing pinned yay" in local_repository and "factory repository is missing patched Hyprland" in local_repository diff --git a/macos/run-qemu-gpu.sh b/macos/run-qemu-gpu.sh index 622fe9e9..bf0b4417 100755 --- a/macos/run-qemu-gpu.sh +++ b/macos/run-qemu-gpu.sh @@ -1434,7 +1434,10 @@ qemu_args=( -action 'reboot=reset,shutdown=poweroff' -netdev "$qemu_netdev" -device 'virtio-net-pci,netdev=omarchy-net,mac=52:54:00:12:34:56,romfile=' - -audiodev 'sdl,id=omarchy-audio' + # Drain HDA into SDL every millisecond. The default 10 ms backend timer can + # leave HDA's 42.7 ms output ring full after scheduling delays; HDA then drops + # the entire ring, producing clicks even when PipeWire reports no xruns. + -audiodev 'sdl,id=omarchy-audio,timer-period=1000' -device 'intel-hda,id=omarchy-hda,romfile=' -device 'hda-micro,bus=omarchy-hda.0,audiodev=omarchy-audio' -serial none diff --git a/tests/audio-continuity.py b/tests/audio-continuity.py new file mode 100644 index 00000000..4a528073 --- /dev/null +++ b/tests/audio-continuity.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +"""Check a captured 16-bit PCM sine for discontinuities and missing duration. + +Generate the source with FFmpeg's sine filter, play through the guest, and +capture the host output. This checks the actual audio path, not video counters. +""" + +import argparse +import array +import json +import math +import sys +import wave + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("capture") + parser.add_argument("--frequency", type=float, default=997) + parser.add_argument("--duration", type=float, required=True) + args = parser.parse_args() + with wave.open(args.capture) as source: + if source.getsampwidth() != 2 or source.getcomptype() != "NONE": + parser.error("capture must be uncompressed 16-bit PCM") + rate, channels = source.getframerate(), source.getnchannels() + samples = array.array("h", source.readframes(source.getnframes())) + if sys.byteorder != "little": + samples.byteswap() + if not 0 < args.frequency < rate / 2 or args.duration <= 0: + parser.error("frequency must be below Nyquist and duration positive") + results = [] + for channel in range(channels): + values = samples[channel::channels] + peak = max(map(abs, values), default=0) + if peak < 100: + raise SystemExit(f"Channel {channel} is silent or too quiet to measure") + active = [i for i, value in enumerate(values) if abs(value) > peak * 0.02] + start, end = active[0], active[-1] + # A sine obeys y[n] = 2*cos(w)*y[n-1] - y[n-2]. Phase jumps, + # inserted silence and dropped samples violate that recurrence. + coefficient = 2 * math.cos(2 * math.pi * args.frequency / rate) + threshold = max(20, peak * 0.05) + groups = [] + previous = -rate + maximum = 0.0 + for i in range(start + 2, end + 1): + residual = abs(values[i] - coefficient * values[i - 1] + values[i - 2]) + maximum = max(maximum, residual) + if residual > threshold: + if i - previous > rate / 100: + groups.append(round((i - start) / rate, 6)) + previous = i + duration = (end - start + 1) / rate + results.append({"channel": channel, "durationSeconds": duration, + "peak": peak, "maximumResidual": maximum, + "discontinuities": len(groups), "timesSeconds": groups, + "passed": not groups and abs(duration - args.duration) < 0.01}) + print(json.dumps({"sampleRate": rate, "channels": results}, indent=2)) + return 0 if all(result["passed"] for result in results) else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/native-video-ffmpeg.py b/tests/native-video-ffmpeg.py new file mode 100644 index 00000000..b5ee5f65 --- /dev/null +++ b/tests/native-video-ffmpeg.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +"""Run inside the guest: compare VA-API pixels and timing with software FFmpeg.""" + +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import statistics +import subprocess +import time + + +def run(command, log, environment): + started = time.monotonic() + with log.open("w") as output: + subprocess.run(command, env=environment, stdout=output, + stderr=subprocess.STDOUT, check=True, timeout=180) + text = log.read_text() + timing = re.search(r"bench: utime=([\d.]+)s stime=([\d.]+)s rtime=([\d.]+)s", text) + counts = re.findall(r"frame=\s*(\d+)", text) + if timing is None or not counts: + raise RuntimeError(f"Missing FFmpeg benchmark result: {log}") + user, system, wall = map(float, timing.groups()) + frames = int(counts[-1]) + return {"frames": frames, "wallSeconds": wall, "cpuSeconds": user + system, + "fps": frames / wall, "processSeconds": time.monotonic() - started} + + +def frame_rows(path): + return [tuple(part.strip() for part in line.split(",")) + for line in path.read_text().splitlines() if line and not line.startswith("#")] + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("inputs", nargs="+", type=Path) + parser.add_argument("--output", required=True, type=Path) + parser.add_argument("--frames", type=int, default=120) + parser.add_argument("--repeats", type=int, default=3) + parser.add_argument("--device", default="/dev/dri/renderD128") + args = parser.parse_args() + if args.frames < 1 or args.repeats < 1: + parser.error("frames and repeats must be positive") + if len({p.stem for p in args.inputs}) != len(args.inputs): + parser.error("input filenames must have distinct stems") + if any(not p.is_file() for p in args.inputs): + parser.error("all inputs must be existing media files") + args.output.mkdir(parents=True, exist_ok=False) + environment = dict(os.environ, LIBVA_DRIVER_NAME="omarchy", + LIBVA_DRIVERS_PATH="/usr/lib/dri", OMARCHY_VIDEO_LOG="1") + # Scope the private, ABI-compatible libraries to these verification processes. + environment["LD_LIBRARY_PATH"] = "/usr/local/lib/omarchy-video/ffmpeg/lib" + version = subprocess.check_output(["/usr/bin/ffmpeg", "-version"], + env=environment, text=True) + report = {"ffmpegVersion": version, "cases": [], "driverSha256": hashlib.sha256( + Path("/usr/lib/dri/omarchy_drv_video.so").read_bytes()).hexdigest()} + common = ["/usr/bin/ffmpeg", "-hide_banner", "-nostdin", "-nostats", "-benchmark"] + for source in args.inputs: + probe = json.loads(subprocess.check_output([ + "/usr/bin/ffprobe", "-v", "error", "-select_streams", "v:0", + "-show_entries", "stream=codec_name,pix_fmt,width,height,r_frame_rate", + "-of", "json", str(source)], env=environment, text=True))["streams"][0] + if probe["pix_fmt"] not in ("yuv420p", "yuv420p10le"): + raise RuntimeError(f"Unsupported test pixel format: {probe}") + pixel_format = "p010le" if probe["pix_fmt"] == "yuv420p10le" else "nv12" + case = dict(input=source.name, inputSha256=hashlib.sha256(source.read_bytes()).hexdigest(), + stream=probe, hashes={}, measurements={"hardware": [], "software": []}) + def command(mode): + hw = ["-hwaccel", "vaapi", "-hwaccel_device", args.device, + "-hwaccel_output_format", "vaapi"] if mode == "hardware" else ["-hwaccel", "none"] + return common + hw + ["-i", str(source), "-map", "0:v:0", "-an", "-sn", + "-frames:v", str(args.frames)] + for mode in ("hardware", "software"): + output = args.output / f"{source.stem}-{mode}.framemd5" + log = args.output / f"{source.stem}-{mode}-hash.log" + filters = ("hwdownload," if mode == "hardware" else "") + "format=" + pixel_format + case["hashes"][mode] = run(command(mode) + ["-vf", filters, "-f", "framemd5", str(output)], + log, environment) + if mode == "hardware" and "hardware decoder opened" not in log.read_text(): + raise RuntimeError(f"Hardware decoder was not confirmed: {log}") + hw_rows = frame_rows(args.output / f"{source.stem}-hardware.framemd5") + sw_rows = frame_rows(args.output / f"{source.stem}-software.framemd5") + if len(hw_rows) != args.frames or hw_rows != sw_rows: + raise RuntimeError(f"Frame count, timestamps or pixels differ: {source}") + case["identicalFrames"] = len(hw_rows) + # Alternate order so the same mode is not always measured first. + for iteration in range(args.repeats): + order = ("hardware", "software") if iteration % 2 == 0 else ("software", "hardware") + for mode in order: + log = args.output / f"{source.stem}-{mode}-null-{iteration}.log" + measured = run(command(mode) + ["-f", "null", "-"], log, environment) + if measured["frames"] != args.frames: + raise RuntimeError(f"Incomplete benchmark: {log}") + case["measurements"][mode].append(measured) + case["median"] = {mode: {metric: statistics.median(m[metric] for m in measurements) + for metric in ("fps", "wallSeconds", "cpuSeconds")} + for mode, measurements in case["measurements"].items()} + report["cases"].append(case) + (args.output / "results.json").write_text(json.dumps(report, indent=2) + "\n") + print(json.dumps({"input": source.name, "identicalFrames": len(hw_rows), + "median": case["median"]}), flush=True) + print(f"PASS: {len(report['cases'])} streams, {args.frames} exact frames each") + + +if __name__ == "__main__": + main() From 38f7f2b7011002eb72d781147f9eb95960efba62 Mon Sep 17 00:00:00 2001 From: Oleg Zuev Date: Wed, 9 Sep 2026 01:11:59 +0300 Subject: [PATCH 3/3] Add native HDR presentation and align SDR client brightness --- .gitattributes | 4 +- README.md | 3 + THIRD_PARTY_NOTICES.md | 9 +- docs/native-hdr.md | 156 ++++ docs/native-video.md | 15 +- guest/build.sh | 5 + guest/hdr/environment.sh | 23 + guest/hdr/linux-source-sha256.json | 19 + guest/hdr/mesa-es3-norm16.patch | 11 + guest/hdr/mpv-wayland-color.patch | 29 + guest/hdr/package.install | 12 + guest/hdr/sources.json | 29 + guest/hdr/virtio-gpu-hdr.patch | 302 +++++++ .../usr/local/bin/omarchy-native-display-sync | 62 +- guest/patches/hyprland/client-sdr-white.patch | 79 ++ guest/scripts/register-local-repository.sh | 3 +- guest/scripts/register-native-hdr.sh | 215 +++++ guest/scripts/register-patched-hyprland.sh | 26 +- guest/spec.json | 10 +- guest/tests/test_native_hdr.py | 67 ++ guest/tests/verify.py | 95 +- guest/video/mpv | 14 + guest/video/vivaldi.sh | 2 +- macos/Tests/run-qemu-ssh-contract.test.sh | 11 +- macos/build-qemu-gpu-runtime.sh | 8 + macos/patches/qemu-cocoa-hdr.patch | 839 ++++++++++++++++++ macos/patches/qemu-cocoa-sdr-white.patch | 165 ++++ macos/run-qemu-gpu.sh | 15 +- tests/native-client-sdr-white.py | 80 ++ tests/native-hdr-presenter.m | 122 +++ tests/native-hdr-presenter.py | 44 + tests/native-sdr-white.py | 69 ++ 32 files changed, 2514 insertions(+), 29 deletions(-) create mode 100644 docs/native-hdr.md create mode 100644 guest/hdr/environment.sh create mode 100644 guest/hdr/linux-source-sha256.json create mode 100644 guest/hdr/mesa-es3-norm16.patch create mode 100644 guest/hdr/mpv-wayland-color.patch create mode 100644 guest/hdr/package.install create mode 100644 guest/hdr/sources.json create mode 100644 guest/hdr/virtio-gpu-hdr.patch create mode 100644 guest/patches/hyprland/client-sdr-white.patch create mode 100755 guest/scripts/register-native-hdr.sh create mode 100644 guest/tests/test_native_hdr.py create mode 100644 macos/patches/qemu-cocoa-hdr.patch create mode 100644 macos/patches/qemu-cocoa-sdr-white.patch create mode 100644 tests/native-client-sdr-white.py create mode 100644 tests/native-hdr-presenter.m create mode 100644 tests/native-hdr-presenter.py create mode 100644 tests/native-sdr-white.py diff --git a/.gitattributes b/.gitattributes index 9fb5c3f3..f9371a05 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,2 @@ -# Blank context lines, including the final hunk's context, are patch syntax. -*.patch whitespace=-blank-at-eol,-blank-at-eof +# Blank lines and a space before context indentation are patch syntax. +*.patch whitespace=-blank-at-eol,-blank-at-eof,-space-before-tab diff --git a/README.md b/README.md index bee48524..4d0cfed1 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ Omarchy's trademark rights. - Hardware-accelerated ARM64 virtualization and VirGL graphics - Nested KVM virtualization on M3 and newer Apple Silicon - Hardware video decoding through the Mac's VideoToolbox media engine +- HDR10 output for YouTube in Vivaldi and local files in mpv - Resizable native window with automatic guest resolution and HiDPI scale updates - Mac audio input/output selection inside Omarchy, with live routing and system-default fallback - FaceTime HD and other Mac cameras exposed to Omarchy as an on-demand 720p webcam @@ -30,6 +31,8 @@ The native video path supports HEVC, AV1 and VP9 in the bundled mpv integration, HEVC in Firefox, and VP9 YouTube playback in Vivaldi. Available codecs depend on the Mac's hardware. See [native video](docs/native-video.md) for application setup, verification and measured performance limits. +See [native HDR](docs/native-hdr.md) for the paired display driver, HDR output +verification and kernel compatibility requirements. ## Changes in this fork diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 861f35a2..7a6e6cc3 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -12,7 +12,7 @@ code. - **Arch Linux ARM packages** — each package retains its own license. The generated package transaction is recorded in `packages.lock.txt`. - **Hyprland** — BSD-3-Clause; the reviewed v0.56.1 source and rounded-border - coverage backport are pinned in `guest/spec.json`. The guest package retains + coverage and client SDR-white patches are pinned in `guest/spec.json`. The guest package retains Hyprland's upstream license and dependency metadata. - **Glaze** — MIT; the pinned v7.2.0 headers are used by the Hyprland build, and their verified upstream license is retained in the rebuilt guest package. @@ -24,6 +24,13 @@ code. the guest under `/usr/share/try-omarchy/native-video-source`; its GPL notice is in `/usr/share/licenses/try-omarchy-native-video`. The system FFmpeg package remains separate. Linked Arch packages retain their own notices. +- **Linux virtio-gpu, Mesa 26.2.1 and mpv 0.41.0** — the native-HDR package + contains a patched GPL-2.0-only kernel module, Mesa under its component + licenses (primarily MIT), and mpv under GPL-2.0-or-later plus linked-library + obligations. Pinned source archives/files, patches and the build recipe are + included under `/usr/share/try-omarchy/native-hdr-source`; license notices + are in `/usr/share/licenses/try-omarchy-native-hdr`. Source identities are + recorded in `guest/hdr/sources.json`. - **PyYAML and packaging** — pinned build dependencies used to build VirGLRenderer and QEMU, respectively; MIT for PyYAML and Apache-2.0/BSD for packaging. They are not loaded by the application at runtime. diff --git a/docs/native-hdr.md b/docs/native-hdr.md new file mode 100644 index 00000000..f6323f38 --- /dev/null +++ b/docs/native-hdr.md @@ -0,0 +1,156 @@ +# Native HDR output + +The native QEMU display can present HDR10/PQ video from Vivaldi's YouTube player +and local files opened with `mpv`. This extends the hardware decoder described in +[native video](native-video.md): preserving ten decoded bits alone does not make +an eight-bit SDR desktop an HDR display. + +## Display path + +The paired guest driver exposes ten-bit RGB scanout and the DRM `Colorspace` and +`HDR_OUTPUT_METADATA` properties. Hyprland composites into BT.2020/PQ. QEMU copies +that scanout into an RGBA16Float IOSurface and presents it through a Metal layer +with extended dynamic range enabled. The Metal shader converts PQ to linear +BT.2020, with linear 1.0 representing 100 cd/m² in the content's metadata. + +ANGLE and Metal share each IOSurface. An exported Metal shared event orders their +GPU work; production presentation does not read back pixels or wait for frame +completion on the CPU. Metal drawable acquisition runs on a separate serial +queue so WindowServer backpressure cannot block QEMU's main loop. If all three +surfaces are in flight, the next refresh retries with the latest guest frame. +The presenter also holds a macOS activity token so App Nap cannot throttle +the running VM when its window loses focus. Normal system sleep remains +allowed. The pool holds three reusable surfaces. At 3840×2160 these +surfaces use approximately 190 MiB, in addition to Metal's drawable pool and the +existing guest framebuffer resources. Surface allocation is bounded to an 8K +pixel count in either display orientation. + +The virtual EDID advertises PQ, BT.2020 RGB, a nominal 1000-nit peak and 400-nit +frame-average peak. CTA encodes the nominal peak as approximately 993 nits. These +are virtual display capabilities, not a measurement of the Mac's panel. macOS +system tone mapping adapts the extended-range output to the display containing +the window. Cocoa queries the active display preset's maximum SDR luminance and +passes it to the guest in an additional-text EDID descriptor (`TO-SDR:`). +The display helper uses that cap for SDR desktop white, bounded by the virtual +display's 1000-nit output. Screen and display-parameter changes update the hint +through the existing display hotplug path. PQ video retains its absolute +luminance; the presenter's 100 cd/m² linear reference and HDR metadata stay +unchanged. The brightness slider on the Mac continues to work normally. + +The host query dynamically loads optional private CoreDisplay APIs to read +`PresetMaxSDRLuminance` from the selected display's active preset. It is a +capability hint, not a panel measurement. It never substitutes HDR peak +brightness, the backlight cap, an EDR ratio, or a Mac model name for an SDR +maximum. Unknown hosts, monitors without a readable preset, and malformed +values retain a 250-nit fallback. An explicit guest monitor rule still wins. +For example, the standard XDR preset may report 600 nits for SDR, while its +separate outdoor auto-brightness limit is 1000 nits and its HDR peak is 1600. + +## Compatibility and application setup + +The transport is a **private paired extension**, not an upstream virtio HDR +standard: feature bit 6, command `0x0400`, magic `0x544f4844`, version 1. The +command contains a 24-byte virtio header and eighteen little-endian 32-bit +fields. Metadata is associated with its scanout resource and latched at that +resource's flush. Unsupported transfers, invalid values and unknown resources +are rejected. Reset and resource destruction clear pending state. + +The launcher enables `x-omarchy-hdr=on` and Cocoa `hdr=on` only when the runtime +exposes the private property. The EDID advertises HDR only after the guest +negotiates the feature and the host presenter is available. An older guest +driver therefore continues to expose its ordinary SDR display. If the HDR +presenter cannot initialize, QEMU uses its SDR presentation path. + +`omarchy-native-display-sync` enables ten-bit HDR only when valid EDID extension +blocks contain both PQ/static metadata and BT.2020 RGB support. Explicit user +monitor rules keep their existing precedence. Vivaldi enables Wayland color +management in addition to its existing VP9 hardware decode flags. + +The pinned Hyprland package also advertises this SDR-white level in Wayland +output and preferred-surface descriptions. Chromium composes its interface into +PQ, so leaving these descriptions at Hyprland's fixed 203-nit reference makes +the browser dim beside an unmanaged wallpaper mapped to the host's SDR maximum. +`guest/patches/hyprland/client-sdr-white.patch` updates client descriptions and +notifies existing surfaces when SDR brightness changes. The compositor's HDR +render description, PQ transfer function, mastering metadata, and tone mapping +remain unchanged. SDR and ICC outputs retain their existing descriptions. +Chromium also uses the advertised white level for its own HDR tone mapping, so +raising it can brighten video midtones while retaining HDR highlights. Keeping +the compositor's PQ interpretation unchanged does not freeze a client's rendering. + +The mpv wrapper selects the private HDR runtime only for an active ten-bit +virtual HDR monitor. Its OpenGL output uses a ten-bit EGL surface and publishes +the renderer's actual target parameters through Wayland color management. The +bundled target is BT.2020/PQ. Command-line options supplied by the user still +take precedence. + +The private Mesa build changes the minimum GLES version for +`EXT_texture_norm16` from 3.1 to 3.0, matching revision 6 of the Khronos extension. +This makes the existing R16/RG16 support available to mpv's P010 importer. The +private mpv patch supplies the Wayland color surface that this pinned Mesa EGL +backend does not manage. Both libraries are scoped to mpv's process; they do +not replace the system Mesa or mpv packages. + +## Building and installing + +`python3 tests/native-client-sdr-white.py` compiles the client-white policy from +the actual Hyprland patch and checks display values, bounds, and preservation of +the internal HDR description. Runtime validation compares the half-float PQ +scanout for a Chromium white page and an unmanaged white surface, then checks +that mpv's explicitly targeted PQ output stays unchanged when SDR white changes. +A tagged VP9/PQ browser fixture separately verifies Chromium's HDR highlights +and its adaptation of midtones to the new white level. These are digital signal +checks; measuring the physical panel luminance requires a colorimeter. + +`guest/scripts/register-native-hdr.sh` runs after native video registration in +the guest builder. `guest/hdr/sources.json` pins source archives, patches, kernel +source files and additional build tools. The output package contains: + +- A virtio-gpu module for **7.2.2-2-aarch64-ARCH**, under `updates/omarchy-hdr`. +- Private Mesa 26.2.1 and mpv 0.41.0 under `/usr/local/lib/omarchy-hdr`. +- Corresponding source archives, patches, build recipe and license notices. +- Source and binary hashes in `/usr/share/try-omarchy/native-hdr.json`. + +Package installation, upgrade and removal regenerate module dependencies and +the initramfs. A saved VM must also receive the initramfs paired with its updated +disk; installing a module only into the root filesystem leaves an older +initramfs driver active. Preserve the VM's existing kernel, command line and +boot-kit identity, and update its initramfs checksum when performing a local +upgrade. + +This is an exact-kernel module, not a DKMS build against arbitrary future +kernels. A newer guest kernel uses its stock SDR driver until a corresponding +HDR package is released. The factory-image build includes the package; an +existing VM upgraded locally does not change the app's older factory image. + +## Verification + +`make test` checks the guest display policy, private runtime selection, source +digests and launcher capability gating. The additional macOS integration test +executes the actual presenter code extracted from the QEMU patch: + +```sh +python3 tests/native-hdr-presenter.py \ + --angle-include /path/to/angle/include \ + --epoxy-include /path/to/libepoxy/include +``` + +It checks SDR → HDR → SDR transitions, BT.709-to-BT.2020 primaries, image +orientation, 100/1000-nit PQ inputs and bounded nonblocking submission while +the presentation queue is stalled. GPU readback and CPU completion waits +belong only to this test. Its numerical result is not a photometer measurement. + +Inside the running guest, inspect `hyprctl -j monitors` for +`XRGB2101010`, `colorManagementPreset: hdr` and SDR luminance matching the +host hint (or 250 when unavailable). `python3 tests/native-sdr-white.py` checks +the native cap conversion and reports the current host hint. For video, +verify the selected decoder, source profile, P010 pixel format and target +BT.2020/PQ separately. Browser quality labels or a ten-bit source file alone +do not prove HDR presentation. + +HDR10 is the tested output contract. Dolby Vision dynamic metadata is not +transported. On the development M5 Pro, sustained checks cover visible YouTube +VP9 Profile 2 at 3840x2160/60, local AV1 10-bit at 3840x2160/60 and local HEVC +Main 10 at 3840x2160/30. The checks measure frame drops after startup and compare +media time with wall time. They are machine-specific playback results, not a +performance guarantee for every Mac or file. diff --git a/docs/native-video.md b/docs/native-video.md index 37dce691..b9643dca 100644 --- a/docs/native-video.md +++ b/docs/native-video.md @@ -1,5 +1,7 @@ # Native hardware video +For ten-bit HDR presentation in Vivaldi and mpv, see [native HDR](native-hdr.md). + The guest's `omarchy` VA-API driver sends compressed video to a supervised Swift helper on the Mac. VideoToolbox sessions require hardware decoding and must confirm `UsingHardwareAcceleratedVideoDecoder=true` before they are exposed to @@ -221,7 +223,12 @@ python3 tests/audio-continuity.py /absolute/path/capture.wav --duration 45 The test checks both channels using a sine recurrence, detects phase jumps and inserted silence, and requires the complete source duration within 10 ms. -The local upgrade used for these tests retained the application's existing -factory image. **Reset Omarchy restores that earlier guest baseline.** A new -factory image built from this branch includes the native-video package through -the guest build integration described above. +The September 8 playback tests used an upgraded existing guest. A subsequent +clean-install check reassembled an unprovisioned, verified factory base with +the current native overlay and verified native-video, HDR and Hyprland packages, +then finalized and repacked it with the project scripts. First boot on a new +user disk reached the graphical desktop; no prior user state was imported. +This verifies the assembled factory image and clean setup, not a complete +from-source container build: the latter stopped because pinned Rust +`1:1.98.0-1` was no longer available from the current Arch ARM repository. +The Rust pin was retained and the verified existing ttfx package was reused. diff --git a/guest/build.sh b/guest/build.sh index 69e8f768..3fd3124a 100755 --- a/guest/build.sh +++ b/guest/build.sh @@ -263,6 +263,11 @@ python3 "$guest_dir/scripts/apply-omarchy-backports.py" --root "$root" --spec "$ --work "$work" \ --spec "$spec" \ --pacman-config "$pacman_config" +"$guest_dir/scripts/register-native-hdr.sh" \ + --root "$root" \ + --work "$work" \ + --spec "$spec" \ + --pacman-config "$pacman_config" "$guest_dir/scripts/register-local-repository.sh" --root "$root" --spec "$spec" arch-chroot "$root" /usr/local/lib/try-omarchy/finalize-rootfs arch-chroot "$root" pacman -Q | LC_ALL=C sort >"$root/usr/share/try-omarchy/packages.lock.txt" diff --git a/guest/hdr/environment.sh b/guest/hdr/environment.sh new file mode 100644 index 00000000..d5e3f8cf --- /dev/null +++ b/guest/hdr/environment.sh @@ -0,0 +1,23 @@ +# Private libraries are scoped to mpv on the active virtual HDR output. +omarchy_hdr_available() { + [[ -n ${WAYLAND_DISPLAY:-} && + -x /usr/local/lib/omarchy-hdr/mpv/bin/mpv && + -f /usr/local/lib/omarchy-hdr/mesa/lib/libEGL_mesa.so.0 ]] || return 1 + hyprctl -j monitors 2>/dev/null | python3 -c ' +import json, sys +try: + displays = json.load(sys.stdin) + ready = any(d.get("name", "").startswith("Virtual-") and + d.get("colorManagementPreset") == "hdr" and + d.get("currentFormat") in ("XRGB2101010", "XBGR2101010") + for d in displays) +except (ValueError, TypeError, AttributeError): + ready = False +raise SystemExit(0 if ready else 1) +' +} + +omarchy_hdr_environment() { + export LD_LIBRARY_PATH="/usr/local/lib/omarchy-hdr/mesa/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" + export LIBGL_DRIVERS_PATH=/usr/local/lib/omarchy-hdr/mesa/lib/dri +} diff --git a/guest/hdr/linux-source-sha256.json b/guest/hdr/linux-source-sha256.json new file mode 100644 index 00000000..c1e0c451 --- /dev/null +++ b/guest/hdr/linux-source-sha256.json @@ -0,0 +1,19 @@ +{ + "virtgpu_drv.c": "b17b50f36256796fa1f5638e4a8c27aaa1e4dde188b8d5bd8488512d0dd316bb", + "virtgpu_kms.c": "3cff197c1e865818c1e9dbc037533bc33cab7fc99492f69f88e45e24ecc66b38", + "virtgpu_gem.c": "dac05e53e7ee9aa1d34a3eb1dfd0775f769693596b56fb489510f79866464cfc", + "virtgpu_vram.c": "6ecee932f9dec3b569554146df3e6da0e4c6219c3cadfe10c26fd95c04860c05", + "virtgpu_display.c": "7f59852820db273beb01a953a982b11e7dc3493d1a0502979104581024476324", + "virtgpu_vq.c": "daf1120c701bed8abed0766213efd30a52a74aef60b15464febd647e3b96d885", + "virtgpu_fence.c": "e53db12ed4148a0f10fdac94955e2d6fc8c21025970e2376f7399a263e15d5fc", + "virtgpu_object.c": "9daae8920b0f0c1f12aca27e24ca3de63a5b86dfbc2992d65bf3e5c13a44eb58", + "virtgpu_debugfs.c": "4249086e79e970de5fb160c224f368e63bcbf7cfaf012e9a6fd22a96ff05f6bd", + "virtgpu_plane.c": "f214902bf33fb569900c7a40ea87072be6c4ee11e52c9ae1d2999c76a5db9769", + "virtgpu_ioctl.c": "2c5c58339580a070473ce4c1eb09342e85cdb3ae5dd5260b6920ca680b3edd27", + "virtgpu_prime.c": "fd3eb065ff4a67aefc9f73f942e61565dba3f056d633884bdb0733785fcd3397", + "virtgpu_trace_points.c": "f076a7c647b7f6a2e36315ba5a56cd7f6e1be98179b98eaf623a9ce72c48443a", + "virtgpu_submit.c": "9e11d9b6c34b90652f8fa4b3bf5da85362bbdbdb2285aed78a5902ff31736611", + "virtgpu_drv.h": "e2efd5568c8fd144ec842fed69c4b7eea7a93aa47768381916f453e76d3c9eeb", + "virtgpu_trace.h": "0f3c8851f7e42743315014d3f9ddfea56b4abfc053305d5f46fd935da45e2ca0", + "Makefile": "b093778baa45116a242fb278c06c8b62d9c1dd5663d79d61233348afe5137222" +} diff --git a/guest/hdr/mesa-es3-norm16.patch b/guest/hdr/mesa-es3-norm16.patch new file mode 100644 index 00000000..053c1606 --- /dev/null +++ b/guest/hdr/mesa-es3-norm16.patch @@ -0,0 +1,11 @@ +--- a/src/mesa/main/extensions_table.h ++++ b/src/mesa/main/extensions_table.h +@@ -341,7 +341,7 @@ + EXT(EXT_texture_lod_bias , dummy_true , GLL, x , ES1, x , 1999) + EXT(EXT_texture_mirror_clamp , EXT_texture_mirror_clamp , GLL, GLC, x , x , 2004) + EXT(EXT_texture_mirror_clamp_to_edge , ARB_texture_mirror_clamp_to_edge , x , x , x , ES2, 2017) +-EXT(EXT_texture_norm16 , EXT_texture_norm16 , x , x , x , 31, 2014) ++EXT(EXT_texture_norm16 , EXT_texture_norm16 , x , x , x , 30, 2014) + EXT(EXT_texture_object , dummy_true , GLL, x , x , x , 1995) + EXT(EXT_texture_query_lod , ARB_texture_query_lod , x , x , x , 30, 2019) + EXT(EXT_texture_rectangle , NV_texture_rectangle , GLL, x , x , x , 2004) diff --git a/guest/hdr/mpv-wayland-color.patch b/guest/hdr/mpv-wayland-color.patch new file mode 100644 index 00000000..6ceae777 --- /dev/null +++ b/guest/hdr/mpv-wayland-color.patch @@ -0,0 +1,29 @@ +--- a/video/out/opengl/context_wayland.c ++++ b/video/out/opengl/context_wayland.c +@@ -20,6 +20,11 @@ + #include + #include + ++#include "config.h" ++#if HAVE_WAYLAND_PROTOCOLS_1_41 ++#include "color-management-v1.h" ++#endif ++ + #include "video/out/present_sync.h" + #include "video/out/wayland_common.h" + #include "context.h" +@@ -145,6 +150,14 @@ + p->gl.DrawBuffer(GL_BACK); + } + ++ // This private build is paired with Omarchy's pinned Mesa EGL runtime, ++ // which does not own a Wayland color-management surface. Publish the ++ // renderer's actual target parameters before every buffer commit. ++#if HAVE_WAYLAND_PROTOCOLS_1_41 ++ if (wl->color_manager && !wl->color_surface) ++ wl->color_surface = wp_color_manager_v1_get_surface( ++ wl->color_manager, wl->surface); ++#endif + eglSwapInterval(p->egl_display, 0); + } + diff --git a/guest/hdr/package.install b/guest/hdr/package.install new file mode 100644 index 00000000..b963ab35 --- /dev/null +++ b/guest/hdr/package.install @@ -0,0 +1,12 @@ +refresh_hdr_initramfs() { + # Pacman retains this install script when removing the package, so the + # fallback initramfs is rebuilt even after the package files are gone. + for directory in /usr/lib/modules/*; do + test -d "$directory/kernel" || continue + depmod -a "${directory##*/}" || return 1 + done + mkinitcpio -P +} +post_install() { refresh_hdr_initramfs; } +post_upgrade() { refresh_hdr_initramfs; } +post_remove() { refresh_hdr_initramfs; } diff --git a/guest/hdr/sources.json b/guest/hdr/sources.json new file mode 100644 index 00000000..10431ffe --- /dev/null +++ b/guest/hdr/sources.json @@ -0,0 +1,29 @@ +{ + "version": "1.0.0", + "kernelVersion": "7.2.2", + "kernelRelease": "7.2.2-2-aarch64-ARCH", + "linuxBaseUrl": "https://raw.githubusercontent.com/gregkh/linux/v7.2.2/drivers/gpu/drm/virtio/", + "linuxManifestSha256": "6b93280bdb830b9643ac47458f924a1887dff5d46577f2432ebfbf2bd6444e8d", + "mesa": { + "version": "26.2.1", + "url": "https://archive.mesa3d.org/mesa-26.2.1.tar.xz", + "sha256": "c47e81bddc4760360a41ac3c5acec38acb81f9d750ecef47e7f3adc7021a4442" + }, + "mpv": { + "version": "0.41.0", + "url": "https://codeload.github.com/mpv-player/mpv/tar.gz/refs/tags/v0.41.0", + "sha256": "ee21092a5ee427353392360929dc64645c54479aefdb5babc5cfbb5fad626209" + }, + "patches": { + "mesa-es3-norm16.patch": "6a7099a1412295843ea5de11d81f41f43cb4b58f679ba4a87a6108e93cb2a8e0", + "mpv-wayland-color.patch": "1d73f289f099675faba42775280b91d8aff8a2b74965a0138db9a928276c86b6", + "virtio-gpu-hdr.patch": "4bb5e42a65d9e86bcb054254cc892fe606fdf90333b27debf9c140711c03bbc0" + }, + "buildPackages": { + "bison": "3.8.2-8", + "flex": "2.6.4-6", + "python-mako": "1.3.12-1", + "python-packaging": "26.3-1", + "python-yaml": "6.0.3-2" + } +} diff --git a/guest/hdr/virtio-gpu-hdr.patch b/guest/hdr/virtio-gpu-hdr.patch new file mode 100644 index 00000000..8c772391 --- /dev/null +++ b/guest/hdr/virtio-gpu-hdr.patch @@ -0,0 +1,302 @@ +diff --git a/Makefile b/Makefile +--- a/Makefile ++++ b/Makefile +@@ -9,3 +9,5 @@ + virtgpu_ioctl.o virtgpu_prime.o virtgpu_trace_points.o virtgpu_submit.o + + obj-$(CONFIG_DRM_VIRTIO_GPU) += virtio-gpu.o ++ ++ccflags-y += -I$(src) +diff --git a/omarchy_hdr.h b/omarchy_hdr.h +--- /dev/null ++++ b/omarchy_hdr.h +@@ -0,0 +1,15 @@ ++/* SPDX-License-Identifier: GPL-2.0 WITH Linux-syscall-note */ ++#ifndef OMARCHY_VIRTIO_HDR_H ++#define OMARCHY_VIRTIO_HDR_H ++/* Private, opt-in Try Omarchy ABI v1. Not a standard virtio feature. */ ++#define VIRTIO_GPU_F_OMARCHY_HDR 6 ++#define VIRTIO_GPU_CMD_OMARCHY_HDR 0x0400 ++#define OMARCHY_HDR_MAGIC 0x544f4844 ++struct virtio_gpu_omarchy_hdr { ++ struct virtio_gpu_ctrl_hdr hdr; ++ __le32 magic, version, scanout_id, resource_id; ++ __le32 colorspace, eotf; ++ __le32 primaries[6], whitepoint[2]; ++ __le32 min_luminance, max_luminance, max_cll, max_fall; ++}; ++#endif +diff --git a/virtgpu_display.c b/virtgpu_display.c +--- a/virtgpu_display.c ++++ b/virtgpu_display.c +@@ -94,6 +94,7 @@ + struct virtio_gpu_device *vgdev = dev->dev_private; + struct virtio_gpu_output *output = drm_crtc_to_virtio_gpu_output(crtc); + ++ output->hdr_initialized = false; + virtio_gpu_cmd_set_scanout(vgdev, output->index, 0, + crtc->mode.hdisplay, + crtc->mode.vdisplay, 0, 0); +@@ -115,6 +116,7 @@ + + drm_crtc_vblank_off(crtc); + ++ output->hdr_initialized = false; + virtio_gpu_cmd_set_scanout(vgdev, output->index, 0, 0, 0, 0, 0); + virtio_gpu_notify(vgdev); + } +@@ -157,7 +159,47 @@ + spin_unlock_irq(&dev->event_lock); + } + ++static void virtio_gpu_crtc_atomic_begin(struct drm_crtc *crtc, ++ struct drm_atomic_commit *state) ++{ ++ struct virtio_gpu_device *vgdev = crtc->dev->dev_private; ++ struct virtio_gpu_output *output = drm_crtc_to_virtio_gpu_output(crtc); ++ struct drm_connector_state *conn = output->conn.state; ++ struct virtio_gpu_omarchy_hdr cmd = {}; ++ const struct hdr_metadata_infoframe *m = NULL; ++ unsigned int i; ++ if (!vgdev->has_omarchy_hdr || !conn) return; ++ cmd.magic = cpu_to_le32(OMARCHY_HDR_MAGIC); ++ cmd.version = cpu_to_le32(1); ++ cmd.scanout_id = cpu_to_le32(output->index); ++ cmd.colorspace = cpu_to_le32(conn->colorspace); ++ if (conn->hdr_output_metadata) ++ m = &((struct hdr_output_metadata *)conn->hdr_output_metadata->data)->hdmi_metadata_type1; ++ if (m) { ++ cmd.eotf = cpu_to_le32(m->eotf); ++ for (i = 0; i < 3; ++i) { ++ cmd.primaries[i*2] = cpu_to_le32(m->display_primaries[i].x); ++ cmd.primaries[i*2+1] = cpu_to_le32(m->display_primaries[i].y); ++ } ++ cmd.whitepoint[0] = cpu_to_le32(m->white_point.x); ++ cmd.whitepoint[1] = cpu_to_le32(m->white_point.y); ++ cmd.min_luminance = cpu_to_le32(m->min_display_mastering_luminance); ++ cmd.max_luminance = cpu_to_le32(m->max_display_mastering_luminance); ++ cmd.max_cll = cpu_to_le32(m->max_cll); ++ cmd.max_fall = cpu_to_le32(m->max_fall); ++ } ++ /* Compare metadata before binding it to this commit's scanout resource. */ ++ if (output->hdr_initialized && !memcmp(&cmd, &output->last_hdr, sizeof(cmd))) return; ++ output->last_hdr = cmd; ++ output->hdr_initialized = true; ++ if (crtc->primary->state->fb) ++ cmd.resource_id = cpu_to_le32(gem_to_virtio_gpu_obj(crtc->primary->state->fb->obj[0])->hw_res_handle); ++ virtio_gpu_cmd_omarchy_hdr(vgdev, &cmd); ++ virtio_gpu_notify(vgdev); ++} ++ + static const struct drm_crtc_helper_funcs virtio_gpu_crtc_helper_funcs = { ++ .atomic_begin = virtio_gpu_crtc_atomic_begin, + .mode_set_nofb = virtio_gpu_crtc_mode_set_nofb, + .atomic_check = virtio_gpu_crtc_atomic_check, + .atomic_flush = virtio_gpu_crtc_atomic_flush, +@@ -238,7 +280,32 @@ + .disable = virtio_gpu_enc_disable, + }; + ++static int virtio_gpu_conn_atomic_check(struct drm_connector *connector, ++ struct drm_atomic_commit *state) ++{ ++ struct drm_connector_state *conn = drm_atomic_get_new_connector_state(state, connector); ++ struct drm_property_blob *blob = conn->hdr_output_metadata; ++ const struct hdr_output_metadata *m; ++ struct drm_crtc_state *crtc; ++ if (blob) { ++ if (blob->length != sizeof(*m)) return -EINVAL; ++ m = blob->data; ++ if (m->metadata_type || m->hdmi_metadata_type1.metadata_type || ++ (m->hdmi_metadata_type1.eotf != 0 && m->hdmi_metadata_type1.eotf != 2)) return -EINVAL; ++ if (m->hdmi_metadata_type1.eotf == 2 && conn->colorspace != DRM_MODE_COLORIMETRY_BT2020_RGB) return -EINVAL; ++ } ++ if (conn->crtc && (!drm_connector_atomic_hdr_metadata_equal(connector->state, conn) || ++ connector->state->colorspace != conn->colorspace)) { ++ crtc = drm_atomic_get_crtc_state(state, conn->crtc); ++ if (IS_ERR(crtc)) return PTR_ERR(crtc); ++ crtc->color_mgmt_changed = true; ++ return drm_atomic_add_affected_planes(state, conn->crtc); ++ } ++ return 0; ++} ++ + static const struct drm_connector_helper_funcs virtio_gpu_conn_helper_funcs = { ++ .atomic_check = virtio_gpu_conn_atomic_check, + .get_modes = virtio_gpu_conn_get_modes, + .mode_valid = virtio_gpu_conn_mode_valid, + }; +@@ -306,6 +373,17 @@ + if (vgdev->has_edid) + drm_connector_attach_edid_property(connector); + ++ if (vgdev->has_omarchy_hdr) { ++ drm_connector_attach_hdr_output_metadata_property(connector); ++ ret = drm_mode_create_hdmi_colorspace_property(connector, ++ BIT(DRM_MODE_COLORIMETRY_DEFAULT) | BIT(DRM_MODE_COLORIMETRY_BT2020_RGB)); ++ if (ret) ++ return ret; ++ ret = drm_connector_attach_colorspace_property(connector); ++ if (ret) ++ return ret; ++ } ++ + drm_simple_encoder_init(dev, encoder, DRM_MODE_ENCODER_VIRTUAL); + drm_encoder_helper_add(encoder, &virtio_gpu_enc_helper_funcs); + encoder->possible_crtcs = 1 << index; +@@ -325,7 +403,12 @@ + struct virtio_gpu_framebuffer *virtio_gpu_fb; + int ret; + +- if (mode_cmd->pixel_format != DRM_FORMAT_HOST_XRGB8888 && ++ struct virtio_gpu_device *vgdev = dev->dev_private; ++ bool hdr_format = vgdev->has_omarchy_hdr && ++ (mode_cmd->pixel_format == DRM_FORMAT_XRGB2101010 || ++ mode_cmd->pixel_format == DRM_FORMAT_XBGR2101010); ++ ++ if (!hdr_format && mode_cmd->pixel_format != DRM_FORMAT_HOST_XRGB8888 && + mode_cmd->pixel_format != DRM_FORMAT_HOST_ARGB8888) + return ERR_PTR(-ENOENT); + +@@ -333,6 +416,12 @@ + obj = drm_gem_object_lookup(file_priv, mode_cmd->handles[0]); + if (!obj) + return ERR_PTR(-EINVAL); ++ ++ /* 10-bit scanout requires a VirGL texture; 2D/dumb transport stays 8-bit. */ ++ if (hdr_format && gem_to_virtio_gpu_obj(obj)->dumb) { ++ drm_gem_object_put(obj); ++ return ERR_PTR(-EINVAL); ++ } + + virtio_gpu_fb = kzalloc_obj(*virtio_gpu_fb); + if (virtio_gpu_fb == NULL) { +diff --git a/virtgpu_drv.c b/virtgpu_drv.c +--- a/virtgpu_drv.c ++++ b/virtgpu_drv.c +@@ -167,6 +167,7 @@ + VIRTIO_GPU_F_RESOURCE_BLOB, + VIRTIO_GPU_F_CONTEXT_INIT, + VIRTIO_GPU_F_BLOB_ALIGNMENT, ++ VIRTIO_GPU_F_OMARCHY_HDR, + }; + static struct virtio_driver virtio_gpu_driver = { + .feature_table = features, +diff --git a/virtgpu_drv.h b/virtgpu_drv.h +--- a/virtgpu_drv.h ++++ b/virtgpu_drv.h +@@ -31,6 +31,7 @@ + #include + #include + #include ++#include "omarchy_hdr.h" + + #include + #include +@@ -175,6 +176,8 @@ + }; + + struct virtio_gpu_output { ++ struct virtio_gpu_omarchy_hdr last_hdr; ++ bool hdr_initialized; + int index; + struct drm_crtc crtc; + struct drm_connector conn; +@@ -252,6 +255,7 @@ + struct ida ctx_id_ida; + + bool has_virgl_3d; ++ bool has_omarchy_hdr; + bool has_edid; + bool has_indirect; + bool has_resource_assign_uuid; +@@ -330,6 +334,8 @@ + void virtio_gpu_array_put_free_work(struct work_struct *work); + + /* virtgpu_vq.c */ ++void virtio_gpu_cmd_omarchy_hdr(struct virtio_gpu_device *vgdev, ++ const struct virtio_gpu_omarchy_hdr *metadata); + int virtio_gpu_alloc_vbufs(struct virtio_gpu_device *vgdev); + void virtio_gpu_free_vbufs(struct virtio_gpu_device *vgdev); + void virtio_gpu_cmd_create_resource(struct virtio_gpu_device *vgdev, +diff --git a/virtgpu_kms.c b/virtgpu_kms.c +--- a/virtgpu_kms.c ++++ b/virtgpu_kms.c +@@ -166,6 +166,8 @@ + if (virtio_has_feature(vgdev->vdev, VIRTIO_GPU_F_VIRGL)) + vgdev->has_virgl_3d = true; + #endif ++ vgdev->has_omarchy_hdr = vgdev->has_virgl_3d && ++ virtio_has_feature(vgdev->vdev, VIRTIO_GPU_F_OMARCHY_HDR); + if (virtio_has_feature(vgdev->vdev, VIRTIO_GPU_F_EDID)) + vgdev->has_edid = true; + +diff --git a/virtgpu_plane.c b/virtgpu_plane.c +--- a/virtgpu_plane.c ++++ b/virtgpu_plane.c +@@ -38,6 +38,10 @@ + DRM_FORMAT_HOST_XRGB8888, + }; + ++static const uint32_t virtio_gpu_hdr_formats[] = { ++ DRM_FORMAT_XRGB8888, DRM_FORMAT_XRGB2101010, DRM_FORMAT_XBGR2101010, ++}; ++ + static const uint32_t virtio_gpu_cursor_formats[] = { + DRM_FORMAT_HOST_ARGB8888, + }; +@@ -260,8 +264,12 @@ + return; + } + +- if (!drm_atomic_helper_damage_merged(old_state, plane->state, &rect)) ++ if (vgdev->has_omarchy_hdr && output->crtc.state->color_mgmt_changed) { ++ drm_rect_init(&rect, plane->state->src_x >> 16, plane->state->src_y >> 16, ++ plane->state->src_w >> 16, plane->state->src_h >> 16); ++ } else if (!drm_atomic_helper_damage_merged(old_state, plane->state, &rect)) { + return; ++ } + + bo = gem_to_virtio_gpu_obj(plane->state->fb->obj[0]); + if (bo->dumb) +@@ -594,8 +602,8 @@ + nformats = ARRAY_SIZE(virtio_gpu_cursor_formats); + funcs = &virtio_gpu_cursor_helper_funcs; + } else { +- formats = virtio_gpu_formats; +- nformats = ARRAY_SIZE(virtio_gpu_formats); ++ formats = vgdev->has_omarchy_hdr ? virtio_gpu_hdr_formats : virtio_gpu_formats; ++ nformats = vgdev->has_omarchy_hdr ? ARRAY_SIZE(virtio_gpu_hdr_formats) : ARRAY_SIZE(virtio_gpu_formats); + funcs = &virtio_gpu_primary_helper_funcs; + } + +diff --git a/virtgpu_trace.h b/virtgpu_trace.h +--- a/virtgpu_trace.h ++++ b/virtgpu_trace.h +@@ -52,5 +52,5 @@ + #endif + + #undef TRACE_INCLUDE_PATH +-#define TRACE_INCLUDE_PATH ../../drivers/gpu/drm/virtio ++#define TRACE_INCLUDE_PATH . + #include +diff --git a/virtgpu_vq.c b/virtgpu_vq.c +--- a/virtgpu_vq.c ++++ b/virtgpu_vq.c +@@ -645,6 +645,16 @@ + virtio_gpu_cleanup_object(bo); + } + ++void virtio_gpu_cmd_omarchy_hdr(struct virtio_gpu_device *vgdev, ++ const struct virtio_gpu_omarchy_hdr *metadata) ++{ ++ struct virtio_gpu_vbuffer *vbuf; ++ struct virtio_gpu_omarchy_hdr *cmd = virtio_gpu_alloc_cmd(vgdev, &vbuf, sizeof(*cmd)); ++ *cmd = *metadata; ++ cmd->hdr.type = cpu_to_le32(VIRTIO_GPU_CMD_OMARCHY_HDR); ++ virtio_gpu_queue_ctrl_buffer(vgdev, vbuf); ++} ++ + void virtio_gpu_cmd_set_scanout(struct virtio_gpu_device *vgdev, + uint32_t scanout_id, uint32_t resource_id, + uint32_t width, uint32_t height, diff --git a/guest/native-overlay/usr/local/bin/omarchy-native-display-sync b/guest/native-overlay/usr/local/bin/omarchy-native-display-sync index 0ec3abc9..28d455fe 100755 --- a/guest/native-overlay/usr/local/bin/omarchy-native-display-sync +++ b/guest/native-overlay/usr/local/bin/omarchy-native-display-sync @@ -7,6 +7,7 @@ drm_root=${OMARCHY_DISPLAY_SYNC_DRM_ROOT:-/sys/class/drm} preferred_mode_from_edid() { python3 - "$1" <<'PY' import pathlib +import re import sys from math import hypot @@ -97,6 +98,50 @@ def scale_for_mode( return f"{clean_120 / 120:.6f}".rstrip("0").rstrip(".") +# Only the paired host presenter and guest driver advertise both CTA signals. +# Check extension checksums and block boundaries before enabling HDR. +hdr = bt2020 = False +for index in range(min(data[126], len(data) // 128 - 1)): + block = data[(index + 1) * 128 : (index + 2) * 128] + if block[0] != 0x02 or sum(block) % 256 or not 4 <= block[2] <= 127: + continue + position = 4 + while position < block[2]: + header = block[position] + end = position + 1 + (header & 31) + if end > block[2]: + break + payload = block[position + 1 : end] + if header >> 5 == 7 and len(payload) >= 3: + if payload[0] == 0x06: + hdr |= bool(payload[1] & 4 and payload[2] & 1) + elif payload[0] == 0x05: + bt2020 |= bool(payload[1] & 128) + position = end +color = "hdr" if hdr and bt2020 else "sdr" + +# The paired Cocoa host can put its SDR cap in a standard additional-text +# descriptor. Only consume complete, checksum-valid blocks and a bounded +# decimal value. A missing hint (older hosts or unknown/external panels) +# preserves the 250-nit fallback; CTA HDR peak is not an SDR white level. +sdr_white = 250 +descriptor_blocks = [(data[:128], 54)] +for index in range(min(data[126], len(data) // 128 - 1)): + block = data[(index + 1) * 128 : (index + 2) * 128] + if block[0] == 0x02 and not sum(block) % 256 and 4 <= block[2] <= 109: + descriptor_blocks.append((block, block[2])) +hints = set() +for block, start in descriptor_blocks: + for offset in range(start, 110, 18): + descriptor = block[offset : offset + 18] + if descriptor[:5] != b"\0\0\0\xfe\0": + continue + match = re.fullmatch(rb"TO-SDR:([0-9]{1,4})", descriptor[5:].strip(b" \n")) + if match and 1 <= int(match[1]) <= 1000: + hints.add(int(match[1])) +if color == "hdr" and len(hints) == 1: + sdr_white = hints.pop() + base_physical_width_mm = data[21] * 10 base_physical_height_mm = data[22] * 10 @@ -178,7 +223,7 @@ if displayid_timings: base_physical_width_mm, base_physical_height_mm, ) - print(f"{preferred}\t{scale}") + print(f"{preferred}\t{scale}\t{color}\t{sdr_white}") raise SystemExit(0) for offset in range(54, 126, 18): @@ -227,7 +272,7 @@ for offset in range(54, 126, 18): physical_width_mm, physical_height_mm, ) - print(f"{decoded}\t{scale}") + print(f"{decoded}\t{scale}\t{color}\t{sdr_white}") raise SystemExit(0) raise SystemExit(1) @@ -237,12 +282,15 @@ PY apply_preferred_modes() { local connector local connector_name + local color + local color_rule local mode local output local preferred local response local rule local scale + local sdr_white shopt -s nullglob for connector in "$drm_root"/card*-Virtual-*; do @@ -254,7 +302,7 @@ apply_preferred_modes() { if ! preferred=$(preferred_mode_from_edid "$connector/edid"); then continue fi - IFS=$'\t' read -r mode scale <<<"$preferred" + IFS=$'\t' read -r mode scale color sdr_white <<<"$preferred" [[ -n $mode && -n $scale ]] || continue # Aquamarine 0.14 does not refresh its mode cache when an existing DRM @@ -265,7 +313,13 @@ apply_preferred_modes() { # command. Update Omarchy's single-output catch-all mode and dynamic scale; # its placement, color, and other policy remain intact, while an explicit # per-output user rule still wins. - rule="hl.monitor({ output = \"\", mode = \"$mode\", scale = \"$scale\" })" + color_rule="" + if [[ $color == hdr ]]; then + # Use the host panel's SDR cap when known. PQ video keeps its absolute + # luminance; the presenter's linear 1.0 reference remains 100 cd/m2. + color_rule=", bitdepth = 10, cm = \"hdr\", sdr_max_luminance = $sdr_white" + fi + rule="hl.monitor({ output = \"\", mode = \"$mode\", scale = \"$scale\"${color_rule} })" if ! response=$(hyprctl eval "$rule" 2>&1); then printf 'omarchy-native-display-sync: failed to apply %s to %s: %s\n' \ "$mode" "$output" "$response" >&2 diff --git a/guest/patches/hyprland/client-sdr-white.patch b/guest/patches/hyprland/client-sdr-white.patch new file mode 100644 index 00000000..659b4c10 --- /dev/null +++ b/guest/patches/hyprland/client-sdr-white.patch @@ -0,0 +1,79 @@ +--- a/src/output/Monitor.hpp ++++ b/src/output/Monitor.hpp +@@ -366,6 +366,8 @@ + int m_cachedCompositorDRMFD = -1; + std::optional m_cachedSameGPU; + ++ NColorManagement::PImageDescription getClientImageDescription() const; ++ + NColorManagement::PImageDescription m_imageDescription = NColorManagement::CImageDescription::from(NColorManagement::SImageDescription{}); + bool m_noShaderCTM = false; // sets drm CTM, restore needed + +--- a/src/output/Monitor.cpp ++++ b/src/output/Monitor.cpp +@@ -649,7 +649,22 @@ + } + } + ++NColorManagement::PImageDescription CMonitor::getClientImageDescription() const { ++ const auto& value = m_imageDescription->value(); ++ if (value.transferFunction != NColorManagement::CM_TRANSFER_FUNCTION_ST2084_PQ || value.icc.present || m_sdrMaxLuminance <= 0) ++ return m_imageDescription; ++ ++ // Clients compose SDR UI into PQ themselves. Advertise the same white level ++ // as the compositor's SDR conversion, without changing its HDR render target ++ // or the absolute luminance / tone mapping of existing HDR video surfaces. ++ const double brightness = std::isfinite(m_sdrBrightness) && m_sdrBrightness > 0 ? m_sdrBrightness : 1.0; ++ const double peak = value.luminances.max > 0 ? value.luminances.max : 10000; ++ const auto reference = static_cast(std::clamp(std::round(m_sdrMaxLuminance * brightness), 1.0, peak)); ++ return m_imageDescription->with({.min = value.luminances.min, .max = value.luminances.max, .reference = reference}); ++} ++ + bool CMonitor::applyMonitorRuleSoft(Config::CMonitorRule&& pMonitorRule) { ++ const auto oldClientDescription = getClientImageDescription(); + m_activeMonitorRule = std::move(pMonitorRule); + m_reservedArea.setStatic(m_activeMonitorRule.m_reservedArea); + m_transform = m_activeMonitorRule.m_transform; +@@ -695,6 +710,9 @@ + } + } + } ++ ++ if (oldClientDescription != getClientImageDescription() && PROTO::colorManagement) ++ PROTO::colorManagement->onMonitorImageDescriptionChanged(m_self); + + Vector2D xfmd = m_transform % 2 == 1 ? Vector2D{m_pixelSize.y, m_pixelSize.x} : m_pixelSize; + m_size = (xfmd / m_scale).round(); +--- a/src/protocols/ColorManagement.cpp ++++ b/src/protocols/ColorManagement.cpp +@@ -269,7 +269,7 @@ + if (!m_output || !m_output->m_monitor.valid()) + RESOURCE->m_resource->sendFailed(WP_IMAGE_DESCRIPTION_V1_CAUSE_NO_OUTPUT, "No output"); + else { +- RESOURCE->m_settings = m_output->m_monitor->m_imageDescription; ++ RESOURCE->m_settings = m_output->m_monitor->getClientImageDescription(); + + RESOURCE->sendMaybeReady(); + } +--- a/src/protocols/core/Compositor.cpp ++++ b/src/protocols/core/Compositor.cpp +@@ -685,7 +685,7 @@ + else if (m_hlSurface.valid() && WINDOW) + monitor = WINDOW->m_monitor; + +- return monitor ? monitor->m_imageDescription : g_pCompositor->getPreferredImageDescription(); ++ return monitor ? monitor->getClientImageDescription() : g_pCompositor->getPreferredImageDescription(); + } + + void CWLSurfaceResource::sortSubsurfaces() { +--- a/src/Compositor.cpp ++++ b/src/Compositor.cpp +@@ -957,7 +957,7 @@ + } + Log::logger->log(Log::WARN, "FIXME: color management protocol is enabled, determine correct preferred image description"); + // should determine some common settings to avoid unnecessary transformations while keeping maximum displayable precision +- return State::monitorState()->monitors().size() == 1 ? State::monitorState()->monitors()[0]->m_imageDescription : ++ return State::monitorState()->monitors().size() == 1 ? State::monitorState()->monitors()[0]->getClientImageDescription() : + CImageDescription::from(SImageDescription{.primaries = NColorPrimaries::BT709}); + } + diff --git a/guest/scripts/register-local-repository.sh b/guest/scripts/register-local-repository.sh index ab78199a..a6a34062 100755 --- a/guest/scripts/register-local-repository.sh +++ b/guest/scripts/register-local-repository.sh @@ -83,7 +83,7 @@ repo_dir="$root/usr/share/try-omarchy/repo" shopt -s nullglob archives=("$repo_dir"/*.pkg.tar.zst) shopt -u nullglob -expected_archive_count=7 +expected_archive_count=8 (( ${#archives[@]} == expected_archive_count )) || fail "local repository expected $expected_archive_count package archive(s), found ${#archives[@]}" [[ ${archives[*]} == *'/try-omarchy-runtime-'* ]] || fail "local repository is missing the Omarchy runtime" @@ -91,6 +91,7 @@ expected_archive_count=7 [[ ${archives[*]} == *'/try-omarchy-ttfx-'* ]] || fail "factory repository is missing pinned ttfx" [[ ${archives[*]} == *'/try-omarchy-yay-'* ]] || fail "factory repository is missing pinned yay" [[ ${archives[*]} == *'/try-omarchy-native-video-'* ]] || fail "factory repository is missing native video" +[[ ${archives[*]} == *'/try-omarchy-native-hdr-'* ]] || fail "factory repository is missing native HDR" [[ ${archives[*]} == *"/hyprland-$expected_hyprland_version-aarch64.pkg.tar.zst"* ]] || fail "factory repository is missing patched Hyprland" [[ ${archives[*]} == *"/voxtype-bin-$expected_voxtype_version-aarch64.pkg.tar.zst"* ]] || diff --git a/guest/scripts/register-native-hdr.sh b/guest/scripts/register-native-hdr.sh new file mode 100755 index 00000000..2c99c95c --- /dev/null +++ b/guest/scripts/register-native-hdr.sh @@ -0,0 +1,215 @@ +#!/bin/bash +# Paired virtual HDR driver and private mpv renderer, built against the guest ABI. +set -euo pipefail +fail() { echo "register-native-hdr: $*" >&2; exit 1; } +root= work= spec= pacman_config= output= +while (($#)); do + case "$1" in + --root) root=${2:-}; shift 2 ;; + --work) work=${2:-}; shift 2 ;; + --spec) spec=${2:-}; shift 2 ;; + --pacman-config) pacman_config=${2:-}; shift 2 ;; + --output) output=${2:-}; shift 2 ;; + *) fail "unknown option: $1" ;; + esac +done +[[ $root == /* && -d $root && $work == /* && -d $work ]] || fail "absolute root and work directories required" +root=$(realpath "$root"); work=$(realpath "$work") +case "$root" in /|/bin|/boot|/etc|/home|/opt|/root|/usr|/var) fail "unsafe staged root" ;; esac +[[ $root != "$work" && $work != "$root/"* ]] || fail "work must be outside staged root" +[[ -f $spec && -f $pacman_config ]] || fail "spec and pacman config required" +[[ -z $output || ( $output == /* && ! -L $output ) ]] || fail "invalid output path" +[[ $(uname -s) == Linux && $(uname -m) == aarch64 ]] || fail "native Linux ARM64 builder required" +guest=$(cd "$(dirname "$0")/.." && pwd -P) +hdr="$guest/hdr" +build=$(mktemp -d "$work/native-hdr-build.XXXXXX") +trap 'rm -rf "$build"' EXIT +stage="$build/package" +mkdir -p "$stage" "$work/download-cache" +python3 - "$hdr" "$spec" "$work/download-cache" "$build" <<'PYTHON' +import hashlib, json, pathlib, subprocess, sys +hdr, spec_path, cache, build = map(pathlib.Path, sys.argv[1:]) +spec = json.loads(spec_path.read_text()) +meta = json.loads((hdr/'sources.json').read_text()) +assert spec['image']['architecture'] == 'aarch64' +assert (meta['version'], meta['kernelVersion'], meta['kernelRelease']) == ('1.0.0', '7.2.2', '7.2.2-2-aarch64-ARCH') +assert meta['linuxBaseUrl'] == 'https://raw.githubusercontent.com/gregkh/linux/v7.2.2/drivers/gpu/drm/virtio/' +assert set(meta['patches']) == {'virtio-gpu-hdr.patch', 'mesa-es3-norm16.patch', 'mpv-wayland-color.patch'} +def verify(path, digest): + return path.is_file() and not path.is_symlink() and hashlib.sha256(path.read_bytes()).hexdigest() == digest +assert verify(hdr/'linux-source-sha256.json', meta['linuxManifestSha256']) +for name, digest in meta['patches'].items(): + assert verify(hdr/name, digest), name +linux = json.loads((hdr/'linux-source-sha256.json').read_text()) +assert len(linux) == 17 +def fetch(name, url, digest): + target = cache/name + if target.is_symlink(): + raise SystemExit('symlinked source cache entry') + if not verify(target, digest): + tmp = build/(name+'.download') + subprocess.run(['curl', '--fail', '--location', '--retry', '2', '--proto', '=https', '--tlsv1.2', + '--silent', '--show-error', url, '-o', str(tmp)], check=True) + if not verify(tmp, digest): + raise SystemExit('source digest mismatch: '+name) + tmp.replace(target) + return target +kernel = build/'kernel'; kernel.mkdir() +for name, digest in linux.items(): + assert pathlib.Path(name).name == name + source = fetch('linux-7.2.2-'+name, meta['linuxBaseUrl']+name, digest) + (kernel/name).write_bytes(source.read_bytes()) +fetch('mesa-26.2.1.tar.xz', meta['mesa']['url'], meta['mesa']['sha256']) +fetch('mpv-0.41.0.tar.gz', meta['mpv']['url'], meta['mpv']['sha256']) +(build/'epoch').write_text(str(spec['image']['sourceDateEpoch'])) +(build/'build-packages').write_text('\n'.join(n+'='+v for n,v in sorted(meta['buildPackages'].items()))+'\n') +PYTHON +kernel_release=7.2.2-2-aarch64-ARCH +kernel_headers="$root/usr/lib/modules/$kernel_release/build" +[[ $(cat "$kernel_headers/include/config/kernel.release") == "$kernel_release" ]] || fail "matching kernel headers required" +# Extra generators belong to the disposable builder, not the staged guest. +python3 - "$pacman_config" "$build/pacman.conf" <<'PYTHON' +import pathlib, sys +lines = []; section = '' +for line in pathlib.Path(sys.argv[1]).read_text().splitlines(): + if line.startswith('[') and line.endswith(']'): + section = line[1:-1] + if section == 'omarchy' or line.startswith('IgnorePkg'): + continue + lines.append(line) +pathlib.Path(sys.argv[2]).write_text('\n'.join(lines)+'\n') +PYTHON +mapfile -t extra_packages <"$build/build-packages" +pacman --noconfirm --config "$build/pacman.conf" -S --needed "${extra_packages[@]}" +for package in "${extra_packages[@]}"; do + [[ $(pacman -Q "${package%%=*}") == "${package/=/ }" ]] || fail "build package version mismatch" +done +for command in meson ninja gcc g++ make pkg-config patch tar bsdtar gzip zstd readelf; do + command -v "$command" >/dev/null || fail "missing build tool: $command" +done +jobs=${OMARCHY_GUEST_BUILD_JOBS:-$(nproc)} +[[ $jobs =~ ^[1-9][0-9]*$ ]] || fail "invalid build job count" +export SOURCE_DATE_EPOCH=$(cat "$build/epoch") +patch -d "$build/kernel" -p1 --batch --fuzz=0 <"$hdr/virtio-gpu-hdr.patch" +make -C "$kernel_headers" M="$build/kernel" -j"$jobs" modules +install -Dm644 "$build/kernel/virtio-gpu.ko" "$stage/usr/lib/modules/$kernel_release/updates/omarchy-hdr/virtio-gpu.ko" +export PKG_CONFIG_SYSROOT_DIR="$root" +export PKG_CONFIG_LIBDIR="$root/usr/lib/pkgconfig:$root/usr/share/pkgconfig" +python3 - "$build" "$root" <<'PYTHON' +import pathlib, shlex, sys +build, root = map(pathlib.Path, sys.argv[1:]) +for name, compiler in [('cc','gcc'), ('cxx','g++')]: + path = build/name + # Meson may pass a sysroot library by absolute filename. Libraries without + # DT_SONAME (notably mujs) would then retain the build directory in DT_NEEDED. + path.write_text('#!/bin/bash\nroot='+shlex.quote(str(root))+'''\nargs=() +for argument; do + case "$argument" in + "$root"/*.so) args+=("-L${argument%/*}" "-l:${argument##*/}") ;; + *) args+=("$argument") ;; + esac +done +exec '''+compiler+' --sysroot="$root" "${args[@]}"\n') + path.chmod(0o755) +(build/'native.ini').write_text('[binaries]\nc = '+repr(str(build/'cc'))+'\ncpp = '+repr(str(build/'cxx'))+'\n') +PYTHON +tar -xJf "$work/download-cache/mesa-26.2.1.tar.xz" --no-same-owner -C "$build" +tar -xzf "$work/download-cache/mpv-0.41.0.tar.gz" --no-same-owner -C "$build" +patch -d "$build/mesa-26.2.1" -p1 --batch --fuzz=0 <"$hdr/mesa-es3-norm16.patch" +patch -d "$build/mpv-0.41.0" -p1 --batch --fuzz=0 <"$hdr/mpv-wayland-color.patch" +meson setup "$build/mesa-build" "$build/mesa-26.2.1" --native-file "$build/native.ini" \ + --prefix=/usr/local/lib/omarchy-hdr/mesa --libdir=lib --buildtype=release \ + -Dgallium-drivers=virgl -Dvulkan-drivers=[] -Dplatforms=wayland,x11 \ + -Dglx=dri -Degl=enabled -Dgbm=enabled -Dglvnd=enabled -Dllvm=disabled \ + -Dgallium-va=disabled -Dvideo-codecs=[] -Dbuild-tests=false \ + -Dopengl=true -Dgles2=enabled -Dgles1=disabled -Ddraw-use-llvm=false -Dvalgrind=disabled +ninja -C "$build/mesa-build" -j"$jobs" +DESTDIR="$stage" meson install -C "$build/mesa-build" --no-rebuild +meson setup "$build/mpv-build" "$build/mpv-0.41.0" --native-file "$build/native.ini" \ + --prefix=/usr/local/lib/omarchy-hdr/mpv --buildtype=release \ + -Dlibmpv=false -Dmanpage-build=disabled -Dhtml-build=disabled -Dpdf-build=disabled \ + -Dwayland=enabled -Degl-wayland=enabled -Dvaapi=enabled -Dpipewire=enabled +ninja -C "$build/mpv-build" -j"$jobs" +install -Dm755 "$build/mpv-build/mpv" "$stage/usr/local/lib/omarchy-hdr/mpv/bin/mpv" +python3 - "$stage/usr/local/lib/omarchy-hdr" "$root" "$build" <<'PYTHON' +import pathlib, re, subprocess, sys +runtime = pathlib.Path(sys.argv[1]) +for binary in sorted(runtime.rglob('*')): + if not binary.is_file() or binary.is_symlink(): + continue + with binary.open('rb') as stream: + if stream.read(4) != b'\x7fELF': + continue + dynamic = subprocess.check_output(['readelf', '-d', str(binary)], text=True) + for needed in re.findall(r'\(NEEDED\).*?\[([^\]]+)\]', dynamic): + if '/' in needed: + raise SystemExit(f'build-path runtime dependency in {binary}: {needed}') + for search in re.findall(r'\((?:RPATH|RUNPATH)\).*?\[([^\]]+)\]', dynamic): + if any(path in search for path in sys.argv[2:]): + raise SystemExit(f'build-path runtime search in {binary}: {search}') +PYTHON +install -m644 "$hdr/environment.sh" "$stage/usr/local/lib/omarchy-hdr/environment.sh" +sources="$stage/usr/share/try-omarchy/native-hdr-source" +licenses="$stage/usr/share/licenses/try-omarchy-native-hdr" +mkdir -p "$sources/linux-7.2.2-virtio" "$licenses" +cp "$work/download-cache/mesa-26.2.1.tar.xz" "$work/download-cache/mpv-0.41.0.tar.gz" "$sources/" +cp -a "$hdr" "$sources/" +cp "$0" "$sources/register-native-hdr.sh" +python3 - "$hdr/linux-source-sha256.json" "$work/download-cache" "$sources/linux-7.2.2-virtio" <<'PYTHON' +import json, pathlib, sys +manifest, cache, output = map(pathlib.Path, sys.argv[1:]) +for name in json.loads(manifest.read_text()): + (output/name).write_bytes((cache/('linux-7.2.2-'+name)).read_bytes()) +PYTHON +cp "$build/mpv-0.41.0/LICENSE.GPL" "$licenses/GPL-2.0" +cp "$build/mpv-0.41.0/LICENSE.LGPL" "$licenses/LGPL-2.1" +cp "$build/mesa-26.2.1/docs/license.rst" "$licenses/Mesa-license-notices" +cp "$guest/../LICENSE" "$licenses/Try-Omarchy-MIT" +install -m644 "$hdr/package.install" "$stage/.INSTALL" +python3 - "$stage" "$hdr" <<'PYTHON' +import hashlib, json, pathlib, sys +stage, hdr = map(pathlib.Path, sys.argv[1:]) +record = {'sources': json.loads((hdr/'sources.json').read_text()), + 'binarySha256': {str(p.relative_to(stage)): hashlib.sha256(p.read_bytes()).hexdigest() + for p in sorted((stage/'usr').rglob('*')) if p.is_file() and not p.is_symlink() + and (p.suffix == '.ko' or '.so' in p.name or p.name == 'mpv')}} +(stage/'usr/share/try-omarchy/native-hdr.json').write_text(json.dumps(record, indent=2)+'\n') +PYTHON +size=$(du -sb "$stage" | awk '{print $1}') +cat >"$stage/.PKGINFO" <.MTREE +) +package="$build/try-omarchy-native-hdr-1.0.0-1-aarch64.pkg.tar.zst" +tar --sort=name --mtime="@$SOURCE_DATE_EPOCH" --owner=0 --group=0 --numeric-owner --format=gnu \ + -C "$stage" -cf - .PKGINFO .INSTALL .MTREE usr | zstd --quiet -12 --threads=1 -o "$package" +if [[ -n $output ]]; then + install -m644 "$package" "$output" + echo "Built native HDR package: $output" + exit 0 +fi +pacman --noconfirm --config "$pacman_config" --root "$root" --dbpath "$root/var/lib/pacman" \ + --logfile "$root/var/log/pacman.log" -U "$package" +install -Dm644 "$package" "$root/usr/share/try-omarchy/repo/$(basename "$package")" +echo "Registered native HDR 1.0.0 for kernel $kernel_release" diff --git a/guest/scripts/register-patched-hyprland.sh b/guest/scripts/register-patched-hyprland.sh index da01609c..2f85bea1 100755 --- a/guest/scripts/register-patched-hyprland.sh +++ b/guest/scripts/register-patched-hyprland.sh @@ -6,7 +6,7 @@ usage() { cat <<'USAGE' Usage: register-patched-hyprland.sh --root ROOT --work WORK --spec SPEC --pacman-config CONFIG -Builds the spec-pinned rounded-border Hyprland backport natively for ARM64, +Builds the spec-pinned rounded-border and client SDR-white Hyprland fixes natively for ARM64, repackages the verified upstream Arch package with the patched executable and public headers, and registers it in the guest's immutable package repository. USAGE @@ -96,6 +96,8 @@ required = { "upstreamPackageSha256", "patch", "patchSha256", + "clientSdrWhitePatch", + "clientSdrWhitePatchSha256", "glazeVersion", "glazeCommit", "glazeUrl", @@ -161,12 +163,14 @@ for key in ( print(architecture) print(source_date_epoch) print(json.dumps(build_packages, sort_keys=True, separators=(",", ":"))) +print(component["clientSdrWhitePatch"]) +print(component["clientSdrWhitePatchSha256"]) PY ) || fail "could not read pinned Hyprland metadata" mapfile -t metadata <<<"$metadata_output" [[ ${metadata[0]:-} == disabled ]] && exit 0 [[ ${metadata[0]:-} == enabled ]] || fail "invalid pinned Hyprland state" -(( ${#metadata[@]} == 22 )) || fail "pinned Hyprland metadata is incomplete" +(( ${#metadata[@]} == 24 )) || fail "pinned Hyprland metadata is incomplete" version=${metadata[1]} pkgrel=${metadata[2]} @@ -189,11 +193,13 @@ issue=${metadata[18]} architecture=${metadata[19]} source_date_epoch=${metadata[20]} build_packages_json=${metadata[21]} +client_sdr_white_patch_relative=${metadata[22]} +client_sdr_white_patch_sha256=${metadata[23]} [[ $architecture == aarch64 ]] || fail "patched Hyprland supports only aarch64" [[ $(uname -m) == aarch64 ]] || fail "patched Hyprland must be built natively on aarch64" [[ $version =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || fail "invalid Hyprland version: $version" -[[ $pkgrel == 3.2 ]] || fail "unexpected Hyprland package release: $pkgrel" +[[ $pkgrel == 3.3 ]] || fail "unexpected Hyprland package release: $pkgrel" [[ $upstream_package_version == "$version-3" ]] || fail "unexpected upstream Hyprland package version" [[ $repository == https://github.com/hyprwm/Hyprland ]] || fail "unexpected Hyprland repository" [[ $url == "$repository/releases/download/v$version/source-v$version.tar.gz" ]] || @@ -201,6 +207,8 @@ build_packages_json=${metadata[21]} [[ $commit =~ ^[0-9a-f]{40}$ ]] || fail "invalid Hyprland commit" [[ $patch_relative == patches/hyprland/rounded-border-coverage.patch ]] || fail "unexpected Hyprland patch path" +[[ $client_sdr_white_patch_relative == patches/hyprland/client-sdr-white.patch ]] || + fail "unexpected Hyprland client SDR-white patch path" [[ $glaze_version =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || fail "invalid Glaze version" [[ $glaze_commit == b518eec7a22e56ffa238b072c07f47efa7cea97f ]] || fail "unexpected Glaze commit" [[ $glaze_url == "https://github.com/stephenberry/glaze/archive/refs/tags/v$glaze_version.tar.gz" ]] || @@ -208,7 +216,7 @@ build_packages_json=${metadata[21]} [[ $license == BSD-3-Clause ]] || fail "unexpected Hyprland license: $license" [[ $issue == https://github.com/omacom/try-omarchy/issues/5 ]] || fail "unexpected Hyprland issue URL" [[ $source_date_epoch =~ ^[0-9]+$ && $source_date_epoch -gt 0 ]] || fail "invalid source date epoch" -for digest in "$sha256" "$upstream_package_sha256" "$patch_sha256" "$glaze_sha256" "$glaze_license_sha256" "$binary_sha256"; do +for digest in "$sha256" "$upstream_package_sha256" "$patch_sha256" "$client_sdr_white_patch_sha256" "$glaze_sha256" "$glaze_license_sha256" "$binary_sha256"; do [[ $digest =~ ^[0-9a-f]{64}$ ]] || fail "invalid Hyprland content digest" done @@ -235,6 +243,9 @@ verify_file() { } verify_file "$patch_sha256" "$patch_path" || fail "Hyprland patch digest mismatch" +client_sdr_white_patch_path="$guest_dir/$client_sdr_white_patch_relative" +[[ -f $client_sdr_white_patch_path && ! -L $client_sdr_white_patch_path ]] || fail "invalid Hyprland client SDR-white patch" +verify_file "$client_sdr_white_patch_sha256" "$client_sdr_white_patch_path" || fail "Hyprland client SDR-white patch digest mismatch" cache_dir="$work/download-cache" install -d -m 0755 "$cache_dir" @@ -347,9 +358,15 @@ verify_file "$glaze_license_sha256" "$glaze_license" || fail "Glaze license dige ( cd "$source_root" + # A work directory inside a checkout must not make git silently skip paths + # outside that checkout's current prefix when applying to extracted sources. + export GIT_CEILING_DIRECTORIES="$stage" git apply --check --no-index --whitespace=error-all "$patch_path" git apply --no-index --whitespace=error-all "$patch_path" git apply --check --reverse --no-index "$patch_path" + git apply --check --no-index --whitespace=error-all "$client_sdr_white_patch_path" + git apply --no-index --whitespace=error-all "$client_sdr_white_patch_path" + git apply --check --reverse --no-index "$client_sdr_white_patch_path" ) || fail "could not apply the verified Hyprland patch" mapfile -t build_package_records < <(python3 - "$build_packages_json" <<'PY' @@ -599,6 +616,7 @@ install -d -m 0755 "$package_root" tar --extract --file "$upstream_tar" --no-same-owner --directory "$package_root" header_paths=( + src/output/Monitor.hpp src/render/OpenGL.hpp src/render/Shader.hpp src/render/pass/TexPassElement.hpp diff --git a/guest/spec.json b/guest/spec.json index 00e8bf4b..f5010a2f 100644 --- a/guest/spec.json +++ b/guest/spec.json @@ -38,7 +38,7 @@ "archLinuxArmPackagesCommit": "0b5418fc3f62860b191cd872cb2f933f9fc77841", "hyprland": { "version": "0.56.1", - "pkgrel": "3.2", + "pkgrel": "3.3", "upstreamPackageVersion": "0.56.1-3", "repository": "https://github.com/hyprwm/Hyprland", "commit": "5c9377c15f85c50648f35ca5a213754f95b93ca0", @@ -52,13 +52,13 @@ "glazeUrl": "https://github.com/stephenberry/glaze/archive/refs/tags/v7.2.0.tar.gz", "glazeSha256": "17dba19ae63ae48f94994f00d49d5cb3c8f1306db1046c534c4828662490b7d4", "glazeLicenseSha256": "5d49e66411a0807a7c8d6b911b9a26b59e940c71aebe561a3ad8b0b80ac4b7b6", - "binarySha256": "c668b05275f2d5cbff66fdb8f4ea4cbbfb7d5a7f9e682f358f3fbcff8494c68a", + "binarySha256": "bc9727b50151cd17f4cedd39c6f75dcbfdd1c9f687671861d51a748a028d8ec9", "license": "BSD-3-Clause", "issue": "https://github.com/omacom/try-omarchy/issues/5", "buildPackages": { "base-devel": "1-2", "binutils": "2.46+r70+g155188ea10a7-1", - "cmake": "4.4.3-1", + "cmake": "4.4.3-2", "gcc": "16.1.1+r12+g301eb08fa2c5-1", "gcc-libs": "16.1.1+r12+g301eb08fa2c5-1", "glibc": "2.43+r22+g8362e8ce10b2-2", @@ -69,7 +69,9 @@ "ninja": "1.13.2-3", "pkgconf": "3.0.6-1", "xorgproto": "2025.1-1" - } + }, + "clientSdrWhitePatch": "patches/hyprland/client-sdr-white.patch", + "clientSdrWhitePatchSha256": "e0ee4857e88043f8ff11eaf421d7c54f8c0955d1331e329873fbd85caf7a7a56" }, "mise": { "version": "2026.8.11", diff --git a/guest/tests/test_native_hdr.py b/guest/tests/test_native_hdr.py new file mode 100644 index 00000000..c096ba5e --- /dev/null +++ b/guest/tests/test_native_hdr.py @@ -0,0 +1,67 @@ +import json +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + + +GUEST = Path(__file__).resolve().parents[1] + + +class NativeHDRTest(unittest.TestCase): + def test_private_runtime_requires_an_active_hdr_output(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + runtime = root / "hdr" + (runtime / "mpv/bin").mkdir(parents=True) + (runtime / "mesa/lib").mkdir(parents=True) + player = runtime / "mpv/bin/mpv" + player.touch() + player.chmod(0o755) + (runtime / "mesa/lib/libEGL_mesa.so.0").touch() + helper = root / "environment.sh" + helper.write_text((GUEST / "hdr/environment.sh").read_text().replace( + "/usr/local/lib/omarchy-hdr", str(runtime))) + fake_hyprctl = root / "hyprctl" + fake_hyprctl.write_text('#!/bin/sh\nprintf "%s\\n" "$HDR_TEST_MONITORS"\n') + fake_hyprctl.chmod(0o755) + environment = {**os.environ, "PATH": str(root) + ":" + os.environ["PATH"], + "WAYLAND_DISPLAY": "wayland-test"} + hdr = {"name": "Virtual-1", "colorManagementPreset": "hdr", + "currentFormat": "XRGB2101010"} + for name, displays, expected in [ + ("paired HDR", [hdr], "HDR"), + ("8-bit", [{**hdr, "currentFormat": "XRGB8888"}], "SDR"), + ("SDR preset", [{**hdr, "colorManagementPreset": "srgb"}], "SDR"), + ("other output", [{**hdr, "name": "HDMI-A-1"}], "SDR"), + ("no output", [], "SDR"), + ("bad response", None, "SDR"), + ]: + with self.subTest(name=name): + environment["HDR_TEST_MONITORS"] = json.dumps(displays) + result = subprocess.run( + ["bash", "-c", 'source "$1"; if omarchy_hdr_available; then ' + 'echo HDR; else echo SDR; fi', "test", str(helper)], + env=environment, text=True, capture_output=True, check=True) + self.assertEqual(result.stdout.strip(), expected) + environment["HDR_TEST_MONITORS"] = json.dumps([hdr]) + environment.pop("WAYLAND_DISPLAY") + result = subprocess.run( + ["bash", "-c", 'source "$1"; omarchy_hdr_available', "test", str(helper)], + env=environment) + self.assertNotEqual(result.returncode, 0) + + def test_source_digests(self): + import hashlib + hdr = GUEST / "hdr" + metadata = json.loads((hdr / "sources.json").read_text()) + for name, expected in metadata["patches"].items(): + with self.subTest(name=name): + self.assertEqual(hashlib.sha256((hdr / name).read_bytes()).hexdigest(), expected) + self.assertEqual(hashlib.sha256((hdr / "linux-source-sha256.json").read_bytes()).hexdigest(), + metadata["linuxManifestSha256"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/guest/tests/verify.py b/guest/tests/verify.py index a4fd65ed..d7890fbc 100755 --- a/guest/tests/verify.py +++ b/guest/tests/verify.py @@ -429,7 +429,7 @@ def main() -> None: hyprland == { "version": "0.56.1", - "pkgrel": "3.2", + "pkgrel": "3.3", "upstreamPackageVersion": "0.56.1-3", "repository": "https://github.com/hyprwm/Hyprland", "commit": "5c9377c15f85c50648f35ca5a213754f95b93ca0", @@ -443,13 +443,15 @@ def main() -> None: "glazeUrl": "https://github.com/stephenberry/glaze/archive/refs/tags/v7.2.0.tar.gz", "glazeSha256": "17dba19ae63ae48f94994f00d49d5cb3c8f1306db1046c534c4828662490b7d4", "glazeLicenseSha256": "5d49e66411a0807a7c8d6b911b9a26b59e940c71aebe561a3ad8b0b80ac4b7b6", - "binarySha256": "c668b05275f2d5cbff66fdb8f4ea4cbbfb7d5a7f9e682f358f3fbcff8494c68a", + "binarySha256": "bc9727b50151cd17f4cedd39c6f75dcbfdd1c9f687671861d51a748a028d8ec9", + "clientSdrWhitePatch": "patches/hyprland/client-sdr-white.patch", + "clientSdrWhitePatchSha256": "e0ee4857e88043f8ff11eaf421d7c54f8c0955d1331e329873fbd85caf7a7a56", "license": "BSD-3-Clause", "issue": "https://github.com/omacom/try-omarchy/issues/5", "buildPackages": { "base-devel": "1-2", "binutils": "2.46+r70+g155188ea10a7-1", - "cmake": "4.4.3-1", + "cmake": "4.4.3-2", "gcc": "16.1.1+r12+g301eb08fa2c5-1", "gcc-libs": "16.1.1+r12+g301eb08fa2c5-1", "glibc": "2.43+r22+g8362e8ce10b2-2", @@ -471,6 +473,12 @@ def main() -> None: and hashlib.sha256(hyprland_patch.read_bytes()).hexdigest() == hyprland["patchSha256"], "rounded-border Hyprland patch digest matches the build spec", ) + client_sdr_patch = GUEST / hyprland["clientSdrWhitePatch"] + check( + client_sdr_patch.is_file() + and hashlib.sha256(client_sdr_patch.read_bytes()).hexdigest() == hyprland["clientSdrWhitePatchSha256"], + "Hyprland client SDR-white patch digest matches the build spec", + ) launcher = read(REPO / "macos/run-qemu-gpu.sh") runtime_preparer = read(REPO / "macos/prepare-qemu-gpu-runtime.sh") check( @@ -691,12 +699,13 @@ def main() -> None: "pacman recovery files snapshot the final local-repository configuration", ) check( - "expected_archive_count=7" in local_repository + "expected_archive_count=8" in local_repository and "factory repository is missing pinned ttfx" in local_repository and "factory repository is missing pinned yay" in local_repository and "factory repository is missing patched Hyprland" in local_repository and "factory repository is missing pinned Voxtype" in local_repository and "factory repository is missing native video" in local_repository + and "factory repository is missing native HDR" in local_repository and "immutable local repository does not have priority" in local_repository and "resolve patched and ARM64-only packages locally" in local_repository and "refusing canonical unsafe root" in local_repository, @@ -1415,6 +1424,84 @@ def main() -> None: "native display sync handles QEMU DisplayID and legacy EDID hotplug modes", ) + # HDR requires both valid CTA PQ/static metadata and BT.2020 RGB. + cta = bytearray(128) + cta[:13] = bytes([2, 3, 13, 0, 0xE3, 5, 0x80, 0, + 0xE4, 6, 4, 1, 0]) + cta[127] = (-sum(cta[:127])) & 0xFF + (legacy_connector / "status").write_text("disconnected\n") + for description, extension, expected_hdr in [ + ("valid PQ BT.2020", cta, True), + ("bad CTA checksum", cta[:127] + bytes([cta[127] ^ 1]), False), + ("no CTA capabilities", bytes(128), False), + ]: + qemu_edid[128:256] = extension + (connector / "edid").write_bytes(qemu_edid) + reload_log.write_text("") + subprocess.run([str(display_sync), "--from-stdin"], + input="ACTION=change\nHOTPLUG=1\n\n", text=True, + env=environment, check=True) + command = reload_log.read_text() + check(('bitdepth = 10, cm = "hdr", sdr_max_luminance = 250' + in command) == expected_hdr, + "native display sync HDR detection: " + description) + + # Native panel SDR caps are independent of CTA HDR peak luminance. + # Exercise both standard locations for additional-text descriptors, + # malformed payloads, checksum corruption, and conflicting hints. + def sdr_hint(text): + return b"\0\0\0\xfe\0" + text.ljust(13, b" ")[:13] + + for description, text, expected in [ + ("Air panel", b"TO-SDR:500", 500), + ("Pro indoor cap", b"TO-SDR:600", 600), + ("outdoor cap", b"TO-SDR:1000", 1000), + ("unknown host", b"no hint", 250), + ("out of range", b"TO-SDR:1001", 250), + ("zero", b"TO-SDR:0", 250), + ("negative", b"TO-SDR:-500", 250), + ("trailing code", b"TO-SDR:600;()", 250), + ]: + extension = bytearray(cta) + extension[13:31] = sdr_hint(text) + extension[127] = (-sum(extension[:127])) & 0xFF + qemu_edid[128:256] = extension + (connector / "edid").write_bytes(qemu_edid) + reload_log.write_text("") + subprocess.run([str(display_sync), "--from-stdin"], + input="ACTION=change\nHOTPLUG=1\n\n", text=True, + env=environment, check=True) + check(f'sdr_max_luminance = {expected}' in reload_log.read_text(), + "native SDR white hint: " + description) + + for description, base_hint, cta_hint, expected in [ + ("base descriptor", b"TO-SDR:500", b"", 500), + ("matching descriptors", b"TO-SDR:600", b"TO-SDR:600", 600), + ("conflicting descriptors", b"TO-SDR:500", b"TO-SDR:600", 250), + ]: + qemu_edid[108:126] = sdr_hint(base_hint) + qemu_edid[127] = (-sum(qemu_edid[:127])) & 0xFF + extension = bytearray(cta) + extension[13:31] = sdr_hint(cta_hint) + extension[127] = (-sum(extension[:127])) & 0xFF + qemu_edid[128:256] = extension + (connector / "edid").write_bytes(qemu_edid) + reload_log.write_text("") + subprocess.run([str(display_sync), "--from-stdin"], + input="ACTION=change\nHOTPLUG=1\n\n", text=True, + env=environment, check=True) + check(f'sdr_max_luminance = {expected}' in reload_log.read_text(), + "native SDR white hint: " + description) + + qemu_edid[255] ^= 1 + (connector / "edid").write_bytes(qemu_edid) + reload_log.write_text("") + subprocess.run([str(display_sync), "--from-stdin"], + input="ACTION=change\nHOTPLUG=1\n\n", text=True, + env=environment, check=True) + check('sdr_max_luminance' not in reload_log.read_text(), + "corrupt HDR capability block cannot enable an SDR white hint") + shell_files = [ GUEST / "test", screensaver_override, diff --git a/guest/video/mpv b/guest/video/mpv index 27d75087..e6d1eec3 100755 --- a/guest/video/mpv +++ b/guest/video/mpv @@ -1,6 +1,20 @@ #!/bin/bash set -euo pipefail source /usr/local/lib/omarchy-video/environment.sh +if [[ -r /usr/local/lib/omarchy-hdr/environment.sh ]]; then + source /usr/local/lib/omarchy-hdr/environment.sh + if omarchy_video_available && omarchy_hdr_available && + omarchy_video_private_ffmpeg /usr/local/lib/omarchy-hdr/mpv/bin/mpv; then + omarchy_video_environment + omarchy_hdr_environment + # Explicit PQ output is paired with the private mpv build's Wayland + # metadata handoff. It also converts SDR and HLG sources correctly. + exec /usr/local/lib/omarchy-hdr/mpv/bin/mpv \ + --hwdec=vaapi --vo=gpu --gpu-api=opengl --gpu-context=wayland \ + --opengl-es=yes --egl-output-format=rgb10_a2 \ + --target-trc=pq --target-prim=bt.2020 --target-peak=1000 "$@" + fi +fi if omarchy_video_available && omarchy_video_private_ffmpeg /usr/bin/mpv; then omarchy_video_environment exec /usr/bin/mpv --hwdec=vaapi --vo=gpu --gpu-api=opengl "$@" diff --git a/guest/video/vivaldi.sh b/guest/video/vivaldi.sh index fdf7a4f3..be17693f 100644 --- a/guest/video/vivaldi.sh +++ b/guest/video/vivaldi.sh @@ -9,7 +9,7 @@ if omarchy_video_available; then export LD_PRELOAD="/usr/local/lib/omarchy-video/arm64-browser-compat.so${LD_PRELOAD:+:$LD_PRELOAD}" OMARCHY_VIDEO_FLAGS+=( --ozone-platform=wayland --use-gl=angle --use-angle=gles - --enable-features=AcceleratedVideoDecodeLinuxGL,AcceleratedVideoDecodeLinuxZeroCopyGL,VaapiIgnoreDriverChecks + --enable-features=AcceleratedVideoDecodeLinuxGL,AcceleratedVideoDecodeLinuxZeroCopyGL,VaapiIgnoreDriverChecks,WaylandWpColorManagerV1 --ignore-gpu-blocklist ) fi diff --git a/macos/Tests/run-qemu-ssh-contract.test.sh b/macos/Tests/run-qemu-ssh-contract.test.sh index bd3926af..cab7f9ea 100755 --- a/macos/Tests/run-qemu-ssh-contract.test.sh +++ b/macos/Tests/run-qemu-ssh-contract.test.sh @@ -98,7 +98,10 @@ case " $* " in ;; *' -machine virt -netdev help '*) printf '%s\n' user ;; *' -machine virt -audiodev help '*) printf '%s\n' sdl ;; - *' -device virtio-gpu-gl-pci,help '*) printf '%s\n' 'romfile=' ;; + *' -device virtio-gpu-gl-pci,help '*) + printf '%s\n' 'romfile=' + if [[ ${FAKE_QEMU_HDR:-0} == 1 ]]; then printf '%s\n' 'x-omarchy-hdr='; fi + ;; *' -machine virt,gic-version=3,virtualization=on '*' -qmp stdio '*) exit "${FAKE_QEMU_NESTED_STATUS:-0}" ;; @@ -441,6 +444,12 @@ assert_contains "$disabled_qemu" \ assert_contains "$(<"$test_root/disabled/storage.log")" select-existing assert_contains "$(<"$test_root/disabled/storage.log")" create +run_scenario hdr-capable 0 '' FAKE_QEMU_HDR=1 +hdr_qemu=$(<"$test_root/hdr-capable/qemu.log") +assert_contains "$hdr_qemu" 'virtio-gpu-gl-pci,max_outputs=1,xres=1920,yres=1080,x-omarchy-hdr=on' +assert_contains "$hdr_qemu" 'cocoa,gl=es,hdr=on,show-cursor=on' +assert_not_contains "$disabled_qemu" 'hdr=on' + run_scenario nested-fallback 0 '' FAKE_QEMU_NESTED_STATUS=1 nested_fallback_qemu=$(<"$test_root/nested-fallback/qemu.log") assert_line_pair "$test_root/nested-fallback/qemu.log" -machine \ diff --git a/macos/build-qemu-gpu-runtime.sh b/macos/build-qemu-gpu-runtime.sh index 6b0fa142..cdcf6a62 100755 --- a/macos/build-qemu-gpu-runtime.sh +++ b/macos/build-qemu-gpu-runtime.sh @@ -54,6 +54,8 @@ shared_folder_patch="$native_dir/patches/qemu-9p-guest-owner.patch" strchrnul_patch="$native_dir/patches/qemu-darwin-strchrnul-compat.patch" video_shmem_patch="$native_dir/patches/qemu-native-video-shmem.patch" display_cadence_patch="$native_dir/patches/qemu-display-cadence.patch" +hdr_patch="$native_dir/patches/qemu-cocoa-hdr.patch" +sdr_white_patch="$native_dir/patches/qemu-cocoa-sdr-white.patch" virgl_macos_patch="$native_dir/patches/virglrenderer-macos-1.0.33.patch" virgl_video_patch="$native_dir/patches/virglrenderer-angle-video.patch" prepare_runtime="$native_dir/prepare-qemu-gpu-runtime.sh" @@ -76,6 +78,8 @@ audio_device_patch_sha256=03aca71c26163c337338cc3b2013c35430690fc0e8b66c5ce92a42 shared_folder_patch_sha256=41247692501655393ae3a40f56915472ab29b6e89c5173e33db1f62cca56632f strchrnul_patch_sha256=ec1048dd0e8ebe53bf7e8a3bca9bf2f5f4336cd607d4cd077437470e9a32094a video_shmem_patch_sha256="d14639df4b08d31cf54828386eab022fd8408e7072aa9517db225dec24243af4" +hdr_patch_sha256=e8aa5f27a8bdfc14cceb4069f3eeeb78fd5432bb57c506216f30541f4944fc0a +sdr_white_patch_sha256=d0246389c826698db014ed9da6687fedc81012dfe4542f783a6c85611ea49eb2 macos_deployment_target=15.0 keycodemap_commit=f5772a62ec52591ff6870b7e8ef32482371f22c6 @@ -398,6 +402,10 @@ patch -d "$source_dir" -p1 -f -i "$strchrnul_patch" patch -d "$source_dir" -p1 -f -i "$video_shmem_patch" verify_file_sha "QEMU display cadence" "$display_cadence_patch" "$display_cadence_patch_sha256" patch -d "$source_dir" -p1 -f -i "$display_cadence_patch" +verify_file_sha "Cocoa HDR and paired virtio metadata" "$hdr_patch" "$hdr_patch_sha256" +patch -d "$source_dir" -p1 -f -i "$hdr_patch" +verify_file_sha "Cocoa SDR panel white" "$sdr_white_patch" "$sdr_white_patch_sha256" +patch -d "$source_dir" -p1 -f -i "$sdr_white_patch" virgl_root="$dependency_root/virglrenderer/$virgl_version" angle_root="$dependency_root/angle/$angle_version" diff --git a/macos/patches/qemu-cocoa-hdr.patch b/macos/patches/qemu-cocoa-hdr.patch new file mode 100644 index 00000000..6d5ce784 --- /dev/null +++ b/macos/patches/qemu-cocoa-hdr.patch @@ -0,0 +1,839 @@ +diff --git a/hw/display/edid-generate.c b/hw/display/edid-generate.c +--- a/hw/display/edid-generate.c ++++ b/hw/display/edid-generate.c +@@ -467,7 +467,7 @@ + /* =============== basic display parameters =============== */ + + /* video input: digital, 8bpc, displayport */ +- edid[20] = 0xa5; ++ edid[20] = info->hdr ? 0xb5 : 0xa5; + + /* screen size: undefined */ + edid[21] = width_mm / 10; +@@ -508,6 +508,18 @@ + edid_desc_xtra3_std(xtra3); + desc = edid_desc_next(edid, dta, desc); + edid_fill_modes(edid, xtra3, dta, info->maxx, info->maxy); ++ if (info->hdr && dta && dta[2] <= 116) { ++ /* CTA colorimetry (BT.2020 RGB), followed by static HDR type 1. ++ * This is a 1000-nit virtual display, not a physical-panel measurement. ++ * The Cocoa EDR presenter tone maps to the actual window's display. */ ++ const uint8_t blocks[] = { 0xe3, 0x05, 0x80, 0x00, ++ 0xe6, 0x06, 0x05, 0x01, 138, 96, 0 }; ++ memcpy(dta + dta[2], blocks, sizeof(blocks)); ++ dta[2] += sizeof(blocks); ++ edid[24] &= ~0x04; /* The native primaries below are not sRGB. */ ++ edid_colorspace(edid, 0.708, 0.292, 0.170, 0.797, ++ 0.131, 0.046, 0.3127, 0.3290); ++ } + /* + * dta video data block is finished at thus point, + * so dta descriptor offsets don't move any more. +diff --git a/hw/display/virtio-gpu-base.c b/hw/display/virtio-gpu-base.c +--- a/hw/display/virtio-gpu-base.c ++++ b/hw/display/virtio-gpu-base.c +@@ -75,6 +75,12 @@ + } + } + ++ info.hdr = g->conf.omarchy_hdr && ++ virtio_vdev_has_feature(&g->parent_obj, VIRTIO_GPU_F_OMARCHY_HDR) && ++ qemu_console_gl_has_hdr(g->scanout[scanout].con); ++ if (info.hdr && !info.name) { ++ info.name = "Omarchy HDR"; ++ } + edid->size = cpu_to_le32(sizeof(edid->edid)); + qemu_edid_generate(edid->edid, sizeof(edid->edid), &info); + } +@@ -263,6 +269,9 @@ + if (virtio_gpu_virgl_enabled(g->conf) || + virtio_gpu_rutabaga_enabled(g->conf)) { + features |= (1 << VIRTIO_GPU_F_VIRGL); ++ } ++ if (g->conf.omarchy_hdr && virtio_gpu_virgl_enabled(g->conf)) { ++ features |= (1 << VIRTIO_GPU_F_OMARCHY_HDR); + } + if (virtio_gpu_edid_enabled(g->conf)) { + features |= (1 << VIRTIO_GPU_F_EDID); +diff --git a/hw/display/virtio-gpu-virgl.c b/hw/display/virtio-gpu-virgl.c +--- a/hw/display/virtio-gpu-virgl.c ++++ b/hw/display/virtio-gpu-virgl.c +@@ -430,6 +430,12 @@ + if (res_iovs != NULL && num_iovs != 0) { + virtio_gpu_cleanup_mapping_iov(g, res_iovs, num_iovs); + } ++ for (unsigned i = 0; i < g->parent_obj.conf.max_outputs; ++i) { ++ struct virtio_gpu_scanout *scanout = &g->parent_obj.scanout[i]; ++ if (scanout->pending_hdr_resource == res->base.resource_id) { ++ scanout->hdr_pending = false; ++ } ++ } + virgl_renderer_resource_unref(res->base.resource_id); + + QTAILQ_REMOVE(&g->reslist, &res->base, next); +@@ -524,6 +530,65 @@ + qemu_console_gl_update(g->parent_obj.scanout[idx].con, x, y, width, height); + } + ++static void virgl_cmd_omarchy_hdr(VirtIOGPU *g, ++ struct virtio_gpu_ctrl_command *cmd) ++{ ++ /* All payload words use little endian; do not expose a C host ABI. */ ++ uint32_t words[18]; ++ struct virtio_gpu_scanout *scanout; ++ QemuHDRMetadata metadata; ++ size_t received; ++ unsigned int i; ++ if (!g->parent_obj.conf.omarchy_hdr || ++ !virtio_vdev_has_feature(&g->parent_obj.parent_obj, VIRTIO_GPU_F_OMARCHY_HDR)) { ++ cmd->error = VIRTIO_GPU_RESP_ERR_UNSPEC; ++ return; ++ } ++ received = iov_to_buf(cmd->elem.out_sg, cmd->elem.out_num, ++ sizeof(struct virtio_gpu_ctrl_hdr), words, sizeof(words)); ++ if (received != sizeof(words) || ++ iov_size(cmd->elem.out_sg, cmd->elem.out_num) != sizeof(words) + sizeof(struct virtio_gpu_ctrl_hdr)) { ++ cmd->error = VIRTIO_GPU_RESP_ERR_INVALID_PARAMETER; ++ return; ++ } ++ for (i = 0; i < G_N_ELEMENTS(words); ++i) { ++ words[i] = le32_to_cpu(words[i]); ++ } ++ if (words[0] != OMARCHY_HDR_MAGIC || words[1] != 1 || ++ words[2] >= g->parent_obj.conf.max_outputs || ++ (words[4] != 0 && words[4] != 9) || ++ (words[5] != 0 && words[5] != 2) || (words[5] == 2 && words[4] != 9)) { ++ cmd->error = VIRTIO_GPU_RESP_ERR_INVALID_PARAMETER; ++ return; ++ } ++ for (i = 6; i < 14; ++i) { ++ if (words[i] > 50000) { ++ cmd->error = VIRTIO_GPU_RESP_ERR_INVALID_PARAMETER; ++ return; ++ } ++ } ++ for (i = 14; i < 18; ++i) { ++ if (words[i] > 65535) { ++ cmd->error = VIRTIO_GPU_RESP_ERR_INVALID_PARAMETER; ++ return; ++ } ++ } ++ scanout = &g->parent_obj.scanout[words[2]]; ++ if (!qemu_console_gl_has_hdr(scanout->con)) { ++ cmd->error = VIRTIO_GPU_RESP_ERR_UNSPEC; ++ return; ++ } ++ if (words[3] && !virtio_gpu_virgl_find_resource(g, words[3])) { ++ cmd->error = VIRTIO_GPU_RESP_ERR_INVALID_RESOURCE_ID; ++ return; ++ } ++ memcpy(&metadata, words + 4, sizeof(metadata)); ++ scanout->pending_hdr = metadata; ++ scanout->pending_hdr_resource = words[3]; ++ scanout->hdr_pending = true; ++ /* Metadata is latched by the matching frame flush, after set_scanout. */ ++} ++ + static void virgl_cmd_resource_flush(VirtIOGPU *g, + struct virtio_gpu_ctrl_command *cmd) + { +@@ -537,6 +602,11 @@ + for (i = 0; i < g->parent_obj.conf.max_outputs; i++) { + if (g->parent_obj.scanout[i].resource_id != rf.resource_id) { + continue; ++ } ++ struct virtio_gpu_scanout *scanout = &g->parent_obj.scanout[i]; ++ if (scanout->hdr_pending && scanout->pending_hdr_resource == rf.resource_id) { ++ qemu_console_gl_hdr_metadata(scanout->con, &scanout->pending_hdr); ++ scanout->hdr_pending = false; + } + virtio_gpu_rect_update(g, i, rf.r.x, rf.r.y, rf.r.width, rf.r.height); + } +@@ -1098,6 +1168,9 @@ + break; + case VIRTIO_GPU_CMD_RESOURCE_DETACH_BACKING: + virgl_resource_detach_backing(g, cmd); ++ break; ++ case VIRTIO_GPU_CMD_OMARCHY_HDR: ++ virgl_cmd_omarchy_hdr(g, cmd); + break; + case VIRTIO_GPU_CMD_SET_SCANOUT: + virgl_cmd_set_scanout(g, cmd); +@@ -1441,6 +1514,9 @@ + for (i = 0; i < g->parent_obj.conf.max_outputs; i++) { + qemu_console_set_surface(g->parent_obj.scanout[i].con, NULL); + qemu_console_gl_scanout_disable(g->parent_obj.scanout[i].con); ++ g->parent_obj.scanout[i].hdr_pending = false; ++ QemuHDRMetadata metadata = { 0 }; ++ qemu_console_gl_hdr_metadata(g->parent_obj.scanout[i].con, &metadata); + } + } + +diff --git a/include/hw/display/edid.h b/include/hw/display/edid.h +--- a/include/hw/display/edid.h ++++ b/include/hw/display/edid.h +@@ -4,6 +4,7 @@ + #define EDID_NAME_MAX_LENGTH 12 + + typedef struct qemu_edid_info { ++ bool hdr; /* virtual BT.2020/PQ display; host compositor handles tone mapping */ + const char *vendor; /* http://www.uefi.org/pnp_id_list */ + const char *name; + const char *serial; +diff --git a/include/hw/virtio/virtio-gpu.h b/include/hw/virtio/virtio-gpu.h +--- a/include/hw/virtio/virtio-gpu.h ++++ b/include/hw/virtio/virtio-gpu.h +@@ -71,7 +71,15 @@ + uint32_t offset; + }; + ++/* Private opt-in Try Omarchy v1 extension; not a standard virtio feature. */ ++#define VIRTIO_GPU_F_OMARCHY_HDR 6 ++#define VIRTIO_GPU_CMD_OMARCHY_HDR 0x0400 ++#define OMARCHY_HDR_MAGIC 0x544f4844 ++ + struct virtio_gpu_scanout { ++ QemuHDRMetadata pending_hdr; ++ uint32_t pending_hdr_resource; ++ bool hdr_pending; + QemuConsole *con; + DisplaySurface *ds; + uint32_t width, height; +@@ -127,6 +135,7 @@ + (_cfg.flags & (1 << VIRTIO_GPU_FLAG_DRM_ENABLED)) + + struct virtio_gpu_base_conf { ++ bool omarchy_hdr; + uint32_t max_outputs; + uint32_t flags; + uint32_t xres; +@@ -171,6 +180,7 @@ + }; + + #define VIRTIO_GPU_BASE_PROPERTIES(_state, _conf) \ ++ DEFINE_PROP_BOOL("x-omarchy-hdr", _state, _conf.omarchy_hdr, false), \ + DEFINE_PROP_UINT32("max_outputs", _state, _conf.max_outputs, 1), \ + DEFINE_PROP_VIRTIO_GPU_OUTPUT_LIST("outputs", _state, _conf.outputs), \ + DEFINE_PROP_BIT("edid", _state, _conf.flags, \ +diff --git a/include/ui/console.h b/include/ui/console.h +--- a/include/ui/console.h ++++ b/include/ui/console.h +@@ -1,6 +1,7 @@ + #ifndef CONSOLE_H + #define CONSOLE_H + ++#include "ui/hdr.h" + #include "ui/qemu-pixman.h" + #include "qom/object.h" + #include "qemu/notify.h" +@@ -204,6 +205,9 @@ + void (*dpy_cursor_define)(DisplayChangeListener *dcl, + QEMUCursor *cursor); + ++ /* Optional: explicitly describes scanout color, never inferred from bit depth. */ ++ bool (*dpy_gl_has_hdr)(DisplayChangeListener *dcl); ++ void (*dpy_gl_hdr_metadata)(DisplayChangeListener *dcl, const QemuHDRMetadata *metadata); + /* required if GL */ + void (*dpy_gl_scanout_disable)(DisplayChangeListener *dcl); + /* required if GL */ +@@ -292,6 +296,8 @@ + bool qemu_console_check_format(QemuConsole *con, + pixman_format_code_t format); + ++bool qemu_console_gl_has_hdr(QemuConsole *con); ++void qemu_console_gl_hdr_metadata(QemuConsole *con, const QemuHDRMetadata *metadata); + void qemu_console_gl_scanout_disable(QemuConsole *con); + void qemu_console_gl_scanout_texture(QemuConsole *con, uint32_t backing_id, + DisplayGLTextureBorrower backing_borrow, +diff --git a/include/ui/hdr.h b/include/ui/hdr.h +--- /dev/null ++++ b/include/ui/hdr.h +@@ -0,0 +1,10 @@ ++/* SPDX-License-Identifier: GPL-2.0-or-later */ ++#ifndef QEMU_UI_HDR_H ++#define QEMU_UI_HDR_H ++#include ++typedef struct QemuHDRMetadata { ++ uint32_t colorspace, eotf; ++ uint32_t primaries[6], whitepoint[2]; ++ uint32_t min_luminance, max_luminance, max_cll, max_fall; ++} QemuHDRMetadata; ++#endif +diff --git a/meson.build b/meson.build +--- a/meson.build ++++ b/meson.build +@@ -1221,7 +1221,7 @@ + endif + + cocoa = dependency('appleframeworks', +- modules: ['Cocoa', 'CoreVideo', 'OpenGL', 'QuartzCore'], ++ modules: ['Cocoa', 'CoreVideo', 'OpenGL', 'QuartzCore', 'Metal', 'IOSurface'], + required: get_option('cocoa')) + + vmnet = dependency('appleframeworks', modules: 'vmnet', required: get_option('vmnet')) +diff --git a/qapi/ui.json b/qapi/ui.json +--- a/qapi/ui.json ++++ b/qapi/ui.json +@@ -1449,6 +1449,9 @@ + # turned off the host window will be resized instead. Defaults to + # "off". (Since 8.2) + # ++# @hdr: Use the Metal EDR presenter with the private virtio HDR ++# extension. Defaults to off. ++# + # @zoom-interpolation: Apply interpolation to smooth output when + # zoom-to-fit is enabled. Defaults to "off". (Since 9.0) + # +@@ -1461,7 +1464,8 @@ + '*immersive': 'bool', + '*swap-opt-cmd': 'bool', + '*zoom-to-fit': 'bool', +- '*zoom-interpolation': 'bool' ++ '*zoom-interpolation': 'bool', ++ '*hdr': 'bool' + } } + + ## +diff --git a/ui/cocoa-hdr.h b/ui/cocoa-hdr.h +--- /dev/null ++++ b/ui/cocoa-hdr.h +@@ -0,0 +1,12 @@ ++/* SPDX-License-Identifier: GPL-2.0-or-later */ ++#ifndef QEMU_COCOA_HDR_H ++#define QEMU_COCOA_HDR_H ++#include "ui/hdr.h" ++#include ++@class CALayer; ++typedef struct QemuHDRPresenter QemuHDRPresenter; ++QemuHDRPresenter *cocoa_hdr_create(CALayer *root, EGLDisplay display, ++ EGLConfig config, EGLContext context); ++bool cocoa_hdr_begin(QemuHDRPresenter *presenter, int width, int height); ++bool cocoa_hdr_present(QemuHDRPresenter *presenter, const QemuHDRMetadata *metadata); ++#endif +diff --git a/ui/cocoa-hdr.m b/ui/cocoa-hdr.m +--- /dev/null ++++ b/ui/cocoa-hdr.m +@@ -0,0 +1,372 @@ ++/* ++ * Metal EDR scanout for Cocoa/ANGLE. ++ * SPDX-License-Identifier: GPL-2.0-or-later ++ * ++ * ANGLE's window configurations are 8-bit. Render into a half-float IOSurface ++ * instead, then sample that same allocation in Metal. Shared GPU events order ++ * the two command queues; no frame pixels or completion waits cross the CPU. ++ */ ++#import ++#import ++#import ++#import ++#import ++#include ++#include ++#include ++#include ++#include ++#include ++#include "cocoa-hdr.h" ++ ++#ifndef EGL_IOSURFACE_ANGLE ++#define EGL_IOSURFACE_ANGLE 0x3454 ++#define EGL_IOSURFACE_PLANE_ANGLE 0x345A ++#define EGL_TEXTURE_TYPE_ANGLE 0x345C ++#define EGL_TEXTURE_INTERNAL_FORMAT_ANGLE 0x345D ++#define EGL_BIND_TO_TEXTURE_TARGET_ANGLE 0x348D ++#endif ++#ifndef EGL_SYNC_METAL_SHARED_EVENT_ANGLE ++#define EGL_SYNC_METAL_SHARED_EVENT_ANGLE 0x34D8 ++#define EGL_SYNC_METAL_SHARED_EVENT_SIGNAL_VALUE_LO_ANGLE 0x34DA ++#endif ++ ++typedef void *(*CopyMetalSharedEvent)(EGLDisplay, EGLSync); ++ ++typedef struct QemuHDRSlot { ++ CVPixelBufferRef pixels; ++ EGLSurface surface; ++ id texture; ++ atomic_bool busy; ++ int width, height; ++} QemuHDRSlot; ++ ++struct QemuHDRPresenter { ++ EGLDisplay display; ++ EGLConfig config; ++ EGLContext context; ++ EGLint target; ++ CopyMetalSharedEvent copy_event; ++ CAMetalLayer *layer; ++ id device; ++ id queue; ++ dispatch_queue_t submit_queue; ++ id activity; ++ id pipeline; ++ QemuHDRSlot slots[3]; ++ int current, next; ++ QemuHDRMetadata metadata; ++ bool metadata_initialized; ++ uint64_t presented, skipped; ++}; ++ ++static NSString *const hdr_shader = @"\n" ++"#include \n" ++"using namespace metal;\n" ++"struct Varying { float4 position [[position]]; float2 uv; };\n" ++"vertex Varying hdr_vertex(uint id [[vertex_id]]) {\n" ++" constexpr float2 positions[] = { {-1,-1}, {3,-1}, {-1,3} };\n" ++" Varying result;\n" ++" result.position = float4(positions[id], 0, 1);\n" ++" // IOSurface's first row is the OpenGL framebuffer's bottom row.\n" ++" result.uv = positions[id] * 0.5 + 0.5;\n" ++" return result;\n" ++"}\n" ++"float3 srgb_linear(float3 value) {\n" ++" return select(value / 12.92, pow((value + 0.055) / 1.055, float3(2.4)),\n" ++" value > 0.04045);\n" ++"}\n" ++"fragment float4 hdr_fragment(Varying in [[stage_in]],\n" ++" texture2d source [[texture(0)]],\n" ++" constant uint2 &mode [[buffer(0)]]) {\n" ++" constexpr sampler nearest(coord::normalized, address::clamp_to_edge, filter::nearest);\n" ++" float3 rgb = max(source.sample(nearest, in.uv).rgb, float3(0));\n" ++" if (mode.y == 2) {\n" ++" // ST 2084 EOTF. Linear 1.0 is 100 cd/m2, not the 10000-nit PQ peak.\n" ++" const float m1 = 2610.0 / 16384.0, m2 = 2523.0 / 32.0;\n" ++" const float c1 = 3424.0 / 4096.0, c2 = 2413.0 / 128.0, c3 = 2392.0 / 128.0;\n" ++" float3 v = pow(min(rgb, float3(1)), float3(1.0 / m2));\n" ++" rgb = pow(max(v - c1, float3(0)) / max(c2 - c3 * v, float3(0.000001)),\n" ++" float3(1.0 / m1)) * 100.0;\n" ++" } else {\n" ++" rgb = srgb_linear(rgb);\n" ++" if (mode.x != 9) {\n" ++" // Linear sRGB/BT.709 to BT.2020, D65. Columns, per Metal's convention.\n" ++" const float3x3 to2020(float3(0.627403896,0.069097289,0.016391439),\n" ++" float3(0.329283038,0.919540395,0.088013308),\n" ++" float3(0.043313066,0.011362316,0.895595253));\n" ++" rgb = to2020 * rgb;\n" ++" }\n" ++" }\n" ++" return float4(rgb, 1);\n" ++"}\n"; ++ ++static void hdr_slot_clear(QemuHDRPresenter *p, QemuHDRSlot *slot) ++{ ++ if (slot->surface != EGL_NO_SURFACE) { ++ eglDestroySurface(p->display, slot->surface); ++ } ++ [slot->texture release]; ++ if (slot->pixels) { ++ CVPixelBufferRelease(slot->pixels); ++ } ++ slot->surface = EGL_NO_SURFACE; ++ slot->texture = nil; ++ slot->pixels = NULL; ++ slot->width = slot->height = 0; ++} ++ ++QemuHDRPresenter *cocoa_hdr_create(CALayer *root, EGLDisplay display, ++ EGLConfig config, EGLContext context) ++{ ++ const char *extensions = eglQueryString(display, EGL_EXTENSIONS); ++ if (!extensions || !strstr(extensions, "EGL_ANGLE_iosurface_client_buffer") || ++ !strstr(extensions, "EGL_ANGLE_metal_shared_event_sync")) { ++ fprintf(stderr, "[cocoa-hdr] IOSurface/shared GPU event extensions unavailable\n"); ++ return NULL; ++ } ++ QemuHDRPresenter *p = calloc(1, sizeof(*p)); ++ if (!p) { ++ return NULL; ++ } ++ p->display = display; ++ p->config = config; ++ p->context = context; ++ p->current = -1; ++ for (unsigned i = 0; i < 3; ++i) { ++ atomic_init(&p->slots[i].busy, false); ++ } ++ p->copy_event = (CopyMetalSharedEvent)eglGetProcAddress("eglCopyMetalSharedEventANGLE"); ++ p->device = MTLCreateSystemDefaultDevice(); ++ p->queue = [p->device newCommandQueue]; ++ p->submit_queue = dispatch_queue_create("org.qemu.cocoa-hdr", DISPATCH_QUEUE_SERIAL); ++ if (!p->copy_event || !p->device || !p->queue || !p->submit_queue || ++ !eglGetConfigAttrib(display, config, EGL_BIND_TO_TEXTURE_TARGET_ANGLE, &p->target) || ++ p->target != EGL_TEXTURE_2D) { ++ goto fail; ++ } ++ ++ NSError *error = nil; ++ id library = [p->device newLibraryWithSource:hdr_shader options:nil error:&error]; ++ if (!library) { ++ fprintf(stderr, "[cocoa-hdr] shader: %s\n", error.localizedDescription.UTF8String); ++ goto fail; ++ } ++ MTLRenderPipelineDescriptor *pipeline = [[MTLRenderPipelineDescriptor alloc] init]; ++ id vertex = [library newFunctionWithName:@"hdr_vertex"]; ++ id fragment = [library newFunctionWithName:@"hdr_fragment"]; ++ pipeline.vertexFunction = vertex; ++ pipeline.fragmentFunction = fragment; ++ pipeline.colorAttachments[0].pixelFormat = MTLPixelFormatRGBA16Float; ++ p->pipeline = [p->device newRenderPipelineStateWithDescriptor:pipeline error:&error]; ++ [vertex release]; ++ [fragment release]; ++ [pipeline release]; ++ [library release]; ++ if (!p->pipeline) { ++ fprintf(stderr, "[cocoa-hdr] pipeline: %s\n", error.localizedDescription.UTF8String); ++ goto fail; ++ } ++ ++ p->layer = [[CAMetalLayer alloc] init]; ++ p->layer.device = p->device; ++ p->layer.pixelFormat = MTLPixelFormatRGBA16Float; ++ p->layer.framebufferOnly = YES; ++ p->layer.wantsExtendedDynamicRangeContent = YES; ++ p->layer.maximumDrawableCount = 3; ++ p->layer.displaySyncEnabled = NO; ++ p->layer.opaque = YES; ++ p->layer.frame = root.bounds; ++ p->layer.contentsScale = root.contentsScale; ++ p->layer.autoresizingMask = kCALayerWidthSizable | kCALayerHeightSizable; ++ CGColorSpaceRef colors = CGColorSpaceCreateWithName(kCGColorSpaceExtendedLinearITUR_2020); ++ p->layer.colorspace = colors; ++ CGColorSpaceRelease(colors); ++ [root addSublayer:p->layer]; ++ /* A VM continues to process guest frames when its window is unfocused. ++ * Explicitly declare that work: App Nap otherwise throttles the offscreen ++ * ANGLE path after launch. The Mac may still enter normal system sleep. ++ */ ++ p->activity = [[[NSProcessInfo processInfo] ++ beginActivityWithOptions:NSActivityUserInitiatedAllowingIdleSystemSleep ++ reason:@"Presenting the virtual machine HDR display"] retain]; ++ fprintf(stderr, "[cocoa-hdr] initialized: RGBA16Float, linear BT.2020, 3 shared surfaces, GPU event synchronization\n"); ++ return p; ++ ++fail: ++ if (p->submit_queue) { ++ dispatch_release(p->submit_queue); ++ } ++ [p->pipeline release]; ++ [p->queue release]; ++ [p->device release]; ++ free(p); ++ return NULL; ++} ++ ++bool cocoa_hdr_begin(QemuHDRPresenter *p, int width, int height) ++{ ++ if (width < 1 || height < 1 || width > 8192 || height > 8192 || ++ (uint64_t)width * height > UINT64_C(8192) * 4320) { ++ return false; ++ } ++ int index = -1; ++ for (unsigned i = 0; i < 3; ++i) { ++ unsigned candidate = (p->next + i) % 3; ++ if (!atomic_load_explicit(&p->slots[candidate].busy, memory_order_acquire)) { ++ index = candidate; ++ break; ++ } ++ } ++ if (index < 0) { ++ ++p->skipped; ++ return false; ++ } ++ QemuHDRSlot *slot = &p->slots[index]; ++ if (slot->width != width || slot->height != height) { ++ hdr_slot_clear(p, slot); ++ NSDictionary *attributes = @{ ++ (id)kCVPixelBufferIOSurfacePropertiesKey: @{}, ++ (id)kCVPixelBufferMetalCompatibilityKey: @YES ++ }; ++ CVReturn result = CVPixelBufferCreate(NULL, width, height, kCVPixelFormatType_64RGBAHalf, ++ (CFDictionaryRef)attributes, &slot->pixels); ++ if (result != kCVReturnSuccess) { ++ fprintf(stderr, "[cocoa-hdr] pixel buffer allocation failed: %d\n", result); ++ return false; ++ } ++ IOSurfaceRef surface = CVPixelBufferGetIOSurface(slot->pixels); ++ MTLTextureDescriptor *texture = [MTLTextureDescriptor ++ texture2DDescriptorWithPixelFormat:MTLPixelFormatRGBA16Float ++ width:width height:height mipmapped:NO]; ++ texture.usage = MTLTextureUsageShaderRead; ++ texture.storageMode = MTLStorageModeShared; ++ slot->texture = [p->device newTextureWithDescriptor:texture iosurface:surface plane:0]; ++ const EGLint attributesEGL[] = { ++ EGL_WIDTH, width, EGL_HEIGHT, height, ++ EGL_IOSURFACE_PLANE_ANGLE, 0, EGL_TEXTURE_TARGET, p->target, ++ EGL_TEXTURE_FORMAT, EGL_TEXTURE_RGBA, ++ EGL_TEXTURE_INTERNAL_FORMAT_ANGLE, GL_RGBA, ++ EGL_TEXTURE_TYPE_ANGLE, GL_HALF_FLOAT, EGL_NONE ++ }; ++ slot->surface = eglCreatePbufferFromClientBuffer(p->display, EGL_IOSURFACE_ANGLE, ++ (EGLClientBuffer)surface, p->config, attributesEGL); ++ if (!slot->texture || slot->surface == EGL_NO_SURFACE) { ++ fprintf(stderr, "[cocoa-hdr] shared texture import failed: EGL 0x%x\n", eglGetError()); ++ hdr_slot_clear(p, slot); ++ return false; ++ } ++ slot->width = width; ++ slot->height = height; ++ } ++ if (!eglMakeCurrent(p->display, slot->surface, slot->surface, p->context)) { ++ return false; ++ } ++ glBindFramebuffer(GL_FRAMEBUFFER, 0); ++ p->current = index; ++ p->next = (index + 1) % 3; ++ return true; ++} ++ ++/* nextDrawable can wait for WindowServer even when displaySyncEnabled is off. ++ * Keep it, and all Metal submission, off QEMU's main loop. A busy slot covers ++ * both this queued work and its GPU completion; the guest can keep rendering ++ * while a hidden or slower host window applies presentation backpressure. ++ */ ++static void hdr_submit(QemuHDRPresenter *p, QemuHDRSlot *slot, ++ id event, QemuHDRMetadata metadata) ++{ ++ CGSize size = CGSizeMake(slot->width, slot->height); ++ if (!CGSizeEqualToSize(p->layer.drawableSize, size)) { ++ p->layer.drawableSize = size; ++ } ++ id drawable = [[p->layer nextDrawable] retain]; ++ if (!drawable) { ++ atomic_store_explicit(&slot->busy, false, memory_order_release); ++ return; ++ } ++ if (!p->metadata_initialized || memcmp(&p->metadata, &metadata, sizeof(metadata))) { ++ p->metadata = metadata; ++ p->metadata_initialized = true; ++ [CATransaction begin]; ++ [CATransaction setDisableActions:YES]; ++ if (metadata.eotf == 2) { ++ float peak = metadata.max_cll ? metadata.max_cll : metadata.max_luminance; ++ peak = fminf(10000, fmaxf(1, peak ? peak : 1000)); ++ float minimum = fminf(peak, metadata.min_luminance / 10000.0f); ++ p->layer.EDRMetadata = [CAEDRMetadata HDR10MetadataWithMinLuminance:minimum ++ maxLuminance:peak opticalOutputScale:100]; ++ } else { ++ p->layer.EDRMetadata = nil; ++ } ++ [CATransaction commit]; ++ fprintf(stderr, "[cocoa-hdr] scanout colorspace=%u eotf=%u min=%g max=%u CLL=%u FALL=%u\n", ++ metadata.colorspace, metadata.eotf, metadata.min_luminance / 10000.0, ++ metadata.max_luminance, metadata.max_cll, metadata.max_fall); ++ } ++ id command = [p->queue commandBuffer]; ++ if (!command) { ++ goto fail_frame; ++ } ++ [command encodeWaitForEvent:event value:1]; ++ MTLRenderPassDescriptor *pass = [MTLRenderPassDescriptor renderPassDescriptor]; ++ pass.colorAttachments[0].texture = drawable.texture; ++ pass.colorAttachments[0].loadAction = MTLLoadActionDontCare; ++ pass.colorAttachments[0].storeAction = MTLStoreActionStore; ++ id encoder = [command renderCommandEncoderWithDescriptor:pass]; ++ if (!encoder) { ++ goto fail_frame; ++ } ++ const uint32_t mode[] = { metadata.colorspace, metadata.eotf }; ++ [encoder setRenderPipelineState:p->pipeline]; ++ [encoder setFragmentTexture:slot->texture atIndex:0]; ++ [encoder setFragmentBytes:mode length:sizeof(mode) atIndex:0]; ++ [encoder drawPrimitives:MTLPrimitiveTypeTriangle vertexStart:0 vertexCount:3]; ++ [encoder endEncoding]; ++ [command presentDrawable:drawable]; ++ [command addCompletedHandler:^(id completed) { ++ if (completed.status == MTLCommandBufferStatusError) { ++ fprintf(stderr, "[cocoa-hdr] GPU presentation failed: %s\n", completed.error.localizedDescription.UTF8String); ++ } ++ atomic_store_explicit(&slot->busy, false, memory_order_release); ++ }]; ++ [command commit]; ++ [drawable release]; ++ return; ++ ++fail_frame: ++ fprintf(stderr, "[cocoa-hdr] Metal command allocation failed\n"); ++ [drawable release]; ++ atomic_store_explicit(&slot->busy, false, memory_order_release); ++} ++ ++bool cocoa_hdr_present(QemuHDRPresenter *p, const QemuHDRMetadata *metadata) ++{ ++ if (p->current < 0) { ++ return false; ++ } ++ QemuHDRSlot *slot = &p->slots[p->current]; ++ p->current = -1; ++ const EGLAttrib attributes[] = { EGL_SYNC_METAL_SHARED_EVENT_SIGNAL_VALUE_LO_ANGLE, 1, EGL_NONE }; ++ EGLSync sync = eglCreateSync(p->display, EGL_SYNC_METAL_SHARED_EVENT_ANGLE, attributes); ++ id event = sync != EGL_NO_SYNC ? (id)p->copy_event(p->display, sync) : nil; ++ if (!event) { ++ fprintf(stderr, "[cocoa-hdr] cannot create GPU handoff fence: EGL 0x%x\n", eglGetError()); ++ if (sync != EGL_NO_SYNC) { ++ eglDestroySync(p->display, sync); ++ } ++ return false; ++ } ++ glFlush(); ++ QemuHDRMetadata frame_metadata = *metadata; ++ atomic_store_explicit(&slot->busy, true, memory_order_release); ++ dispatch_async(p->submit_queue, ^{ ++ @autoreleasepool { ++ hdr_submit(p, slot, event, frame_metadata); ++ } ++ }); ++ ++p->presented; ++ [event release]; ++ eglDestroySync(p->display, sync); ++ return true; ++} +diff --git a/ui/cocoa.m b/ui/cocoa.m +--- a/ui/cocoa.m ++++ b/ui/cocoa.m +@@ -178,6 +178,9 @@ + + #ifdef CONFIG_EGL + static EGLSurface egl_surface; ++#include "cocoa-hdr.h" ++static QemuHDRPresenter *hdr_presenter; ++static QemuHDRMetadata hdr_metadata; + #endif + + static void cocoa_gl_switch(DisplayChangeListener *dcl, +@@ -2439,8 +2442,18 @@ + #ifdef CONFIG_EGL + if (egl_surface) { + with_gl_view_ctx(^{ +- cocoa_gl_render(); +- eglSwapBuffers(qemu_egl_display, egl_surface); ++ if (hdr_presenter) { ++ NSSize size = [cocoaView convertSizeToBacking:[cocoaView frame].size]; ++ if (cocoa_hdr_begin(hdr_presenter, size.width, size.height)) { ++ cocoa_gl_render(); ++ if (!cocoa_hdr_present(hdr_presenter, &hdr_metadata)) { ++ gl_dirty = true; ++ } ++ } ++ } else { ++ cocoa_gl_render(); ++ eglSwapBuffers(qemu_egl_display, egl_surface); ++ } + cocoa_gl_log_error("eglSwapBuffers"); + }); + +@@ -2454,8 +2467,27 @@ + } + } + ++static bool cocoa_gl_has_hdr(DisplayChangeListener *dcl) ++{ ++#ifdef CONFIG_EGL ++ return hdr_presenter != NULL; ++#else ++ return false; ++#endif ++} ++ ++static void cocoa_gl_hdr_metadata(DisplayChangeListener *dcl, const QemuHDRMetadata *metadata) ++{ ++#ifdef CONFIG_EGL ++ hdr_metadata = *metadata; ++#endif ++} ++ + static void cocoa_gl_scanout_disable(DisplayChangeListener *dcl) + { ++#ifdef CONFIG_EGL ++ memset(&hdr_metadata, 0, sizeof(hdr_metadata)); ++#endif + gl_scanout_borrow = NULL; + gl_dirty = true; + +@@ -2494,6 +2526,8 @@ + .dpy_mouse_set = cocoa_mouse_set, + .dpy_cursor_define = cocoa_cursor_define, + ++ .dpy_gl_has_hdr = cocoa_gl_has_hdr, ++ .dpy_gl_hdr_metadata = cocoa_gl_hdr_metadata, + .dpy_gl_scanout_disable = cocoa_gl_scanout_disable, + .dpy_gl_scanout_texture = cocoa_gl_scanout_texture, + .dpy_gl_update = cocoa_gl_scanout_flush, +@@ -2541,6 +2575,11 @@ + + static void cocoa_display_init(DisplayState *ds, DisplayOptions *opts) + { ++ if (opts->u.cocoa.has_hdr && opts->u.cocoa.hdr && opts->gl != DISPLAY_GL_MODE_ES) { ++ error_report("Cocoa HDR requires gl=es"); ++ exit(1); ++ } ++ + NSAutoreleasePool * pool = [[NSAutoreleasePool alloc] init]; + QemuCocoaAppController *controller; + +@@ -2579,7 +2618,23 @@ + if (!gl_view_ctx) { + exit(1); + } +- egl_surface = qemu_egl_init_surface(gl_view_ctx, [cocoaView layer]); ++ if (opts->u.cocoa.has_hdr && opts->u.cocoa.hdr) { ++ const EGLint attributes[] = { EGL_WIDTH, 1, EGL_HEIGHT, 1, EGL_NONE }; ++ egl_surface = eglCreatePbufferSurface(qemu_egl_display, qemu_egl_config, attributes); ++ if (egl_surface) { ++ hdr_presenter = cocoa_hdr_create([cocoaView layer], qemu_egl_display, ++ qemu_egl_config, gl_view_ctx); ++ } ++ if (!hdr_presenter) { ++ warn_report("Cocoa HDR unavailable; using SDR display output"); ++ if (egl_surface) { ++ eglDestroySurface(qemu_egl_display, egl_surface); ++ } ++ } ++ } ++ if (!hdr_presenter) { ++ egl_surface = qemu_egl_init_surface(gl_view_ctx, [cocoaView layer]); ++ } + if (!egl_surface) { + exit(1); + } +diff --git a/ui/console.c b/ui/console.c +--- a/ui/console.c ++++ b/ui/console.c +@@ -935,6 +935,28 @@ + return con->gl->ops->dpy_gl_ctx_make_current(con->gl, ctx); + } + ++bool qemu_console_gl_has_hdr(QemuConsole *con) ++{ ++ DisplayChangeListener *dcl; ++ QLIST_FOREACH(dcl, &con->ds->listeners, next) { ++ if (con == dcl->con && dcl->ops->dpy_gl_has_hdr && ++ dcl->ops->dpy_gl_has_hdr(dcl)) { ++ return true; ++ } ++ } ++ return false; ++} ++ ++void qemu_console_gl_hdr_metadata(QemuConsole *con, const QemuHDRMetadata *metadata) ++{ ++ DisplayChangeListener *dcl; ++ QLIST_FOREACH(dcl, &con->ds->listeners, next) { ++ if (con == dcl->con && dcl->ops->dpy_gl_hdr_metadata) { ++ dcl->ops->dpy_gl_hdr_metadata(dcl, metadata); ++ } ++ } ++} ++ + void qemu_console_gl_scanout_disable(QemuConsole *con) + { + DisplayState *s = con->ds; +diff --git a/ui/meson.build b/ui/meson.build +--- a/ui/meson.build ++++ b/ui/meson.build +@@ -71,6 +71,7 @@ + system_ss.add(files('input-linux.c', 'udmabuf.c')) + endif + system_ss.add(when: cocoa, if_true: files('cocoa.m')) ++system_ss.add(when: [cocoa, opengl, egl], if_true: files('cocoa-hdr.m')) + + vnc_ss = ss.source_set() + vnc_ss.add(files( diff --git a/macos/patches/qemu-cocoa-sdr-white.patch b/macos/patches/qemu-cocoa-sdr-white.patch new file mode 100644 index 00000000..dbc25dfe --- /dev/null +++ b/macos/patches/qemu-cocoa-sdr-white.patch @@ -0,0 +1,165 @@ +diff --git a/hw/display/edid-generate.c b/hw/display/edid-generate.c +--- a/hw/display/edid-generate.c ++++ b/hw/display/edid-generate.c +@@ -538,6 +538,16 @@ + desc = edid_desc_next(edid, dta, desc); + } + ++ if (desc && info->hdr && info->sdr_white_nits >= 1 && ++ info->sdr_white_nits <= 1000) { ++ /* Standard additional-text descriptor with a private paired hint. ++ * Keep SDR policy separate from the CTA HDR mastering capabilities. */ ++ char text[14]; ++ snprintf(text, sizeof(text), "TO-SDR:%u", info->sdr_white_nits); ++ edid_desc_text(desc, 0xfe, text); ++ desc = edid_desc_next(edid, dta, desc); ++ } ++ + while (desc) { + edid_desc_dummy(desc); + desc = edid_desc_next(edid, dta, desc); +diff --git a/hw/display/virtio-gpu-base.c b/hw/display/virtio-gpu-base.c +--- a/hw/display/virtio-gpu-base.c ++++ b/hw/display/virtio-gpu-base.c +@@ -81,6 +81,10 @@ + if (info.hdr && !info.name) { + info.name = "Omarchy HDR"; + } ++ if (info.hdr) { ++ const QemuUIInfo *ui = qemu_console_get_ui_info(g->scanout[scanout].con); ++ info.sdr_white_nits = ui ? ui->sdr_white_nits : 0; ++ } + edid->size = cpu_to_le32(sizeof(edid->edid)); + qemu_edid_generate(edid->edid, sizeof(edid->edid), &info); + } +diff --git a/include/hw/display/edid.h b/include/hw/display/edid.h +--- a/include/hw/display/edid.h ++++ b/include/hw/display/edid.h +@@ -5,6 +5,7 @@ + + typedef struct qemu_edid_info { + bool hdr; /* virtual BT.2020/PQ display; host compositor handles tone mapping */ ++ uint32_t sdr_white_nits; /* optional native SDR white hint, in cd/m2 */ + const char *vendor; /* http://www.uefi.org/pnp_id_list */ + const char *name; + const char *serial; +diff --git a/include/ui/console.h b/include/ui/console.h +--- a/include/ui/console.h ++++ b/include/ui/console.h +@@ -123,6 +123,7 @@ + uint32_t width; + uint32_t height; + uint32_t refresh_rate; ++ uint32_t sdr_white_nits; /* optional host SDR cap, 0 means unknown */ + } QemuUIInfo; + + /* cursor data format is 32bit RGBA */ +diff --git a/ui/cocoa-sdr-white.h b/ui/cocoa-sdr-white.h +--- /dev/null ++++ b/ui/cocoa-sdr-white.h +@@ -0,0 +1,75 @@ ++/* SPDX-License-Identifier: GPL-2.0-or-later */ ++#ifndef QEMU_COCOA_SDR_WHITE_H ++#define QEMU_COCOA_SDR_WHITE_H ++ ++#include ++#include ++#include ++#import ++ ++static uint32_t cocoa_sdr_nits_from_preset(CFDictionaryRef preset) ++{ ++ if (!preset || CFGetTypeID(preset) != CFDictionaryGetTypeID() || ++ CFDictionaryGetValue(preset, CFSTR("PresetValid")) != kCFBooleanTrue) { ++ return 0; ++ } ++ CFTypeRef value = CFDictionaryGetValue(preset, CFSTR("PresetMaxSDRLuminance")); ++ double nits = 0; ++ if (!value || CFGetTypeID(value) != CFNumberGetTypeID() || ++ !CFNumberGetValue((CFNumberRef)value, kCFNumberDoubleType, &nits) || ++ !isfinite(nits) || nits < 1 || nits > 10000) { ++ return 0; ++ } ++ /* The paired virtual HDR display has a 1000-nit output contract. */ ++ return (uint32_t)lround(fmin(nits, 1000)); ++} ++ ++static uint32_t cocoa_sdr_white_nits(NSScreen *screen) ++{ ++ NSNumber *number = screen.deviceDescription[@"NSScreenNumber"]; ++ if (!number) { ++ return 0; ++ } ++ ++ /* Read the active display preset's SDR maximum. A panel's backlight cap ++ * also rises for HDR, so it cannot describe SDR white. Likewise, neither ++ * an HDR peak nor an EDR ratio is a maximum SDR luminance in cd/m2. ++ * These optional CoreDisplay read APIs are private; resolve at runtime ++ * and omit the hint if the API or a valid display preset is unavailable. ++ * No preset or host brightness setting is ever changed here. */ ++ typedef uint32_t (*PresetCount)(uint32_t); ++ typedef int32_t (*ActivePreset)(uint32_t); ++ typedef CFDictionaryRef (*CopyPreset)(uint32_t, uint32_t); ++ static PresetCount count; ++ static ActivePreset active; ++ static CopyPreset copy; ++ static dispatch_once_t once; ++ dispatch_once(&once, ^{ ++ void *library = dlopen("/System/Library/Frameworks/CoreDisplay.framework/CoreDisplay", ++ RTLD_LAZY | RTLD_LOCAL); ++ if (library) { ++ count = (PresetCount)dlsym(library, "CoreDisplay_Display_GetPresetCount"); ++ active = (ActivePreset)dlsym(library, "CoreDisplay_Display_GetActivePresetIndex"); ++ copy = (CopyPreset)dlsym(library, "CoreDisplay_Display_CopyPreset"); ++ } ++ /* Keep the framework loaded while its function pointers are in use. */ ++ }); ++ if (!count || !active || !copy) { ++ return 0; ++ } ++ uint32_t display = number.unsignedIntValue; ++ uint32_t total = count(display); ++ int32_t index = active(display); ++ if (!total || total > 4096 || index < 0 || (uint32_t)index >= total) { ++ return 0; ++ } ++ CFDictionaryRef preset = copy(display, (uint32_t)index); ++ uint32_t nits = cocoa_sdr_nits_from_preset(preset); ++ if (preset) { ++ CFRelease(preset); ++ } ++ /* A preset changed while reading: the next screen notification retries. */ ++ return active(display) == index ? nits : 0; ++} ++ ++#endif +diff --git a/ui/cocoa.m b/ui/cocoa.m +--- a/ui/cocoa.m ++++ b/ui/cocoa.m +@@ -27,6 +27,7 @@ + #include "qemu/osdep.h" + + #import ++#include "ui/cocoa-sdr-white.h" + #import + #include + #include +@@ -712,6 +713,8 @@ + bool isFullscreen = ([[self window] styleMask] & NSWindowStyleMaskFullScreen) != 0; + CVDisplayLinkRef displayLink; + uint32_t refreshRate = 0; ++ ++ info.sdr_white_nits = cocoa_sdr_white_nits([[self window] screen]); + + frameSize = isFullscreen ? [self screenSafeAreaSize] : [self frame].size; + +@@ -1397,6 +1400,9 @@ + self = [super init]; + if (self) { + NSRect frame = cocoa_initial_window_frame(); ++ [[NSNotificationCenter defaultCenter] addObserver:self ++ selector:@selector(windowDidChangeScreen:) ++ name:NSApplicationDidChangeScreenParametersNotification object:nil]; + + // create a view and add it to the window + #ifdef CONFIG_OPENGL diff --git a/macos/run-qemu-gpu.sh b/macos/run-qemu-gpu.sh index bf0b4417..49362f2f 100755 --- a/macos/run-qemu-gpu.sh +++ b/macos/run-qemu-gpu.sh @@ -177,6 +177,13 @@ gpu_help=$("$qemu_bin" -device virtio-gpu-gl-pci,help 2>&1) || { fail "cannot inspect the staged VirGL device" } gpu_device='virtio-gpu-gl-pci,max_outputs=1,xres=1920,yres=1080' +cocoa_hdr='' +# The private HDR feature is negotiated by the matching guest kernel module. +# Older guest kernels keep their SDR EDID and framebuffer formats. +if [[ $gpu_help == *x-omarchy-hdr* ]]; then + gpu_device+=',x-omarchy-hdr=on' + cocoa_hdr=',hdr=on' +fi if [[ $gpu_help == *'romfile='* ]]; then gpu_device+=',romfile=' fi @@ -565,6 +572,8 @@ hyprland = exact_keys( { "binarySha256", "buildPackages", + "clientSdrWhitePatch", + "clientSdrWhitePatchSha256", "commit", "glazeCommit", "glazeLicenseSha256", @@ -607,8 +616,8 @@ exact_keys( hyprland_identity = hashlib.sha256( json.dumps(hyprland, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8") ).hexdigest() -if hyprland_identity != "edd58c17fc115b375d8e8b9b5eb7eb78008e89c05867b3ed2f2834287badcae8": - fail("factory Hyprland component is not the reviewed rounded-border build") +if hyprland_identity != "b3142e903d5a987588d263aa5440752a2d85678e4536b299cb75af03dafe0ded": + fail("factory Hyprland component is not the reviewed patched build") mise = exact_keys( supply_chain.get("mise"), {"binarySha256", "license", "reportedVersion", "sha256", "url", "version"}, @@ -1454,7 +1463,7 @@ qemu_args=( # Full grab keeps every Command chord with the focused guest in either # presentation mode. Immersive launches Full Screen and hard-hides the Mac # menu bar and Dock; otherwise Cocoa opens a centered, resizable window. - -display "cocoa,gl=es,show-cursor=on,zoom-to-fit=on,full-screen=$cocoa_full_screen,full-grab=on,immersive=$cocoa_immersive,swap-opt-cmd=off" + -display "cocoa,gl=es${cocoa_hdr},show-cursor=on,zoom-to-fit=on,full-screen=$cocoa_full_screen,full-grab=on,immersive=$cocoa_immersive,swap-opt-cmd=off" -device 'virtio-keyboard-pci,romfile=' -device 'virtio-tablet-pci,romfile=' -object 'rng-random,id=omarchy-rng,filename=/dev/urandom' diff --git a/tests/native-client-sdr-white.py b/tests/native-client-sdr-white.py new file mode 100644 index 00000000..c4ea45bc --- /dev/null +++ b/tests/native-client-sdr-white.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +"""Compile the actual Hyprland patch's client-white policy in a small harness.""" +from pathlib import Path +import re +import shutil +import subprocess +import tempfile + +root = Path(__file__).resolve().parents[1] +patch = (root / "guest/patches/hyprland/client-sdr-white.patch").read_text() +added = "\n".join(line[1:] for line in patch.splitlines() if line.startswith("+") and not line.startswith("+++")) +method = re.search(r"NColorManagement::PImageDescription CMonitor::getClientImageDescription\(\) const \{.*?^\}", added, re.S | re.M) +assert method, "client SDR-white implementation is missing" +source = r""" +#include +#include +#include +#include +#include +#include +namespace NColorManagement { +enum Transfer { SDR, CM_TRANSFER_FUNCTION_ST2084_PQ }; +struct Luminances { float min = 0; uint32_t max = 993, reference = 203; }; +struct Value { Transfer transferFunction = CM_TRANSFER_FUNCTION_ST2084_PQ; struct { bool present = false; } icc; Luminances luminances; }; +struct Description { + Value data; + const Value& value() const { return data; } + std::shared_ptr with(Luminances luminances) const { + auto copy = std::make_shared(*this); copy->data.luminances = luminances; return copy; + } +}; +using PImageDescription = std::shared_ptr; +} +struct CMonitor { + NColorManagement::PImageDescription m_imageDescription = std::make_shared(); + int m_sdrMaxLuminance = 600; + float m_sdrBrightness = 1; + NColorManagement::PImageDescription getClientImageDescription() const; +}; +""" + method[0] + r""" +int main() { + CMonitor monitor; + const auto original = monitor.m_imageDescription; + for (int white : {80, 100, 250, 500, 600}) { + monitor.m_sdrMaxLuminance = white; + auto client = monitor.getClientImageDescription(); + assert(client->value().luminances.reference == static_cast(white)); + assert(client->value().luminances.max == 993); + assert(client->value().transferFunction == NColorManagement::CM_TRANSFER_FUNCTION_ST2084_PQ); + assert(monitor.m_imageDescription == original && original->value().luminances.reference == 203); + } + monitor.m_sdrBrightness = 1.25f; + assert(monitor.getClientImageDescription()->value().luminances.reference == 750); + monitor.m_sdrBrightness = 10; + assert(monitor.getClientImageDescription()->value().luminances.reference == 993); + for (float brightness : {0.f, -1.f, std::numeric_limits::infinity(), std::numeric_limits::quiet_NaN()}) { + monitor.m_sdrBrightness = brightness; + assert(monitor.getClientImageDescription()->value().luminances.reference == 600); + } + monitor.m_sdrBrightness = 1; + monitor.m_sdrMaxLuminance = 0; + assert(monitor.getClientImageDescription() == original); + monitor.m_sdrMaxLuminance = -1; + assert(monitor.getClientImageDescription() == original); + monitor.m_sdrMaxLuminance = 600; + original->data.transferFunction = NColorManagement::SDR; + assert(monitor.getClientImageDescription() == original); + original->data.transferFunction = NColorManagement::CM_TRANSFER_FUNCTION_ST2084_PQ; + original->data.icc.present = true; + assert(monitor.getClientImageDescription() == original); +} +""" +compiler = shutil.which("c++") +assert compiler, "a C++20 compiler is required" +with tempfile.TemporaryDirectory(prefix="omarchy-client-sdr-white-") as directory: + directory = Path(directory) + (directory / "test.cpp").write_text(source) + subprocess.run([compiler, "-std=c++20", "-Wall", "-Wextra", "-Werror", str(directory / "test.cpp"), "-o", str(directory / "test")], check=True) + subprocess.run([str(directory / "test")], check=True) +print("PASS: client SDR white follows the display policy; HDR render description and SDR/ICC modes are preserved") diff --git a/tests/native-hdr-presenter.m b/tests/native-hdr-presenter.m new file mode 100644 index 00000000..e5942ea9 --- /dev/null +++ b/tests/native-hdr-presenter.m @@ -0,0 +1,122 @@ +// Integration test: GPU readback is restricted to this test harness. +#include "ui/cocoa-hdr.m" +#include +#include + +static id captured_output; +static IMP original_next_drawable; +static id capture_drawable(id layer, SEL selector) { + id drawable = ((id (*)(id, SEL))original_next_drawable)(layer, selector); + [captured_output release]; + captured_output = [drawable.texture retain]; + return drawable; +} + + +static float pq(float nits) { + double v = pow(nits / 10000.0, 2610.0 / 16384.0); + return pow((3424.0/4096.0 + (2413.0/128.0)*v) / + (1 + (2392.0/128.0)*v), 2523.0/32.0); +} + +static void close_to(const char *label, float actual, float expected) { + printf("%s actual=%.6f expected=%.6f\n", label, actual, expected); + assert(fabsf(actual-expected) < fmaxf(0.006f, fabsf(expected)*0.009f)); +} + +static void frame(QemuHDRPresenter *p, QemuHDRMetadata metadata, bool hdr) { + enum { W=256, H=128 }; + assert(cocoa_hdr_begin(p,W,H)); + glViewport(0,0,W,H); + glEnable(GL_SCISSOR_TEST); + float values[4][4]={{1,1,1,1},{0.5,0.5,0.5,1},{1,0,0,1},{0,1,0,1}}; + if(hdr) { + values[0][0]=values[0][1]=values[0][2]=pq(100); + values[1][0]=values[1][1]=values[1][2]=pq(1000); + values[2][0]=pq(1000);values[3][1]=pq(1000); + } + for(int i=0;i<4;i++) { + glScissor((i%2)*W/2,(i/2)*H/2,W/2,H/2); + glClearColor(values[i][0],values[i][1],values[i][2],1); + glClear(GL_COLOR_BUFFER_BIT); + } + glDisable(GL_SCISSOR_TEST); + assert(glGetError()==GL_NO_ERROR); + assert(cocoa_hdr_present(p,&metadata)); + // The test waits for the production submission queue, then reads its actual drawable. + dispatch_sync(p->submit_queue, ^{}); + id output=[captured_output retain]; + assert(output); + id result=[p->device newBufferWithLength:W*H*8 options:MTLResourceStorageModeShared]; + id command=[p->queue commandBuffer]; + id blit=[command blitCommandEncoder]; + [blit copyFromTexture:output sourceSlice:0 sourceLevel:0 sourceOrigin:MTLOriginMake(0,0,0) + sourceSize:MTLSizeMake(W,H,1) toBuffer:result destinationOffset:0 + destinationBytesPerRow:W*8 destinationBytesPerImage:W*H*8]; + [blit endEncoding];[command commit];[command waitUntilCompleted]; + assert(command.status==MTLCommandBufferStatusCompleted); + __fp16 *pixels=result.contents; + float expectedSDR[4][3]={{1,1,1},{0.214041,0.214041,0.214041}, + {0.627404,0.0690973,0.0163914},{0.329283,0.919540,0.0880133}}; + float expectedHDR[4][3]={{1,1,1},{10,10,10},{10,0,0},{0,10,0}}; + for(int i=0;i<4;i++) { + // Bottom GL quadrants must appear at the bottom of the Metal drawable. + int x=(i%2)*W/2+W/4, y=H-1-((i/2)*H/2+H/4); + for(int c=0;c<3;c++) { + char label[80];snprintf(label,sizeof(label),"%s quadrant=%d channel=%d",hdr?"HDR":"SDR",i,c); + close_to(label,(float)pixels[(y*W+x)*4+c],hdr?expectedHDR[i][c]:expectedSDR[i][c]); + } + } + [result release];[output release]; +} + +int main(void) { @autoreleasepool { + [NSApplication sharedApplication]; + [NSApp setActivationPolicy:NSApplicationActivationPolicyAccessory]; + NSWindow *window=[[NSWindow alloc] initWithContentRect:NSMakeRect(50,50,256,128) + styleMask:NSWindowStyleMaskTitled backing:NSBackingStoreBuffered defer:NO]; + window.title=@"HDR pipeline verification"; + window.contentView.wantsLayer=YES; + [window orderFront:nil]; + CFRunLoopRunInMode(kCFRunLoopDefaultMode,0.05,false); + PFNEGLGETPLATFORMDISPLAYEXTPROC getDisplay=(void*)eglGetProcAddress("eglGetPlatformDisplayEXT"); + EGLint da[]={0x3203,0x3489,EGL_NONE}; + EGLDisplay d=getDisplay(0x3202,EGL_DEFAULT_DISPLAY,da); + assert(eglInitialize(d,NULL,NULL));assert(eglBindAPI(EGL_OPENGL_ES_API)); + EGLint ca[]={EGL_RENDERABLE_TYPE,EGL_OPENGL_ES3_BIT,EGL_SURFACE_TYPE,EGL_PBUFFER_BIT,EGL_NONE}; + EGLConfig config;EGLint count;assert(eglChooseConfig(d,ca,&config,1,&count)&&count); + EGLint ctxa[]={EGL_CONTEXT_CLIENT_VERSION,3,EGL_NONE}; + EGLContext ctx=eglCreateContext(d,config,EGL_NO_CONTEXT,ctxa);assert(ctx!=EGL_NO_CONTEXT); + QemuHDRPresenter *presenter=cocoa_hdr_create(window.contentView.layer,d,config,ctx);assert(presenter); + Method next=class_getInstanceMethod([CAMetalLayer class], @selector(nextDrawable)); + original_next_drawable=method_setImplementation(next, (IMP)capture_drawable); + presenter->layer.framebufferOnly=NO; // Test readback only. Production stays framebuffer-only. + QemuHDRMetadata sdr={0}; + QemuHDRMetadata hdr={.colorspace=9,.eotf=2,.max_luminance=1000,.max_cll=1000,.max_fall=400}; + frame(presenter,sdr,false);frame(presenter,hdr,true);frame(presenter,sdr,false); + assert(presenter->presented==3 && presenter->skipped==0); + // Simulate a stalled WindowServer acquisition. Three queued frames fill the + // bounded pool; another refresh must return without waiting on submission. + dispatch_semaphore_t entered=dispatch_semaphore_create(0); + dispatch_semaphore_t unblock=dispatch_semaphore_create(0); + dispatch_async(presenter->submit_queue, ^{ + dispatch_semaphore_signal(entered); + dispatch_semaphore_wait(unblock, dispatch_time(DISPATCH_TIME_NOW, 2*NSEC_PER_SEC)); + }); + dispatch_semaphore_wait(entered, DISPATCH_TIME_FOREVER); + for(int i=0;i<3;i++) { + assert(cocoa_hdr_begin(presenter,256,128)); + glClearColor(0,0,0,1);glClear(GL_COLOR_BUFFER_BIT); + assert(cocoa_hdr_present(presenter,&sdr)); + } + assert(!cocoa_hdr_begin(presenter,256,128)); + assert(presenter->skipped==1); + dispatch_semaphore_signal(unblock); + dispatch_sync(presenter->submit_queue, ^{}); + id drain=[presenter->queue commandBuffer]; + [drain commit];[drain waitUntilCompleted]; + dispatch_release(entered);dispatch_release(unblock); + puts("PASS: SDR/PQ/SDR, 100/1000-nit values, primaries, orientation, GPU handoff, nonblocking bounded submission"); + [window orderOut:nil]; + return 0; +} } diff --git a/tests/native-hdr-presenter.py b/tests/native-hdr-presenter.py new file mode 100644 index 00000000..a7a108e8 --- /dev/null +++ b/tests/native-hdr-presenter.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +"""Check the actual Cocoa HDR shader and ANGLE-to-Metal GPU handoff on macOS.""" +import argparse +import pathlib +import re +import subprocess +import tempfile + +ROOT = pathlib.Path(__file__).resolve().parents[1] +parser = argparse.ArgumentParser(description=__doc__) +parser.add_argument("--angle-include", type=pathlib.Path, required=True) +parser.add_argument("--epoxy-include", type=pathlib.Path, required=True) +parser.add_argument("--runtime", type=pathlib.Path, default=ROOT/"macos/.build/qemu-gpu-runtime") +args = parser.parse_args() +patch = (ROOT/"macos/patches/qemu-cocoa-hdr.patch").read_text() +required = {"ui/cocoa-hdr.m", "ui/cocoa-hdr.h", "include/ui/hdr.h"} +with tempfile.TemporaryDirectory(prefix="omarchy-hdr-test-") as temporary: + source = pathlib.Path(temporary) + found = set() + for block in re.split(r"(?=^diff --git )", patch, flags=re.M): + if not block.startswith("diff --git "): + continue + name = block.splitlines()[0].split(" b/", 1)[1] + if name not in required: + continue + assert "--- /dev/null\n" in block, name + added = "\n".join(line[1:] for line in block.splitlines() + if line.startswith("+") and not line.startswith("+++"))+"\n" + target = source/name + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(added) + found.add(name) + assert found == required + executable = source/"presenter-test" + libraries = args.runtime.resolve()/"lib" + subprocess.run(["clang", "-std=gnu11", "-fblocks", + "-I"+str(source), "-I"+str(source/"include"), + "-I"+str(args.angle_include), "-I"+str(args.epoxy_include), + str(ROOT/"tests/native-hdr-presenter.m"), "-o", str(executable), + "-L"+str(libraries), "-Wl,-rpath,"+str(libraries), + "-lEGL", "-lGLESv2", "-lepoxy.0", + "-framework", "AppKit", "-framework", "CoreVideo", "-framework", "IOSurface", + "-framework", "Metal", "-framework", "QuartzCore"], check=True) + subprocess.run([str(executable)], check=True) diff --git a/tests/native-sdr-white.py b/tests/native-sdr-white.py new file mode 100644 index 00000000..67ada370 --- /dev/null +++ b/tests/native-sdr-white.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +"""Exercise the actual Cocoa SDR-cap reader without changing display settings.""" +import pathlib +import re +import subprocess +import tempfile + +ROOT = pathlib.Path(__file__).resolve().parents[1] +patch = (ROOT / "macos/patches/qemu-cocoa-sdr-white.patch").read_text() +block = next(block for block in re.split(r"(?=^diff --git )", patch, flags=re.M) + if block.startswith("diff --git a/ui/cocoa-sdr-white.h ")) +assert "--- /dev/null\n" in block +header = "\n".join(line[1:] for line in block.splitlines() + if line.startswith("+") and not line.startswith("+++")) + "\n" +with tempfile.TemporaryDirectory(prefix="omarchy-sdr-white-") as temporary: + directory = pathlib.Path(temporary) + (directory / "cocoa-sdr-white.h").write_text(header) + (directory / "check.m").write_text(r''' +#include "cocoa-sdr-white.h" +#include +#include + +int main(void) { + @autoreleasepool { + struct { double nits; uint32_t expected; } cases[] = { + {500, 500}, {600, 600}, {1000, 1000}, {100, 100}, + {1, 1}, {1600, 1000}, {0, 0}, {-600, 0}, + {10001, 0}, {NAN, 0}, {INFINITY, 0} + }; + for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); ++i) { + NSDictionary *value = @{@"PresetValid": @YES, + @"PresetMaxSDRLuminance": @(cases[i].nits)}; + assert(cocoa_sdr_nits_from_preset((CFDictionaryRef)value) + == cases[i].expected); + } + assert(cocoa_sdr_nits_from_preset(NULL) == 0); + assert(cocoa_sdr_nits_from_preset((CFDictionaryRef)CFSTR("600")) == 0); + NSArray *invalid = @[ + @{@"PresetValid": @NO, @"PresetMaxSDRLuminance": @600}, + @{@"PresetValid": @1, @"PresetMaxSDRLuminance": @600}, + @{@"PresetMaxSDRLuminance": @600}, + @{@"PresetValid": @YES, @"PresetMaxSDRLuminance": @YES}, + @{@"PresetValid": @YES, @"PresetMaxSDRLuminance": @"600"}, + @{@"PresetValid": @YES, @"PresetMaxHDRLuminance": @1600} + ]; + for (NSDictionary *value in invalid) { + assert(cocoa_sdr_nits_from_preset((CFDictionaryRef)value) == 0); + } + NSDictionary *independent = @{@"PresetValid": @YES, + @"PresetMaxSDRLuminance": @600, @"PresetMaxHDRLuminance": @1600}; + assert(cocoa_sdr_nits_from_preset((CFDictionaryRef)independent) == 600); + assert(cocoa_sdr_white_nits(nil) == 0); + for (NSScreen *screen in NSScreen.screens) { + NSNumber *number = screen.deviceDescription[@"NSScreenNumber"]; + uint32_t nits = cocoa_sdr_white_nits(screen); + printf("display=%u builtin=%d sdrWhiteNits=%u\n", + number.unsignedIntValue, + CGDisplayIsBuiltin(number.unsignedIntValue), nits); + } + puts("SDR-cap conversion and host query passed"); + } +} +''') + binary = directory / "check" + subprocess.run(["clang", "-std=gnu11", "-fblocks", "-Wall", "-Wextra", "-Werror", + "-mmacosx-version-min=15.0", "-Werror=unguarded-availability-new", + str(directory / "check.m"), "-o", str(binary), + "-framework", "AppKit"], check=True) + subprocess.run([str(binary)], check=True)