Skip to main content

Spike 07 — Cross-platform offscreen GL (Linux EGL, Windows WGL)

THE ANSWER, if you came here for one

Linux offscreen GL: YES. WORKS. MEASURED ON REAL LINUX, THREE TIMES.

EGL_MESA_platform_surfaceless gives PyMOL a desktop OpenGL 4.5 (Compatibility Profile) context with no GPU, no /dev/dri, no DISPLAY, no X server, no Wayland — Mesa 22.3.6 / llvmpipe is the whole dependency, and every distro ships it. In that context PyMOL rendered a 5,684-atom cartoon of packages/engine/test/dat/1tii.pdb with glGetError() == 0 and 13,578/76,800 non-black pixels, and the backend pick pass selected an atom on 3 of 3 clicks. GLEW printed nothing and use_shaders stayed on. Verbatim transcript: §2.8. Independently re-run from scratch by a second pass that was told to distrust §2.8: §2.9, identical to the pixel. Re-run a third time on 2026-08-02, after the monorepo reorganisation, to prove the validator still finds its own sources: §2.10, every value identical again. What is not covered is in §4.1 and is narrow: only llvmpipe, only Mesa 22.3.6, only arm64 — no NVIDIA EGL_EXT_platform_device on the proprietary driver, no radeonsi/iris/zink, no non-glvnd stack. Reproduce in one command: bash scripts/test-gl-linux.sh (needs Docker/podman anywhere), or --quick for EGL only in ~1 min, or --native on an actual Linux box. CI runs the identical validator on ubuntu-latest via .github/workflows/webclient-gl-linux.yml.

Windows offscreen GL: UNVERIFIED. wgl.py HAS NEVER EXECUTED.

It has been reviewed line by line against the WGL/Win32 ABI and six defects were fixed by inspection, one of them a real cdecl-vs-__stdcall bug over the entire framebuffer group (§8.3). Reviewed is not run. §4.2 is the honest list of what a Windows run would settle and §6.4 is the manual procedure. Do not describe this platform as working.

Status: LINUX VERIFIED FOR REAL. WINDOWS STILL UNVERIFIED.
  • Linux / egl.py — RUN, on real Linux, end to end. In a debian:bookworm-slim container (linux/arm64) with Mesa 22.3.6, no GPU, no /dev/dri, no DISPLAY, no X server at all: EGL_MESA_platform_surfaceless gave a desktop OpenGL 4.5 (Compatibility Profile) llvmpipe context, one FBO bound before the first draw, glReadPixels returned the exact colour cleared, PyMOL rendered a 5,684-atom cartoon of packages/engine/test/dat/1tii.pdb with glGetError() == 0, and the backend pick pass selected an atom on 3 of 3 clicks. Full transcript in §2.8. Reproduce with bash scripts/test-gl-linux.sh; CI runs the identical validator on ubuntu-latest via .github/workflows/webclient-gl-linux.yml. Three real defects were found by running it and are fixed — see §2.7.
  • Windows / wgl.py — NOT EXECUTED. No Windows host was reachable. It has been re-reviewed line by line against the WGL/Win32 ABI, six defects were fixed by inspection (the authoritative list is §4.2), and the struct marshalling is verified portably. It must still be run. §6.4 is the manual procedure and §4.2 is the honest list of what that would settle. The fourth defect (found in the re-review recorded in §2.9) was a cdecl-vs-__stdcall mismatch on every entry point resolved through wglGetProcAddress — invisible on x64, stack-corrupting on win32, and it covered the whole framebuffer group. See §8.3.
Everything in §5 was executed on macOS and every transcript is verbatim; everything in §2.8 was executed on Linux and is verbatim. This closes the deferral in code-ownership.md:149 (“Linux (EGL surfaceless / GLX pbuffer) and Windows (WGL + hidden window) are a separate spike”) and in 04-picking.md:600-601. Product-owner decision 2: cross-platform offscreen GL is funded now. Files delivered:

0. TL;DR


1. The interface all three backends implement

Agreed with WP-02, which owns glcontext/__init__.py and glcontext/cgl.py:
Failures are tenmol_bridge.errors.NoOffscreenGL(message, **detail) (errors.py:132), whose detail always carries platform, backend and a machine-readable reason. Both new modules import that symbol with a try/except ImportError local fallback so they stay importable if the bridge package is ever vendored without errors.py. info() from egl.py and wgl.py is a strict superset of cgl.py’s key set — verified in §5.4 — so /healthz and the doctor script can render all three the same way. The two invariants every backend honours, both inherited from spike 04:
  1. Contexts are per-thread. eglMakeCurrent and wglMakeCurrent are per-thread exactly like CGLSetCurrentContext. Create on the engine thread. info()["ownerThread"] records the thread that last made it current.
  2. Never regenerate the FBO on resize. check_gl_stereo_capable latches G->ShaderMgr->defaultBackbuffer.framebuffer from GL_FRAMEBUFFER_BINDING on the first draw (packages/engine/layer5/PyMOL.cpp:2236-2239). resize() only re-glRenderbufferStorages the attachments of the same FBO name. Windows additionally never resizes the hidden window, and Linux never resizes the pbuffer — nothing is drawn to either.

2. Linux design (egl.py)

2.1 Why EGL, not GLX

GLX needs an X server. The deployment model is a local desktop replacement, but the same bridge has to survive ssh, containers and headless workstations. EGL surfaceless has no such dependency and is the standard headless-GL path on Linux today.

2.2 Display selection ladder

