Skip to content

icd_shim: serve VK_KHR_xcb_surface so a GL guest takes the acquire-gate fps cap - #35

Merged
utkarshdalal merged 3 commits into
masterfrom
feat/icd-xcb-surface
Oct 31, 2026
Merged

utkarshdalal merged 3 commits into
masterfrom
feat/icd-xcb-surface

Conversation

@joshuatam

Copy link
Copy Markdown
Collaborator

What this does

Caps a GL session at the step where a frame is acquired, the same gate a Vulkan session already goes through — no gamescope-side limiter involved.

A guest GL app here is GL-on-Vulkan (Mesa zink), and zink's kopper WSI is xcb-only on X11: it creates its swapchain from VK_KHR_xcb_surface. The shim advertised only VK_KHR_wayland_surface and VK_KHR_xlib_surface, so that create never happened — gn_vkCreateSwapchainKHR found no shim surface and returned VK_ERROR_INITIALIZATION_FAILED. No swapchain of ours means no acquire call of ours, so the frame cap applied in gn_acquire_common (checked before a slot is handed out) never reached a GL guest, and only Vulkan was paced.

Serve the extension:

  • advertise VK_KHR_xcb_surface and strip it from the real instance create, like the other two proxied surface extensions;
  • handle vkCreateXcbSurfaceKHR and vkGetPhysicalDeviceXcbPresentationSupportKHR;
  • both X11 create infos differ only in connection type and neither present route reads it (the override route binds the XID to a gamescope surface, the DRI3 route opens its own connection from DISPLAY), so the surface construction is one helper, x11_surface(), shared by xlib and xcb.

GL now creates the same GnSurface, over the same swapchain ring, paced by the same acquire gate as a Vulkan guest.

Why acquire, and not gamescope PR #22

gamescope #22 moves the compositor's non-VRR limit onto a vblank time grid so a late compositor wakeup stops holding the next release a whole vblank longer. The grid is the right fix for the arithmetic error it describes, but it is still a display-side pacer, and it cannot align with the cap this project actually enforces:

  • gamescope's paint gate reaches a client only through wl_surface.frame (steamcompmgr_latch_frame_done → unlockedForFrameCallback) or through the FIFO commit deferral, which needs gamescope to read the surface as FIFO. This shim asks for no frame callbacks and declares mailbox, so a target set in gamescope never reaches the guest — a null we have already measured, not a knob left unturned.
  • The cap is enforced at acquire: a frame that may not start yet waits before it is given an image. Nothing rendered is thrown away and nothing already on its way to the panel is delayed. A display-side limiter costs up to a period of display delay by construction.
  • One limiter, one number: the app's target reaches the shim, and the shim paces the guest. Two limiters (gamescope's + ours) with different rounding is the mismatch this PR removes for GL.

So the better option for #22's problem is: keep the cap at acquire and close the GL hole underneath it — which is what this PR does. With this, #22 is not needed for a capped session on this stack; its grid arithmetic remains independently correct for a client that does use frame callbacks.

Files

file change
icd_shim/src/proxy.rs proxy module note; VK_KHR_xcb_surface advertised (extra 2→3) and stripped from the real create; gn_vkCreateXcbSurfaceKHR, gn_vkGetPhysicalDeviceXcbPresentationSupportKHR; shared x11_surface()
icd_shim/src/vk.rs VkXcbSurfaceCreateInfoKHR
docs/04-gamenative-app.md surface interception: three surface extensions, why xcb exists, both X11 routes land in one surface
docs/19-fps-limiter-acquire-rework.md GL row of the placement matrix; §7 risk list: zink guest is capped at acquire, a GL client presenting without Vulkan (glx/DRI3 into Xwayland) still never reaches the ICD

Verification

  • cargo check -p adrenotools_gamescope — clean.
  • cargo test -p adrenotools_gamescope --lib — 60 passed.
  • cargo clippy host and --target aarch64-linux-android — one pre-existing warning (present.rs:131, manual_is_multiple_of) in a file this PR does not touch; no new warnings.
  • No #[no_mangle] added, so tools/exports/ and check-exports.sh are untouched.

Not run on a device yet. Two things to watch there:

  1. A GL guest now creates its swapchain on the shim, so any WSI gap the Vulkan path has becomes reachable from GL — proxy swapchain create and the format/refusal lines are the tell.
  2. A GL client that presents without Vulkan at all (glx straight into Xwayland) still never touches the ICD and has no cap from us. Xwayland is built -Dglx=false, so that path is closed by absence rather than by this shim.

