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_surfacelessgives PyMOL a desktop OpenGL 4.5 (Compatibility Profile) context with no GPU, no/dev/dri, noDISPLAY, 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 ofpackages/engine/test/dat/1tii.pdbwithglGetError() == 0and 13,578/76,800 non-black pixels, and the backend pick pass selected an atom on 3 of 3 clicks. GLEW printed nothing anduse_shadersstayedon. 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 NVIDIAEGL_EXT_platform_deviceon 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--quickfor EGL only in ~1 min, or--nativeon an actual Linux box. CI runs the identical validator onubuntu-latestvia.github/workflows/webclient-gl-linux.yml.Windows offscreen GL: UNVERIFIED.
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-wgl.pyHAS NEVER EXECUTED.__stdcallbug 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 adebian:bookworm-slimcontainer (linux/arm64) with Mesa 22.3.6, no GPU, no/dev/dri, noDISPLAY, no X server at all:EGL_MESA_platform_surfacelessgave a desktop OpenGL 4.5 (Compatibility Profile) llvmpipe context, one FBO bound before the first draw,glReadPixelsreturned the exact colour cleared, PyMOL rendered a 5,684-atom cartoon ofpackages/engine/test/dat/1tii.pdbwithglGetError() == 0, and the backend pick pass selected an atom on 3 of 3 clicks. Full transcript in §2.8. Reproduce withbash scripts/test-gl-linux.sh; CI runs the identical validator onubuntu-latestvia.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-__stdcallmismatch on every entry point resolved throughwglGetProcAddress— invisible on x64, stack-corrupting on win32, and it covered the whole framebuffer group. See §8.3.
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 ownsglcontext/__init__.py and glcontext/cgl.py:
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:
- Contexts are per-thread.
eglMakeCurrentandwglMakeCurrentare per-thread exactly likeCGLSetCurrentContext. Create on the engine thread.info()["ownerThread"]records the thread that last made it current. - Never regenerate the FBO on resize.
check_gl_stereo_capablelatchesG->ShaderMgr->defaultBackbuffer.framebufferfromGL_FRAMEBUFFER_BINDINGon 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 survivessh, 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 withTENMOL_EGL_PLATFORM=surfaceless|device|default.
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), perEGL_EXT_client_extensions). This is the Mesa path: llvmpipe, iris, radeonsi, zink. No DRM node, no X, no seat.EGL_EXT_platform_device(0x313F) —eglQueryDevicesEXTtheneglGetPlatformDisplayEXT(0x313F, device, NULL). This is the NVIDIA proprietary headless path. Pick a specific GPU withTENMOL_EGL_DEVICE=<index>; the device is labelled fromeglQueryDeviceStringEXT(EGL_DRM_RENDER_NODE_FILE_EXT)and surfaces ininfo()["eglDevice"].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.
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:
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).
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):
SingletonPyMOL().start() + load + draw with DISPLAY unset, with
stderr captured separately:
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:- The container really ran the working-tree file, not a stale copy baked into an image layer:
sha256sum /src/bridge/tenmol_bridge/glcontext/egl.pyinside 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.
_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 topackages/, 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 aImplemented (§3.4citation inglcontext/egl.py, this is not the section you want. Two comments there cite§3.4for findings that live elsewhere, and the numbering was never corrected:egl.py:264(theEGL_BAD_DISPLAYrefcount,_display_refs) means §2.7 D-EGL-1, andegl.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.
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
surfacelessand thedeviceladders. - The ARB framebuffer group resolves, the FBO is complete, and the name survives four resizes.
- GLEW resolves under EGL and prints nothing;
use_shadersstayson. - PyMOL renders,
glGetError()is 0, andrender/framestream.py:PixelReadbackreads back throughctx.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:
fboEntryPointswasARBand 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.
- Only llvmpipe was exercised. No NVIDIA proprietary
EGL_EXT_platform_device, no radeonsi/iris, nozink. Thedeviceladder 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/wglCreateContextsucceed on a real ICD at all. - That the
gdi-genericdetection fires when — and only when — it should. - That
wglGetProcAddressresolves the ARB framebuffer group under a legacywglCreateContextcontext (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 hiddenCS_OWNDCwindow and no message pump. - That
DestroyWindow+UnregisterClassWat shutdown leave nothing behind. - That PyMOL picks through it.
DescribePixelFormat’s return value was ignored. On failure it leaves the descriptor untouched, sodwFlagswould read back as0— which hasPFD_GENERIC_FORMATclear, i.e. the Microsoft GDI Generic software rasteriser would have been misreported as a hardware ICD and thegdi-genericguard, the entire reason §3.2 exists, would never have fired. Now a hardNoOffscreenGL(reason="describe-pixel-format-failed").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.RegisterClassWtreatedERROR_CLASS_ALREADY_EXISTSas fatal. A hard kill betweenCreateWindowExWandUnregisterClassWleaves the atom registered; the class is then exactly what we want, so it is now a warning andUnregisterClassWis skipped for a class we did not register.create_contexthonouredTENMOL_WGL_BACKEND=anglebefore the platform check, so on macOS/Linux it traded a clear “wrong platform” error for an obscure “cannot load libEGL”.DestroyWindowhas Win32 thread affinity that is stricter thanwglMakeCurrent’s — it only works on the thread that calledCreateWindowExW.creator_threadis now recorded separately fromowner_thread(which follows the GL context) and a mismatch atrelease()appends a warning naming both threads.- Every entry point resolved through
wglGetProcAddresswas called through a cdecl pointer althoughAPIENTRYis__stdcall(GLFunctions._bind, shared withegl.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, sinceopengl32.dllexports only OpenGL 1.1. Fixed byegl._functype()(WINFUNCTYPEon Windows,CFUNCTYPEelsewhere); 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.
packages/bridge/.venv/bin/python):
wc.lpfnWndProc = ctypes.cast(user32.DefWindowProcW, c_void_p).value— storing the realDefWindowProcWaddress rather than a Python callback is what removes the callback-lifetime hazard entirely.wcbeing a local is fine:RegisterClassWcopies the class.CS_OWNDC+ReleaseDC— withCS_OWNDCthe DC belongs to the window andReleaseDCis a no-op, which is harmless and keeps the teardown symmetric.- No message pump.
CreateWindowExW/DestroyWindowdispatchWM_CREATE/WM_DESTROYsynchronously toDefWindowProcW; a never-shown DC holder needs nothing else. wglGetProcAddresssentinel list(1, 2, 3, 0xFFFFFFFF, 0xFFFFFFFFFFFFFFFF, -1)— withrestype = c_void_pthe negative entries are unreachable, but they cost nothing and document the quirk.ATOM = c_uint16forRegisterClassW’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
libEGLand noctypes.WinDLL. - Every
ctypesprototype has the arity, argument order and argument types the EGL/WGL ABI specifies, and every enum matches the Khronos/mingw-w64 headers (§7 provenance). PIXELFORMATDESCRIPTORmarshals to 40 bytes andWNDCLASSWto 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/opengl32shared objects. - All 29 documented failure
reasons fire, and none of them leaks an untyped exception. - Platform dispatch selects
egl/wgl/cglcorrectly under a monkeypatchedsys.platform. - The real macOS CGL path still works after these changes (
Apple M4 Max,2.1 Metal - 89.4).
- 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
__stdcallvscdeclgenuinely differs — that is why every Win32 DLL is opened withctypes.WinDLL, and whyegl.pyalso usesWinDLLwhensys.platformis Windows. - That a real Windows ICD accepts these pixel-format ladders, that
wglGetProcAddressresolves 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 eglQueryDevicesEXTon the proprietary Linux driver. Thedeviceladder was exercised (§2.8) but only against Mesa’s softwareEGLDevice._load_gl()doesdlsymonlibGL.so.1for 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
Theno-windllreason is a bug this test found:ctypes.WinDLLdoes not exist off-Windows, so referencing it directly leaked a rawAttributeErrorinstead of a typedNoOffscreenGL. 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:
*** 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: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 touchesglcontext.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.
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 ascheck_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 provesSceneRender’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 byscripts/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:
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:
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 ofscripts/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:
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
- libglvnd hides driver capability (Linux).
highest risk— downgraded.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. - GLEW flavour (Linux).
could still fail— resolved for GLEW 2.2 on Mesa. The built_cmd*.solinkslibGLEW.so.2.2,libGLX.so.0andlibX11.so.6, and withDISPLAYunset a full start + load + draw wrote nothing to stderr and leftuse_shaderson(§2.8).packages/engine/layer0/ShaderMgr.cpp:566-573swallowingGLEW_ERROR_NO_GLX_DISPLAYis doing exactly its job. GLEW < 2.2 is still untested. 32-bit Windows calling convention.BUG FOUND AND FIXED — this risk was real, and “WinDLLis used everywhere it matters” was wrong.WinDLLgives__stdcallonly to functions fetched as DLL exports. BothGLFunctions._bindand_EGL._procbuild their pointers from a raw address returned bywglGetProcAddress/eglGetProcAddress, and a raw address carries no calling convention — they usedctypes.CFUNCTYPE, i.e. cdecl, whilegl.xmldeclares every GL entry pointAPIENTRYandeglplatform.hdeclares every EGL oneEGLAPIENTRY, both__stdcallon 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, becauseopengl32.dllexports only OpenGL 1.1 and everything else necessarily comes fromwglGetProcAddress. Fixed byegl._functype(), which selectsWINFUNCTYPEon Windows andCFUNCTYPEelsewhere. Still unverified on Windows like the rest ofwgl.py, and still worth thepy -3.12-32run in §6.4 step 4 — but the ABI reading is now correct rather than accidentally correct.- Windows services have no window station.
CreateWindowExWfails. 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. - RDP degrades the GPU stack. A machine that passes on the console may return GDI Generic over RDP. Detected, not worked around.
- 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.
FIXED — this prediction was correct and it was reproduced (eglTerminateis called inrelease().EGL_BAD_DISPLAY) the first time two contexts existed.egl.pynow reference-counts the display and only the last context terminates it.wgl.pyhas 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.No CI.FIXED for Linux. These tests turned out to need no GPU and no DRI device at all: Mesa’sEGL_MESA_platform_surfaceless+ llvmpipe is enough, which is what makes.github/workflows/webclient-gl-linux.ymlpossible on a stockubuntu-latestrunner. 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.- llvmpipe is a software rasteriser, and the bridge cannot tell.
info()["renderer"]saysllvmpipe, 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, readrendererbefore anything else. Deliberately not made fatal: a slow bridge beats no bridge. - 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.
- WP-02 /
glcontext/cgl.py— add aglproperty (or an equivalent accessor forglClear/glClearColor/glReadPixels) toCGLContextso §6.1 runs its draw+readback check on macOS too. It currently reports[SKIP]. Optionally also add the additiveinfo()keysapi/desktopGL/fboEntryPoints/warningsso all three backends return the same shape;egl.pyandwgl.pyalready emit every keycgl.pydoes. - WP-02 /
glcontext/cgl.py—cgl.pyuses the*EXTframebuffer entry points, but PyMOL binds the default framebuffer with the ARB callglBindFramebuffer(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. - WP-00 /
scripts/doctor.mjs— its “GL context creation” preflight should calltenmol_bridge.glcontext.create_contextand printinfo(), and on failure printegl.probe()/wgl.probe(). Those two functions exist precisely for it and never raise. - WP-00 /
scripts/bootstrap.sh— on Linux, check forlibEGL.so.1andlibGL.so.1and print theapt-get/dnfline from §6.3 if missing. PyMOL’s own build already needslibglew-dev. code-ownership.md:149and: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:746and:835still sayglcontext.py; it is a package,glcontext/, with four modules.code-ownership.md:841-842— “glcontext.pyis platform-dispatched with only the CGL implementation present; other platforms raise a typedNoOffscreenGL” is superseded: all three implementations are present.04-picking.md:600-601— the “Linux/Windows parity is a separate spike” bullet can point here.WP-04 /— DONE by WP-04.raster.pyrender/framestream.py:PixelReadbackalready prefersctx.gl; the Linux run confirms it (PixelReadback source: context.gl).GLFunctionshas been givenglPixelStoreiandglReadBufferso the two optional lookupsPixelReadback._resolvemakes now succeed on Linux and Windows instead of silently degrading.- CI owner — mark the platform tests with the existing
glpytest marker (packages/bridge/pyproject.toml), which already says “needs a real offscreen GL context”. - WP-02 /
glcontext/cgl.py— check for defect D-EGL-2 (§2.7). IfCGLContext.release()deletes its FBO/renderbuffers without first callingCGLSetCurrentContext(self), it has the same bug: the deletes land in whatever context is current. Measured on EGL, fixed inegl.pyandwgl.py;cgl.pyis not mine to touch. - WP-02 /
glcontext/__init__.py— theContextprotocol should growis_current()andrelease_current().egl.pyandwgl.pyboth have them now; they are what makes a thread hand-off legal (EGL returnsEGL_BAD_ACCESSwithout them, measured), and callers should be able to rely on them existing. - 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 forlibEGL.so.1/libGL.so.1and print theapt-get/dnfline from §6.3 if they are missing, and otherwise say nothing. - WP-00 /
scripts/doctor.mjs— same as item 3, and it can now also suggestbash scripts/test-gl-linux.sh --quickwhen the Linux GL preflight fails. - Screenshots owner —
docs/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.pngis a ready-made one. .gitignoreowner — add/.tenmol-gl-out/. That isscripts/test-gl-linux.sh’s default--outdirectory, 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-outmatches nothing, while.depsandpackages/bridge/.venvare correctly ignored. CI is unaffected (the workflow passes--out "$RUNNER_TEMP/gl-out"), and so is theegl-pymoljob’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, andegl.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:
- GL —
egl.create_context()first, theninvocation.options.no_gui = 0(⇒pmgui = 1⇒G->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 ofSceneRendera 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.raycovers high-quality stills with no context.cmd.png/cmd.draw/cmd.mpngall 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.pyandwgl.pyare load-bearing on Linux and Windows, and the silent-failure mode is the dangerous part: a Linux bridge with nolibEGLstarts fine, logs one line, renders nothing in Mode P and picks nothing without ever raising.