Tried in order; override the whole thing with TENMOL_EGL_PLATFORM=surfaceless|device|default.
  1. EGL_MESA_platform_surfaceless (0x31DD) — eglGetPlatformDisplayEXT(0x31DD, EGL_DEFAULT_DISPLAY, NULL). Gated on the string appearing in the client extension list (eglQueryString(EGL_NO_DISPLAY, EGL_EXTENSIONS), per EGL_EXT_client_extensions). This is the Mesa path: llvmpipe, iris, radeonsi, zink. No DRM node, no X, no seat.
  2. EGL_EXT_platform_device (0x313F) — eglQueryDevicesEXT then eglGetPlatformDisplayEXT(0x313F, device, NULL). This is the NVIDIA proprietary headless path. Pick a specific GPU with TENMOL_EGL_DEVICE=<index>; the device is labelled from eglQueryDeviceStringEXT(EGL_DRM_RENDER_NODE_FILE_EXT) and surfaces in info()["eglDevice"].
  3. eglGetDisplay(EGL_DEFAULT_DISPLAY) — last resort. On Mesa this resolves to X11/Wayland/GBM depending on the environment, so it only works where a display server is reachable.
Each candidate is eglInitialized; the first that initialises wins. If all fail, the error lists every attempt and the client extension string, which is the single most useful diagnostic on a broken box.

2.3 Desktop GL, enforced

eglBindAPI(EGL_OPENGL_API) then EGL_RENDERABLE_TYPE = EGL_OPENGL_BIT in eglChooseConfig. PyMOL’s non-ES path calls glPushMatrix/glPopMatrix (packages/engine/layer1/ScenePicking.cpp:283,306) and glShadeModel (:234,272), and links -lGL — GLES cannot run it. Two independent checks: the eglBindAPI return, and GL_VERSION not starting with "OpenGL ES" (some drivers hand back an ES context anyway). A GL version below 2.0 is also rejected (reason="gl-too-old"), because that means a software fallback with no shader and usually no FBO support.

2.4 Config and context ladders

eglChooseConfig is retried with progressively weaker requests: 8/8/8/8+D24+S8+D24 → drop alpha → D16 → don’t-care. 8/8/8/8 matters: the FBO’s RGBA8 colour renderbuffer is what PickColorConverterSetRgbaBitsFromGL (packages/engine/layer1/ScenePicking.cpp:38-84) measures, and 32 bits total means a one-pass pick for any realistic scene. eglCreateContext ladder — the first entry is an empty attribute list on purpose. The EGL default is MAJOR=1, MINOR=0, and for the OpenGL API that means “any context compatible with GL 1.0”, i.e. the driver’s highest compatibility profile. That is exactly what PyMOL wants, and it matches what a plain wglCreateContext gives on Windows. Fallbacks: explicit 2.1, then 3.2 + EGL_CONTEXT_OPENGL_COMPATIBILITY_PROFILE_BIT.

2.5 Surface

A pbuffer sized to the viewport is created if the config offers one — EGL_MESA_platform_surfaceless explicitly permits pbuffers (“The surfaceless platform imposes no platform-specific restrictions on the creation of pbuffers”). If it fails we fall back to eglMakeCurrent(dpy, EGL_NO_SURFACE, EGL_NO_SURFACE, ctx), which requires EGL_KHR_surfaceless_context or EGL ≥ 1.5; if neither is present that is a hard, explicit failure rather than a mysterious EGL_BAD_SURFACE later. Nothing is ever drawn to the surface — PyMOL’s default framebuffer is our FBO.

2.6 Which libGL

libGL.so.1 first, then libGL.so, then libOpenGL.so.0; override with TENMOL_GL_LIB. libGL.so.1 is deliberate: that is the SONAME PyMOL’s _cmd extension links (setup.py:736-739), so loading it (with RTLD_GLOBAL) guarantees the bridge and the engine share one dispatch table. GL entry points are resolved dlsym(libGL) first, eglGetProcAddress second — eglGetProcAddress is only guaranteed to return core GL functions when EGL_KHR_get_all_proc_addresses is present. Measured: ldd on the built _cmd*.so inside the Linux container shows it pulling in libGL.so.1, libGLEW.so.2.2, libGLdispatch.so.0, libGLX.so.0 and libX11.so.6. Linking libGLX/libX11 is not the same as needing an X server: nothing ever calls XOpenDisplay, and the whole suite runs with DISPLAY unset. Do not try to “fix” this by unlinking them; setup.py is upstream and stays untouched.

2.7 Three defects the first real run exposed (all fixed)

Every one of these was invisible to the mock-driver harness in §5, because a mock cannot disagree with you. The transcripts are in §2.8. D-EGL-1 — eglTerminate was not reference-counted, and EGLDisplay is a process-global singleton. eglGetPlatformDisplayEXT with the same platform + native display returns the same EGLDisplay to every caller, and the EGL 1.5 spec has eglTerminate mark all resources on that display for deletion — there is no refcount in the driver. So the old release(), which always terminated, killed every other context in the process. Measured: with two contexts open, a.release() made b.make_current() fail with EGL_BAD_DISPLAY. This was already listed as residual risk §8.7 (“if that ever changes, refcount the display”); it is now done rather than predicted. _display_refs in egl.py counts live contexts per display and only the last one terminates (and only the last one calls eglReleaseThread, which would otherwise unbind a sibling context from this thread). D-EGL-2 — release() deleted its FBO against whatever context happened to be current. glDeleteFramebuffers/glDeleteRenderbuffers act on the current context, not on the context that owns the names. Releasing context A while B was current therefore deleted B’s FBO name 1 and renderbuffers 1 and 2. Measured: after the fix to D-EGL-1 alone, B survived but its next draw returned GL_INVALID_FRAMEBUFFER_OPERATION (0x0506) and read back [0,0,0,0]. release() now binds its own context before deleting, and if it cannot bind (another thread holds it) it skips the deletes entirely — eglDestroyContext reclaims every object in the context anyway. wgl.py had the identical bug and it is fixed there too, by inspection. cgl.py should be checked; see §9. D-EGL-3 — cross-thread hand-off was impossible, and the docstring said the opposite. The old class docstring claimed only that release() unbinds first. In fact eglMakeCurrent from thread B while thread A still holds the context returns EGL_BAD_ACCESS — EGL has no “steal”, the owning thread must unbind. Measured and now asserted in the acceptance test. EGLContext grew is_current() and release_current(), make_current() short-circuits when it is already current on the calling thread, and the EGL_BAD_ACCESS message now names the owning thread and says what to do. WGLContext grew the same two methods, because wglMakeCurrent documents the same one-thread-at-a-time rule (unverified there). Also added while here: GLFunctions now binds glPixelStorei and glReadBuffer, which render/framestream.py:PixelReadback looks for on ctx.gl and silently degrades without.