joshuatam and others added 3 commits October 31, 2026 14:38
…te fps cap

A GL guest on this stack is GL-on-Vulkan (Mesa zink), and zink's kopper WSI is
xcb-only on X11: it creates its swapchain from VK_KHR_xcb_surface. The shim
advertised only VK_KHR_wayland_surface and VK_KHR_xlib_surface, so that create
never happened — gn_vkCreateSwapchainKHR found no shim surface and returned
VK_ERROR_INITIALIZATION_FAILED. With no swapchain of ours there is no acquire
call of ours either, so the frame cap (applied in gn_acquire_common, before a
slot is handed out) never reached a GL guest and only Vulkan was paced.

Serve the extension: advertise it, strip it from the real instance create like
the other two proxied surface extensions, and handle
vkCreateXcbSurfaceKHR / vkGetPhysicalDeviceXcbPresentationSupportKHR. Both X11
create infos differ only in connection type and neither present route reads it
(the override route binds the XID to a gamescope surface, the DRI3 route opens
its own connection from DISPLAY), so the surface construction is one helper,
x11_surface(), shared by xlib and xcb. GL now creates the same GnSurface, on
the same swapchain ring, paced by the same gate as a Vulkan guest.

This is the DRM-side answer to gamescope PR #22 (release limited frames on the
vblank time grid): a gamescope-side limiter cannot align with the acquire gate
because its paint gate only reaches a client that asked for wl_surface.frame or
FIFO, and this shim asks for neither. Capping at acquire is where a frame that
may not start yet waits — nothing rendered is thrown away and nothing on its
way to the panel is delayed.

icd_shim/src/vk.rs: VkXcbSurfaceCreateInfoKHR.
docs/04-gamenative-app.md: the shim now advertises three surface extensions,
and why xcb exists (zink WSI), including that both X11 routes land in one
surface.
docs/19-fps-limiter-acquire-rework.md: the GL row of the placement matrix and
the §7 risk list now say a zink guest is capped at acquire, and that a GL
client presenting without Vulkan (glx/DRI3 into Xwayland) still never reaches
the ICD.
The cap moved from the release floor to the acquire, and its census did not.
`REL_HOLD_N`/`REL_HOLD_SUM_US`/`REL_HOLD_MAX_US`/`REL_HOLD_GE_8333`/`REL_CAPPED_N`
were never incremented after the move, so the `cap=[…]` block printed
`held=0 held_us=0 …` for a cap that was working and for one that was inert
alike, and the documented reading rule ("held>0 means the cap is working") could
not be satisfied by any session. A target that never arrived, a target that never
bound, and a gate that bound and still delivered short were indistinguishable.

Count the gate where it acts, in gn_acquire_common: one park per acquire (the
first instant the deadline held it, not one per wake-up, since a wake-up that
finds the deadline still ahead parks again), the total parked time, and its worst
case. Derive `acq_fps` from the acquire interval window already collected at the
same site — the rate at the instant the cap acts, which is the only number a
target can be checked against; the app's frame ring is a display count and reads
low on its own when the compositor coalesces.

cap=[fps= notes= gate_holds= gate_us= gate_avg_us= gate_max_us= acq_fps=]

docs/10-fps-limit.md, docs/19-fps-limiter-acquire-rework.md: the census fields
and how they read — notes=0 a target that never arrived, notes>0 with
gate_holds=0 a target that arrived and never bound (the guest or the compositor
is the slower element), gate_holds>0 with acq_fps under target a schedule fault.

Also correct the §3.1 pseudocode in docs/19: it described `max(next + period,
now)`, which is not the shipped rule. The schedule steps `next += period` while
the acquire is less than a whole period late, and re-anchors on `now + period`
only past that, so a late frame cannot put its lateness into the cadence.
A zink guest takes the gate only through kopper's xcb surface. The container's
libGL.so.1 is Mesa's libgl-xlib target (software xlib winsys, XShmPutImage):
it renders on a headless Vulkan instance, never creates an X11 surface, and
LIBGL_KOPPER_DISABLE is not a string in it. Measured 2026-10-31 on a Retroid
Pocket 6 with this shim loaded in SteamWorld Dig 2: no swapchain, no cap line,
55.9 fps at a 60 cap. Until the kopper/EGL backend ships, GL guests are capped
by gamescope's own limiter, which is why it stays armed.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants