Repository navigation
feat(sokol): GLCORE/GLES3 PBO readback + publishFrame (#122 slice 1) - #124
Conversation
PR SummaryMedium Risk Overview The sokol desktop/mobile templates now include a Updates tests to assert the new codegen (gating, ordering, teardown) and adds regression checks that raylib’s existing readback path and non-sokol/non-loop backends remain unaffected. Reviewed by Cursor Bugbot for commit b2a3977. Bugbot is set up for automated code reviews on this repo. Configure here. |
There was a problem hiding this comment.
Code Review
This pull request introduces Sokol-specific PBO readback for preview mode, implementing a 3-deep PBO ring with asynchronous DMA for OpenGL-based platforms. The changes include new template blocks for initialization, per-frame readback, and cleanup, as well as comprehensive tests for the generated code. Reviewers identified a potential compatibility issue with glMapBuffer on Android GLES 3.0, a logic bug where dimension state is updated even if buffer allocation fails, and suggested checking the return value of glUnmapBuffer to prevent publishing corrupt data.
| \\const _SokolPreviewGl = if (_sokol_preview_gl_enabled) struct { | ||
| \\ pub const PIXEL_PACK_BUFFER: c_uint = 0x88EB; | ||
| \\ pub const STREAM_READ: c_uint = 0x88E1; | ||
| \\ pub const READ_ONLY: c_uint = 0x88B8; | ||
| \\ pub const PACK_ALIGNMENT: c_uint = 0x0D05; | ||
| \\ pub const RGBA: c_uint = 0x1908; | ||
| \\ pub const UNSIGNED_BYTE: c_uint = 0x1401; | ||
| \\ pub const pixelStorei = @extern( | ||
| \\ *const fn (pname: c_uint, param: c_int) callconv(.c) void, | ||
| \\ .{ .name = "glPixelStorei" }, | ||
| \\ ); | ||
| \\ pub const genBuffers = @extern( | ||
| \\ *const fn (n: c_int, buffers: [*]c_uint) callconv(.c) void, | ||
| \\ .{ .name = "glGenBuffers" }, | ||
| \\ ); | ||
| \\ pub const deleteBuffers = @extern( | ||
| \\ *const fn (n: c_int, buffers: [*]const c_uint) callconv(.c) void, | ||
| \\ .{ .name = "glDeleteBuffers" }, | ||
| \\ ); | ||
| \\ pub const bindBuffer = @extern( | ||
| \\ *const fn (target: c_uint, buffer: c_uint) callconv(.c) void, | ||
| \\ .{ .name = "glBindBuffer" }, | ||
| \\ ); | ||
| \\ pub const bufferData = @extern( | ||
| \\ *const fn (target: c_uint, size: isize, data: ?*const anyopaque, usage: c_uint) callconv(.c) void, | ||
| \\ .{ .name = "glBufferData" }, | ||
| \\ ); | ||
| \\ pub const readPixels = @extern( | ||
| \\ *const fn (x: c_int, y: c_int, w: c_int, h: c_int, fmt: c_uint, ty: c_uint, data: ?*anyopaque) callconv(.c) void, | ||
| \\ .{ .name = "glReadPixels" }, | ||
| \\ ); | ||
| \\ pub const mapBuffer = @extern( | ||
| \\ *const fn (target: c_uint, access: c_uint) callconv(.c) ?*anyopaque, | ||
| \\ .{ .name = "glMapBuffer" }, | ||
| \\ ); | ||
| \\ pub const unmapBuffer = @extern( | ||
| \\ *const fn (target: c_uint) callconv(.c) u8, | ||
| \\ .{ .name = "glUnmapBuffer" }, | ||
| \\ ); | ||
| \\} else struct {}; |
There was a problem hiding this comment.
The use of glMapBuffer via @extern may cause issues on Android (GLES 3.0). While glMapBuffer is standard in Desktop OpenGL, it is not part of the GLES 3.0 core specification (which uses glMapBufferRange). Many Android drivers provide it as an extension, but it is not guaranteed to be present as a linkable symbol.
Since this PR targets GLES3 for Android, consider using glMapBufferRange or verifying that glMapBuffer is indeed available on the target Android environments. If you switch to glMapBufferRange, you will also need the GL_MAP_READ_BIT constant (0x0001).
There was a problem hiding this comment.
Applied — switched extern to glMapBufferRange (universal across GL 3.0+ and GLES 3.0+) with a new MAP_READ_BIT = 0x0001 constant. Avoids the GLES3 GL_OES_mapbuffer dependency entirely. See e75ed83.
| \\ if (_preview_pixel_buf.len != _needed_bytes) { | ||
| \\ if (_preview_pixel_buf.len != 0) _preview_allocator.free(_preview_pixel_buf); | ||
| \\ _preview_pixel_buf = _preview_allocator.alloc(u8, _needed_bytes) catch &[_]u8{}; | ||
| \\ } | ||
| \\ _preview_last_w = _sw; | ||
| \\ _preview_last_h = _sh; | ||
| \\ _preview_frame_idx = 0; | ||
| \\ } |
There was a problem hiding this comment.
If the CPU staging buffer allocation fails (line 1348), the preview readback is effectively disabled until the next window resize. This happens because _preview_last_w and _preview_last_h are updated (lines 1350-1351) even if the allocation fails, preventing the resize block from re-running in subsequent frames.
To make the readback more resilient to transient memory pressure, consider only updating the 'last' dimensions if the allocation succeeds.
if (_preview_pixel_buf.len != _needed_bytes) {
if (_preview_pixel_buf.len != 0) _preview_allocator.free(_preview_pixel_buf);
_preview_pixel_buf = _preview_allocator.alloc(u8, _needed_bytes) catch &[_]u8{};
}
if (_preview_pixel_buf.len == _needed_bytes) {
_preview_last_w = _sw;
_preview_last_h = _sh;
_preview_frame_idx = 0;
}
There was a problem hiding this comment.
Applied — _preview_last_w/h (and _preview_frame_idx = 0) now only commit when the alloc succeeded (_preview_pixel_buf.len == _needed_bytes). A transient OOM heals on the next frame instead of stranding the readback. See e75ed83.
| \\ const _dst_row = _preview_pixel_buf.ptr + (@as(usize, _y) * _row_bytes); | ||
| \\ @memcpy(_dst_row[0.._row_bytes], _src_row[0.._row_bytes]); | ||
| \\ } | ||
| \\ _ = _SokolPreviewGl.unmapBuffer(_SokolPreviewGl.PIXEL_PACK_BUFFER); |
There was a problem hiding this comment.
The return value of glUnmapBuffer is ignored. While usually safe, glUnmapBuffer returns GL_FALSE if the buffer content became corrupt during the mapping (e.g., due to a screen resolution change or context loss). In such cases, the data in _preview_pixel_buf might be invalid and should ideally not be published.
There was a problem hiding this comment.
Applied — branched on glUnmapBuffer return; publishFrame only fires when it returns non-zero. Memcpy still runs with potentially-garbage data, but the editor won't see a torn frame. See e75ed83.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit dcaa08c. Configure here.
Addresses three bot findings on PR #124: **gemini high + cursor medium** — `glMapBuffer` is not in core GLES3 (only `GL_OES_mapbuffer`), so Android GLES3 builds covered by the `_sokol_preview_gl_enabled` gate (`os.tag == .linux` which includes Android) would fail to link. Switched the extern to `glMapBufferRange` (core in GL 3.0+ AND GLES 3.0+), the universal portable choice. New `MAP_READ_BIT = 0x0001` constant replaces `READ_ONLY = 0x88B8`. **gemini medium** — CPU staging buffer alloc failure was non-recoverable: `_preview_last_w/h` were updated unconditionally, so subsequent frames saw matching dims, skipped the resize block, and hit the `_preview_pixel_buf.len != _needed_bytes` guard's `break :_readback` forever. Now `_preview_last_w/h` and `_preview_frame_idx = 0` only commit if the alloc actually produced a buffer of the right size, so a transient OOM heals on the next frame. **gemini medium** — `glUnmapBuffer` returns `GL_FALSE` (0) when the buffer contents became corrupt during the map (context loss, screen- resolution change racing the readback). Now branches on the return: the memcpy already ran with potentially-garbage data, but we skip `publishFrame` so the editor doesn't display a torn frame. `zig build test --summary all` — 297/301 pass (4 pre-existing skips, unchanged from baseline). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extends the per-frame PBO readback that landed for raylib in #120 (labelle-engine#544) to sokol-backend games on GL paths — default sokol-zig picks GLCORE on Linux desktop, GLES3 on Android/web. Metal (Darwin) and D3D11 (Windows) are deferred to slices 2 and 3 of #122 and emit no-op fallbacks here. What lands in the generated main: - Module-scope `@extern` decls for the PBO entry points (glReadPixels / glGenBuffers / glBindBuffer / glBufferData / glMapBuffer / glUnmapBuffer / glPixelStorei / glDeleteBuffers) + GL 2.1+ constants, wrapped in a `_SokolPreviewGl = if (_sokol_preview_gl_enabled) struct { ... } else struct {}` namespace. The `else struct {}` branch declares no symbols, so a Metal / D3D11 sokol build never references unresolved GL externs at link time — sokol_clib on Linux already links libGL via the upstream sokol-zig build, so the GL path resolves transitively without needing the per-OS linker shim raylib's PR added to build_zig.txt. - Module-scope PBO state (`_preview_pbos`, frame counter, last published dims, `_preview_pixel_buf`, `_preview_allocator`). Sokol's `init` / `frame` / `cleanup` callbacks have no shared local stack, so the locals raylib's main() uses for the same purpose live at file scope here. `_preview_allocator` is stashed in the `init` callback so the frame + cleanup callbacks can grow / free the CPU staging buffer without reaching back through `g`. - Per-frame block between `window.flushScene()` / GUI draw and `window.endFrame()`: bind pbo[N%3] -> glReadPixels (async DMA); from frame 2 onwards glMapBuffer pbo[(N-2)%3] -> memcpy with bottom-up -> top-down row flip -> `Preview.publishFrame`. Same 3-deep ring + 2-frame priming gap as raylib's loop (and the imgui-preview-poc reference). - Resize handling: dims read every frame via `window.width()` / `window.height()`. On change, re-issue `Preview.beginFrameStream` (idempotent — engine tears down the old SHM ring), re-glBufferData the PBOs, re-alloc the CPU buffer, reset the priming counter. - Cleanup callback: `endFrameStream` + `glDeleteBuffers` + free the CPU buffer BEFORE `_p.sendBye(.normal)` so SHM / PBO teardown runs while the engine still holds the socket open (LIFO with the graceful protocol close — same shape as raylib's `defer` stack). Plumbing changes: - New `{{preview_readback}}` placeholder in `backends/sokol/templates/desktop.txt` + `mobile.txt`, sitting right before `window.endFrame()`. `window.endFrame` internally calls `sg.endPass(); sg.commit();` — glReadPixels on GL_BACK must run before the swap, mirroring raylib's pre-endDrawing placement. - `PREVIEW_READBACK_HELPERS_SOKOL` (module-scope `_sokol_preview_gl_enabled` + PBO state + `_SokolPreviewGl` namespace) + `PREVIEW_READBACK_INIT_SOKOL` (allocator stash) + `PREVIEW_READBACK_FRAME_SOKOL` (per-frame body) + `PREVIEW_READBACK_CLEANUP_SOKOL` (PBO teardown) snippets in `src/main_zig.zig`. Concatenated only when `cfg.backend == .sokol` so wasm-raylib (which also takes the callback branch but isn't sokol) keeps emitting just the control-plane wiring. The `_sokol_preview_gl_enabled` comptime flag is a single-switch on `builtin.os.tag` (`.linux` -> true, else false). Android in Zig is `os.tag == .linux` with `abi == .android`, so the `.linux` arm covers both desktop GLCORE and Android GLES3. Emscripten / wasm32 is left as a no-op for slice 1 — WebGL2's PBO support is limited and slice 1 doesn't ship a wasm story. Tests: - New "sokol desktop emits PBO readback + publishFrame between flushScene and endFrame" asserts the GL externs, the `_sokol_preview_gl_enabled` comptime gate + `else struct {}` branch, frame counters, beginFrameStream / publishFrame ordering, source-order placement relative to `window.endFrame()`, cleanup-teardown-before-bye ordering, and that the generated source parses cleanly via `std.zig.Ast.parse`. - New "raylib desktop still emits its own PBO readback after the sokol slice lands" regression lock — asserts the raylib codepath is untouched (no sokol identifiers leak into raylib output, and vice-versa). - New "non-loop, non-sokol backends do not pull in any preview pixel publish path" locks the cfg.backend gate so sdl / bgfx / wgpu (loop branch) and wasm-raylib (callback branch, non-sokol) all regress loudly if the conditional ever drifts. - Full suite: 297/301 pass (4 pre-existing skipped — Zig 0.16 preview migration stubs), up from 294/298 (3 new tests, all pass). - End-to-end: regenerated `examples/sokol/.labelle/sokol_desktop` + `examples/sokol_imgui/.labelle/sokol_desktop` via the assembler binary and built both with `zig build` (default Metal backend on macOS — comptime gate elides the GL block) and `zig build -Doptimize=ReleaseSafe` — both link cleanly with the Metal framework, no undefined-symbol errors. Out of scope (explicit, separate slices of #122): - Metal / IOSurface readback (slice 2). - D3D11 staging-texture readback (slice 3). - macOS sokol pixel publish (depends on slice 2's IOSurface path; this PR's Metal build is a no-op fallback on macOS). - WebGL2 / emscripten preview (limited PBO support, follow-up). - Other loop backends (sdl / bgfx / wgpu) — separate tickets. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Addresses three bot findings on PR #124: **gemini high + cursor medium** — `glMapBuffer` is not in core GLES3 (only `GL_OES_mapbuffer`), so Android GLES3 builds covered by the `_sokol_preview_gl_enabled` gate (`os.tag == .linux` which includes Android) would fail to link. Switched the extern to `glMapBufferRange` (core in GL 3.0+ AND GLES 3.0+), the universal portable choice. New `MAP_READ_BIT = 0x0001` constant replaces `READ_ONLY = 0x88B8`. **gemini medium** — CPU staging buffer alloc failure was non-recoverable: `_preview_last_w/h` were updated unconditionally, so subsequent frames saw matching dims, skipped the resize block, and hit the `_preview_pixel_buf.len != _needed_bytes` guard's `break :_readback` forever. Now `_preview_last_w/h` and `_preview_frame_idx = 0` only commit if the alloc actually produced a buffer of the right size, so a transient OOM heals on the next frame. **gemini medium** — `glUnmapBuffer` returns `GL_FALSE` (0) when the buffer contents became corrupt during the map (context loss, screen- resolution change racing the readback). Now branches on the return: the memcpy already ran with potentially-garbage data, but we skip `publishFrame` so the editor doesn't display a torn frame. `zig build test --summary all` — 297/301 pass (4 pre-existing skips, unchanged from baseline). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
e75ed83 to
b2a3977
Compare
Slice 2 of labelle-assembler#122. Adds the Windows sokol-on-D3D11 preview readback path, matching the protocol shape and 3-buffer ring + 2-frame priming gap that slice 1 (#124) ships for sokol-on-GL. Sokol back-buffer access goes through the public sokol-zig API (`sg_d3d11_device`, `sg_d3d11_device_context`, `sapp_d3d11_get_swap_chain`) plus COM vtable dispatch on `IDXGISwapChain::GetBuffer`. Swizzles BGRA→RGBA during the memcpy because the DXGI swapchain defaults to `DXGI_FORMAT_B8G8R8A8_UNORM` while the SHM consumer expects RGBA. The new helper/init/frame/cleanup blocks live behind a `_sokol_preview_d3d11_enabled` comptime gate (`builtin.os.tag == .windows`) so the COM-style externs never reach the link line on Linux/Darwin builds. The GL block from #124 stays unchanged. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Slice 2 of the sokol PIE viewport work (after #124's GL slice). Emits a Metal/IOSurface readback path alongside the GL block — mutually exclusive gates (_sokol_preview_gl_enabled = .linux, _sokol_preview_metal_enabled = .macos/.ios) keep exactly one path active per target, with zero linker symbols from the other. The Metal path: - libobjc msgSend bridging (selectors + typed @extern wrappers cached in a comptime-gated struct namespace so non-Darwin builds carry no objc references) - blit copyFromTexture from the swapchain drawable to a managed-storage MTLTexture, synchronizeResource (macOS only — iOS shared storage no-ops), waitUntilCompleted, getBytes - BGRA8 -> RGBA8 swizzle, then Preview.publishFrameIOSurface (labelle-engine#547) which swizzles back into the IOSurface ring - placed AFTER window.endFrame() (post sg.commit) so the swapchain texture has the rendered contents — GL block stays pre-endFrame - new {{preview_readback_post}} template slot in both desktop.txt and mobile.txt - IOSurface + CoreFoundation frameworks added to the sokol exe link line on macos/ios Engine API: sufficient as-is for Path B; the double-swizzle (producer BGRA->RGBA, engine RGBA->BGRA) is a known wart that a future publishFrameIOSurfaceBgra would eliminate. Stub: window.metalCurrentDrawable currently returns null. The pinned sokol-zig replaced the legacy sapp_metal_get_current_drawable accessor with a struct-returning sapp_get_swapchain() that calls [CAMetalLayer nextDrawable] on every call — invoking it from our readback would acquire a fresh empty drawable, not the one sokol rendered into. All wiring is in place; flipping the stub once the upstream API gap is resolved is a single-line change. Readback no-ops cleanly until then. Tests: - new "sokol emits Metal/IOSurface readback block alongside GL block": asserts the gate, namespace, selectors, IOSurface lifecycle, source- order (begin before publish, readback after endFrame, cleanup before bye), AST parse cleanliness - extended sokol GL test to keep passing unchanged - extended raylib + non-sokol leak-through tests to also lock out the Metal block Verified locally: zig build + zig build test --summary all (300/304 pass, 4 pre-existing skips). zig build of the regenerated examples/sokol/.labelle/sokol_desktop on macOS arm64 links cleanly against IOSurface, CoreFoundation, Metal, AppKit, libobjc. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(sokol): Metal/IOSurface producer for macOS (#125) Slice 2 of the sokol PIE viewport work (after #124's GL slice). Emits a Metal/IOSurface readback path alongside the GL block — mutually exclusive gates (_sokol_preview_gl_enabled = .linux, _sokol_preview_metal_enabled = .macos/.ios) keep exactly one path active per target, with zero linker symbols from the other. The Metal path: - libobjc msgSend bridging (selectors + typed @extern wrappers cached in a comptime-gated struct namespace so non-Darwin builds carry no objc references) - blit copyFromTexture from the swapchain drawable to a managed-storage MTLTexture, synchronizeResource (macOS only — iOS shared storage no-ops), waitUntilCompleted, getBytes - BGRA8 -> RGBA8 swizzle, then Preview.publishFrameIOSurface (labelle-engine#547) which swizzles back into the IOSurface ring - placed AFTER window.endFrame() (post sg.commit) so the swapchain texture has the rendered contents — GL block stays pre-endFrame - new {{preview_readback_post}} template slot in both desktop.txt and mobile.txt - IOSurface + CoreFoundation frameworks added to the sokol exe link line on macos/ios Engine API: sufficient as-is for Path B; the double-swizzle (producer BGRA->RGBA, engine RGBA->BGRA) is a known wart that a future publishFrameIOSurfaceBgra would eliminate. Stub: window.metalCurrentDrawable currently returns null. The pinned sokol-zig replaced the legacy sapp_metal_get_current_drawable accessor with a struct-returning sapp_get_swapchain() that calls [CAMetalLayer nextDrawable] on every call — invoking it from our readback would acquire a fresh empty drawable, not the one sokol rendered into. All wiring is in place; flipping the stub once the upstream API gap is resolved is a single-line change. Readback no-ops cleanly until then. Tests: - new "sokol emits Metal/IOSurface readback block alongside GL block": asserts the gate, namespace, selectors, IOSurface lifecycle, source- order (begin before publish, readback after endFrame, cleanup before bye), AST parse cleanliness - extended sokol GL test to keep passing unchanged - extended raylib + non-sokol leak-through tests to also lock out the Metal block Verified locally: zig build + zig build test --summary all (300/304 pass, 4 pre-existing skips). zig build of the regenerated examples/sokol/.labelle/sokol_desktop on macOS arm64 links cleanly against IOSurface, CoreFoundation, Metal, AppKit, libobjc. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(sokol-metal): use labelle-toolkit/sokol-zig fork for cached drawable The upstream sokol-zig (floooh/sokol-zig#154) doesn't expose sapp_metal_get_current_drawable in its current shape — its sapp_get_swapchain() acquires a fresh [CAMetalLayer nextDrawable] on every call, so re-calling it post-render hands us an empty drawable rather than the one sokol-gfx just rendered into. Forked the repo as labelle-toolkit/sokol-zig and added the accessor on branch `feat/expose-cached-metal-drawable` (commit 92cca34): it caches the drawable acquired by _sapp_macos_mtl_swapchain_next() (and the iOS equivalent) in a new _sapp.macos.mtl.cur_drawable field and re-exposes it via the public sapp_metal_get_current_drawable() C accessor, with a matching Zig binding `sapp.metalGetCurrentDrawable()`. This PR pins backends/sokol to the fork branch and wires the previously-stubbed metalCurrentDrawable() in window.zig to call through. Verified by linking the generated examples/sokol target and confirming _sapp_metal_get_current_drawable resolves in the output binary — the Metal/IOSurface readback path can now actually run instead of no-op'ing behind the null-drawable gate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Minor release shipping the full sokol PIE viewport producer matrix plus raylib's macOS zero-copy iosurface mode: - #121 — raylib: prefer IOSurface mode on macOS. Comptime platform switch in `PREVIEW_READBACK_SETUP`/`_LOOP` selects `beginFrameStreamIOSurface`/`publishFrameIOSurface`/`endFrameStreamIOSurface` on `os.tag == .macos`, falls back to SHM mode elsewhere. - #122 / #124 — sokol slice 1: GLCORE/GLES3 PBO readback for Linux desktop + Android. 3-deep PBO ring with 2-frame priming, gated on `_sokol_preview_gl_enabled = (os.tag == .linux)`. - #122 / #127 — sokol slice 3: D3D11 staging-texture readback for Windows. COM-vtable dispatch on `IDXGISwapChain::GetBuffer` + 3- buffer staging ring + BGRA→RGBA swizzle. Gated on `_sokol_preview_d3d11_enabled = (os.tag == .windows)`. - #122 / #128 — sokol slice 2: Metal/IOSurface zero-copy producer for macOS + iOS. Path B (MTLBlitCommandEncoder copy from swapchain to IOSurface-backed MTLTexture). Pinned to the labelle-toolkit/sokol-zig fork (`feat/expose-cached-metal-drawable` branch) which restores the `sapp_metal_get_current_drawable` accessor — upstream tracking at floooh/sokol-zig#154. - #549 — preview_mode: extract `allocShmName` helper (refactor). The three sokol slices coexist via mutually-exclusive comptime gates; the editor (labelle-gui v0.3.0) dispatches consumer mode based on `frame_offer.format` ("bgra8" → SHM, "iosurface_bgra8" → zero-copy). Paired downstream: labelle-engine v1.37.3 (Preview frame-stream APIs) and labelle-gui v0.3.0 (iosurface consumer + FBO blit). Out of scope (filed as follow-ups): - engine#551 — Windows port of preview_mode (POSIX TCP + shm). - engine#552 — `publishFrameIOSurfaceBgra` variant to skip the producer↔engine double-swizzle. - floooh/sokol-zig#154 — restore the upstream cached-drawable accessor. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Closes the first slice of #122. Extends the per-frame PBO readback that landed for raylib in #120 (labelle-engine#544) to sokol-backend games on GL paths — default sokol-zig picks GLCORE on Linux desktop and GLES3 on Android/web. Metal (Darwin) and D3D11 (Windows) are deferred to slices 2 and 3 of #122 and emit no-op fallbacks here.
What lands in the generated main
(a) Emit-point in the sokol template — new
{{preview_readback}}placeholder inbackends/sokol/templates/desktop.txt+mobile.txt, sitting right beforewindow.endFrame().window.endFrameinternally callssg.endPass(); sg.commit();— glReadPixels on GL_BACK must run before the swap, mirroring raylib's pre-endDrawingplacement.(b) GL backend gating shape — module-scope
_sokol_preview_gl_enabled: boolderived frombuiltin.os.tag(.linux→ true, else false; Android isos.tag == .linuxwithabi == .android, so the single arm covers both desktop GLCORE and Android GLES3). The GL extern decls + constants live inside a_SokolPreviewGl = if (_sokol_preview_gl_enabled) struct { ... } else struct {}namespace — theelse struct {}branch has no symbols, so a Metal / D3D11 sokol build never references unresolved GL externs at link time. sokol_clib on Linux already links libGL via the upstream sokol-zig build, so the GL path resolves transitively without needing the per-OS linker shim raylib's #120 added tosrc/templates/build_zig.txt. Per-frame block + init seed + cleanup teardown are also each wrapped inif (comptime _sokol_preview_gl_enabled) { ... }so the entire path evaporates on Metal / D3D11 builds.PBO state lives at module scope (
_preview_pbos: [3]c_uint, frame counter, last published dims,_preview_pixel_buf,_preview_allocator) because sokol'sinit/frame/cleanupcallbacks have no shared local stack frame. The allocator is stashed ininitso frame + cleanup can grow / free the CPU staging buffer without reaching back throughg. Cleanup teardown (endFrameStream+glDeleteBuffers+free) runs BEFORE_p.sendBye(.normal)so SHM / PBO unwind happens while the engine still holds the socket open (LIFO with the graceful protocol close).(c) Test additions:
_sokol_preview_gl_enabledcomptime gate +else struct {}branch, frame counters,beginFrameStream/publishFrameordering, source-order placement relative towindow.endFrame(), cleanup-teardown-before-bye ordering, and that the generated source parses cleanly viastd.zig.Ast.parse.(d) Verified locally:
zig build && zig build test(Zig 0.16): 297/301 pass (4 pre-existing skipped — Zig 0.16 preview migration stubs), up from baseline 294/298 with the 3 new tests all passing.examples/sokol/.labelle/sokol_desktop+examples/sokol_imgui/.labelle/sokol_desktopvia the assembler binary directly and built both withzig buildandzig build -Doptimize=ReleaseSafe. On macOS the comptime gate elides the GL block (Metal default backend) so both link cleanly with the Metal framework and no undefined-symbol errors. Regeneratedexamples/raylib/.labelle/raylib_desktopand built — raylib codepath unchanged.(e) Out of scope (explicit, separate slices of #122):
Test plan
zig build && zig build testclean (297/301 pass, 4 pre-existing skipped)zig buildit on macOS (Metal — comptime gate elides GL path)zig buildit on macOS🤖 Generated with Claude Code