2.8 The verbatim Linux transcript

Host: macOS arm64 with Docker Desktop 27.4.0. Guest: debian:bookworm-slim, linux/arm64, Mesa 22.3.6, python3 3.11 / venv, PyMOL 3.2.0a built from this tree with the scripts/bootstrap.sh recipe (--config-settings use-msgpackc=c++11, mmtf-cpp on PREFIX_PATH). The container has no GPU, no /dev/dri, no DISPLAY, no WAYLAND_DISPLAY:
Command: bash scripts/test-gl-linux.sh (from the repo root, on macOS — the script packs the tracked working tree, builds the image and runs the validator inside it).
The image linux-egl-pymol.png is a rainbow-spectrum cartoon of 1TII — visually indistinguishable in structure and orientation from the cmd.ray render of the same scene, which is the strongest available evidence that the GL path is not producing garbage. The device platform also works on the same box (Mesa exposes a software EGLDevice, so this exercises the code path the NVIDIA proprietary driver takes, even without NVIDIA):
GLEW is silent. A full SingletonPyMOL().start() + load + draw with DISPLAY unset, with stderr captured separately:
Empty. use_shaders is still on, i.e. glewInit() succeeded and disableShaders(G) (packages/engine/layer0/ShaderMgr.cpp:590-597) did not run. This settles residual risk §8.2 for GLEW 2.2 on Mesa. Both the --quick path (Mesa only, no PyMOL build, ~1 min) and the --native path that CI uses were run and passed too.

2.9 Independent re-validation (second run, different session)

§2.8 was re-run from scratch by a second pass whose explicit brief was that these modules “have NEVER executed” — i.e. it was told to distrust the claims above and re-measure. It reproduced them. Recording it because “the docstring says it was verified” is not evidence, and this is:
Two things were checked that a passing test does not by itself establish:
  • The container really ran the working-tree file, not a stale copy baked into an image layer: sha256sum /src/bridge/tenmol_bridge/glcontext/egl.py inside the container matched the host’s (ae886ddde399f334…), and the imported module resolved to /src/bridge/…/egl.py.
  • PyMOL was really compiled in the image, not imported from a wheel: /venv/lib/python3.11/site-packages/pymol/_cmd.cpython-311-aarch64-linux-gnu.so.
The _functype fix in §8.3 was made after this run and the full suite was then re-run with the patched egl.py mounted over the built image: identical result, 13578/76800 non-black pixels to the pixel, 3/3 picks. The 129 bridge tests still pass (129 passed in 30.20s). The CI workflow’s apt list was also verified for real rather than assumed — every one of libegl1 libegl-mesa0 libgl1 libglx-mesa0 libgl1-mesa-dri libopengl0 libglvnd0 mesa-utils-bin resolves on both ubuntu:22.04 and ubuntu:24.04, and from tenmol_bridge.glcontext import egl was confirmed to import under a bare python3 with no bridge dependencies installed (the egl-surfaceless job installs no Python packages, so anything heavier in tenmol_bridge/__init__.py would have made that job red on the first push).

2.10 Third reproduction — 2026-08-02, after the monorepo reorganisation

Re-run because §2.8/§2.9 predate the move to packages/, and a validator that no longer finds its own sources is a silent pass waiting to happen. bash scripts/test-gl-linux.sh --quick, host macOS arm64 / Docker 27.4.0, guest debian:bookworm-slim linux/arm64. Every value is identical to §2.8’s, to the digit:
--quick skips the PyMOL build, so this run re-establishes checks 1–6 (the EGL context itself, the FBO, the resize invariant, D-EGL-1 and D-EGL-3) and not checks 7–9 (the PyMOL render, the pick, cmd.ray). Those were last run in §2.9; run the script without --quick to redo them, which takes about 30× longer because it builds PyMOL inside the image. Two notes for whoever runs it next: pass --out <somewhere outside the repo>, because the default .tenmol-gl-out/ is still not in .gitignore (§9 item 15); and the run leaves a Docker image behind, so docker image rm <tag> afterwards.

3. Windows design (wgl.py)

3.1 A hidden window, not a pbuffer

WGL_ARB_pbuffer is itself obtained through wglGetProcAddress, which needs a current context, which needs a window — so a window is unavoidable either way. We create one WS_POPUP \| WS_CLIPSIBLINGS \| WS_CLIPCHILDREN window, without WS_VISIBLE and without ShowWindow, take its DC, set a pixel format on it once, and render only into an FBO. CS_OWNDC is required: without it GetDC returns a DC from the common pool, the pixel format is not guaranteed to persist, and a later wglMakeCurrent can fail with ERROR_INVALID_PIXEL_FORMAT (2000) — which the module names explicitly in that error message. The window class registers DefWindowProcW’s address as lpfnWndProc rather than a Python callback, which sidesteps the usual ctypes callback-lifetime hazard entirely. Class names are TenmolOffscreenGL_<pid>_<n> so repeated create/release cycles never collide. No message pump is needed: the window is never shown and never drawn to.

