Repository navigation
feat(sokol-metal): route game render into Path-A offscreen target (#133) - #135
Conversation
Path-A (#131/#132) allocated the IOSurface ring + MTLTexture wrappers + sokol-gfx external images, but the game's render pass still targeted the swapchain — so the editor's Game View saw a correctly-formed frame_offer / frame_published exchange against a zero-filled surface. This adds a small `window.setEditorRenderTarget` / `clearEditorRenderTarget` shim plus a per-slot `sg.View` + `sg.Attachments` cache, and splits the per-frame Metal block into a pre-render arm (rebuilds the ring on resize, picks `_write_slot`, arms the shim before `window.beginPass`) and a post-render publish (`signalSlotReady` + clear). The next `window.beginPass` then routes `g.render()` directly into the IOSurface bytes the editor will sample. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PR SummaryMedium Risk Overview Adds a small window-backend shim ( Updates the sokol templates/codegen to introduce a new Reviewed by Cursor Bugbot for commit eb828af. 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 implements the render-into-IOSurface functionality for the Metal Path-A preview on macOS, enabling offscreen rendering for the editor. It introduces a setEditorRenderTarget shim in the Sokol window backend to redirect render passes and updates the codegen templates to include pre-render and post-render blocks for attachment management and frame publishing. Review feedback identifies a typo in the sg.makeView field name and suggests adding error checks for resource creation handles, as well as extending the rollback loop to prevent resource leaks during initialization failures.
| \\ const _view = _sg.makeView(.{ | ||
| \\ .color_attachment = .{ .image = _img }, | ||
| \\ }); |
There was a problem hiding this comment.
The field name in sg.makeView likely contains a typo. Standard sokol-zig bindings use the plural color_attachments (matching the C API color_attachments[SG_MAX_COLOR_ATTACHMENTS]) rather than the singular color_attachment.
\ const _view = _sg.makeView(.{
\ .color_attachments = .{ .{ .image = _img } },
\ });
There was a problem hiding this comment.
False positive — sokol-zig 1.0's ViewDesc.color_attachment is singular. From the pinned source zig-pkg/sokol/src/sokol/gfx.zig:3815-3820:
pub const ViewDesc = extern struct {
_start_canary: u32 = 0,
texture: TextureViewDesc = .{},
storage_buffer: BufferViewDesc = .{},
storage_image: ImageViewDesc = .{},
color_attachment: ImageViewDesc = .{},
resolve_attachment: ImageViewDesc = .{},
...
};Each view binds ONE attachment of one kind (texture / storage_buffer / storage_image / color_attachment / resolve_attachment / depth_stencil_attachment). The C API's color_attachments[] array lives on AttachmentsDesc, not ViewDesc. Attachments is the struct that aggregates multiple views; this view is one of those attachments.
| \\ const _img = _sg.makeImage(_desc); | ||
| \\ _preview_mtl_sg_images[_slot] = _img; | ||
| \\ const _view = _sg.makeView(.{ | ||
| \\ .color_attachment = .{ .image = _img }, | ||
| \\ }); | ||
| \\ _preview_mtl_views[_slot] = _view; | ||
| \\ var _att: _sg.Attachments = .{}; | ||
| \\ _att.colors[0] = _view; | ||
| \\ _preview_mtl_attachments[_slot] = _att; | ||
| \\ } | ||
| \\ if (!_alloc_ok) { | ||
| \\ // Rollback any partially-allocated ring so | ||
| \\ // the next resize attempt starts clean. | ||
| \\ var _i: u32 = 0; | ||
| \\ while (_i < _slot) : (_i += 1) { |
There was a problem hiding this comment.
The results of sg.makeImage and sg.makeView are not checked for failure. If the resource pools are exhausted, these functions return handles with an id of 0. Using such handles in subsequent calls (like makeView or setEditorRenderTarget) can lead to assertions or undefined behavior. Additionally, the rollback loop should be updated to include the current _slot to ensure that if makeImage succeeds but makeView fails, the image at the current slot is properly destroyed.
\ const _img = _sg.makeImage(_desc);
\ if (_img.id == 0) { _alloc_ok = false; break; }
\ _preview_mtl_sg_images[_slot] = _img;
\ const _view = _sg.makeView(.{
\ .color_attachments = .{ .{ .image = _img } },
\ });
\ if (_view.id == 0) { _alloc_ok = false; break; }
\ _preview_mtl_views[_slot] = _view;
\ var _att: _sg.Attachments = .{};
\ _att.colors[0] = _view;
\ _preview_mtl_attachments[_slot] = _att;
\ }
\ if (!_alloc_ok) {
\ // Rollback any partially-allocated ring so
\ // the next resize attempt starts clean.
\ var _i: u32 = 0;
\ while (_i <= _slot) : (_i += 1) {
There was a problem hiding this comment.
Applied in eb828af. Both makeImage and makeView now check .id == 0 and bail into the rollback block. Also extended the rollback loop bound to _i <= _slot so the just-failed slot's partial state (MTLTexture, possibly also the sokol image handle) gets released too — caught while writing the fix; without it a mid-init failure leaked the IOSurface texture + sokol image.
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 5b07f12. Configure here.
… typo Addresses two bot findings: **gemini high — `color_attachment` typo in `sg.makeView`** — FALSE POSITIVE. sokol-zig 1.0's `ViewDesc.color_attachment` is singular (verified against the pinned source at `zig-pkg/sokol/src/sokol/gfx.zig:3820`: each view binds ONE attachment of one kind — texture / storage_buffer / storage_image / color_attachment / resolve_attachment / depth_stencil_attachment). The C API's `color_attachments[]` array lives on `AttachmentsDesc`, not `ViewDesc`. Posted a reply on the inline comment with the evidence; no code change. **gemini medium / cursor medium — `makeImage` / `makeView` results not checked for failure** — VALID. Both fns return handles with `.id = 0` when the sokol pool is exhausted. Now check explicitly: if either returns a zero-id handle, mark `_alloc_ok = false` and fall into the rollback block. Extended the rollback loop bound `_i < _slot` → `_i <= _slot and _i < _PreviewMtlRingMax` so the just-failed slot's partial state (MTLTexture already in `_preview_mtl_textures[_slot]`, possibly also `_preview_mtl_sg_images[_slot]`) gets released too — without this, a mid-init failure leaked the IOSurface texture + sokol image handle. Codegen tests still pass: 306/310 (4 skipped baseline). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Minor release completing the **sokol-Metal Path A** stack: - **#133 / #135** — route the game's `sg.beginPass` into the Path-A offscreen IOSurface render target. New gfx-layer shim in `backends/sokol/src/window.zig` (`setEditorRenderTarget` / `clearEditorRenderTarget`); new template slot `{{preview_pre_render}}` fires before the game render to arm the shim with the current ring slot's `sg.View` + `sg.Attachments`. The existing `{{preview_readback}}` slot disarms it and calls `signalSlotReady` after the render. - **#134** — un-pinned `backends/sokol/build.zig.zon` from the labelle-toolkit/sokol-zig fork (`feat/expose-cached-metal-drawable` branch) back to upstream `floooh/sokol-zig@8b1a651e`. Path A doesn't need the fork's `sapp_metal_get_current_drawable` accessor. Together with assembler v0.22.0's Path A scaffolding and engine v1.37.4's `getIOSurfaceAt`/`signalSlotReady` APIs, the sokol-Metal PIE producer is now **end-to-end functional**: the game renders directly into IOSurface-backed `sg.Image`s, the editor samples them zero-copy via `CGLTexImageIOSurface2D`, no post-frame blit, no drawable lifetime concerns. Plus bonus latent compile fix from #132's Path A bringup that codegen tests didn't catch (#135 surfaced it during live build). Review-fix from #135: null-check `makeImage`/`makeView` returns + extend the rollback loop to cover the just-failed slot's partial state (caught a leak path). Out of scope: the sokol-zig fork repo itself (un-archive cleanup is a separate task; harmless to leave it alive). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Closes #133.
Shim API
Added to
backends/sokol/src/window.zig:Plus a
pub const gfx_types = sokol.gfx;re-export so the generatedmain.zigcan reachsg.Image/sg.View/sg.Attachments/makeView/destroyViewwithout needing a directsokoldep on the root module — fixing a latent build break from #132 where the rootmain.zigreferenced@import("sokol").gfxwithout a corresponding module import.Path-A frame state (
backends/sokol/src/main_zig.zigcodegen,PREVIEW_READBACK_HELPERS_METAL_SOKOL) grew two per-slot arrays and two scalar flags:_preview_mtl_views[ring]— color-attachment Views built off the per-slot sg.Image_preview_mtl_attachments[ring]—.colors[0] = viewpre-built once, struct-copied per frame_preview_mtl_write_slot— slot index shared between pre-render and post-render arms_preview_mtl_target_active— flag the post-render arm reads to decide whether to publishWhere the override fires
PREVIEW_READBACK_FRAME_METAL_SOKOLsplit into two halves:Pre-render (new
{{preview_pre_render}}template slot, fires afterg.tick(dt)and beforewindow.beginFrame()/window.beginPass):beginFrameStreamIOSurface, walksgetIOSurfaceAt→createIOSurfaceTexture→makeImage(mtl_textures[…])→makeView(.color_attachment)→Attachments.colors[0] = view.isFrameAccepted(): picks_write_slot = _preview_frame_idx % ring_size, callswindow.setEditorRenderTarget(_preview_mtl_attachments[_write_slot]), sets_preview_mtl_target_active = true.Post-render (existing
{{preview_readback}}slot, fires beforewindow.endFrame()):_preview_mtl_target_active:_p.signalSlotReady(_preview_mtl_write_slot)+_preview_frame_idx +%= 1.window.clearEditorRenderTarget()so the next frame'sbeginPassdefaults back to the swapchain even if the pre-render arm bailed.Template placeholder
{{preview_pre_render}}added tobackends/sokol/templates/desktop.txtandmobile.txt. Empty-string for non-iosurface backend variants (the block is comptime-gated on_sokol_preview_metal_enabled, so non-Darwin sokol builds evaporate it entirely).Smoke check
zig build test): 306/310 pass, 4 skipped, 0 failures. New testsokol Metal Path-A routes game render into the offscreen IOSurface target (#133)asserts the exact ordering setEditorRenderTarget → g.render() → signalSlotReady → clearEditorRenderTarget, plus cleanup-side clear-before-teardown invariant.examples/sokol/.labelle/sokol_desktop/zig-out/bin/game(macOS native,zig buildexit 0) — thewindow.gfx_typesre-export fixes a latent compile break in feat(sokol-metal): rework to Path A — offscreen IOSurface render target (#131) #132 where@import("sokol")wasn't reachable from the root module.NSApplicationevent-loop setup (the NSApp blocker called out in the ticket), so the headlessLABELLE_PREVIEW=…smoke against a TCP listener that locks an IOSurface and reads back bytes wasn't runnable from this environment. The codegen tests are the strongest signal here.Test plan
zig build(assembler) succeedszig build test(assembler) — 306/310 pass, 4 skipped, 0 failuresexamples/sokol/.labelle/sokol_desktop/with the new assembler,zig buildsucceeds on macOS arm64