feat: media bitrate control, HDR (#346)

Fixes: #335 

Still a work-in-progress.

---------

Co-authored-by: DatCaptainHorse <DatCaptainHorse@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Wanjohi <elviswanjohi47@gmail.com>
This commit is contained in:
Kristian Ollikainen
2026-09-25 12:13:34 +03:00
committed by GitHub
co-authored by DatCaptainHorse Claude Opus 5 Wanjohi
parent 1c721962f4
commit 0811f57f1a
64 changed files with 15151 additions and 2702 deletions
+38 -28
View File
@@ -13,9 +13,9 @@
# this image is plain Arch rather than a distribution chosen for its init.
# ref(d-0064)
#
# Proton is here, and it is not a closed component: it is proton-cachyos built
# from source with --enable-wow64, which is what removes the need for a whole
# 32-bit host stack. Valve's steamclient.so is a different thing and is NOT
# Proton is here, and it is not a closed component: it is proton-ge built
# from source wow64-only, which is what removes the need for a whole 32-bit
# host stack. Valve's steamclient.so is a different thing and is NOT
# here — that one is closed, and nestri/CLAUDE.md is explicit that nothing
# closed enters this repo. Whatever layers it on top of runtime_prod is a
# closed build outside this repo — see build/README.md.
@@ -31,8 +31,7 @@
# scoped to one stage instead, `FROM ${PROTON_IMAGE}` expands to nothing, and
# the build fails with "no FROM statement found" — which says nothing about
# the actual mistake. See the Proton stage below for what this is.
ARG PROTON_IMAGE=ghcr.io/nestrilabs/proton-cachyos-native-wow64:11.0-20260703
ARG PROTON_IMAGE=ghcr.io/nestrilabs/proton-ge:11-7
# ───────────────────────────────────────────────────────────
# initial / builder
@@ -72,18 +71,14 @@ ENV ARTIFACTS=/artifacts
# ───────────────────────────────────────────────────────────
FROM builder AS mesa-build
ARG MESA_GIT=https://gitlab.freedesktop.org/mesa/mesa.git
ARG MESA_COMMIT=8ace865d958b0f17254afc427db21b0ad1747b4b
ARG MESA_GIT=https://gitlab.freedesktop.org/nowrep/mesa.git
ARG MESA_COMMIT=1af89bea491deabc4741700f8df4d41edfd22f7a
ARG JOBS=
# Our patches to the amdgpu native-context path, applied on top of the pinned
# commit. They are not cosmetic: upstream's winsys re-queries device-static
# facts on a path that costs a synchronous round trip to the host under
# virtio, and it was most of a frame's time. See each patch's own message.
#
# `git apply` and not `git am`: no committer identity is needed, and a patch
# that no longer applies stops the build here rather than producing an image
# that is quietly unpatched and slow.
COPY build/patches/mesa /build/patches/mesa
RUN test -n "$JOBS" || JOBS=$(nproc) && \
@@ -173,14 +168,13 @@ RUN --mount=type=cache,target=/root/.cargo/registry \
# `FROM scratch` over the tree, so its root *is* the tree and there is nothing
# in it to run — only something to copy from.
#
# Built with `--enable-wow64`, which is the whole reason it is a build of ours
# rather than the distribution's package. wow64 runs 32-bit Windows code
# inside a 64-bit unix process, so a box needs no lib32 anything: no 32-bit
# glibc, no second Mesa for i686, and — the one that matters most here — no
# Built wow64-only, which is the whole reason it is a build of ours rather
# than a released one. wow64 runs 32-bit Windows code inside a 64-bit unix
# process, so a box needs no lib32 anything: no 32-bit glibc, no second Mesa
# for i686, and — the one that matters most here — no
# second capture layer, because the game is a 64-bit process and loads the
# 64-bit Vulkan loader the existing layer already sits in. The distribution's
# package is built without the flag, which is exactly why it depends on
# lib32-*.
# 64-bit Vulkan loader the existing layer already sits in. The released builds
# carry a 32-bit unix side, which is exactly why they need lib32-*.
#
# Override to build it yourself; the tag is a version and moves deliberately.
# ───────────────────────────────────────────────────────────
@@ -269,7 +263,7 @@ RUN pacman -Syu --noconfirm --needed \
libglvnd libdrm libepoxy libxxf86vm libinput wayland \
expat zlib llvm-libs lm_sensors elfutils libva shaderc vulkan-icd-loader \
pixman libxkbcommon xcb-util-keysyms xorg-xwayland \
pipewire pipewire-audio wireplumber opus \
pipewire pipewire-audio pipewire-pulse libpulse wireplumber opus \
python libunwind \
&& rm -f /usr/share/libalpm/hooks/dbus-reload.hook \
&& pacman -Rdd --noconfirm systemd systemd-sysvcompat \
@@ -281,6 +275,15 @@ RUN pacman -Syu --noconfirm --needed \
# after the prefix had already been created -- so the session got past every
# check that Proton was present and usable.
#
# `pipewire-pulse` and `libpulse` are Wine's audio. Its driver speaks only the
# PulseAudio protocol, so without the translation layer a game starts, renders
# and plays silently, with nothing failing to say why. `libpulse` is what that
# driver, `winepulse.so`, links, and it would arrive as a dependency of
# pipewire-pulse anyway. It is named because the dependency is Wine's and not
# pipewire-pulse's, and a library that only arrives as somebody else's
# dependency goes away when they do. The check below covers `winepulse.so`, so a
# missing library fails the build rather than the session.
# `python` is not a build dependency here -- the builder stage has its own for
# Mesa -- it is a *runtime* one. The compatibility tool's entry point is a
# Python script, so a box without an interpreter starts a game and the launch
@@ -368,7 +371,7 @@ COPY --from=mesa-build /artifacts/mesa /
COPY --from=nestri-build /artifacts/nestri /
# The Proton tree, whose image root is the tree, so this lands it at
# /usr/share/steam/compatibilitytools.d/proton-cachyos.
# /usr/share/steam/compatibilitytools.d/proton-ge.
#
# Deliberately not in the strip manifest above: that list is our own build
# output, and the two stripping decisions are not the same one. Proton ships
@@ -470,11 +473,17 @@ RUN for intruder in /usr/lib/systemd/systemd /sbin/openrc-init /usr/bin/openrc-i
# directory. Forty files, every one a false positive, hiding the one real
# entry.
#
# So: the programs in `bin/`, which are ordinary ELF and resolve normally, and
# So: the programs in `bin/`, which are ordinary ELF and resolve normally,
# `ntdll.so`, which is the first thing Wine loads and the one that linked the
# missing library. That is exactly the failure that got through, with none of
# the noise. The Windows-side DLLs beside them are not ELF and `ldd` skips them
# anyway.
# missing library, and `winepulse.so`, which is audio. That is exactly the
# failure that got through, with none of the noise. The Windows-side DLLs beside
# them are not ELF and `ldd` skips them anyway.
#
# Each file is resolved with its own directory on the library path. That is
# where Wine's loader finds the siblings a unix object links by soname, so
# `winepulse.so` naming `ntdll.so` resolves the way it does at run time, and
# what is left is a library the image really lacks. For every other file in
# the list it changes nothing.
# The output is one file per line with its own missing libraries under it, and
# then every missing library once at the end. That last list is what somebody
# acts on -- it is the set of packages to add -- and forty files each naming the
@@ -486,13 +495,14 @@ RUN failed=0; \
: > /tmp/missing-libs; \
for f in /usr/bin/nesinit /usr/bin/nescope /usr/bin/neshub /usr/bin/neswire \
/usr/lib/libnescapture_layer.so \
/usr/bin/dbus-daemon /usr/bin/pipewire /usr/bin/wireplumber /usr/bin/ip \
/usr/bin/dbus-daemon /usr/bin/pipewire /usr/bin/pipewire-pulse \
/usr/bin/wireplumber /usr/bin/ip \
/usr/lib/libgallium-*.so /usr/lib/libEGL_mesa.so.0 \
/usr/lib/libvulkan_*.so /usr/lib/dri/*.so /usr/lib/gbm/*.so \
/usr/share/steam/compatibilitytools.d/proton-cachyos/files/bin/* \
/usr/share/steam/compatibilitytools.d/proton-cachyos/files/lib*/wine/*-unix/ntdll.so; do \
[ -e "$f" ] || continue; \
libs="$(ldd "$f" 2>/dev/null | awk '/not found/ { print $1 }')"; \
libs="$(LD_LIBRARY_PATH="${f%/*}" ldd "$f" 2>/dev/null | awk '/not found/ { print $1 }')"; \
[ -n "$libs" ] || continue; \
failed=1; \
printf ' %s\n' "$f" >&2; \
@@ -507,8 +517,8 @@ RUN failed=0; \
fi
RUN for required in /usr/bin/nesinit /usr/bin/nescope /usr/bin/neshub /usr/bin/neswire \
/usr/bin/dbus-daemon /usr/bin/pipewire /usr/bin/wireplumber /usr/bin/ip \
/usr/bin/python3 \
/usr/bin/dbus-daemon /usr/bin/pipewire /usr/bin/pipewire-pulse \
/usr/bin/wireplumber /usr/bin/ip /usr/bin/python3 \
/usr/share/steam/compatibilitytools.d/proton-cachyos/proton; do \
test -x "$required" || { echo "the image is missing $required" >&2; exit 1; }; \
done
+16 -93
View File
@@ -1,102 +1,25 @@
# ═══════════════════════════════════════════════════════════
# proton-cachyos, built wow64-only
# proton-ge, built wow64-only, as a copyable image
#
# This file does not build Proton. It packages a Proton tree that was already
# built, and it cannot build it: proton-ge's build runs its own steps in the
# Steam Runtime SDK image through a container engine, so it runs on the host,
# and running it inside this build would mean nested containers.
# `scripts/proton-build.sh` does the build, and this file wraps the result.
#
# Separate from the guest Containerfile because it is a separate cadence:
# hours to build, and only when PROTON_TAG moves. The guest image pulls the
# result from a registry instead of rebuilding it, which is why this file is
# not part of that build and is not reached by `make build`.
# result from a registry instead of rebuilding it, which is why none of this
# is reached by `make build`.
#
# The final stage is FROM scratch, so the image *is* the Proton tree — nothing
# to run, only something to COPY --from.
# It is FROM scratch, so the image *is* the Proton tree. There is nothing in
# it to run, only something to COPY --from.
#
# Build and publish with `make proton-image` / `make proton-push`. **Its
# context is this directory**, not the repository root the guest build uses:
# all it needs is the two scripts beside it, and a context of the whole tree
# would hand it a multi-gigabyte `output/` for no reason.
#
# `PROTON_TAG` is the one thing to change, and the Makefile derives the
# published image's version from it. They are the same number in two
# spellings, and an image whose name does not say which Proton is inside it
# is worse than no image.
# ═══════════════════════════════════════════════════════════
FROM docker.io/archlinux:base-devel AS builder
# proton-cachyos-native's makedepends, minus every lib32-* (that is the whole
# point of --enable-wow64), with two substitutions Arch requires: ocl-icd
# provides opencl-icd-loader, and mesa-libgl is folded into libglvnd.
#
# glib2-devel is the one that is easy to miss — it carries glib-mkenums, which
# libsoup's meson looks up through glib-2.0's pkg-config variables and fails on
# obscurely. unzip and zip are for the xalia dist step. afdko is not packaged at
# all, and the fonts submodule needs it, so it comes from PyPI.
RUN pacman -Syu --noconfirm --needed \
alsa-lib clang cmake curl ffmpeg fontforge giflib git glib2-devel \
glslang gnutls gtk3 libgphoto2 libglvnd libpulse libva libxcomposite \
libxinerama libxxf86vm lld mesa meson ninja nasm \
opencl-headers ocl-icd pcsclite perl perl-json python python-pip \
python-pefile python-setuptools-scm rsync rust samba unixodbc \
unzip zip v4l-utils vulkan-headers vulkan-icd-loader wayland \
wayland-protocols wget xorg-util-macros \
mingw-w64-gcc mingw-w64-binutils mingw-w64-crt mingw-w64-headers \
mingw-w64-winpthreads \
&& pip install --break-system-packages --no-cache-dir afdko \
&& pacman -Scc --noconfirm
# widl generates vkd3d's public headers. Without it autoconf sets HAVE_WIDL to
# false, the headers are silently not generated, and the build dies an hour
# later on a missing vkd3d_d3dx9shader.h. Arch ships widl only inside `wine`,
# which requires multilib; the AUR's mingw-w64-tools builds it standalone, so do
# the same.
#
# The digest is not a formality. What is extracted here has its `configure` and
# its makefiles run as root in this builder, and what they produce is copied
# into the image a box runs -- so whoever can change these bytes can change what
# runs on every host. SourceForge hands the request to whichever mirror it
# likes, over a connection this builder does not pin, and the project publishes
# no signature. The digest is the only thing that makes the mirror not matter.
#
# Taken 2026-09-14 from two different mirrors of v14.0.0, which agreed. A
# mismatch here is not a thing to paper over by taking the new digest: it means
# the bytes behind this exact version string changed, and that wants looking at
# before it wants fixing.
ARG MINGW_W64_VER=14.0.0
ARG MINGW_W64_SHA256=6eaf921d9eb987d3820b364ea9775bc19b965ec81490b6fdd716526c28e1995c
RUN curl -fL --retry 3 -o /tmp/mingw-w64.tar.bz2 \
"https://sourceforge.net/projects/mingw-w64/files/mingw-w64/mingw-w64-release/mingw-w64-v${MINGW_W64_VER}.tar.bz2/download" \
&& echo "${MINGW_W64_SHA256} /tmp/mingw-w64.tar.bz2" | sha256sum -c - \
&& tar xf /tmp/mingw-w64.tar.bz2 -C /tmp \
&& for arch in i686-w64-mingw32 x86_64-w64-mingw32; do \
mkdir -p "/tmp/widl-${arch}" && cd "/tmp/widl-${arch}" \
&& "/tmp/mingw-w64-v${MINGW_W64_VER}/mingw-w64-tools/widl/configure" \
--prefix=/usr --target="${arch}" --program-prefix="${arch}-" \
&& make -j"$(nproc)" && make install; \
done \
&& rm -rf /tmp/mingw-w64* /tmp/widl-*
WORKDIR /build
ARG PROTON_GIT=https://github.com/CachyOS/proton-cachyos.git
ARG PROTON_TAG=cachyos-11.0-20260703-native
ARG GECKO_VER=2.47.4
ARG MONO_VER=11.2.0
ARG XALIA_VER=0.4.9
ARG JOBS=
# Fetch and build are separate layers on purpose: the submodule checkout runs
# well past ten minutes, and a build that fails on a flag or a missing tool must
# not pay for it again.
COPY scripts/proton-fetch.sh /build/proton-fetch.sh
RUN PROTON_GIT="${PROTON_GIT}" PROTON_TAG="${PROTON_TAG}" \
GECKO_VER="${GECKO_VER}" MONO_VER="${MONO_VER}" XALIA_VER="${XALIA_VER}" \
bash /build/proton-fetch.sh
COPY scripts/proton-build.sh /build/proton-build.sh
RUN GECKO_VER="${GECKO_VER}" MONO_VER="${MONO_VER}" JOBS="${JOBS}" \
bash /build/proton-build.sh
# ═══════════════════════════════════════════════════════════
# The publishable artifact: the Proton tree and nothing else
# context is the built tree** (`output/proton/obj/dist`), and not this
# directory or the repository root: the tree is the whole input, and it is
# the only thing the build needs to send.
# ═══════════════════════════════════════════════════════════
FROM scratch
COPY --from=builder /artifacts/proton/ /
ARG BUILD_NAME=proton-ge
COPY . /usr/share/steam/compatibilitytools.d/${BUILD_NAME}/
@@ -1,15 +0,0 @@
# This build's context is `build/`, not the repository root, because all it
# needs is the two scripts beside the Containerfile.
#
# `output/` is the reason this file exists. It holds packed rootfs images —
# multiple gigabytes each — and a build context is copied before the first
# instruction runs, so without this line every Proton build starts by moving
# the last one it produced.
output
# Nothing else here is an input to this build.
etc
README.md
Makefile
Containerfile
Containerfile.containerignore
+83 -12
View File
@@ -1,5 +1,5 @@
SHELL := /bin/bash
.PHONY: build build-debug image image-debug proton-image proton-push clean help
.PHONY: build build-debug image image-debug kernel kernel-clean proton-image proton-push proton-clean clean help
CONTAINER_RT := $(shell command -v podman 2>/dev/null || command -v docker 2>/dev/null)
ifeq ($(CONTAINER_RT),)
@@ -37,14 +37,53 @@ IMAGE_NAME := ghcr.io/nestrilabs/nestri/base
# number in two spellings and an image whose name does not say which Proton is
# inside it is worse than no image at all. Changing the tag by hand and
# forgetting the version is exactly the mistake this removes.
PROTON_TAG ?= cachyos-11.0-20260703-native
PROTON_VERSION := $(PROTON_TAG:cachyos-%-native=%)
PROTON_GIT ?= https://github.com/gloriouseggroll/proton-ge-custom.git
PROTON_TAG ?= GE-Proton11-7
PROTON_VERSION := 11.0-20260703
PROTON_IMAGE ?= ghcr.io/nestrilabs/proton-cachyos-native-wow64
PROTON_REF := $(PROTON_IMAGE):$(PROTON_VERSION)
# The directory name under compatibilitytools.d, and the name Steam shows.
PROTON_NAME := proton-ge
OUTPUT_DIR := output
# Source, build tree, ccache and cargo downloads. Tens of gigabytes, and it
# lives under output/ because that directory is already ignored by git and
# kept out of the guest build's context.
PROTON_WORK := $(OUTPUT_DIR)/proton
ROOTFS_SIZE ?= 3G
FORCE_REBUILD ?=
# The guest kernel: CachyOS's fork, for its scheduler patches, with our own
# minimal config on top (kernel/nestri.fragment). The tree is a clone rather
# than vendored, and lives under output/ for the same reason Proton's does.
# KERNEL_SRC may point at an existing tree elsewhere.
KERNEL_GIT ?= https://github.com/CachyOS/linux.git
KERNEL_REF ?= cachyos-7.2.6-1
# The target hardware is at least AVX2-capable. Never `native`: the build host
# is not the target. Empty leaves the tree's own generic choice alone.
KERNEL_MARCH ?= x86-64-v3
# Experimental: KERNEL_INFINITY=1 applies the Infinity scheduler series (fair,
# rt and DRM scheduler rework) on top. It builds in its own tree and installs
# under its own name, so the stock kernel is never patched and switching the
# flag back and forth never needs a revert.
#
# The series is published per CachyOS release, so its directory is derived
# from KERNEL_REF rather than written twice: bumping the kernel to a release
# the series does not cover fails at the patch step, not in a booted box.
KERNEL_INFINITY ?=
INFINITY_GIT ?= https://github.com/galpt/infinity-sched-new.git
INFINITY_REV ?= e6c85d841f25e21393a7ea47fdef286318915d8c
INFINITY_SERIES := patches/cachyos/tuned-eevdf/$(KERNEL_REF:cachyos-%=%)
INFINITY_WORK := $(OUTPUT_DIR)/infinity-sched
ifneq ($(KERNEL_INFINITY),)
KERNEL_SRC ?= $(OUTPUT_DIR)/kernel-infinity
KERNEL_OUTPUT := $(OUTPUT_DIR)/vmlinux-infinity
else
KERNEL_SRC ?= $(OUTPUT_DIR)/kernel
KERNEL_OUTPUT := $(OUTPUT_DIR)/vmlinux
endif
build:
DOCKER_BUILDKIT=1 $(CONTAINER_RT) build $(if $(FORCE_REBUILD),--no-cache,) \
--build-arg PROTON_IMAGE=$(PROTON_REF) \
@@ -55,13 +94,20 @@ build-debug:
--build-arg PROTON_IMAGE=$(PROTON_REF) \
-f Containerfile -t $(IMAGE_NAME):debug --target runtime_debug $(CONTEXT)
# Hours, and only when PROTON_TAG moves. Its context is this directory rather
# than the repository root: the two scripts beside the Containerfile are the
# whole input, and the root would hand it everything else for nothing.
# Hours, and only when PROTON_TAG moves. Two steps: the build runs on the host,
# because proton-ge's build drives the container engine itself, and the
# finished tree is then the whole context of a FROM scratch image. See the head
# of Containerfile.proton for why the build cannot run inside it.
#
# A second run of the same tag resumes where the last one stopped. A new tag,
# or FORCE_REBUILD, starts the tree over but keeps ccache.
proton-image:
DOCKER_BUILDKIT=1 $(CONTAINER_RT) build $(if $(FORCE_REBUILD),--no-cache,) \
--build-arg PROTON_TAG=$(PROTON_TAG) \
-f Containerfile.proton -t $(PROTON_REF) .
PROTON_GIT=$(PROTON_GIT) PROTON_TAG=$(PROTON_TAG) PROTON_WORK=$(PROTON_WORK) \
BUILD_NAME=$(PROTON_NAME) CONTAINER_ENGINE=$(notdir $(CONTAINER_RT)) \
FORCE_REBUILD=$(FORCE_REBUILD) \
bash scripts/proton-build.sh
$(CONTAINER_RT) build --build-arg BUILD_NAME=$(PROTON_NAME) \
-f Containerfile.proton -t $(PROTON_REF) $(PROTON_WORK)/obj/dist
@echo "Built $(PROTON_REF)"
# Publishing is what makes `make build` cheap for everyone else, since that
@@ -82,8 +128,29 @@ image-debug: build-debug
@mkdir -p $(OUTPUT_DIR)
bash scripts/mkimage.sh $(IMAGE_NAME):debug $(OUTPUT_DIR)/rootfs-debug.ext4 $(ROOTFS_SIZE)
# Not part of `build`: it changes far less often than the rootfs, and nothing
# in the image depends on it. A rerun rebuilds only what changed in the tree.
kernel:
KERNEL_GIT=$(KERNEL_GIT) KERNEL_REF=$(KERNEL_REF) KERNEL_SRC=$(KERNEL_SRC) \
KERNEL_MARCH=$(KERNEL_MARCH) KERNEL_OUTPUT=$(KERNEL_OUTPUT) \
KERNEL_INFINITY=$(KERNEL_INFINITY) INFINITY_GIT=$(INFINITY_GIT) \
INFINITY_REV=$(INFINITY_REV) INFINITY_SERIES=$(INFINITY_SERIES) \
INFINITY_WORK=$(INFINITY_WORK) \
bash scripts/kernel-build.sh
# Leaves $(PROTON_WORK) and the kernel alone: one is hours of build and ccache,
# the other a clone and a warm object tree, and a rootfs is neither.
# proton-clean and kernel-clean are the ones that drop them.
clean:
rm -rf $(OUTPUT_DIR)
find $(OUTPUT_DIR) -mindepth 1 -maxdepth 1 ! -name proton ! -name 'kernel*' \
! -name 'vmlinux*' ! -name infinity-sched -exec rm -rf {} + 2>/dev/null || true
kernel-clean:
rm -rf $(OUTPUT_DIR)/kernel $(OUTPUT_DIR)/kernel-infinity $(INFINITY_WORK) \
$(OUTPUT_DIR)/vmlinux $(OUTPUT_DIR)/vmlinux-infinity
proton-clean:
rm -rf $(PROTON_WORK)
help:
@echo "Usage:"
@@ -91,9 +158,13 @@ help:
@echo " make build-debug Build the runtime_debug container image"
@echo " make image Build + pack runtime_prod into output/rootfs.ext4"
@echo " make image-debug Build + pack runtime_debug into output/rootfs-debug.ext4"
@echo " make clean Remove output/"
@echo " make kernel Build the guest kernel into output/vmlinux"
@echo " make KERNEL_INFINITY=1 kernel Experimental Infinity scheduler build → output/vmlinux-infinity"
@echo " make kernel-clean Remove both kernel trees and images"
@echo " make clean Remove the rootfs images from output/"
@echo " make proton-image Build Proton from source (hours)"
@echo " make proton-clean Remove the Proton source, build tree and ccache"
@echo " make proton-push Build it and publish it"
@echo " make FORCE_REBUILD=1 ... Rebuild from scratch, no layer cache"
@echo " make PROTON_TAG=... ... Use a different proton-cachyos tag"
@echo " make PROTON_TAG=... ... Use a different proton-ge tag"
@echo " make ROOTFS_SIZE=8G image Override the packed image size (default 5G)"
+94 -26
View File
@@ -66,17 +66,16 @@ wrong about Proton: it is compiled from source, which is not a thing you can
do with closed software. Keeping it out cost a box the only way it has to run
a Windows title, for a rule that did not apply to it.
What it is: **proton-cachyos built with `--enable-wow64`**, pulled by tag as a
What it is: **proton-ge built wow64-only**, pulled by tag as a
published image rather than rebuilt here, because it takes hours and moves
only when its own tag does. `PROTON_IMAGE` overrides the tag, and it has to be
declared before the first `FROM` — an `ARG` a `FROM` expands is global or it
declared before the first `FROM`: an `ARG` a `FROM` expands is global or it
is nothing, and getting that wrong fails with `no FROM statement found`, which
says nothing about the actual mistake. wow64 is the whole reason it is a build of ours
and not the distribution's package — it runs 32-bit Windows code inside a
64-bit unix process, so a box needs no lib32 glibc, no second Mesa for i686,
and no second capture layer for 32-bit titles to be captured. The
distribution's package is built without the flag, which is exactly why it
depends on `lib32-*`.
says nothing about the actual mistake. wow64 is the whole reason it is a build
of ours and not a released one. It runs 32-bit Windows code inside a 64-bit
unix process, so a box needs no lib32 glibc, no second Mesa for i686, and no
second capture layer for 32-bit titles to be captured. The released builds
carry a 32-bit unix side, which is exactly why they need `lib32-*`.
It costs about 1.4 GB of image, and it is the one thing in here that is
payload-shaped: a compatibility layer for Windows games in an image that is
@@ -92,6 +91,59 @@ the virtio-gpu native-context protocol never drift apart. Only Mesa —
`virglrenderer` is the host half of that protocol and nesbox builds its own,
patched, from `nesbox/patches/`; nothing in this image carries it.
## The guest kernel
```sh
make kernel # clone if needed, configure, verify, build → output/vmlinux
make KERNEL_SRC=~/src/linux kernel # build an existing tree instead
make kernel-clean # drop the tree and the image
```
CachyOS's fork (`KERNEL_REF` in the `Makefile`), taken for its scheduler
patches, not its config: theirs is a desktop build with thousands of modules,
and this guest has `CONFIG_MODULES` off and no `/lib/modules` at all.
- **`kernel/nestri.fragment` is the source of truth**, and says why each entry
is there. It is merged onto the tree's `.config`, resolved with
`olddefconfig`, and then **checked**: any entry that did not survive fails the
build. `merge_config.sh` and `olddefconfig` both drop options quietly, and
the worst of these fails as perfectly healthy, perfectly silent audio.
- **`kernel/base.config` is only a seed** for a tree with no `.config`, so a
fresh clone does not start from `defconfig`'s enormous driver set. Change the
fragment, not the seed and not a tree's `.config`.
- **`vmlinux`, not `bzImage`.** The guest is loaded as a raw ELF with no
bootloader in the path. It is ~16 MB unstripped, which costs nothing at run
time: only the loadable segments are mapped.
- **`-march=x86-64-v3`** goes in through `KCFLAGS`. It is safe in a kernel:
the kernel's own `-mno-sse -mno-avx …` masks every vector extension off
whatever the flag order, leaving v3's integer ISA.
- The tree is off the pinned ref (a bisect, a local patch)? The build warns
and builds what is there rather than checking the ref out over your work.
### Experimental: the Infinity scheduler
```sh
make KERNEL_INFINITY=1 kernel # → output/vmlinux-infinity
```
Applies [infinity-sched](https://github.com/galpt/infinity-sched-new)'s
series (GPL-2), which reworks the fair, RT and DRM schedulers for latency
under load. It is pinned by commit (`INFINITY_REV`), and the series directory
comes from `KERNEL_REF`, since upstream publishes one per CachyOS release.
- **Its own tree and its own image.** It builds in `output/kernel-infinity`,
so the stock kernel is never patched and switching between the two needs no
revert.
- **All or nothing, zero fuzz.** The whole series is checked against the
stacked result before any of it is applied. The applied commit is recorded in
the tree; to move `INFINITY_REV`, start over with `make kernel-clean`.
- **Only the CPU half does anything here.** virtio-gpu does not use the DRM
scheduler and `CONFIG_DRM_SCHED` is not built, so the GPU patch is compiled
out. It is applied anyway because upstream says a partial series
misbehaves.
- Upstream's `/sys/kernel/debug/infinity_*` counters need `CONFIG_DEBUG_FS`,
which this kernel does not have.
## Two packages that look droppable and are not
`llvm-libs` is 164 MB, the largest single thing in the image after Proton, and
@@ -109,13 +161,14 @@ a package list says that; the check below is what said it.
`make build` **pulls** Proton by tag; it does not build it. Building it takes
hours and it changes only when its tag moves, so it is one image published
once and copied into every guest image after that. `Containerfile.proton` is
once and copied into every guest image after that. `make proton-image` is
that build, and it lives here so the published tag stays reproducible from
this tree rather than from somebody's laptop.
```sh
make proton-image # the current tag
make PROTON_TAG=cachyos-11.1-20261115-native proton-image
make proton-image # the current tag
make PROTON_TAG=GE-Proton11-8 proton-image
make proton-clean # drop the source, build tree and ccache
```
**`PROTON_TAG` is the only thing to change.** The published version is derived
@@ -125,23 +178,38 @@ which Proton is inside it is worse than no image. The `Containerfile`'s own
`PROTON_IMAGE` default is a fallback for a bare container build; going through
`make` is what keeps them in step.
Its **context is `build/`**, not the repository root the guest build uses. All
it needs is the two scripts beside it, and `Containerfile.proton.containerignore`
keeps `output/` out of that context — a build context is copied before the
first instruction runs, so without it every Proton build would begin by moving
the last rootfs image it produced.
**The build runs on the host, not in a `podman build`.** proton-ge's build is
container-driven itself: `make` runs outside, and every step runs in the Steam
Runtime SDK image through the container engine, so the host needs only git,
make and podman. Wrapping that in a container build would mean nested
containers. So there are two steps:
Two things in the recipe are worth knowing before changing it:
1. `scripts/proton-build.sh` clones the tag with its submodules, applies
proton-ge's patch set, and runs its build with one change: the arch list
drops the 32-bit unix side, which is what makes it wow64-only. Everything
happens under `output/proton/`, which is tens of gigabytes.
2. `Containerfile.proton` is `FROM scratch` with the built tree as its whole
context, so the image is the tree and nothing else.
- **Fetch and build are separate layers on purpose.** The submodule checkout
runs well past ten minutes, and a build that fails on a flag or a missing
tool must not pay for that again. Keep anything that can fail *fast* in
`proton-build.sh`.
- **`widl` is built by hand from the mingw-w64 release.** Without it autoconf
quietly sets `HAVE_WIDL` to false, vkd3d's public headers are never
generated, and the build dies an hour later on a missing header. Arch ships
`widl` only inside `wine`, which wants multilib — which is the thing
`--enable-wow64` exists to avoid.
Things worth knowing before changing it:
- **A rerun of the same tag resumes.** The clone, the patching and the
configure step each run once per tag, and proton-ge's own make picks up
where it stopped. A new tag or `FORCE_REBUILD=1` starts the tree over;
ccache and the cargo downloads survive both. `make clean` leaves all of it
alone, and `make proton-clean` removes it.
- **The patch script does not fail on a patch that does not apply.** It
carries on and exits 0, so `proton-build.sh` greps its output
(`output/proton/patch.log`) and stops. Otherwise the result is an image
that looks fine and is missing a fix.
- **`patches/proton-ge/` is ours, applied after proton-ge's own set.** It holds
what wow64-only needs that proton-ge's makefile does not handle, and fixes
for things a tag pinned that have since moved. Each patch says why it exists
at its top, and each one fails the build outright once it stops applying,
which on a tag bump is usually upstream having fixed it.
- **The patch script is not idempotent**, so a tree is patched exactly once.
An interrupted run resets every submodule to the commits the tag pins before
patching again.
## There is no init system in here, and that is the design
@@ -0,0 +1,66 @@
# pipewire-pulse, the PulseAudio protocol on top of PipeWire, for clients that
# speak nothing else. Wine's audio driver is one of them.
#
# The socket moves out of the service user's runtime directory. That directory
# is 0700 and the workload runs as a different user, so a client there finds
# nothing and plays silently, which is the failure PipeWire's own socket was
# moved for. The workload is pointed here with PULSE_SERVER, and nesinit starts
# this under a umask that lets a user who does not own the socket open it. This
# path is written in nesinit too, and a test there compares the two.
#
# A drop-in replaces a key of pulse.properties rather than appending to it, so
# the default "unix:native" socket is not created as well.
pulse.properties = {
server.address = [ "unix:/run/pipewire/pulse-native" ]
# module-always-sink creates a "Dummy Output" sink whenever no other sink
# exists, and while neswire's sink is not up yet that is always. A second
# sink is a second default-node candidate, and a game that picks it plays
# into nothing. neswire has to be the only sink; see the WirePlumber
# drop-in.
pulse.cmd.always-sink = false
# The root is read-only and every box starts from the same image, so there
# is nothing to restore and nowhere to save, which is the reasoning behind
# turning off WirePlumber's state hooks. Left on, these are one more place
# that could pin a default.
pulse.cmd.device-manager = false
pulse.cmd.device-restore = false
pulse.cmd.stream-restore = false
# Wine sizes its whole audio path from this number. At startup its driver
# opens a probe stream asking for a 1-frame request size, reads back what
# the server grants, which is this floor, and makes the device period ten
# times that. Streams then queue three periods. The default floor of 256
# frames therefore becomes a 53 ms period and about 160 ms of audio in
# flight, which is desktop playback and not a game being streamed.
#
# 48 frames (1 ms) makes that period 10 ms, the default period Windows
# itself uses, so a game gets the buffering it was written for. A stream
# then asks for 480-frame requests, which is what is granted, so the floor
# affects the probe and nothing else Wine does.
pulse.min.req = 48/48000
# What a client gets when it asks for no particular buffering. The shipped
# default is two seconds, which is only harmless where nobody is listening
# live. 10 ms requests and 40 ms of target buffer are enough for a client
# that does not say.
pulse.default.req = 480/48000
pulse.default.tlength = 1920/48000
}
# pipewire-pulse.conf raises the minimum node latency to 1024 frames (21.3 ms)
# inside a virtual machine, which a box is. That puts every Pulse client, and
# therefore Wine, at least 21 ms behind, whatever it asked for. Floor it where
# the graph itself is floored instead; see the pipewire.conf drop-in for why
# 256 frames. A drop-in appends to this array, and every matching rule applies
# in order, so this runs after the shipped rule and wins.
pulse.properties.rules = [
{ matches = [ { cpu.vm.name = !null } ]
actions = {
update-props = {
pulse.min.quantum = 256/48000
}
}
}
]
@@ -0,0 +1,24 @@
# The graph's clock, floored where the audio sender needs it.
#
# Inside a virtual machine, pipewire.conf raises the graph's minimum quantum to
# 1024 frames (21.3 ms), because a VM's timers jitter and small quanta then
# underrun on real audio hardware. A box is a VM, so that rule applies, and it
# silently overrides neswire's request for its 5 ms packet size. Every sample
# then waits for a 21 ms cycle before it can be encoded.
#
# The floor comes down to 256 frames (5.3 ms), the smallest power of two that
# holds one of neswire's packets. It is not lower than that on purpose: nothing
# here needs less, and the reason for the rule, a VM's timer driving the graph,
# still holds, because the Dummy-Driver is exactly that.
#
# A drop-in appends to a rules array, and every matching rule applies in order,
# so this one runs after the shipped one and wins.
context.properties.rules = [
{ matches = [ { cpu.vm.name = !null } ]
actions = {
update-props = {
default.clock.min-quantum = 256
}
}
}
]
@@ -3,14 +3,13 @@
# The goal is that `neswire` is the *only* Audio/Sink in the graph, so
# default-node selection has exactly one candidate and cannot pick wrong.
#
# The "Dummy Output" (`auto_null`) sink is not disabled here, and no longer
# needs to be: it came from pipewire-pulse's `module-always-sink`, and
# pipewire-pulse is no longer installed. WirePlumber ships
# scripts/fallback-sink.lua, which creates a node by the same name, but no
# component in wireplumber.conf references it, so it never loads. If auto_null
# ever comes back, it came back with pipewire-pulse -- the switch is
# `pulse.cmd.always-sink = false` in a pipewire-pulse.conf.d drop-in, not
# anything on this side.
# The "Dummy Output" (`auto_null`) sink is not disabled here, because it does
# not come from this side. pipewire-pulse creates it with `module-always-sink`,
# and the pipewire-pulse drop-in turns that off with
# `pulse.cmd.always-sink = false`. WirePlumber ships scripts/fallback-sink.lua,
# which creates a node by the same name, but no component in wireplumber.conf
# references it, so it never loads. If auto_null appears, look at the
# pipewire-pulse drop-in first.
#
# pipewire.conf's Dummy-Driver / Freewheel-Driver are a third thing again:
# support.node.driver objects with no ports -- drivers, not sinks, so nothing
File diff suppressed because it is too large Load Diff
+207
View File
@@ -0,0 +1,207 @@
# Kconfig fragment for the guest kernel.
#
# Merged onto the tree's config by scripts/kernel-build.sh with the kernel's own
# merge_config.sh, then resolved with `make olddefconfig`, then checked: every
# entry below must hold in the final .config or the build stops. A fragment
# rather than a saved full config: a full .config pins thousands of symbols and
# rots the moment the tree is bumped, while this says only what the guest
# actually requires and lets the version's own defaults handle the rest.
#
# Every entry here is load-bearing or a deliberate performance choice. Nothing
# is speculative.
# ── Gaming needs ─────────────────────────────────────────
# NTSYNC is a great improvement over fsync and esync approaches.
CONFIG_NTSYNC=y
# ── Timers ───────────────────────────────────────────────
# The one that cost a day of silent audio. The guest has no sound hardware, so
# PipeWire drives its whole graph off a timerfd at a 2.67ms cycle, which a
# HZ_PERIODIC/250 kernel cannot express. Symptom: everything reports healthy,
# neswire sends a steady ~3 kbps of Opus, and every sample is zero.
CONFIG_HIGH_RES_TIMERS=y
CONFIG_NO_HZ_IDLE=y
CONFIG_HZ_1000=y
# ── Shared memory ────────────────────────────────────────
# PipeWire moves audio through memfd, not through its socket. Wine uses POSIX
# shm and semaphores directly. /dev/shm itself is mounted by nesinit --
# devtmpfs does not provide it.
CONFIG_SHMEM=y
CONFIG_MEMFD_CREATE=y
CONFIG_TMPFS=y
CONFIG_DEVTMPFS=y
CONFIG_DEVTMPFS_MOUNT=y
# ── What PipeWire schedules on ───────────────────────────
# FUTEX_PI is also what Proton's fsync uses.
CONFIG_TIMERFD=y
CONFIG_EVENTFD=y
CONFIG_EPOLL=y
CONFIG_SIGNALFD=y
CONFIG_FUTEX=y
CONFIG_FUTEX_PI=y
# ── Clock sources ────────────────────────────────────────
# Listed because they were correct throughout the silent-audio episode. A good
# clock *source* says nothing about whether the kernel can schedule a timer
# *event* at the resolution asked for; only the block above governs that.
CONFIG_KVM_GUEST=y
CONFIG_PARAVIRT_CLOCK=y
CONFIG_X86_TSC=y
# ── Steal time ───────────────────────────────────────────
# Time the host spends running something else on a vCPU is subtracted from
# whatever task was on it, rather than charged to it. vCPUs share a set of host
# cores instead of owning one each, so steal happens, and without this the
# scheduler reads it as the task having run -- a frame thread preempted by the
# host looks, to the fair class, like one that used its slice.
CONFIG_PARAVIRT_TIME_ACCOUNTING=y
# ── Idle: poll before halting ────────────────────────────
# An idle vCPU that halts exits to the host, and waking it costs an IPI and a
# VM entry. The frame pipeline sleeps and wakes every few milliseconds, so that
# is paid constantly. haltpoll spins for a bounded, adaptive window first
# (guest_halt_poll_ns, 200us), so a wakeup that comes quickly never leaves the
# guest. When the host offers it, the guest also switches host-side polling
# off, so the two never poll at once.
#
# Built in but inert: it loads only if the host sets KVM_HINTS_REALTIME or the
# command line forces it. The hint is the wrong tool -- it also promises the
# vCPUs are never preempted, and the guest drops paravirt spinlocks on the
# strength of that, which a shared set of host cores cannot promise. Forcing
# the idle driver gets the polling and nothing else. Built into the command
# line, which is prepended to what the host passes, so no caller has to know.
# The cost is host CPU spent spinning on an idle vCPU.
#
# Prepended, not replacing: with OVERRIDE off the host's arguments come after
# this and the last value wins, so `cpuidle_haltpoll.force=0` from the host
# still turns it off.
CONFIG_HALTPOLL_CPUIDLE=y
CONFIG_CPU_IDLE_GOV_HALTPOLL=y
CONFIG_CMDLINE_BOOL=y
CONFIG_CMDLINE="cpuidle_haltpoll.force=1 swiotlb=noforce"
# CONFIG_CMDLINE_OVERRIDE is not set
# ── No bounce buffer ─────────────────────────────────────
# With more than about 3 GiB of RAM the guest has memory above 4 GiB, and x86
# then sets aside a 64 MiB SWIOTLB bounce buffer and zeroes it at boot, which
# makes the host back all of it. Nothing here ever bounces: the virtio devices
# do not offer ACCESS_PLATFORM, so virtio skips the DMA API entirely, and there
# is no other DMA-capable device. `swiotlb=noforce` in the command line above
# skips the allocation. A device that genuinely needs a 32-bit DMA mask would
# fail to map rather than bounce; there is none.
# ── microVM transport ────────────────────────────────────
CONFIG_VIRTIO=y
CONFIG_VIRTIO_PCI=y
CONFIG_VIRTIO_MMIO=y
CONFIG_VIRTIO_BLK=y # root on /dev/vda
CONFIG_VIRTIO_CONSOLE=y # hvc0, the only way into a guest that will not boot
CONFIG_VIRTIO_NET=y
CONFIG_VIRTIO_FS=y # the shares the boot descriptor names
CONFIG_FUSE_FS=y # virtiofs needs it
CONFIG_VSOCKETS=y # neshub's link to the host
CONFIG_VIRTIO_VSOCKETS=y
CONFIG_VIRTIO_BALLOON=y
CONFIG_DRM=y
CONFIG_DRM_VIRTIO_GPU=y
# ── Filesystems ──────────────────────────────────────────
CONFIG_EXT4_FS=y # output/rootfs.ext4, and a box's writable install layer
CONFIG_MISC_FILESYSTEMS=y # the menu EROFS lives under; off in base.config, which drops it silently
CONFIG_EROFS_FS=y # a game's build image, the install overlay's lower layer
CONFIG_OVERLAY_FS=y # the session overlay over a read-only install
# ── Memory typing ────────────────────────────────────────
# Page Attribute Table, and the MTRR support it depends on.
#
# Both are `def_bool y` upstream, promptable only under EXPERT -- so they were
# switched off deliberately at some point, and the guest booted logging
# "PAT support disabled because CONFIG_X86_PAT is disabled in the kernel".
#
# Without PAT there are no write-combining mappings. This guest exists to push
# frames through virtio-gpu, and WC on the GPU aperture is precisely what PAT
# provides; every frame would go through uncached or writeback mappings
# instead. Not a correctness problem, which is why it went unnoticed.
CONFIG_MTRR=y
CONFIG_X86_PAT=y
# ── POSIX ACLs ───────────────────────────────────────────
# Nothing depends on the ACLs themselves; without them, anything that sets one
# on a device node or a file logs "Operation not supported" at every boot, and
# a boot log full of failures that do not matter hides the ones that do.
CONFIG_EXT4_FS_POSIX_ACL=y
CONFIG_TMPFS_POSIX_ACL=y
# ── Performance ──────────────────────────────────────────
# Chosen, not required. The mainline subset of what CachyOS enables, minus
# anything needing patches. PREEMPT_LAZY is the tree's own default and is left
# alone; PREEMPT_DYNAMIC on top allows `preempt=full` from the command line
# without a rebuild.
CONFIG_PREEMPT_DYNAMIC=y
CONFIG_LRU_GEN=y
CONFIG_LRU_GEN_ENABLED=y
CONFIG_TRANSPARENT_HUGEPAGE=y
CONFIG_TRANSPARENT_HUGEPAGE_MADVISE=y
CONFIG_PSI=y
# ── Undoing the minimal seed ─────────────────────────────
# base.config descends from a size-minimised config with EXPERT on, and EXPERT
# is what makes each of these promptable. Every one is `default y` (or the
# default choice) upstream, and every one was off in the built kernel. They
# are pinned here because nothing else would stop a reseed from bringing them
# back.
#
# SLUB_TINY drops the per-CPU slab caches, so every kmalloc and kfree takes the
# locked slow path. Its own help text says it is not for performance. This
# guest allocates constantly: network buffers, GPU ioctls, io_uring, futexes.
# CONFIG_SLUB_TINY is not set
#
# Without ADVISE_SYSCALLS, madvise() and fadvise() return ENOSYS. THP above is
# madvise-only, so no user mapping could ever get a huge page, and allocators
# could never hand memory back with MADV_DONTNEED or MADV_FREE.
CONFIG_ADVISE_SYSCALLS=y
#
# Without MEMBARRIER, Wine and the .NET runtime fall back to an mprotect() trick
# to flush other threads' write buffers, which forces a TLB shootdown across
# every vCPU -- an IPI storm, and each IPI is a VM exit. RSEQ gives glibc and
# per-CPU allocators their fast paths.
CONFIG_MEMBARRIER=y
CONFIG_RSEQ=y
#
# -O2, not -Os: the whole kernel was being compiled for size, and a guest that
# runs Wine spends much of its time in syscalls.
CONFIG_CC_OPTIMIZE_FOR_PERFORMANCE=y
# CONFIG_CC_OPTIMIZE_FOR_SIZE is not set
#
# Without jump labels, every static key is a memory load and a branch instead
# of a patched no-op, on paths such as steal-time accounting, the scheduler and
# cgroup checks.
CONFIG_JUMP_LABEL=y
# Autogroup is deliberately OFF, which is a departure from CachyOS.
#
# It groups tasks by session id, which is a desktop tuning: it keeps a
# terminal's `make -j` from starving the browser. This guest is a
# single-application appliance with no interactive sessions to balance
# against each other, so there is nothing for it to do but add overhead.
# CONFIG_SCHED_AUTOGROUP is not set
# ── Deliberately absent ──────────────────────────────────
# No CONFIG_SND of any kind. The guest has no sound hardware and wants none:
# neswire is a virtual PipeWire sink, and an ALSA stack would only add a second
# thing that could claim to be the default output.
# Speculation mitigations are off, as a decision rather than an oversight.
#
# `lscpu` in the guest reports Spectre v1/v2, SSB, SRSO and TSA as Vulnerable,
# and that is intended. This is a single-tenant sandbox: one player's session,
# torn down at the end of it, with the VM boundary as the isolation. The
# attacks these defend against are cross-tenant side channels, and the cost is
# paid on every syscall and context switch in a workload that is latency
# bound.
#
# What would change this: running two players' sessions on one box at the same
# time, or anything of value living inside the guest. Neither is true today.
# CONFIG_CPU_MITIGATIONS is not set
@@ -1,140 +0,0 @@
From 4dcb0baf29136c90776133afb714bac3c4cb7686 Mon Sep 17 00:00:00 2001
From: DatCaptainHorse <DatCaptainHorse@users.noreply.github.com>
Date: Thu, 17 Sep 2026 21:50:25 +0300
Subject: [PATCH] radv/amdgpu: cache AMDGPU_INFO_MEMORY briefly on the virtio
path
Heap usage is asked for far more often than it changes. Measured on an
amdgpu native context with one game running, RADV asked for it ~47 times
per 8.8 ms frame -- the same answer, 47 times, each one a synchronous round
trip to the host rather than an ioctl.
Serve a recent answer instead, for one millisecond. That is inside the
contract of what the value is for: heap usage feeds VK_EXT_memory_budget
and the winsys's own eviction decisions, and the spec calls those estimates
that may be out of date. It is also short enough that an application
allocating hard still sees its own pressure within a frame at any plausible
frame rate.
Only on the virtio path, where a query costs a round trip; a local ioctl is
cheap enough that caching it would be complexity for nothing. Only this
query, and only at its natural size: every other AMDGPU_INFO_* either
carries a caller-supplied selector in the union, so one cached answer would
be the wrong answer to the next question, or is asked once at startup.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---
src/amd/common/virtio/amdgpu_virtio.c | 53 +++++++++++++++++++
src/amd/common/virtio/amdgpu_virtio_device.c | 1 +
src/amd/common/virtio/amdgpu_virtio_private.h | 6 +++
3 files changed, 60 insertions(+)
diff --git a/src/amd/common/virtio/amdgpu_virtio.c b/src/amd/common/virtio/amdgpu_virtio.c
index 00494d6a750..dd38ef141f5 100644
--- a/src/amd/common/virtio/amdgpu_virtio.c
+++ b/src/amd/common/virtio/amdgpu_virtio.c
@@ -19,8 +19,25 @@
#include "drm-uapi/amdgpu_drm.h"
#include "util/log.h"
+#include "util/os_time.h"
#include "util/u_math.h"
+/* How long a cached AMDGPU_INFO_MEMORY answer is reused, in nanoseconds.
+ *
+ * Bounded by what the value is for. Heap usage feeds VK_EXT_memory_budget and
+ * the winsys's own eviction decisions, and the spec calls those values
+ * estimates that may be out of date -- so a millisecond of staleness is inside
+ * the contract, while a round trip per ask is not free here the way it is on a
+ * local ioctl.
+ *
+ * A millisecond rather than a frame: it is short enough that an application
+ * allocating hard still sees its own pressure within a frame at any plausible
+ * rate, and long enough to collapse the repeats. Measured on an amdgpu native
+ * context with one game running, RADV asked for this ~47 times per 8.8 ms
+ * frame -- the same answer, 47 synchronous round trips.
+ */
+#define AMDVGPU_MEMORY_INFO_TTL_NS (1000 * 1000)
+
int
amdvgpu_query_info(amdvgpu_device_handle dev, struct drm_amdgpu_info *info)
{
@@ -32,6 +49,30 @@ amdvgpu_query_info(amdvgpu_device_handle dev, struct drm_amdgpu_info *info)
struct amdgpu_ccmd_query_info_rsp *rsp;
assert(0 == (offsetof(struct amdgpu_ccmd_query_info_rsp, payload) % 8));
+ /* AMDGPU_INFO_MEMORY is asked far more often than it changes, and over
+ * virtio every ask is a synchronous round trip to the host rather than an
+ * ioctl. Serve a recent answer instead.
+ *
+ * Only this query, and only at its natural size: every other query either
+ * has a caller-supplied selector in the union -- so one cached answer would
+ * be the wrong answer to the next question -- or is asked once at startup
+ * and costs nothing.
+ */
+ bool cacheable = info->query == AMDGPU_INFO_MEMORY &&
+ info->return_size == sizeof(struct drm_amdgpu_memory_info);
+ if (cacheable) {
+ int64_t now = os_time_get_nano();
+ simple_mtx_lock(&dev->memory_info_mutex);
+ if (dev->memory_info_stamp &&
+ now - dev->memory_info_stamp < AMDVGPU_MEMORY_INFO_TTL_NS) {
+ memcpy((void *)(uintptr_t)info->return_pointer, &dev->memory_info,
+ sizeof(dev->memory_info));
+ simple_mtx_unlock(&dev->memory_info_mutex);
+ return 0;
+ }
+ simple_mtx_unlock(&dev->memory_info_mutex);
+ }
+
req->hdr = AMDGPU_CCMD(QUERY_INFO, req_len);
memcpy(&req->info, info, sizeof(struct drm_amdgpu_info));
@@ -43,6 +84,18 @@ amdvgpu_query_info(amdvgpu_device_handle dev, struct drm_amdgpu_info *info)
memcpy((void*)(uintptr_t)info->return_pointer, rsp->payload, info->return_size);
+ if (cacheable) {
+ simple_mtx_lock(&dev->memory_info_mutex);
+ memcpy(&dev->memory_info, rsp->payload, sizeof(dev->memory_info));
+ /* Stamped after the answer is in hand, so the window covers the time the
+ * value is actually served rather than the round trip that fetched it.
+ * A zero stamp means "never fetched", so a clock that returns zero here
+ * costs a re-fetch rather than pinning a stale answer forever.
+ */
+ dev->memory_info_stamp = os_time_get_nano();
+ simple_mtx_unlock(&dev->memory_info_mutex);
+ }
+
return 0;
}
diff --git a/src/amd/common/virtio/amdgpu_virtio_device.c b/src/amd/common/virtio/amdgpu_virtio_device.c
index eecfd6aa11a..7e58913d520 100644
--- a/src/amd/common/virtio/amdgpu_virtio_device.c
+++ b/src/amd/common/virtio/amdgpu_virtio_device.c
@@ -136,6 +136,7 @@ int amdvgpu_device_initialize(int fd, uint32_t *drm_major, uint32_t *drm_minor,
dev->vdev = vdev;
simple_mtx_init(&dev->handle_to_vbo_mutex, mtx_plain);
+ simple_mtx_init(&dev->memory_info_mutex, mtx_plain);
simple_mtx_init(&dev->contexts_mutex, mtx_plain);
dev->handle_to_vbo = _mesa_hash_table_u64_create(NULL);
diff --git a/src/amd/common/virtio/amdgpu_virtio_private.h b/src/amd/common/virtio/amdgpu_virtio_private.h
index 743f8f74b5c..fb877dc8d28 100644
--- a/src/amd/common/virtio/amdgpu_virtio_private.h
+++ b/src/amd/common/virtio/amdgpu_virtio_private.h
@@ -60,6 +60,12 @@ struct amdvgpu_device {
struct drm_amdgpu_info_device dev_info;
+ /* AMDGPU_INFO_MEMORY, cached for a short while. See amdvgpu_query_info(). */
+ simple_mtx_t memory_info_mutex;
+ struct drm_amdgpu_memory_info memory_info;
+ /* os_time_get_nano() when memory_info was fetched; 0 means never. */
+ int64_t memory_info_stamp;
+
/* Blob id are per drm_file identifiers of host blobs.
* Use a monotically increased integer to assign the blob id.
*/
@@ -0,0 +1,602 @@
From 35db2e58006deca5c6383517f9228eb32a608548 Mon Sep 17 00:00:00 2001
From: DatCaptainHorse <DatCaptainHorse@users.noreply.github.com>
Date: Wed, 23 Sep 2026 20:13:47 +0300
Subject: [PATCH] virtio/vdrm, ac: log where threads wait, per wait and per
second
A guest thread under a native context can be blocked on the host in more
places than it looks: a synchronous request, a submission that holds
eb_lock across an ioctl which itself waits on in-fences, a BO wait, or an
ordinary syncobj wait. When a frame runs long there is no way to tell from
the outside which of those it was, or whether it was any of them.
Two knobs, in milliseconds, both off when unset or 0:
- MESA_SLOW_WAIT_MS logs each single wait longer than the value, with its
command, ring and how the time split between waiting for eb_lock, the
submit or flush ioctl, the fence, and the host catching up.
- MESA_WAIT_STATS logs, once a second per thread, the time and count spent
in each kind of wait, when the total reached the value. Many short waits
that add up to a long frame never trip a per-wait threshold; totals show
them, and a second with a long frame can be compared with one without.
Syncobj waits are timed by wrapping the device's sync provider, since the
Vulkan runtime waits through it directly and never through the ac_drm_cs_*
helpers. The wrapper is installed only when a knob is set, and times only
waits that could block; a poll is not.
Every line carries a UTC wall-clock stamp and the thread id, so it can be
lined up with other components' logs. The shared pieces live in a header-only
util/u_wait_log.h, so no build file changes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---
src/amd/common/ac_linux_drm.c | 197 +++++++++++++++++++++++++++++++++
src/util/u_wait_log.h | 133 ++++++++++++++++++++++
src/virtio/vdrm/vdrm.c | 80 +++++++++++++
src/virtio/vdrm/vdrm.h | 14 +++
src/virtio/vdrm/vdrm_virtgpu.c | 20 +++-
5 files changed, 443 insertions(+), 1 deletion(-)
create mode 100644 src/util/u_wait_log.h
diff --git a/src/amd/common/ac_linux_drm.c b/src/amd/common/ac_linux_drm.c
index 63b27058ec1..d2f16aefc83 100644
--- a/src/amd/common/ac_linux_drm.c
+++ b/src/amd/common/ac_linux_drm.c
@@ -14,10 +14,205 @@
#include <time.h>
#include <unistd.h>
+#include "util/u_wait_log.h"
+
#ifdef HAVE_AMDGPU_VIRTIO
#include "virtio/amdgpu_virtio.h"
#endif
+/* Wait logging for blocking syncobj waits -- what vkWaitForFences and timeline
+ * semaphore waits come down to. See util/u_wait_log.h for the knobs; they are
+ * the virtio transport's too, so one setting shows both a thread waiting on
+ * the GPU and a thread waiting on the host. Polls (a zero timeout) are never
+ * timed.
+ *
+ * Done by wrapping the device's sync provider rather than the ac_drm_cs_*
+ * helpers, because the Vulkan runtime waits through the provider directly and
+ * never calls those helpers: timing them saw almost nothing. The wrapper is
+ * installed only when a knob is set, so it costs nothing otherwise.
+ */
+enum { SYNCOBJ_WAIT, TIMELINE_WAIT, WAIT_KINDS };
+static const char *const wait_kinds[WAIT_KINDS] = { "syncobj", "timeline" };
+static __thread struct u_wait_log_window wait_window;
+
+static void
+note_wait(unsigned kind, int64_t t0, unsigned num_handles, int ret)
+{
+ int64_t took = os_time_get_nano() - t0;
+ u_wait_log_account(&wait_window, "sync", wait_kinds, WAIT_KINDS, kind, took);
+
+ int64_t slow = u_wait_log_slow_ns();
+ if (slow && took > slow) {
+ char stamp[16];
+ u_wait_log_stamp(stamp);
+ mesa_logw("%s sync: %s wait on %u syncobj(s) took %.1f ms (ret %d) tid %d", stamp,
+ wait_kinds[kind], num_handles, took / 1e6, ret, gettid());
+ }
+}
+
+struct timed_sync_provider {
+ struct util_sync_provider base;
+ struct util_sync_provider *inner;
+};
+
+static struct util_sync_provider *
+inner_of(struct util_sync_provider *p)
+{
+ return ((struct timed_sync_provider *)p)->inner;
+}
+
+static int
+timed_create(struct util_sync_provider *p, uint32_t flags, uint32_t *handle)
+{
+ return inner_of(p)->create(inner_of(p), flags, handle);
+}
+
+static int
+timed_destroy(struct util_sync_provider *p, uint32_t handle)
+{
+ return inner_of(p)->destroy(inner_of(p), handle);
+}
+
+static int
+timed_handle_to_fd(struct util_sync_provider *p, uint32_t handle, int *out_obj_fd)
+{
+ return inner_of(p)->handle_to_fd(inner_of(p), handle, out_obj_fd);
+}
+
+static int
+timed_fd_to_handle(struct util_sync_provider *p, int obj_fd, uint32_t *handle)
+{
+ return inner_of(p)->fd_to_handle(inner_of(p), obj_fd, handle);
+}
+
+static int
+timed_import_sync_file(struct util_sync_provider *p, uint32_t handle, int sync_file_fd)
+{
+ return inner_of(p)->import_sync_file(inner_of(p), handle, sync_file_fd);
+}
+
+static int
+timed_export_sync_file(struct util_sync_provider *p, uint32_t handle, int *out_sync_file_fd)
+{
+ return inner_of(p)->export_sync_file(inner_of(p), handle, out_sync_file_fd);
+}
+
+static int
+timed_wait(struct util_sync_provider *p, uint32_t *handles, unsigned num_handles,
+ int64_t timeout_nsec, unsigned flags, uint32_t *first_signaled)
+{
+ struct util_sync_provider *in = inner_of(p);
+ if (!timeout_nsec)
+ return in->wait(in, handles, num_handles, timeout_nsec, flags, first_signaled);
+
+ int64_t t0 = os_time_get_nano();
+ int ret = in->wait(in, handles, num_handles, timeout_nsec, flags, first_signaled);
+ note_wait(SYNCOBJ_WAIT, t0, num_handles, ret);
+ return ret;
+}
+
+static int
+timed_reset(struct util_sync_provider *p, const uint32_t *handles, uint32_t handle_count)
+{
+ return inner_of(p)->reset(inner_of(p), handles, handle_count);
+}
+
+static int
+timed_signal(struct util_sync_provider *p, const uint32_t *handles, uint32_t handle_count)
+{
+ return inner_of(p)->signal(inner_of(p), handles, handle_count);
+}
+
+static int
+timed_timeline_signal(struct util_sync_provider *p, const uint32_t *handles, uint64_t *points,
+ uint32_t handle_count)
+{
+ return inner_of(p)->timeline_signal(inner_of(p), handles, points, handle_count);
+}
+
+static int
+timed_timeline_wait(struct util_sync_provider *p, uint32_t *handles, uint64_t *points,
+ unsigned num_handles, int64_t timeout_nsec, unsigned flags,
+ uint32_t *first_signaled)
+{
+ struct util_sync_provider *in = inner_of(p);
+ if (!timeout_nsec)
+ return in->timeline_wait(in, handles, points, num_handles, timeout_nsec, flags,
+ first_signaled);
+
+ int64_t t0 = os_time_get_nano();
+ int ret = in->timeline_wait(in, handles, points, num_handles, timeout_nsec, flags,
+ first_signaled);
+ note_wait(TIMELINE_WAIT, t0, num_handles, ret);
+ return ret;
+}
+
+static int
+timed_query(struct util_sync_provider *p, uint32_t *handles, uint64_t *points,
+ uint32_t handle_count, uint32_t flags)
+{
+ return inner_of(p)->query(inner_of(p), handles, points, handle_count, flags);
+}
+
+static int
+timed_transfer(struct util_sync_provider *p, uint32_t dst_handle, uint64_t dst_point,
+ uint32_t src_handle, uint64_t src_point, uint32_t flags)
+{
+ return inner_of(p)->transfer(inner_of(p), dst_handle, dst_point, src_handle, src_point,
+ flags);
+}
+
+static struct util_sync_provider *wrap_timed(struct util_sync_provider *inner);
+
+static void
+timed_finalize(struct util_sync_provider *p)
+{
+ inner_of(p)->finalize(inner_of(p));
+ free(p);
+}
+
+static struct util_sync_provider *
+timed_clone(struct util_sync_provider *p)
+{
+ struct util_sync_provider *cloned = inner_of(p)->clone(inner_of(p));
+ return cloned ? wrap_timed(cloned) : NULL;
+}
+
+/* Wrap `inner` so its blocking waits are timed. On allocation failure the
+ * inner provider is returned as it is: logging is not worth failing a device.
+ */
+static struct util_sync_provider *
+wrap_timed(struct util_sync_provider *inner)
+{
+ struct timed_sync_provider *t = calloc(1, sizeof(*t));
+ if (!t)
+ return inner;
+ t->inner = inner;
+ /* An operation the inner provider leaves NULL stays NULL here, since
+ * callers test for it rather than call through it.
+ */
+#define FWD(op) .op = inner->op ? timed_##op : NULL
+ t->base = (struct util_sync_provider) {
+ FWD(create),
+ FWD(destroy),
+ FWD(handle_to_fd),
+ FWD(fd_to_handle),
+ FWD(import_sync_file),
+ FWD(export_sync_file),
+ FWD(wait),
+ FWD(reset),
+ FWD(signal),
+ FWD(timeline_signal),
+ FWD(timeline_wait),
+ FWD(query),
+ FWD(transfer),
+ FWD(finalize),
+ FWD(clone),
+ };
+#undef FWD
+ return &t->base;
+}
+
struct ac_drm_device {
union {
amdgpu_device_handle adev;
@@ -82,6 +277,8 @@ int ac_drm_device_initialize(int fd, bool is_virtio,
}
if (r == 0) {
+ if (u_wait_log_enabled() && (*dev)->p)
+ (*dev)->p = wrap_timed((*dev)->p);
(*dev)->is_virtio = is_virtio;
/* Device-static, so it is asked once here rather than on every heap
* query. A failure is not fatal: the caller falls back to querying it.
diff --git a/src/util/u_wait_log.h b/src/util/u_wait_log.h
new file mode 100644
index 00000000000..ce96256f371
--- /dev/null
+++ b/src/util/u_wait_log.h
@@ -0,0 +1,133 @@
+/*
+ * SPDX-License-Identifier: MIT
+ */
+
+/* Logging for time a thread spends waiting, for finding what a frame that
+ * runs long was blocked on.
+ *
+ * Two knobs, both in milliseconds and both off when unset or 0:
+ *
+ * - MESA_SLOW_WAIT_MS: log each single wait longer than this.
+ * - MESA_WAIT_STATS: once a second, per thread, log how much time went to
+ * each kind of wait, if the total reached this. Many
+ * short waits that add up to a long frame never trip a
+ * per-wait threshold; totals show them.
+ *
+ * Every line carries a UTC wall-clock stamp, so it can be lined up with other
+ * components' logs. Header-only, so each file that uses it keeps its own
+ * per-thread window and reports under its own name.
+ */
+
+#ifndef U_WAIT_LOG_H
+#define U_WAIT_LOG_H
+
+#include <stdbool.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <string.h>
+#include <time.h>
+#include <unistd.h>
+
+#include "util/log.h"
+#include "util/os_time.h"
+#include "util/u_debug.h"
+
+#define U_WAIT_LOG_MAX_KINDS 8
+
+struct u_wait_log_window {
+ int64_t start;
+ int64_t ns[U_WAIT_LOG_MAX_KINDS];
+ uint32_t n[U_WAIT_LOG_MAX_KINDS];
+};
+
+/* A millisecond option as nanoseconds, read once into *cache. */
+static inline int64_t
+u_wait_log_option_ns(const char *name, int64_t *cache)
+{
+ if (*cache < 0)
+ *cache = debug_get_num_option(name, 0) * 1000000ll;
+ return *cache;
+}
+
+static inline int64_t
+u_wait_log_slow_ns(void)
+{
+ static int64_t cache = -1;
+ return u_wait_log_option_ns("MESA_SLOW_WAIT_MS", &cache);
+}
+
+static inline int64_t
+u_wait_log_stats_ns(void)
+{
+ static int64_t cache = -1;
+ return u_wait_log_option_ns("MESA_WAIT_STATS", &cache);
+}
+
+/* Whether any wait timing is wanted at all. */
+static inline bool
+u_wait_log_enabled(void)
+{
+ return u_wait_log_slow_ns() || u_wait_log_stats_ns();
+}
+
+/* "HH:MM:SS.mmm" in UTC. */
+static inline void
+u_wait_log_stamp(char buf[16])
+{
+ struct timespec ts;
+ struct tm tm;
+ clock_gettime(CLOCK_REALTIME, &ts);
+ gmtime_r(&ts.tv_sec, &tm);
+ snprintf(buf, 16, "%02d:%02d:%02d.%03ld", tm.tm_hour, tm.tm_min, tm.tm_sec,
+ ts.tv_nsec / 1000000);
+}
+
+/* Add one wait of `ns` nanoseconds of kind `kind` to this thread's window,
+ * and report and reset the window once it is a second old.
+ */
+static inline void
+u_wait_log_account(struct u_wait_log_window *w, const char *who,
+ const char *const *kinds, unsigned num_kinds,
+ unsigned kind, int64_t ns)
+{
+ int64_t floor = u_wait_log_stats_ns();
+ if (!floor || kind >= num_kinds || num_kinds > U_WAIT_LOG_MAX_KINDS)
+ return;
+
+ int64_t now = os_time_get_nano();
+ if (!w->start)
+ w->start = now;
+ w->ns[kind] += ns;
+ w->n[kind]++;
+
+ if (now - w->start < 1000000000ll)
+ return;
+
+ int64_t total = 0;
+ for (unsigned i = 0; i < num_kinds; i++)
+ total += w->ns[i];
+
+ if (total >= floor) {
+ char line[256];
+ size_t len = 0;
+ line[0] = '\0';
+ for (unsigned i = 0; i < num_kinds && len < sizeof(line); i++) {
+ if (!w->n[i])
+ continue;
+ int wrote = snprintf(line + len, sizeof(line) - len, " %s %.1f/%u",
+ kinds[i], w->ns[i] / 1e6, w->n[i]);
+ if (wrote < 0)
+ break;
+ len += wrote;
+ }
+ char stamp[16];
+ u_wait_log_stamp(stamp);
+ mesa_logw("%s %s: tid %d waited %.1f ms of %.0f:%s", stamp, who, gettid(),
+ total / 1e6, (now - w->start) / 1e6, line);
+ }
+
+ memset(w, 0, sizeof(*w));
+ w->start = now;
+}
+
+#endif /* U_WAIT_LOG_H */
diff --git a/src/virtio/vdrm/vdrm.c b/src/virtio/vdrm/vdrm.c
index d09df9bb99d..4819219cbc5 100644
--- a/src/virtio/vdrm/vdrm.c
+++ b/src/virtio/vdrm/vdrm.c
@@ -4,10 +4,40 @@
*/
#include "util/u_math.h"
+#include "util/u_wait_log.h"
#include "util/perf/cpu_trace.h"
#include "vdrm.h"
+/* Wait logging for the transport: see util/u_wait_log.h for the knobs.
+ *
+ * Every path here can stall the caller on the host, and execbuf holds eb_lock
+ * while it does, which stalls every other thread of this device too. The
+ * split into lock, flush, fence, host and submit time says which.
+ */
+static const char *const wait_kinds[VDRM_WAIT_COUNT] = {
+ [VDRM_WAIT_LOCK] = "lock",
+ [VDRM_WAIT_FLUSH] = "flush",
+ [VDRM_WAIT_FENCE] = "fence",
+ [VDRM_WAIT_HOST] = "host",
+ [VDRM_WAIT_SUBMIT] = "submit",
+ [VDRM_WAIT_BO] = "bo_wait",
+};
+
+static __thread struct u_wait_log_window wait_window;
+
+void
+vdrm_account_wait(enum vdrm_wait kind, int64_t ns)
+{
+ u_wait_log_account(&wait_window, "vdrm", wait_kinds, VDRM_WAIT_COUNT, kind, ns);
+}
+
+static double
+ms(int64_t ns)
+{
+ return ns / 1e6;
+}
+
struct vdrm_device * vdrm_virtgpu_connect(int fd, uint32_t context_type);
struct vdrm_device * vdrm_vpipe_connect(uint32_t context_type);
@@ -114,7 +144,11 @@ vdrm_execbuf(struct vdrm_device *vdev, struct vdrm_execbuf_params *p)
MESA_TRACE_FUNC();
+ bool timed = u_wait_log_enabled();
+ int64_t t0 = timed ? os_time_get_nano() : 0;
+
simple_mtx_lock(&vdev->eb_lock);
+ int64_t t_locked = timed ? os_time_get_nano() : 0;
p->req->seqno = ++vdev->next_seqno;
@@ -127,6 +161,23 @@ vdrm_execbuf(struct vdrm_device *vdev, struct vdrm_execbuf_params *p)
out_unlock:
simple_mtx_unlock(&vdev->eb_lock);
+ if (timed) {
+ int64_t t_done = os_time_get_nano();
+ vdrm_account_wait(VDRM_WAIT_LOCK, t_locked - t0);
+ vdrm_account_wait(VDRM_WAIT_SUBMIT, t_done - t_locked);
+
+ int64_t slow = u_wait_log_slow_ns();
+ if (slow && t_done - t0 > slow) {
+ char stamp[16];
+ u_wait_log_stamp(stamp);
+ mesa_logw("%s vdrm: execbuf cmd %u ring %d took %.1f ms (lock %.1f, submit %.1f; "
+ "%u in-syncobjs, in-fence %s) tid %d",
+ stamp, p->req->cmd, p->ring_idx, ms(t_done - t0), ms(t_locked - t0),
+ ms(t_done - t_locked), p->num_in_syncobjs,
+ p->has_in_fence_fd ? "yes" : "no", gettid());
+ }
+ }
+
return ret;
}
@@ -141,7 +192,11 @@ vdrm_send_req(struct vdrm_device *vdev, struct vdrm_ccmd_req *req, bool sync)
uintptr_t fence = 0;
int ret = 0;
+ bool timed = u_wait_log_enabled();
+ int64_t t0 = timed ? os_time_get_nano() : 0;
+
simple_mtx_lock(&vdev->eb_lock);
+ int64_t t_locked = timed ? os_time_get_nano() : 0;
ret = enqueue_req(vdev, req);
if (ret || !sync)
@@ -151,16 +206,41 @@ vdrm_send_req(struct vdrm_device *vdev, struct vdrm_ccmd_req *req, bool sync)
out_unlock:
simple_mtx_unlock(&vdev->eb_lock);
+ int64_t t_flushed = timed ? os_time_get_nano() : 0;
if (ret)
return ret;
+ int64_t t_fenced = t_flushed;
if (sync) {
MESA_TRACE_SCOPE("vdrm_execbuf sync");
vdev->funcs->wait_fence(vdev, fence);
+ if (timed)
+ t_fenced = os_time_get_nano();
vdrm_host_sync(vdev, req);
}
+ if (timed) {
+ int64_t t_done = os_time_get_nano();
+ vdrm_account_wait(VDRM_WAIT_LOCK, t_locked - t0);
+ vdrm_account_wait(VDRM_WAIT_FLUSH, t_flushed - t_locked);
+ if (sync) {
+ vdrm_account_wait(VDRM_WAIT_FENCE, t_fenced - t_flushed);
+ vdrm_account_wait(VDRM_WAIT_HOST, t_done - t_fenced);
+ }
+
+ int64_t slow = u_wait_log_slow_ns();
+ if (slow && t_done - t0 > slow) {
+ char stamp[16];
+ u_wait_log_stamp(stamp);
+ mesa_logw("%s vdrm: %s ccmd %u took %.1f ms (lock %.1f, flush %.1f, "
+ "fence %.1f, host %.1f) tid %d",
+ stamp, sync ? "sync" : "async", req->cmd, ms(t_done - t0),
+ ms(t_locked - t0), ms(t_flushed - t_locked),
+ ms(t_fenced - t_flushed), ms(t_done - t_fenced), gettid());
+ }
+ }
+
return 0;
}
diff --git a/src/virtio/vdrm/vdrm.h b/src/virtio/vdrm/vdrm.h
index fa676896f23..dbb6bfd2441 100644
--- a/src/virtio/vdrm/vdrm.h
+++ b/src/virtio/vdrm/vdrm.h
@@ -114,6 +114,20 @@ int vdrm_execbuf(struct vdrm_device *vdev, struct vdrm_execbuf_params *p);
void vdrm_host_sync(struct vdrm_device *vdev, const struct vdrm_ccmd_req *req);
+/* Kinds of wait the transport accounts for; see util/u_wait_log.h. */
+enum vdrm_wait {
+ VDRM_WAIT_LOCK, /* waiting for eb_lock */
+ VDRM_WAIT_FLUSH, /* the flush ioctl, lock held */
+ VDRM_WAIT_FENCE, /* a sync request's fence */
+ VDRM_WAIT_HOST, /* the host catching up after the fence */
+ VDRM_WAIT_SUBMIT, /* the execbuf ioctl, lock held */
+ VDRM_WAIT_BO, /* a BO wait */
+ VDRM_WAIT_COUNT,
+};
+
+/* Add one wait to this thread's per-second totals (MESA_WAIT_STATS). */
+void vdrm_account_wait(enum vdrm_wait kind, int64_t ns);
+
/**
* Import dmabuf fd returning a GEM handle
*/
diff --git a/src/virtio/vdrm/vdrm_virtgpu.c b/src/virtio/vdrm/vdrm_virtgpu.c
index bccd4a5d279..9b9d5dc63c4 100644
--- a/src/virtio/vdrm/vdrm_virtgpu.c
+++ b/src/virtio/vdrm/vdrm_virtgpu.c
@@ -13,6 +13,7 @@
#include "drm-uapi/virtgpu_drm.h"
#include "util/libsync.h"
#include "util/log.h"
+#include "util/u_wait_log.h"
#include "util/perf/cpu_trace.h"
@@ -213,9 +214,26 @@ virtgpu_bo_wait(struct vdrm_device *vdev, uint32_t handle)
};
int ret;
+ bool timed = u_wait_log_enabled();
+ int64_t t0 = timed ? os_time_get_nano() : 0;
+
/* Side note, this ioctl is defined as IO_WR but should be IO_W: */
ret = virtgpu_ioctl(vgdev->fd, VIRTGPU_WAIT, &args);
- if (ret && errno == EBUSY)
+ int err = errno;
+
+ if (timed) {
+ int64_t took = os_time_get_nano() - t0;
+ vdrm_account_wait(VDRM_WAIT_BO, took);
+ int64_t slow = u_wait_log_slow_ns();
+ if (slow && took > slow) {
+ char stamp[16];
+ u_wait_log_stamp(stamp);
+ mesa_logw("%s vdrm: bo_wait handle %u took %.1f ms tid %d", stamp, handle,
+ took / 1e6, gettid());
+ }
+ }
+
+ if (ret && err == EBUSY)
return -EBUSY;
return 0;
--
2.55.0
@@ -0,0 +1,26 @@
kaldi's i386 and x86_64 builds share one source directory, and its cmake
writes generated files into it, so proton-ge serializes the two with plain
ordering rules. Those rules name the i386 steps unconditionally.
A wow64-only build has no i386-unix in ARCHS, so the i386 steps are never
defined, and make stops with "no rule to make target .kaldi-i386-configure".
Only order against them when they exist.
diff --git a/Makefile.in b/Makefile.in
index f8d959c..9b92fec 100644
--- a/Makefile.in
+++ b/Makefile.in
@@ -573,9 +573,13 @@ $(eval $(call rules-cmake,kaldi,aarch64,unix))
# the same source directory.
#
# The below assures that only a single configure/build step can run at a time.
+# The i386 steps exist only when i386-unix is in ARCHS; a wow64-only build has
+# none, and ordering against them would ask for a target with no rule.
+ifneq ($(findstring i386-unix,$(ARCHS)),)
$(OBJ)/.kaldi-x86_64-configure: $(OBJ)/.kaldi-i386-configure
$(OBJ)/.kaldi-i386-build: $(OBJ)/.kaldi-x86_64-configure
$(OBJ)/.kaldi-i386-build: $(OBJ)/.kaldi-x86_64-build
+endif
##
## vosk
@@ -0,0 +1,22 @@
protonfixes pins unzip to Debian revision 6.0-29 and fetches it from the
live pool. The pool keeps only current revisions, so once 6.0-31 landed the
download started failing with a 404 and the build with it.
snapshot.debian.org keeps every revision Debian has published, at a stable
path per timestamp. This is the first snapshot that carries 6.0-29, and the
orig tarball has been in the pool since 2009, so both files resolve there
and are the same bytes the pin always meant.
diff --git a/protonfixes/Makefile b/protonfixes/Makefile
index 4dcd851..8fab279 100644
--- a/protonfixes/Makefile
+++ b/protonfixes/Makefile
@@ -146,7 +146,7 @@ DEFINES = -DACORN_FTYPE_NFS -DWILD_STOP_AT_DIR -DLARGE_FILE_SUPPORT \
-DNO_WORKING_ISPRINT
UNZIP_VERSION := 6.0
UNZIP_DEBIAN_REVISION := 29
-UNZIP_BASE_URL := https://deb.debian.org/debian/pool/main/u/unzip
+UNZIP_BASE_URL := https://snapshot.debian.org/archive/debian/20250311T215724Z/pool/main/u/unzip
UNZIP_ORIG_TARBALL := unzip_$(UNZIP_VERSION).orig.tar.gz
UNZIP_DEBIAN_TARBALL := unzip_$(UNZIP_VERSION)-$(UNZIP_DEBIAN_REVISION).debian.tar.xz
UNZIP_DOWNLOAD_DIR := $(OBJDIR)/downloads/unzip
@@ -0,0 +1,43 @@
The x86_64 wine build gets VKD3D_PE_LIBS with a -L to the x86_64 vkd3d
DLLs. That is right while a separate i386 wine build exists and gets its
own value.
A wow64-only build has no i386 wine build: the x86_64 one builds the i386
PE side as well, and wine takes one VKD3D_PE_LIBS for every PE arch. The
i386 wined3d.dll then links against the x86_64 libvkd3d-1.dll and fails
with "file format not recognized". ld does not skip a mismatched DLL
named with -l:, so listing both directories does not work either.
Under wow64, keep the library names in VKD3D_PE_LIBS and pass each arch
its own directory through i386_LDFLAGS and x86_64_LDFLAGS, which wine's
configure already takes per arch. The i386 vkd3d was only ever built
as a dependency of the i386 wine build, so the x86_64 one now waits for it.
diff --git a/Makefile.in b/Makefile.in
index f8d959c..2ac0b34 100644
--- a/Makefile.in
+++ b/Makefile.in
@@ -635,9 +639,23 @@ WINE_aarch64_AUTOCONF_ARGS = \
WINE_i386_AUTOCONF_ARGS += \
VKD3D_PE_CFLAGS="-I$(VULKAN_HEADERS_i386_DST)/include -I$(VKD3D_i386_DST)/include/vkd3d" \
VKD3D_PE_LIBS="-L$(VKD3D_i386_LIBDIR)/vkd3d/i386-windows -l:libvkd3d-1.dll -l:libvkd3d-shader-1.dll -l:libvkd3d-utils-1.dll"
+ifneq ($(findstring i386-unix,$(ARCHS)),)
WINE_x86_64_AUTOCONF_ARGS += \
VKD3D_PE_CFLAGS="-I$(VULKAN_HEADERS_x86_64_DST)/include -I$(VKD3D_x86_64_DST)/include/vkd3d" \
VKD3D_PE_LIBS="-L$(VKD3D_x86_64_LIBDIR)/vkd3d/x86_64-windows -l:libvkd3d-1.dll -l:libvkd3d-shader-1.dll -l:libvkd3d-utils-1.dll"
+else
+# Without i386-unix there is no i386 wine build, and the x86_64 one builds the
+# i386 PE side too (wow64). It links both, but VKD3D_PE_LIBS is one value for
+# every PE arch, so a -L in it would hand the i386 link the x86_64 DLLs. Name
+# the libraries there and give each arch its own directory through its own
+# LDFLAGS instead.
+WINE_x86_64_AUTOCONF_ARGS += \
+ VKD3D_PE_CFLAGS="-I$(VULKAN_HEADERS_x86_64_DST)/include -I$(VKD3D_x86_64_DST)/include/vkd3d" \
+ VKD3D_PE_LIBS="-l:libvkd3d-1.dll -l:libvkd3d-shader-1.dll -l:libvkd3d-utils-1.dll"
+WINE_i386-windows_LIBFLAGS = -L$(VKD3D_i386_LIBDIR)/vkd3d/i386-windows
+WINE_x86_64-windows_LIBFLAGS = -L$(VKD3D_x86_64_LIBDIR)/vkd3d/x86_64-windows
+$(OBJ)/.wine-x86_64-build: $(OBJ)/.vkd3d-i386-post-build
+endif
WINE_DEPENDS = ffmpeg openfst kaldi vosk libxml2 libxkbcommon libpcap
WINE_i386_DEPENDS = vkd3d
+155
View File
@@ -0,0 +1,155 @@
#!/usr/bin/env bash
# Builds the guest kernel and installs it at "${KERNEL_OUTPUT}". Runs on the
# host, as yourself: nothing here needs root.
#
# The tree is CachyOS's fork, taken for its patches rather than its config.
# Theirs is a desktop distro config with thousands of modules; this guest has
# no /lib/modules at all. What must hold is kernel/nestri.fragment, merged onto
# whatever .config the tree has and then verified, so a version bump that
# quietly drops an option fails here instead of in a booted box.
set -euo pipefail
: "${KERNEL_GIT:?}"
: "${KERNEL_REF:?}"
: "${KERNEL_SRC:?}"
: "${KERNEL_OUTPUT:?}"
JOBS="${JOBS:-$(nproc)}"
KERNEL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../kernel" && pwd)"
FRAGMENT="${KERNEL_DIR}/nestri.fragment"
SEED="${KERNEL_DIR}/base.config"
# Resolved now, because everything below runs from inside the tree.
mkdir -p "$(dirname "${KERNEL_OUTPUT}")"
KERNEL_OUTPUT="$(cd "$(dirname "${KERNEL_OUTPUT}")" && pwd)/$(basename "${KERNEL_OUTPUT}")"
# ── Source ──────────────────────────────────────────────
if [[ ! -f "${KERNEL_SRC}/Makefile" ]]; then
echo "kernel: cloning ${KERNEL_REF} into ${KERNEL_SRC}"
mkdir -p "$(dirname "${KERNEL_SRC}")"
git clone --depth=1 --branch "${KERNEL_REF}" "${KERNEL_GIT}" "${KERNEL_SRC}"
else
have="$(git -C "${KERNEL_SRC}" describe --tags --exact-match 2>/dev/null || echo unknown)"
if [[ "${have}" != "${KERNEL_REF}" ]]; then
# Not fatal: a bisect or a local patch is a legitimate reason to be off
# the pinned ref, and silently checking it out would throw that away.
echo "kernel: tree is at '${have}', KERNEL_REF pins '${KERNEL_REF}'; building what is there" >&2
fi
fi
cd "${KERNEL_SRC}"
# ── Infinity scheduler (experimental) ───────────────────
# Applied once per tree, and recorded, because `patch -N` on an already patched
# tree does not skip cleanly: it rejects every hunk. The whole series goes in or
# none of it does -- upstream is explicit that a partial series misbehaves -- so
# every patch is dry-run against the stacked result before any is applied.
#
# In this guest only the fair and rt halves do anything. virtio-gpu does not
# use the DRM scheduler, so the gpu patch is compiled out with the rest of
# drivers/gpu/drm/scheduler; it is applied anyway to keep the series whole.
if [[ -n "${KERNEL_INFINITY:-}" ]]; then
: "${INFINITY_GIT:?}" "${INFINITY_REV:?}" "${INFINITY_SERIES:?}" "${INFINITY_WORK:?}"
stamp=".nestri-infinity-rev"
if [[ "$(git -C "${INFINITY_WORK}" rev-parse HEAD 2>/dev/null)" != "${INFINITY_REV}" ]]; then
echo "kernel: fetching infinity-sched ${INFINITY_REV}"
rm -rf "${INFINITY_WORK}"
git init -q "${INFINITY_WORK}"
git -C "${INFINITY_WORK}" fetch -q --depth=1 "${INFINITY_GIT}" "${INFINITY_REV}"
git -C "${INFINITY_WORK}" checkout -q FETCH_HEAD
fi
series_dir="${INFINITY_WORK}/${INFINITY_SERIES}"
[[ -f "${series_dir}/series" ]] || {
echo "kernel: infinity-sched has no series at ${INFINITY_SERIES} for ${KERNEL_REF}" >&2
exit 1
}
have="$(cat "${stamp}" 2>/dev/null || true)"
if [[ "${have}" == "${INFINITY_REV}" ]]; then
echo "kernel: infinity series already applied"
elif [[ -n "${have}" ]]; then
echo "kernel: tree carries infinity ${have}, INFINITY_REV pins ${INFINITY_REV}" >&2
echo "kernel: start the tree over with \`make kernel-clean\`" >&2
exit 1
else
if [[ -n "$(git status --porcelain --untracked-files=no)" ]]; then
echo "kernel: ${KERNEL_SRC} has local changes; not applying the series over them" >&2
exit 1
fi
mapfile -t patches < <(grep -v '^[[:space:]]*\(#\|$\)' "${series_dir}/series")
# git apply --check takes the whole list and checks each patch against
# the result of the ones before it, which a per-file `patch --dry-run`
# cannot do.
git apply --check "${patches[@]/#/${series_dir}/}"
for p in "${patches[@]}"; do
echo "kernel: applying ${p}"
# -F 0: zero fuzz. Offsets are fine; a hunk that only fits
# approximately is a scheduler change landing somewhere it was not
# written for.
patch -p1 -N -F 0 --quiet < "${series_dir}/${p}"
done
echo "${INFINITY_REV}" > "${stamp}"
fi
fi
# ── Config ──────────────────────────────────────────────
# A fresh tree has no .config. The seed is a known-good minimal config that
# olddefconfig migrates to whatever version the tree is at; it only saves a
# fresh tree from `make defconfig`, whose driver set is enormous next to what a
# microVM needs. An existing .config is always preferred.
if [[ ! -f .config ]]; then
echo "kernel: seeding .config from kernel/base.config"
cp "${SEED}" .config
fi
echo "kernel: merging kernel/nestri.fragment"
# -m merges without running a config target, so olddefconfig resolves
# dependencies once, in one place.
./scripts/kconfig/merge_config.sh -m .config "${FRAGMENT}" >/dev/null
make olddefconfig >/dev/null
# ── Verify the fragment actually took ───────────────────
# merge_config.sh warns about overridden symbols but exits 0, and olddefconfig
# will happily drop an option whose dependencies are unmet. Neither is loud
# enough for a setting whose failure mode is silent audio, so check the result
# rather than the intent. Both halves count: an option that must be on, and one
# that must be off.
missing=()
total=0
while read -r want; do
total=$((total + 1))
case "${want}" in
CONFIG_*) grep -qx "${want}" .config || missing+=("${want%%=*}") ;;
"# "*) grep -qx "${want}" .config || missing+=("${want:2} (must be off)") ;;
esac
done < <(grep -E '^(CONFIG_[A-Z0-9_]+=|# CONFIG_[A-Z0-9_]+ is not set)' "${FRAGMENT}" \
| sed -E 's/^(CONFIG_[A-Z0-9_]+=[^[:space:]#]+)[[:space:]]*#.*/\1/')
if (( ${#missing[@]} )); then
echo "kernel: these fragment entries did not survive olddefconfig:" >&2
printf '%s\n' "${missing[@]}" >&2
exit 1
fi
echo "kernel: all ${total} fragment entries hold"
# ── Build ───────────────────────────────────────────────
# -march goes in through KCFLAGS because mainline has no Kconfig for
# microarchitecture levels. It is safe for the kernel even at x86-64-v3:
# arch/x86/Makefile passes -mno-sse -mno-mmx -mno-sse2 -mno-avx and friends,
# and gcc applies those as a mask over -march regardless of flag order, so the
# kernel gets v3's integer ISA (BMI2, LZCNT, MOVBE) and its scheduling model
# and never touches a vector register.
make_args=()
if [[ -n "${KERNEL_MARCH:-}" ]]; then
make_args+=("KCFLAGS=-march=${KERNEL_MARCH}")
echo "kernel: building with -march=${KERNEL_MARCH}"
fi
# vmlinux, not bzImage: the guest is booted by an ELF loader with no
# bootloader in the path, and a bzImage is a self-decompressing image behind a
# real-mode setup header, not an ELF.
make -j"${JOBS}" "${make_args[@]}" vmlinux
cp vmlinux "${KERNEL_OUTPUT}"
echo "kernel: installed ${KERNEL_OUTPUT} ($(numfmt --to=iec "$(stat -c %s "${KERNEL_OUTPUT}")"))"
+137 -83
View File
@@ -1,96 +1,150 @@
#!/usr/bin/env bash
# Builds proton-cachyos from the tree proton-fetch.sh laid down. Container-only.
# Builds proton-ge wow64-only and leaves the finished tree in "${PROTON_WORK}/obj/dist".
# Runs on the host, not in a container.
#
# The one thing that matters here is --enable-wow64: it builds wine so that
# 32-bit Windows code runs inside a 64-bit unix process, thunking down to the
# 64-bit host libraries. Without it, Proton needs a complete 32-bit host stack —
# lib32 glibc, a second Mesa built for i686, and a second nescapture layer,
# because a 32-bit game would load the 32-bit Vulkan loader and our 64-bit
# capture layer would be invisible to it. With it, none of that exists.
# It has to run on the host because proton-ge's build is itself container-driven:
# `make` runs outside, and every step runs in the Steam Runtime SDK image, where
# the toolchains live, through the engine it is configured with. There is no
# mode without a container, and a container engine inside `podman build` is
# nested containers, which is a lot of fragile setup for no gain. So the only
# thing this script needs from the host is git, make and the engine. The
# Makefile packages the result afterwards.
#
# The cost is that the distro package cannot be used: proton-cachyos-native is
# packaged without the flag, which is exactly why it depends on lib32-*.
# The one thing we change is the arch list: it becomes wow64-only, and that
# change is the reason this is our own build and not a download. wow64 runs
# 32-bit Windows code inside a 64-bit unix process. Without it Proton needs a
# complete 32-bit host stack: lib32 glibc, a second Mesa built for i686, and a
# second nescapture layer, because a 32-bit game would load the 32-bit Vulkan
# loader and our 64-bit capture layer would be invisible to it. The released
# builds carry an i386 unix side, which is exactly why they need lib32-*.
#
# Everything else is proton-ge's own recipe: the same SDK image, the same flags
# and the same patch set. The one addition is patches/proton-ge/: fixes for the
# places its makefile assumes a 32-bit unix side that wow64 does not have, and
# for things a tag pinned that have since moved out from under it.
set -euo pipefail
: "${GECKO_VER:?}"
: "${MONO_VER:?}"
: "${PROTON_GIT:?}"
: "${PROTON_TAG:?}"
: "${PROTON_WORK:?}"
: "${BUILD_NAME:?}"
ENGINE="${CONTAINER_ENGINE:-podman}"
JOBS="${JOBS:-$(nproc)}"
BUILD_NAME="proton-cachyos"
SRC_DIR="/build/proton-cachyos"
BUILD_DIR="/build/build"
OUT_DIR="/artifacts/proton/usr/share/steam/compatibilitytools.d/${BUILD_NAME}"
[[ -d "${SRC_DIR}" ]] || { echo "no source tree — proton-fetch.sh did not run"; exit 1; }
mkdir -p "${PROTON_WORK}"
PROTON_WORK="$(cd "${PROTON_WORK}" && pwd)"
SRC="${PROTON_WORK}/src"
OBJ="${PROTON_WORK}/obj"
STAMP_TAG="${PROTON_WORK}/.tag"
STAMP_PATCHED="${PROTON_WORK}/.patched"
PATCH_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../patches/proton-ge" && pwd)"
# ── Toolchain wrappers ──────────────────────────────────
# Proton's build calls the compiler by GNU triplet. Arch's gcc does not install
# under those names, so stand in for them. The i686 set is generated too: with
# wow64 nothing should reach for it, and if something does, failing on a missing
# 32-bit header beats silently building a 32-bit unix library we then have to
# ship libraries for.
WRAP=/build/wrappers
rm -rf "$WRAP" && mkdir -p "$WRAP"
_wrappers() {
local arch="$1" gccflag="$2" ldflag="$3" asflag="$4" stripfmt="$5"
local l t
for l in ar ranlib nm; do
ln -sf "/usr/bin/gcc-${l}" "${WRAP}/${arch}-pc-linux-gnu-${l}"
# Two builds in one tree do not fail cleanly. They race on the same objects and
# leave half-written files that a later build trusts. A failed make also keeps
# running its in-flight jobs for a while after it reports the error, so the
# first build is often still running when the second one starts.
exec 9>"${PROTON_WORK}/.lock"
flock -n 9 || { echo "proton: another build is using ${PROTON_WORK}" >&2; exit 1; }
# ccache and cargo's downloads are kept outside src/ and obj/, so a new tag or
# FORCE_REBUILD throws away the build and keeps the parts that are correct to
# reuse. proton-ge's makefile mounts both into the container from these
# variables.
export CCACHE_DIR="${PROTON_WORK}/ccache"
export CARGO_HOME="${PROTON_WORK}/cargo"
mkdir -p "${CCACHE_DIR}" "${CARGO_HOME}"
if [[ -n "${FORCE_REBUILD:-}" || "$(cat "${STAMP_TAG}" 2>/dev/null)" != "${PROTON_TAG}" ]]; then
echo "proton: fresh tree for ${PROTON_TAG}"
rm -rf "${SRC}" "${OBJ}" "${STAMP_TAG}" "${STAMP_PATCHED}"
fi
# ── Fetch ───────────────────────────────────────────────
if [[ ! -e "${STAMP_TAG}" ]]; then
rm -rf "${SRC}"
git clone --branch "${PROTON_TAG}" --depth=1 "${PROTON_GIT}" "${SRC}"
# No --depth here: submodules are pinned to commits that are often not a
# branch tip. --filter=tree:0 keeps the download down instead.
git -C "${SRC}" submodule update --init --filter=tree:0 --recursive
echo "${PROTON_TAG}" > "${STAMP_TAG}"
fi
# The SDK image is pinned by proton-ge's own makefile, per tag. Asking it keeps
# the patch step below and the build on the same image.
SDK_IMAGE="$(make --silent --no-print-directory -f "${SRC}/Makefile.in" \
SRCDIR="${SRC}" get-steamrt-image)"
# ── Patch ───────────────────────────────────────────────
# The patch script edits the tree in place and is not idempotent: it resets
# some submodules first and not others. So a tree is patched once, and one that
# was interrupted halfway is reset to the commits the tag pins before trying
# again.
#
# It is run in the SDK image rather than on the host, so it does not depend on
# the host's python, patch or wget.
#
# The script carries on past a patch that does not apply and exits 0 anyway.
# The upstream instructions are to grep its output for failures, so that is
# what happens here. The alternative is an image that looks fine and is missing
# a fix.
#
# A build tree does not survive its source being re-patched. Changing
# Makefile.in re-syncs every component's source copy, but a component's
# configure step depends on that sync order-only, so it does not rerun, and its
# old build directory is left pointing at generated autotools files the sync
# just removed. So patching starts obj/ over too. ccache keeps that cheap.
if [[ ! -e "${STAMP_PATCHED}" ]]; then
rm -rf "${OBJ}"
git -C "${SRC}" reset -q --hard
git -C "${SRC}" submodule foreach -q --recursive 'git reset -q --hard && git clean -qfdx'
"${ENGINE}" run --rm -v "${SRC}:${SRC}" -w "${SRC}" "${SDK_IMAGE}" \
./patches/protonprep-valve-staging.sh 2>&1 | tee "${PROTON_WORK}/patch.log"
if grep -Ei 'hunk .* failed|saving rejects|can.t find file|malformed patch|skipping patch|^error' \
"${PROTON_WORK}/patch.log"; then
echo "proton: patches did not apply cleanly, see ${PROTON_WORK}/patch.log" >&2
exit 1
fi
# Ours go on top. They are paths from the root of the tree, submodules
# included. `git apply` fails outright on a patch that no longer applies,
# which is what a tag bump should do: each one says why it exists, so the
# question is only whether upstream fixed it.
for p in "${PATCH_DIR}"/*.patch; do
[[ -e "$p" ]] || continue
echo "proton: applying $(basename "$p")"
git -C "${SRC}" apply "$p"
done
for t in gcc g++; do
printf '#!/usr/bin/bash\n/usr/bin/%s %s "$@"\n' "$t" "$gccflag" \
> "${WRAP}/${arch}-pc-linux-gnu-${t}"
chmod 755 "${WRAP}/${arch}-pc-linux-gnu-${t}"
done
printf '#!/usr/bin/bash\n/usr/bin/ld %s "$@"\n' "$ldflag" > "${WRAP}/${arch}-pc-linux-gnu-ld"
printf '#!/usr/bin/bash\n/usr/bin/as %s "$@"\n' "$asflag" > "${WRAP}/${arch}-pc-linux-gnu-as"
printf '#!/usr/bin/bash\n/usr/bin/strip -F %s "$@"\n' "$stripfmt" > "${WRAP}/${arch}-pc-linux-gnu-strip"
chmod 755 "${WRAP}/${arch}-pc-linux-gnu-"{ld,as,strip}
}
_wrappers x86_64 "-m64" "-melf_x86_64" "--64" "elf64-x86-64"
_wrappers i686 "-m32" "-melf_i386" "--32" "elf32-i386"
export PATH="${WRAP}:${PATH}"
touch "${STAMP_PATCHED}"
fi
# ── Configure ───────────────────────────────────────────
# configure.sh refuses an in-tree build, and it test-runs the SDK image to work
# out how the engine maps file ownership, so it is also where a broken engine
# setup shows up first.
mkdir -p "${OBJ}"
if [[ ! -e "${OBJ}/Makefile" ]]; then
(cd "${OBJ}" && "${SRC}/configure.sh" \
--build-name="${BUILD_NAME}" \
--container-engine="${ENGINE}")
fi
# ── Build ───────────────────────────────────────────────
# -march=nocona matches the distro packaging: Proton has to run on whatever CPU
# the guest is given, and the VMM does not promise a feature level.
export CFLAGS="-O3 -march=nocona -mtune=core-avx2"
export CXXFLAGS="${CFLAGS}"
export RUSTFLAGS="-C opt-level=3 -C target-cpu=nocona"
export LDFLAGS="-Wl,-O1,--sort-common,--as-needed"
export RUSTUP_TOOLCHAIN=stable
# ARCHS drops i386-unix, which leaves wine configured for x86_64 unix with an
# i386 PE side. That is wow64. Every component rule is gated on ARCHS, so the
# 32-bit unix builds of everything else go with it. ENABLE_WOW64 makes the
# proton script ask wine for a wow64 prefix. proton-ge ships it as a switch
# but never turns it on.
#
# A command-line variable reaches the container build too: the outer make
# hands its overrides to the inner one.
#
# SOURCE_DATE_EPOCH is the tag's commit time rather than now, so two builds of
# one tag stamp the same dates into their output.
make -C "${OBJ}" \
J="${JOBS}" \
ARCHS="i386-windows x86_64-windows x86_64-unix" \
ENABLE_WOW64=1 \
SOURCE_DATE_EPOCH="$(git -C "${SRC}" log -1 --format=%ct)" \
dist
mkdir -p "${BUILD_DIR}"
cd "${BUILD_DIR}"
ROOTLESS_CONTAINER="" \
"${SRC_DIR}/configure.sh" \
--container-engine="none" \
--proton-sdk-image="" \
--build-name="${BUILD_NAME}" \
--without-extras=all \
--without-vklayers=all \
--without-steamrt-depends \
--without-tts \
--without-nvidia-libs \
--enable-wow64
# The top-level make is serial by design; SUBJOBS is what it hands to each
# component's build.
SUBJOBS="${JOBS}" make -j1 dist
# ── Install ─────────────────────────────────────────────
mkdir -p "${OUT_DIR}"
cp -a "${BUILD_DIR}/dist/." "${OUT_DIR}/"
# Debug symbols in the bundled PE runtimes are dead weight in a guest image.
cd "${OUT_DIR}/files"
find "share/wine/gecko/wine-gecko-${GECKO_VER}-x86" -name '*.dll' -o -name '*.exe' 2>/dev/null \
| xargs -r i686-w64-mingw32-strip --strip-debug 2>/dev/null || true
find "share/wine/gecko/wine-gecko-${GECKO_VER}-x86_64" -name '*.dll' -o -name '*.exe' 2>/dev/null \
| xargs -r x86_64-w64-mingw32-strip --strip-debug 2>/dev/null || true
find "share/wine/mono/wine-mono-${MONO_VER}" -name '*.dll' -o -name '*.exe' 2>/dev/null \
| xargs -r i686-w64-mingw32-strip --strip-debug 2>/dev/null || true
rm -rf "${BUILD_DIR}"
echo "proton: installed to ${OUT_DIR}"
echo "proton: built ${OBJ}/dist"
-57
View File
@@ -1,57 +0,0 @@
#!/usr/bin/env bash
# Fetches proton-cachyos' source and its bundled runtimes. Container-only.
#
# Deliberately its own script, and its own layer: the submodule checkout runs
# well past ten minutes, and it must not be redone every time a build flag or a
# missing dependency changes. Keep everything that can fail *fast* in
# proton-build.sh instead.
set -euo pipefail
: "${PROTON_GIT:?}"
: "${PROTON_TAG:?}"
: "${GECKO_VER:?}"
: "${MONO_VER:?}"
: "${XALIA_VER:?}"
SRC_DIR="/build/proton-cachyos"
git clone --branch "${PROTON_TAG}" --depth=1 "${PROTON_GIT}" "${SRC_DIR}"
cd "${SRC_DIR}"
# Relative submodule paths resolve against origin, so it has to be the real URL
# even though we cloned by tag.
git remote set-url origin "${PROTON_GIT}"
# No --depth here: submodules are pinned to commits that are often not a branch
# tip. --filter=tree:0 keeps the download down instead.
git submodule update --init --filter=tree:0 --recursive
# Still needed with wow64: these are PE-side, and a 32-bit Windows program wants
# the 32-bit gecko and mono regardless of how wine is built.
mkdir -p contrib
for url in \
"https://dl.winehq.org/wine/wine-gecko/${GECKO_VER}/wine-gecko-${GECKO_VER}-x86.tar.xz" \
"https://dl.winehq.org/wine/wine-gecko/${GECKO_VER}/wine-gecko-${GECKO_VER}-x86_64.tar.xz" \
"https://github.com/madewokherd/wine-mono/releases/download/wine-mono-${MONO_VER}/wine-mono-${MONO_VER}-x86.tar.xz" \
"https://github.com/madewokherd/xalia/releases/download/xalia-${XALIA_VER}/xalia-${XALIA_VER}-net48-mono.zip" \
; do
curl -fL --retry 3 -o "contrib/$(basename "$url")" "$url"
done
# Proton's cargo rule runs `cargo build --locked --offline`, so every crate has
# to be in CARGO_HOME before the build starts — including the git dependencies,
# which is what the "you are in the offline mode" failure is really saying. The
# error names a URL that is perfectly reachable; the build simply refuses to go
# out and get it.
#
# gst-plugins-rs is the only cargo component in the tree. Both targets are
# fetched: wow64 should mean nothing builds the i386 unix side, but a fetch is
# metadata only and costs almost nothing next to being wrong about that.
#
# CARGO_HOME is left at its default so it lands in this layer and the build
# layer inherits it.
export CARGO_NET_GIT_FETCH_WITH_CLI=true
export RUSTUP_TOOLCHAIN=stable
cd "${SRC_DIR}/gst-plugins-rs"
cargo fetch --locked --target x86_64-unknown-linux-gnu
cargo fetch --locked --target i686-unknown-linux-gnu
echo "proton: source at ${SRC_DIR}"