3.2 The failure this module exists to catch

On a Windows Server / RDP / VM session with no vendor ICD, ChoosePixelFormat cheerfully returns the Microsoft GDI Generic implementation — PFD_GENERIC_FORMAT set, PFD_GENERIC_ACCELERATED clear, GL_VERSION == "1.1.0", GL_RENDERER == "GDI Generic". That is a software rasteriser with no FBO support at all, and PyMOL would fail late and incomprehensibly. wgl.py calls DescribePixelFormat on the chosen format (the only way to see those flags — ChoosePixelFormat never reports them back), classifies the result as icd / mcd / gdi-generic, and raises NoOffscreenGL(reason="gdi-generic") up front with a remediation hint.

3.3 Loading GL entry points

opengl32.dll exports only OpenGL 1.1. glGetString, glGetIntegerv, glGetError, glViewport, glFinish, glReadPixels, glClear, glClearColor come from the DLL export table; everything FBO-related must come from wglGetProcAddress, which requires a current context. A family of older ICDs returns the sentinels 1, 2, 3 and -1 instead of NULL on failure; those are filtered. The DLL export table is queried first and wglGetProcAddress second. That is deliberate and it is the opposite of the Linux order: wglGetProcAddress is specified to return NULL for OpenGL 1.1 entry points — which is exactly the list above — while opengl32.dll exports nothing newer. The two sources are disjoint, so either order finds every name; asking the DLL first is simply the order that never asks a function for something it is documented to refuse. (An earlier docstring in egl.py asserted the reverse order was mandatory on Windows. It was wrong, and wgl.py had always done the right thing; the docstring is corrected.) Calling convention. Addresses from wglGetProcAddress carry no calling convention, and every GL entry point is APIENTRY = __stdcall. Building those pointers with ctypes.CFUNCTYPE (cdecl) — which is what the code did until the re-review in §2.9 — is a no-op on x86-64 and stack corruption on 32-bit x86. egl._functype() now selects WINFUNCTYPE on Windows. Note this hazard applies only to the wglGetProcAddress/eglGetProcAddress paths: functions taken straight off a WinDLL already get __stdcall from ctypes. See §8.3.

3.4 ANGLE

If you arrived here from a §3.4 citation in glcontext/egl.py, this is not the section you want. Two comments there cite §3.4 for findings that live elsewhere, and the numbering was never corrected: egl.py:264 (the EGL_BAD_DISPLAY refcount, _display_refs) means §2.7 D-EGL-1, and egl.py:965 (thread affinity, EGL_BAD_ACCESS, no “steal”) means §2.7 D-EGL-3, measured in §2.8 check 5. Reported to that file’s owner, not edited here.
Implemented (TENMOL_WGL_BACKEND=angle, reusing egl.py with egl_libs=("libEGL.dll",), gl_libs=("libGLESv2.dll",)) purely so a Windows box can diagnose with it. It must not ship: ANGLE gives OpenGL ES, and glewInit() (packages/engine/layer0/ShaderMgr.cpp:566) resolves through wglGetProcAddress with no WGL context current, so it fails and disableShaders(G) (ShaderMgr.cpp:590-597) runs. The context adds a warnings[] entry saying exactly this.

4. Honesty: what is and is not proven

If you arrived here from glcontext/wgl.py:86’s “the manual Windows acceptance procedure is §4”, the procedure is §6.4. This section is the list of what that procedure would settle (§4.2), which is the next most useful thing. Reported to that file’s owner, not edited here.

4.1 Linux — settled

Everything below moved from “not proven” to “measured” in §2.8:
  • Surfaceless EGL exists and initialises with no GPU, no DRI device, no display server.
  • eglBindAPI(EGL_OPENGL_API) succeeds and the resulting context is desktop GL 4.5 compatibility, not GLES.
  • Real drivers accept these attribute lists — both the surfaceless and the device ladders.
  • The ARB framebuffer group resolves, the FBO is complete, and the name survives four resizes.
  • GLEW resolves under EGL and prints nothing; use_shaders stays on.
  • PyMOL renders, glGetError() is 0, and render/framestream.py:PixelReadback reads back through ctx.gl (source == "context.gl").
  • PyMOL picks — 3/3 clicks selected a CA atom. Spike 04’s macOS-only picking result now has a Linux twin.
  • Residual risk §8.1 (libglvnd hiding driver capability) did not bite: fboEntryPoints was ARB and the FBO was complete. On this stack glvnd’s stubs and the driver agree. It remains a theoretical risk on a non-glvnd stack, which was not available to test.
Still not covered on Linux, honestly:
  • Only llvmpipe was exercised. No NVIDIA proprietary EGL_EXT_platform_device, no radeonsi/iris, no zink. The device ladder ran, but against Mesa’s software device.
  • Only Mesa 22.3.6 (Debian bookworm), arm64. The CI workflow adds Ubuntu 22.04 and 24.04 (Mesa 23.x/24.x, x86-64) on the next push, which is the cheapest way to widen this.
  • Only one PyMOL rep (cartoon) at one size. This is a GL-context test, not a rendering parity test; image parity across platforms belongs to the ray image-diff suites.

4.2 Windows — NOT settled

