diff --git a/.gitattributes b/.gitattributes index a0848b62..f9371a05 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,2 @@ -*.patch whitespace=-blank-at-eol +# 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 4d85f003..4d0cfed1 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,8 @@ 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 @@ -25,7 +27,12 @@ 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. +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 2baf3cae..7a6e6cc3 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -12,12 +12,28 @@ 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. - **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. +- **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. - **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-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 new file mode 100644 index 00000000..b9643dca --- /dev/null +++ b/docs/native-video.md @@ -0,0 +1,234 @@ +# 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 +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)`. + +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 +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. + +## 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 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 aa5dc796..3fd3124a 100755 --- a/guest/build.sh +++ b/guest/build.sh @@ -258,6 +258,16 @@ 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-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/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/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/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/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..a6a34062 100755 --- a/guest/scripts/register-local-repository.sh +++ b/guest/scripts/register-local-repository.sh @@ -83,13 +83,15 @@ 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=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" [[ ${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[*]} == *'/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-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/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 79a43124..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", @@ -112,7 +114,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 +175,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 +513,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/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 814cd274..d7890fbc 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", @@ -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,11 +699,13 @@ def main() -> None: "pacman recovery files snapshot the final local-repository configuration", ) check( - "expected_archive_count=6" 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, @@ -1414,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/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..e6d1eec3 --- /dev/null +++ b/guest/video/mpv @@ -0,0 +1,22 @@ +#!/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 "$@" +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..be17693f --- /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,WaylandWpColorManagerV1 + --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" @@ -97,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}" ;; @@ -440,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 4d9412e0..cdcf6a62 100755 --- a/macos/build-qemu-gpu-runtime.sh +++ b/macos/build-qemu-gpu-runtime.sh @@ -52,6 +52,12 @@ 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" +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" pinned_bottles="$native_dir/pinned-runtime-bottles.sh" @@ -71,6 +77,9 @@ pause_ownership_patch_sha256=1a5729b36eb3e437395d41883a10c3c652df71d289d5df84d95 audio_device_patch_sha256=03aca71c26163c337338cc3b2013c35430690fc0e8b66c5ce92a42f59a9b3334 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 @@ -103,6 +112,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 +131,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 +302,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 +331,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 +340,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 +361,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 +386,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 +399,13 @@ 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" +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" @@ -473,6 +524,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 +601,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-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/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..49362f2f 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 \ @@ -176,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 @@ -386,6 +394,7 @@ runtime = exact_keys( "sharedFolder", "storage", "virtualMachineMonitor", + "video", }, "build spec runtime", ) @@ -403,6 +412,7 @@ expected_devices = [ "intel-hda", "hda-micro", "virtio-9p-pci", + "omarchy-video-shmem", ] clipboard = { "device": "virtserialport", @@ -476,6 +486,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 +517,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 +551,7 @@ supply_chain_keys = { "archLinuxArmPackagesRepository", "hyprland", "mise", + "nativeVideo", "omarchyPackagesCommit", "omarchyPackagesRepository", "ttfx", @@ -550,6 +572,8 @@ hyprland = exact_keys( { "binarySha256", "buildPackages", + "clientSdrWhitePatch", + "clientSdrWhitePatchSha256", "commit", "glazeCommit", "glazeLicenseSha256", @@ -592,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"}, @@ -698,7 +722,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 +769,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 +1007,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 +1048,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 +1305,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" @@ -1397,7 +1443,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 @@ -1414,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' @@ -1431,6 +1480,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 +1520,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 +1551,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 +1562,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 +1596,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 +1684,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/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-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) 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-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() 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; +}