From c0174628cd8b9b377c1d2bf7eb72eaf4057559a6 Mon Sep 17 00:00:00 2001 From: Wanjohi Date: Wed, 14 Oct 2026 11:23:35 +0300 Subject: [PATCH 1/5] fix(build): give the guest's OpenGL driver its EGL and GLX frontends Mesa was built with zink as its only Gallium driver and every frontend disabled, so a game rendering with OpenGL found no GL and showed a black screen. EGL, GLX, GBM and GLES2 are enabled, with the x11 platform for Xwayland clients. --- build/Containerfile | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/build/Containerfile b/build/Containerfile index 0a068d7e..4ff69045 100644 --- a/build/Containerfile +++ b/build/Containerfile @@ -80,6 +80,13 @@ ARG MESA_GIT=https://gitlab.freedesktop.org/mesa/mesa.git ARG MESA_COMMIT=a4a47003a194effad8cabe727f74985e9417476f ARG JOBS= +# OpenGL is zink: GL on top of whichever Vulkan driver the box has, radv over +# native context or the forwarded NVIDIA one, so one build serves both. Its +# frontends have to be on for any of it to be reachable. With EGL, GLX, GBM and +# GLES off, a GL game found no GL at all and ran with sound and a black screen, +# which is what three alpha testers saw. GLX needs the x11 platform for +# Xwayland's clients, and Xwayland needs GBM to give them DRI3. +# # 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 @@ -93,17 +100,18 @@ RUN test -n "$JOBS" || JOBS=$(nproc) && \ meson setup builddir \ -Dprefix=/usr \ -Dbuildtype=release \ - -Dplatforms=wayland \ + -Dplatforms=wayland,x11 \ -Dgallium-drivers=zink \ -Dvulkan-drivers=amd,intel \ -Damdgpu-virtio=true \ -Dintel-virtio-experimental=true \ -Dvideo-codecs=all \ - -Degl=disabled \ - -Dglx=disabled \ + -Degl=enabled \ + -Dglx=dri \ + -Dglvnd=enabled \ -Dgles1=disabled \ - -Dgles2=disabled \ - -Dgbm=disabled \ + -Dgles2=enabled \ + -Dgbm=enabled \ -Dgallium-va=disabled \ -Db_ndebug=true && \ mold -run ninja -C builddir -j${JOBS:-$(nproc)} && \ From 5f2ccdfbf25462731aef65184b6884d54c3b2f63 Mon Sep 17 00:00:00 2001 From: Wanjohi Date: Thu, 15 Oct 2026 00:41:30 +0300 Subject: [PATCH 2/5] fix(nesinit): let a game reserve more address space than the box has RAM The kernel's default overcommit policy refuses any single allocation larger than RAM plus swap. A box has no swap, so the ceiling is the box's own size, and an engine or driver that reserves address space it never touches is refused before it uses any of it. Dome Keeper asked for 9 GiB on an 8 GiB box and never started. Set vm.overcommit_memory=1 alongside vm.max_map_count, so a request is granted and the OOM killer acts on what is actually used. In a box that is the game: init has already exempted itself. --- apps/nesinit/src/system.rs | 42 +++++++++++++++++++++++++++++++++----- 1 file changed, 37 insertions(+), 5 deletions(-) diff --git a/apps/nesinit/src/system.rs b/apps/nesinit/src/system.rs index 5a952c9a..8246b58a 100644 --- a/apps/nesinit/src/system.rs +++ b/apps/nesinit/src/system.rs @@ -219,11 +219,26 @@ fn devices() { /// or Wine, and the process dies — here as a `wineserver crashed` with the game /// already gone. SteamOS and every distribution that packages Proton raise it /// to this value for that reason. -const SYSCTLS: &[(&str, &str, &str)] = &[( - "vm/max_map_count", - "2147483642", - "a game that maps more than 65530 regions (Unreal Engine 5 does) dies on a failed allocation", -)]; +/// - `vm.overcommit_memory`: **the kernel's default refuses any one allocation +/// larger than RAM plus swap**, and a box has no swap, so the ceiling is the +/// box's size: 8 GiB at 1080p. Engines and drivers reserve address space far +/// past what they touch, and a reservation is refused before a byte of it is +/// used — Dome Keeper asked for 9 GiB on an 8 GiB box and never started +/// (2026-10-14). `1` grants every request and leaves the OOM killer to act on +/// what is actually used; in a box that is the game, since this process has +/// already refused it. +const SYSCTLS: &[(&str, &str, &str)] = &[ + ( + "vm/max_map_count", + "2147483642", + "a game that maps more than 65530 regions (Unreal Engine 5 does) dies on a failed allocation", + ), + ( + "vm/overcommit_memory", + "1", + "a game that reserves more address space than the box has RAM is refused it and does not start", + ), +]; /// How many files a process may hold open, which every process in the box /// inherits from here. @@ -678,6 +693,23 @@ mod tests { assert!("2147483642".parse::().unwrap() > 65530 * 1000); } + #[test] + fn a_reservation_bigger_than_the_box_is_granted() { + let root = scratch("overcommit"); + std::fs::create_dir_all(root.join("vm")).unwrap(); + std::fs::write(root.join("vm/overcommit_memory"), "0\n").unwrap(); + + apply_sysctls(&root); + + assert_eq!( + std::fs::read_to_string(root.join("vm/overcommit_memory")) + .unwrap() + .trim(), + "1" + ); + let _ = std::fs::remove_dir_all(&root); + } + /// A box on a kernel without the knob still boots; the cost is said, not /// raised. #[test] From 23ba9b5cae640cf10a34e9c1a35864f31e3a0817 Mon Sep 17 00:00:00 2001 From: Wanjohi Date: Thu, 15 Oct 2026 10:03:44 +0300 Subject: [PATCH 3/5] fix(nescope): send frame callbacks to Xwayland windows Since frame callbacks moved onto the commit path, the commit handler found a window by its xdg toplevel. An Xwayland window has none, so no Proton game got a frame callback or presentation feedback. Xwayland then completes a flip only on its one-second fallback timer, and a game presenting FIFO stalls in the driver after its first frame. --- apps/nescope/src/handlers.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/nescope/src/handlers.rs b/apps/nescope/src/handlers.rs index 2a0d341e..7d4cdcf6 100644 --- a/apps/nescope/src/handlers.rs +++ b/apps/nescope/src/handlers.rs @@ -100,14 +100,14 @@ impl CompositorHandler for NescopeState { s }; + // Matched on the window's surface, not its xdg toplevel: an Xwayland + // window has no toplevel, and matching on one left every Proton game + // without frame callbacks. Xwayland then completes a flip only on its + // one-second fallback timer, and a game presenting FIFO stalls. let Some(window) = self .space .elements() - .find(|w| { - w.toplevel() - .map(|t| t.wl_surface() == &root) - .unwrap_or(false) - }) + .find(|w| w.wl_surface().is_some_and(|s| *s == root)) .cloned() else { return; From a73c0c4cfc0caa50451a05a4529432208ae33d09 Mon Sep 17 00:00:00 2001 From: Wanjohi Date: Thu, 15 Oct 2026 10:28:44 +0300 Subject: [PATCH 4/5] fix(nescope): answer every commit, not only a mapped window's A Vulkan client presenting FIFO waits in the driver for its frame callback. Chromium's GPU process draws for a window another process owns, which Wine's Wayland driver gives a surface with no role, so no window here matched it and the game presented one frame and waited forever: Sheepy's black screen. Nothing here paces against a display, so every commit now gets its frame callbacks and feedback. --- apps/nescope/src/handlers.rs | 27 ++++++++++++++++----------- apps/nescope/src/state.rs | 5 +++-- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/apps/nescope/src/handlers.rs b/apps/nescope/src/handlers.rs index 7d4cdcf6..075ec57f 100644 --- a/apps/nescope/src/handlers.rs +++ b/apps/nescope/src/handlers.rs @@ -101,21 +101,26 @@ impl CompositorHandler for NescopeState { }; // Matched on the window's surface, not its xdg toplevel: an Xwayland - // window has no toplevel, and matching on one left every Proton game - // without frame callbacks. Xwayland then completes a flip only on its - // one-second fallback timer, and a game presenting FIFO stalls. - let Some(window) = self + // window has no toplevel. + let window = self .space .elements() .find(|w| w.wl_surface().is_some_and(|s| *s == root)) - .cloned() - else { - return; - }; + .cloned(); + if let Some(window) = &window { + window.on_commit(); + self.game_frame_count += 1; + } - window.on_commit(); - self.game_frame_count += 1; - self.present_now(&root, &window); + // Every commit is answered, mapped window or not. A Vulkan client + // presenting FIFO waits in the driver for this frame callback, and + // not every surface a game draws to is a window here: Chromium's GPU + // process draws for a window another process owns, which Wine's + // Wayland driver gives a surface of its own with no role. Unanswered, + // that game presents one frame and waits forever. There is no screen + // here to pace against -- capture paces itself -- so answering every + // commit costs nothing a mapped window does not already get. + self.present_now(&root); } fn destroyed(&mut self, surface: &WlSurface) { diff --git a/apps/nescope/src/state.rs b/apps/nescope/src/state.rs index b3d13fb6..b682ae66 100644 --- a/apps/nescope/src/state.rs +++ b/apps/nescope/src/state.rs @@ -735,13 +735,14 @@ impl NescopeState { } } - pub(crate) fn present_now(&mut self, root: &WlSurface, window: &Window) { + pub(crate) fn present_now(&mut self, root: &WlSurface) { let output = self.output.clone(); let now = self.clock.now(); self.presentation_seq += 1; let mut fb = OutputPresentationFeedback::new(&output); - window.take_presentation_feedback( + take_presentation_feedback_surface_tree( + root, &mut fb, |_, _| Some(output.clone()), |_, _| wp_presentation_feedback::Kind::Vsync, From 6770684e5c8f14ea436ad3a12a6924c2ed494581 Mon Sep 17 00:00:00 2001 From: Wanjohi Date: Thu, 15 Oct 2026 10:56:29 +0300 Subject: [PATCH 5/5] fix(build): let Proton's Wine create non-ASCII file names under Steam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Steam starts games with LC_ALL=C, and Wine reads the C locale as ASCII for every Unix file name it makes, so a name like '• drum loop.webm' could not be created at all. Sheepy, an NW.js game, unpacks its app from a zip at start, stopped at that file, and showed NW.js's empty window. Proton is now patched before it builds (patches/proton//), and the first patch reads an ASCII locale as UTF-8. The image name gains -nN for our patch level, so a patched build never takes the plain tag's name. --- build/Containerfile.proton | 12 ++++++++++++ build/Makefile | 7 ++++++- .../0001-ntdll-read-an-ASCII-locale-as-UTF-8.patch | 14 ++++++++++++++ 3 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 build/patches/proton/wine/0001-ntdll-read-an-ASCII-locale-as-UTF-8.patch diff --git a/build/Containerfile.proton b/build/Containerfile.proton index a8721017..172eea6f 100644 --- a/build/Containerfile.proton +++ b/build/Containerfile.proton @@ -110,6 +110,18 @@ RUN pacman -Syu --noconfirm --needed \ libsm libtheora xz \ && pacman -Scc --noconfirm +# Our own patches, one directory per submodule they apply to: patches/proton/ +# wine/ applies in wine/. Its own layer, after the fetch and the dependencies, +# so a patch change costs the build and nothing before it. A patch that no +# longer applies to a new tag stops here, before the hours of building. +COPY patches/proton /build/patches/proton +RUN set -e; cd /build/patches/proton; \ + for dir in */; do \ + for p in "${dir}"*.patch; do \ + echo "applying ${p}"; \ + patch -d "/build/proton-cachyos/${dir%/}" -p1 --forward < "${p}"; \ + done; \ + done 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 diff --git a/build/Makefile b/build/Makefile index d167742b..be252cfb 100644 --- a/build/Makefile +++ b/build/Makefile @@ -38,8 +38,13 @@ 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_PATCHES` counts our own changes on top of that tag (patches/proton/). +# Bump it whenever they change, so a patched build never takes the name of the +# one before it; reset it to 0 when the tag moves. PROTON_TAG ?= cachyos-11.0-20261005-native -PROTON_VERSION := $(PROTON_TAG:cachyos-%-native=%) +PROTON_PATCHES ?= 1 +PROTON_VERSION := $(PROTON_TAG:cachyos-%-native=%)-n$(PROTON_PATCHES) PROTON_IMAGE ?= ghcr.io/nestrilabs/proton-cachyos-native-wow64 PROTON_REF := $(PROTON_IMAGE):$(PROTON_VERSION) OUTPUT_DIR := output diff --git a/build/patches/proton/wine/0001-ntdll-read-an-ASCII-locale-as-UTF-8.patch b/build/patches/proton/wine/0001-ntdll-read-an-ASCII-locale-as-UTF-8.patch new file mode 100644 index 00000000..50060c15 --- /dev/null +++ b/build/patches/proton/wine/0001-ntdll-read-an-ASCII-locale-as-UTF-8.patch @@ -0,0 +1,14 @@ +--- a/dlls/ntdll/unix/env.c ++++ b/dlls/ntdll/unix/env.c +@@ -286,6 +286,11 @@ + + if (!(ctype = setlocale( LC_CTYPE, "" ))) name = "UTF-8"; + else if (!(name = nl_langinfo( CODESET ))) return; ++ /* The C and POSIX locales name plain ASCII, in which a non-ASCII file ++ * name cannot be created at all. Steam starts games with LC_ALL=C, so ++ * under it every such name failed; ASCII is a subset of UTF-8, so ++ * reading it as UTF-8 changes nothing that worked before. */ ++ if (!strcmp( name, "ANSI_X3.4-1968" )) name = "UTF-8"; + TRACE_(nls)( "Unix LC_CTYPE %s, using %s codeset\n", debugstr_a(ctype), debugstr_a(name) ); + + /* remove punctuation characters from charset name */