wgl.py has still never executed. Do not describe it as working. What a Windows run would settle, and nothing else can:
  • That ChoosePixelFormat/SetPixelFormat/wglCreateContext succeed on a real ICD at all.
  • That the gdi-generic detection fires when — and only when — it should.
  • That wglGetProcAddress resolves the ARB framebuffer group under a legacy wglCreateContext context (it should; GLEW does the same thing), and that the 1/2/3/-1 sentinel handling is not needed and not harmful.
  • That glewInit() succeeds with a hidden CS_OWNDC window and no message pump.
  • That DestroyWindow + UnregisterClassW at shutdown leave nothing behind.
  • That PyMOL picks through it.
Reviewed and fixed by inspection in this pass, still unrun:
  1. DescribePixelFormat’s return value was ignored. On failure it leaves the descriptor untouched, so dwFlags would read back as 0 — which has PFD_GENERIC_FORMAT clear, i.e. the Microsoft GDI Generic software rasteriser would have been misreported as a hardware ICD and the gdi-generic guard, the entire reason §3.2 exists, would never have fired. Now a hard NoOffscreenGL(reason="describe-pixel-format-failed").
  2. release() deleted the FBO against whatever context was current (D-EGL-2, measured on the EGL twin). Now binds its own context first, or skips the deletes.
  3. RegisterClassW treated ERROR_CLASS_ALREADY_EXISTS as fatal. A hard kill between CreateWindowExW and UnregisterClassW leaves the atom registered; the class is then exactly what we want, so it is now a warning and UnregisterClassW is skipped for a class we did not register.
  4. create_context honoured TENMOL_WGL_BACKEND=angle before the platform check, so on macOS/Linux it traded a clear “wrong platform” error for an obscure “cannot load libEGL”.
  5. DestroyWindow has Win32 thread affinity that is stricter than wglMakeCurrent’s — it only works on the thread that called CreateWindowExW. creator_thread is now recorded separately from owner_thread (which follows the GL context) and a mismatch at release() appends a warning naming both threads.
  6. Every entry point resolved through wglGetProcAddress was called through a cdecl pointer although APIENTRY is __stdcall (GLFunctions._bind, shared with egl.py). Invisible on x86-64, where there is one calling convention — and therefore invisible to every review this file has had — but stack-corrupting on 32-bit x86, and it covered the entire framebuffer group, since opengl32.dll exports only OpenGL 1.1. Fixed by egl._functype() (WINFUNCTYPE on Windows, CFUNCTYPE elsewhere); the Linux suite was re-run after the change and is bit-identical, and the same helper now also covers _EGL._proc, which matters for the ANGLE diagnostic path. §8.3.
Re-verified portably after those edits (macOS, packages/bridge/.venv/bin/python):
Reviewed and deliberately left alone (they are correct, recording so the next reviewer does not re-litigate them):
  • wc.lpfnWndProc = ctypes.cast(user32.DefWindowProcW, c_void_p).value — storing the real DefWindowProcW address rather than a Python callback is what removes the callback-lifetime hazard entirely. wc being a local is fine: RegisterClassW copies the class.
  • CS_OWNDC + ReleaseDC — with CS_OWNDC the DC belongs to the window and ReleaseDC is a no-op, which is harmless and keeps the teardown symmetric.
  • No message pump. CreateWindowExW/DestroyWindow dispatch WM_CREATE/WM_DESTROY synchronously to DefWindowProcW; a never-shown DC holder needs nothing else.
  • wglGetProcAddress sentinel list (1, 2, 3, 0xFFFFFFFF, 0xFFFFFFFFFFFFFFFF, -1) — with restype = c_void_p the negative entries are unreachable, but they cost nothing and document the quirk.
  • ATOM = c_uint16 for RegisterClassW’s return, WPARAM = c_size_t, LPARAM = c_ssize_t — all correct for x64 and x86.

4.3 Proven on macOS only (unchanged) — transcripts in §5:

  • Both modules import cleanly on a host with no libEGL and no ctypes.WinDLL.
  • Every ctypes prototype has the arity, argument order and argument types the EGL/WGL ABI specifies, and every enum matches the Khronos/mingw-w64 headers (§7 provenance).
  • PIXELFORMATDESCRIPTOR marshals to 40 bytes and WNDCLASSW to 72 bytes (x86-64) — cross-checked against a C compiler compiling the same struct definitions.
  • The full call sequence — display → API → config → context → surface → make-current → FBO → resize → release — is issued in the right order with the right enum values, driven through mock libEGL/libGL/user32/gdi32/opengl32 shared objects.
  • All 29 documented failure reasons fire, and none of them leaks an untyped exception.
  • Platform dispatch selects egl/wgl/cgl correctly under a monkeypatched sys.platform.
  • The real macOS CGL path still works after these changes (Apple M4 Max, 2.1 Metal - 89.4).
STILL NOT proven — needs Windows hardware (the Linux entries that used to live here have moved to §4.1; do not re-add them):
  • That the Windows calling convention is honoured. Both sides of the mock harness are System V AMD64 on macOS. On x86-64 Windows this is very likely fine (uniform convention, and ctypes handles it), but on 32-bit Windows __stdcall vs cdecl genuinely differs — that is why every Win32 DLL is opened with ctypes.WinDLL, and why egl.py also uses WinDLL when sys.platform is Windows.
  • That a real Windows ICD accepts these pixel-format ladders, that wglGetProcAddress resolves the ARB framebuffer group, and that GLEW initialises against a hidden window.
  • That PyMOL renders and picks through a WGL context. §6.4 is the test that settles it.
  • NVIDIA's eglQueryDevicesEXT on the proprietary Linux driver. The device ladder was exercised (§2.8) but only against Mesa’s software EGLDevice.
  • _load_gl() does dlsym on libGL.so.1 for the ARB framebuffer group. With libglvnd those symbols are always present as dispatch stubs regardless of what the vendor driver supports, so the ARB-vs-EXT group choice is decided by libglvnd, not by the driver. On the stack in §2.8 the stubs and the driver agreed (fboEntryPoints: ARB, FBO complete), and the FBO-completeness check is the backstop — but a non-glvnd stack was not available. Still the first thing to check if Linux misbehaves.

