fix: OpenGL games draw, Chromium games present, Proton makes non-ASCII file names (#401)

Games that showed a black screen with sound now render. Found by testing
on an AMD host: none of these were GPU-vendor specific.

**OpenGL games (Dome Keeper, Godot): fixed and confirmed.**
- `c017462`: the guest's Mesa had no EGL/GLX frontends, so a GL game had
nothing to load and never created a device. The image now ships them,
with glvnd's vendor file, so GL reaches zink and the capture layer sees
the frames.
- `5f2ccdf`: `vm.overcommit_memory=1`. The kernel default refused Dome
Keeper's 9 GiB reservation on an 8 GiB box before a byte was used.

**Chromium / NW.js games (Sheepy) stalled after one frame: fixed.**
- `a73c0c4`: nescope answers every commit with frame callbacks and
feedback, not only commits from a surface it maps to a window.
Chromium's GPU process draws for a window another process owns. Wine's
Wayland driver gives that surface no role, so its FIFO present waited
forever in the driver.
- `23ba9b5`: a window is matched by its surface, not its xdg toplevel,
so Xwayland windows are paced too.

**Proton could not create non-ASCII file names: patch added, build
pending.**
- `6770684`: Steam starts games with `LC_ALL=C`, and Wine reads that as
ASCII for every Unix file name. NW.js stops unpacking its app at
`media/• drum loop….webm` and shows an empty window.
- The patch reads an ASCII locale as UTF-8. Proton now takes patches
from `build/patches/proton/<submodule>/`, and a patched image is tagged
`-nN` (`11.0-20261005-n1`).
- Nothing points at that tag yet; hosts move to it in a separate change,
after it is built and tested.

Not covered: Metro 2033 Redux's report looks like the frame-callback
stall, but it was not retested.
This commit is contained in:
Wanjohi
2026-10-15 08:19:23 +00:00
committed by GitHub
7 changed files with 103 additions and 26 deletions
+18 -13
View File
@@ -100,22 +100,27 @@ impl CompositorHandler for NescopeState {
s
};
let Some(window) = self
// Matched on the window's surface, not its xdg toplevel: an Xwayland
// window has no toplevel.
let window = self
.space
.elements()
.find(|w| {
w.toplevel()
.map(|t| t.wl_surface() == &root)
.unwrap_or(false)
})
.cloned()
else {
return;
};
.find(|w| w.wl_surface().is_some_and(|s| *s == root))
.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) {
+3 -2
View File
@@ -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,
+37 -5
View File
@@ -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::<u64>().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]
+13 -5
View File
@@ -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)} && \
+12
View File
@@ -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
+6 -1
View File
@@ -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
@@ -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 */