5. What was executed here (verbatim)

Interpreter: .../scratchpad/venv/bin/python (Python 3.13.3, the venv PyMOL 3.2.0a0 is installed in).

5.1 Import, struct layout, and the failure paths

pyflakes 3.4.0 reports nothing on either file (and does report both errors in a deliberately broken control file, so it really ran).

5.2 Platform dispatch

The no-windll reason is a bug this test found: ctypes.WinDLL does not exist off-Windows, so referencing it directly leaked a raw AttributeError instead of a typed NoOffscreenGL. Both modules now look it up defensively.

5.3 Mock-driver harness

Three shared objects were compiled (cc -shared -fPIC) implementing the EGL, GL and Win32 ABIs, logging every call with its arguments, and driven by env-var knobs. egl.py was pointed at them with TENMOL_EGL_LIB/TENMOL_GL_LIB; wgl.py was driven with sys.platform = "win32" and ctypes.WinDLL monkeypatched onto the mock. The happy-path EGL trace, verbatim, is the call sequence a Linux driver will see:
15 EGL scenarios, all as designed:
14 WGL scenarios, all as designed. The happy-path trace shows the invariants:
No *** BUG assertion in the mocks fired: nSize was always 40, sizeof(WNDCLASSW) matched the C struct, WS_VISIBLE was never set, and the lpfnWndProc address round-tripped to DefWindowProcW.

5.4 Structural parity with cgl.py

Real CGLContext (hardware) vs mock-backed EGLContext, same process:

5.5 The acceptance script, run three ways

The script in §6.1 was run against the mock as Linux, against the mock as Windows, and for real on macOS/CGL:
Real macOS run:
The SKIP is the one gap: cgl.py has no .gl accessor. See §9 item 1.

5.6 The stage-2 PyMOL script is itself verified — on macOS

The §6.2 script is platform-neutral (it only touches glcontext.create_context), so it was run unchanged and for real through the CGL backend. This matters: it means a failure on Linux or Windows is a platform failure, not a bug in the acceptance test.
27 hits out of 30 clicks, 13 distinct atoms, cmd.ray + cmd.png fine — spike 04’s result, reproduced through the new platform-neutral entry point.

6. THE VALIDATION COMMANDS

Linux: done and automated — go to §6.3. scripts/test-gl-linux.sh is §6.1 and §6.2 plus the thread and display checks, with the assertions baked in; §2.8 is its output. Windows: still to run — go to §6.4. §6.1 and §6.2 are kept because they are the standalone, copy-pasteable scripts the Windows procedure needs, and because they document what the automated validator asserts.

6.1 Stage 1 (both platforms): pure GL, no PyMOL

Save as check_offscreen_gl.py next to the repo, run with the bridge venv’s python. Exit code 0 = pass. It is deliberately PyMOL-free so it can be run before PyMOL is built.

6.2 Stage 2 (both platforms): PyMOL renders and PICKS through it

This is the real acceptance — it is spike 04 §3.2 ported to the platform-neutral API. A pick returning a real atom name proves SceneRender’s body ran (packages/engine/layer1/SceneRender.cpp:270), which is the entire point of having a context at all.

6.3 Linux: DONE — one command, and it is automated

Superseded by scripts/test-gl-linux.sh, which is the §6.1 and §6.2 stages plus the thread/display checks, in one file, with the assertions baked in. It runs anywhere:
Exit status is the validator’s; every check prints PASS/FAIL with the measured value, and the rendered PNGs land in --out (default .tenmol-gl-out/). .github/workflows/webclient-gl-linux.yml runs the identical script on ubuntu-latest: egl-surfaceless (matrix over ubuntu-22.04 and ubuntu-24.04, --native --quick, ~2 min) and egl-pymol (bootstrap then --native, uploads the render as an artifact). Both were verified locally by running the exact same invocations inside a Linux container before the workflow was committed. Prerequisites, if you are setting a machine up by hand rather than using the script:
Still worth running by hand, on hardware the container cannot provide:
Report back if any of those differ from §2.8: the info() JSON, which eglPlatform won, whether fboEntryPoints was still ARB, and anything GLEW printed on stderr.

6.4 Windows: the manual procedure (NOT AUTOMATED, NOT RUN)

There is no a Windows equivalent of scripts/test-gl-linux.sh (not written) on purpose: writing an unrun automation script for an unrun code path just adds a second unverified artefact. Do this by hand, once, on real Windows, and then automate what you learned. What you need first. A Windows 10/11 box with a vendor GPU driver installed (the whole point of §3.2 is that Microsoft’s fallback is not good enough), logged in interactively — not a service, not a scheduled task. Then build the tree the way .github/workflows/build.yml builds it on Windows (Miniforge, catch2/freetype/glew/glm/libpng/libxml2-devel/ libnetcdf, mmtf-cpp and msgpack-c headers copied into %CONDA_PREFIX%\Library\include, then pip install .), and pip install -e bridge. Save §6.1’s script as check_offscreen_gl.py and §6.2’s as check_pymol_pick.py. Note that §6.2 loads a structure with cmd.fab(...); if the chempy fragment data is missing in your build, point it at test\dat\1tii.pdb instead — that is what the Linux run used. Then, in order:
Report back — the checklist, in this order. Anything that is not PASS is a finding, not a nuisance; the Linux run found three real defects and they were all in the “surely this is fine” category. Paste the info() JSON verbatim into this file as §2.8’s Windows twin, note the driver version, and only then may §4.2 be rewritten.

7. Provenance of every constant

No value in either module was written from memory. Reproduce:
WGL_ARB_create_context tokens were also read (wglext.h:63-83) but are not used: plain wglCreateContext already yields the driver’s highest compatibility profile, which is what PyMOL wants, and using it avoids the two-context dance that wglCreateContextAttribsARB requires.

8. Residual risks

  1. libglvnd hides driver capability (Linux). highest riskdowngraded. dlsym(libGL.so.1, "glGenFramebuffers") succeeds on any glvnd system regardless of the vendor driver, so the ARB-vs-EXT group choice is decided by glvnd. Measured on Mesa/glvnd in §2.8: the stubs and the driver agreed (fboEntryPoints: ARB, FBO complete, PyMOL rendered). The FBO-completeness check remains the backstop. Untested on a non-glvnd stack.
  2. GLEW flavour (Linux). could still failresolved for GLEW 2.2 on Mesa. The built _cmd*.so links libGLEW.so.2.2, libGLX.so.0 and libX11.so.6, and with DISPLAY unset a full start + load + draw wrote nothing to stderr and left use_shaders on (§2.8). packages/engine/layer0/ShaderMgr.cpp:566-573 swallowing GLEW_ERROR_NO_GLX_DISPLAY is doing exactly its job. GLEW < 2.2 is still untested.
  3. 32-bit Windows calling convention. BUG FOUND AND FIXED — this risk was real, and “WinDLL is used everywhere it matters” was wrong. WinDLL gives __stdcall only to functions fetched as DLL exports. Both GLFunctions._bind and _EGL._proc build their pointers from a raw address returned by wglGetProcAddress/eglGetProcAddress, and a raw address carries no calling convention — they used ctypes.CFUNCTYPE, i.e. cdecl, while gl.xml declares every GL entry point APIENTRY and eglplatform.h declares every EGL one EGLAPIENTRY, both __stdcall on Windows. On x86-64 there is only one convention so this is a no-op (which is exactly why it survived review twice); on 32-bit x86 Windows it corrupts the stack on every call. It covered the entire framebuffer group, because opengl32.dll exports only OpenGL 1.1 and everything else necessarily comes from wglGetProcAddress. Fixed by egl._functype(), which selects WINFUNCTYPE on Windows and CFUNCTYPE elsewhere. Still unverified on Windows like the rest of wgl.py, and still worth the py -3.12-32 run in §6.4 step 4 — but the ABI reading is now correct rather than accidentally correct.
  4. Windows services have no window station. CreateWindowExW fails. This is detected and reported with the right advice, but it means the Windows bridge, like the macOS one (spike 04 §7.3), must run as the logged-in user, not as a service.
  5. RDP degrades the GPU stack. A machine that passes on the console may return GDI Generic over RDP. Detected, not worked around.
  6. Pbuffer size is fixed at creation and never resized. Correct as long as nothing is ever drawn to the surface. If some future code path renders to the default framebuffer instead of the FBO, it will silently get the wrong size — do not add such a path.
  7. eglTerminate is called in release(). FIXED — this prediction was correct and it was reproduced (EGL_BAD_DISPLAY) the first time two contexts existed. egl.py now reference-counts the display and only the last context terminates it. wgl.py has no equivalent (each context owns its own window/DC/HGLRC), but the related bug — deleting the FBO against a foreign current context — was ported blind. See §2.7 and §4.2.2.
  8. No CI. FIXED for Linux. These tests turned out to need no GPU and no DRI device at all: Mesa’s EGL_MESA_platform_surfaceless + llvmpipe is enough, which is what makes .github/workflows/webclient-gl-linux.yml possible on a stock ubuntu-latest runner. Windows still needs an interactive session with a vendor ICD and is still ungated; gate it the way spike 04 §8 asks for the macOS picking tests when a runner exists.
  9. llvmpipe is a software rasteriser, and the bridge cannot tell. info()["renderer"] says llvmpipe, but nothing refuses it. On a headless server that is exactly right (it is the reason Linux works at all); on a workstation that was supposed to have a GPU it is a silent 20× performance cliff — a 320×240 cartoon draw took 134 ms here versus single-digit milliseconds on hardware. If Mode P frame budgets are ever missed on Linux, read renderer before anything else. Deliberately not made fatal: a slow bridge beats no bridge.
  10. Only one Mesa version and one architecture were tested (22.3.6, arm64). The CI matrix (ubuntu-22.04 / ubuntu-24.04, x86-64) widens that on the next push and costs ~2 minutes.

9. Changes other owners must make (reported, not applied)

Re-checked against the tree on 2026-08-02. Nine of the fifteen are still open and the commands that prove it are in the table; do not re-derive them.
  1. WP-02 / glcontext/cgl.py — add a gl property (or an equivalent accessor for glClear/glClearColor/glReadPixels) to CGLContext so §6.1 runs its draw+readback check on macOS too. It currently reports [SKIP]. Optionally also add the additive info() keys api/desktopGL/fboEntryPoints/warnings so all three backends return the same shape; egl.py and wgl.py already emit every key cgl.py does.
  2. WP-02 / glcontext/cgl.pycgl.py uses the *EXT framebuffer entry points, but PyMOL binds the default framebuffer with the ARB call glBindFramebuffer (packages/engine/layer0/ShaderMgr.cpp:1829-1831). On Apple’s driver the namespaces are aliased so it works, and spike 04 proved it empirically — but consider switching to the ARB names for consistency with the other two backends.
  3. WP-00 / scripts/doctor.mjs — its “GL context creation” preflight should call tenmol_bridge.glcontext.create_context and print info(), and on failure print egl.probe() / wgl.probe(). Those two functions exist precisely for it and never raise.
  4. WP-00 / scripts/bootstrap.sh — on Linux, check for libEGL.so.1 and libGL.so.1 and print the apt-get/dnf line from §6.3 if missing. PyMOL’s own build already needs libglew-dev.
  5. code-ownership.md:149 and :1256-1257 — “Linux (EGL surfaceless / GLX pbuffer) and Windows (WGL + hidden window) are a separate spike” and open question 2 are resolved: funded and implemented, pending hardware validation. Also :746 and :835 still say glcontext.py; it is a package, glcontext/, with four modules.
  6. code-ownership.md:841-842 — “glcontext.py is platform-dispatched with only the CGL implementation present; other platforms raise a typed NoOffscreenGL” is superseded: all three implementations are present.
  7. 04-picking.md:600-601 — the “Linux/Windows parity is a separate spike” bullet can point here.
  8. WP-04 / raster.pyDONE by WP-04. render/framestream.py:PixelReadback already prefers ctx.gl; the Linux run confirms it (PixelReadback source: context.gl). GLFunctions has been given glPixelStorei and glReadBuffer so the two optional lookups PixelReadback._resolve makes now succeed on Linux and Windows instead of silently degrading.
  9. CI owner — mark the platform tests with the existing gl pytest marker (packages/bridge/pyproject.toml), which already says “needs a real offscreen GL context”.
  10. WP-02 / glcontext/cgl.pycheck for defect D-EGL-2 (§2.7). If CGLContext.release() deletes its FBO/renderbuffers without first calling CGLSetCurrentContext(self), it has the same bug: the deletes land in whatever context is current. Measured on EGL, fixed in egl.py and wgl.py; cgl.py is not mine to touch.
  11. WP-02 / glcontext/__init__.py — the Context protocol should grow is_current() and release_current(). egl.py and wgl.py both have them now; they are what makes a thread hand-off legal (EGL returns EGL_BAD_ACCESS without them, measured), and callers should be able to rely on them existing.
  12. WP-00 / scripts/bootstrap.sh:156-158 — the Linux branch still prints “offscreen GL on Linux (EGL surfaceless) is NOT implemented yet; the bridge will start but the viewport will be degraded”. That is now false. It should check for libEGL.so.1/libGL.so.1 and print the apt-get/dnf line from §6.3 if they are missing, and otherwise say nothing.
  13. WP-00 / scripts/doctor.mjs — same as item 3, and it can now also suggest bash scripts/test-gl-linux.sh --quick when the Linux GL preflight fails.
  14. Screenshots ownerdocs/screenshots/ has no Linux evidence. The artefact from .github/workflows/webclient-gl-linux.yml (linux-egl-render) or a local .tenmol-gl-out/linux-egl-pymol.png is a ready-made one.
  15. .gitignore owner — add /.tenmol-gl-out/. That is scripts/test-gl-linux.sh’s default --out directory, and it is currently not ignored, so a developer who runs the script with defaults dirties the working tree with two PNGs. Verified: git check-ignore -v .tenmol-gl-out matches nothing, while .deps and packages/bridge/.venv are correctly ignored. CI is unaffected (the workflow passes --out "$RUNNER_TEMP/gl-out"), and so is the egl-pymol job’s “the build did not dirty the tree” gate, which runs before the acceptance step. The script was left with its repo-local default on purpose — the images are the evidence and developers should find them — so the fix belongs in .gitignore, which is not this spike’s file to edit.

10. Does the backend still need a GL context at all?

The wave’s north star: if Mode G becomes complete and picking moves client-side, the backend needs no GL context, and egl.py/wgl.py become optional rather than load-bearing. This section answers “what would still break” with measurements, not opinion.

10.1 The experiment

The same script, the same structure (packages/engine/test/dat/1tii.pdb, 5,684 atoms, cartoon, 320×240), run twice in the same Linux container:
  • GLegl.create_context() first, then invocation.options.no_gui = 0 (⇒ pmgui = 1G->HaveGUI = 1, packages/engine/layer5/PyMOL.cpp:2248).
  • NO-GL — no EGL context is created at all, no_gui = 1 (⇒ HaveGUI = 0, which makes the entire body of SceneRender a no-op, packages/engine/layer1/SceneRender.cpp:270).

10.2 What that means, feature by feature

10.3 The honest verdict

A GL-free backend is reachable, and picking is the only real blocker.
  • Mode G completeness (defect D6) removes the rendering need for GL. cmd.ray covers high-quality stills with no context. cmd.png/cmd.draw/cmd.mpng all keep working via the ray fallback — slower, and with different semantics that must be documented, but not broken.
  • Client-side picking (per-vertex pick data shipped with the geometry) removes the last need. Spike 04 §6 argues it is “strictly worse” and enumerates the cost — 16 rep-specific pick sources and all the click actions, not just selection. That argument stands; this section only establishes that it is the only thing standing between the product and a GL-free backend, and that everything else has been measured rather than assumed.
  • Until both land, egl.py and wgl.py are load-bearing on Linux and Windows, and the silent-failure mode is the dangerous part: a Linux bridge with no libEGL starts fine, logs one line, renders nothing in Mode P and picks nothing without ever raising.
What this work changes regardless of the north star: Linux no longer needs EGL hardware — it needs Mesa, which every distro ships and which needs no GPU, no DRI node and no X server. That is a much cheaper dependency than “a GPU and a display server”, and it is why the CI workflow can exist at all. Windows is the platform that still genuinely requires a real vendor ICD and an interactive session, and it is also the one still unverified.