Skip to content

feat(screenshot): wire engine.requestedScreenshot() into raylib + sokol templates (#227) - #210

Merged
apotema merged 4 commits into
mainfrom
feat/227-screenshot-template
May 26, 2026
Merged

feat(screenshot): wire engine.requestedScreenshot() into raylib + sokol templates (#227)#210
apotema merged 4 commits into
mainfrom
feat/227-screenshot-template

Conversation

@apotema

@apotema apotema commented May 26, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Adds a per-frame screenshot capture block to both desktop templates (backends/raylib/templates/desktop.txt, backends/sokol/templates/desktop.txt).
  • Sokol gets a window.takeScreenshot stub that prints "not yet supported" — real sokol-gfx readback (Metal blit / GL glReadPixels / D3D11 staging) is a follow-up; the template wiring lands now so the CLI flag + engine helper can ship together.
  • Raylib uses the existing window.takeScreenshot shim that calls raylib's TakeScreenshot (PNG/BMP/TGA by extension).
  • Bumps assembler to 0.33.0 (template change → minor bump). Requires labelle-engine >= 1.45.0.

Behavior

  • When LABELLE_SCREENSHOT_PATH is unset, the new block is a single null test the optimizer collapses — no change to generated main.zig shape for normal runs.
  • When set, the block fires window.takeScreenshot(req.path) exactly once after req.after_sec wall-clock elapses (measured from main-loop entry), then exits cleanly so CI / agent flows don't need a separate --timeout.

Test plan

  • zig build ok
  • zig build test — exit 0 (template files are runtime-loaded; specs cover codegen + harness)
  • Smoke verify end-to-end after the matching CLI + engine PRs land (labelle run --screenshot=/tmp/x.png --after=2s)

Depends on

  • labelle-engine#588 (provides engine.requestedScreenshot)

…ol templates (#227)

Adds a per-frame screenshot capture block to both desktop templates.
The block reads `engine.requestedScreenshot()` once (which checks the
`LABELLE_SCREENSHOT_PATH` env var the CLI sets), and once
`after_sec` wall-clock elapses calls `window.takeScreenshot(req.path)`
exactly once, then quits cleanly so CI / agent flows that use the
screenshot as their signal don't have to wait for a separate timeout.

Raylib: existing `window.takeScreenshot` shim calls raylib's builtin
`TakeScreenshot`, which picks PNG/BMP/TGA by extension.

Sokol: adds a `window.takeScreenshot` stub that prints a "not yet
supported" warning. Real sokol-gfx readback (Metal blit / GL
`glReadPixels` / D3D11 staging) is a follow-up — see comment in
backends/sokol/src/window.zig. The template wiring lands now so the
CLI flag + engine helper can ship together; once the real readback
lands no template changes are needed.

When `LABELLE_SCREENSHOT_PATH` is unset, `engine.requestedScreenshot()`
returns null and the per-frame branch is a single `null` test the
optimizer collapses — no change to generated frame loops for normal
runs.

Bumps assembler to 0.33.0 — template change, minor bump because it
changes generated `main.zig` shape for projects that set the env var.
Requires labelle-engine >= 1.45.0 (provides `requestedScreenshot`).
@cursor

cursor Bot commented May 26, 2026

Copy link
Copy Markdown

PR Summary

Low Risk
Template-only wiring with no-op path when unset; sokol does not write files yet, and raylib exit-after-screenshot only affects CI/agent runs that set the env var.

Overview
Generated desktop games can now honor labelle-cli#227 screenshot requests driven by LABELLE_SCREENSHOT_PATH and an --after delay via engine.requestedScreenshot().

Both raylib and sokol desktop templates add a per-frame block that waits until after_sec elapses from main-loop entry, then calls window.takeScreenshot once and exits (raylib breaks the loop; sokol calls requestQuit()). When the env var is unset, behavior stays a cheap null check. Raylib captures after endDrawing() so the presented framebuffer is read; it uses the existing raylib TakeScreenshot shim.

Sokol adds a takeScreenshot stub in window.zig that logs a warning instead of writing a file (real readback deferred). Sokol keeps screenshot state at module scope and lazy-inits on the first frame to match callback-driven framing.

Assembler version bumps 0.32.3 → 0.33.0 for the template change.

Reviewed by Cursor Bugbot for commit f90f82a. Bugbot is set up for automated code reviews on this repo. Configure here.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces support for automated screenshot capture across the raylib and sokol backends, triggered after a specified delay to assist with CI and agent workflows. The raylib backend leverages its native screenshot capabilities, while the sokol backend currently stubs the feature with a warning. The review feedback suggests optimizing the timing logic by pre-calculating target timestamps in nanoseconds rather than performing float conversions and divisions on every frame. Additionally, it is recommended to use standard logging (std.log.warn) instead of std.debug.print for user-facing warnings in the sokol backend.

Comment thread backends/raylib/templates/desktop.txt Outdated
Comment on lines +31 to +32
var screenshot_req: ?engine.ScreenshotRequest = engine.requestedScreenshot();
const loop_start_ns: i128 = std.time.nanoTimestamp();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Instead of tracking the loop start time and calculating the elapsed seconds on every frame, we can pre-calculate the target timestamp in nanoseconds once on startup. This avoids float conversions, divisions, and runtime @intCast checks on every frame.

    var screenshot_req: ?engine.ScreenshotRequest = engine.requestedScreenshot();
    var screenshot_target_ns: ?i128 = if (screenshot_req) |req|
        std.time.nanoTimestamp() + @as(i128, @intFromFloat(req.after_sec * 1_000_000_000.0))
    else
        null;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Declining: the per-frame work is one float div + cmp once screenshot_req is null (the common case), which the optimizer keeps near-free. The current code's units-in-seconds reads more cleanly against the after_sec field on the request struct, and tracking loop_start_ns makes the "since main-loop entry" semantics explicit in the variable name. Happy to revisit if profiling ever shows this matters.

Comment on lines +50 to +61
if (screenshot_req) |req| {
const now_ns: i128 = std.time.nanoTimestamp();
const elapsed_sec: f32 = @as(f32, @floatFromInt(@as(i64, @intCast(now_ns - loop_start_ns)))) / 1_000_000_000.0;
if (elapsed_sec >= req.after_sec) {
window.takeScreenshot(req.path);
screenshot_req = null;
// Exit cleanly so CI / agent flows that use the
// screenshot as their signal don't have to wait for
// a separate `--timeout` to fire.
break;
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Use the pre-calculated screenshot_target_ns to check if the screenshot delay has elapsed. This simplifies the per-frame check to a simple integer comparison.

        if (screenshot_target_ns) |target_ns| {
            if (std.time.nanoTimestamp() >= target_ns) {
                window.takeScreenshot(screenshot_req.?.path);
                screenshot_target_ns = null;
                // Exit cleanly so CI / agent flows that use the
                // screenshot as their signal don't have to wait for
                // a separate `--timeout` to fire.
                break;
            }
        }

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Declining: the per-frame work is one float div + cmp once screenshot_req is null (the common case), which the optimizer keeps near-free. The current code's units-in-seconds reads more cleanly against the after_sec field on the request struct, and tracking loop_start_ns makes the "since main-loop entry" semantics explicit in the variable name. Happy to revisit if profiling ever shows this matters.

Comment on lines +15 to +17
var screenshot_req: ?@import("labelle-engine").ScreenshotRequest = null;
var screenshot_start_ns: i128 = 0;
var screenshot_initialized: bool = false;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Pre-calculate the target timestamp in nanoseconds instead of tracking the start time and calculating elapsed seconds on every frame. This avoids float conversions, divisions, and runtime @intCast checks on every frame.

var screenshot_req: ?@import("labelle-engine").ScreenshotRequest = null;
var screenshot_target_ns: ?i128 = null;
var screenshot_initialized: bool = false;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Declining: the per-frame work is one float div + cmp once screenshot_req is null (the common case), which the optimizer keeps near-free. The current code's units-in-seconds reads more cleanly against the after_sec field on the request struct, and tracking loop_start_ns makes the "since main-loop entry" semantics explicit in the variable name. Happy to revisit if profiling ever shows this matters.

Comment on lines +68 to +81
if (!screenshot_initialized) {
screenshot_req = @import("labelle-engine").requestedScreenshot();
screenshot_start_ns = std.time.nanoTimestamp();
screenshot_initialized = true;
}
if (screenshot_req) |req| {
const now_ns: i128 = std.time.nanoTimestamp();
const elapsed_sec: f32 = @as(f32, @floatFromInt(@as(i64, @intCast(now_ns - screenshot_start_ns)))) / 1_000_000_000.0;
if (elapsed_sec >= req.after_sec) {
window.takeScreenshot(req.path);
screenshot_req = null;
window.requestQuit();
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Use the pre-calculated screenshot_target_ns to check if the screenshot delay has elapsed. This simplifies the per-frame check to a simple integer comparison.

    if (!screenshot_initialized) {
        screenshot_req = @import("labelle-engine").requestedScreenshot();
        if (screenshot_req) |req| {
            screenshot_target_ns = std.time.nanoTimestamp() + @as(i128, @intFromFloat(req.after_sec * 1_000_000_000.0));
        }
        screenshot_initialized = true;
    }
    if (screenshot_target_ns) |target_ns| {
        if (std.time.nanoTimestamp() >= target_ns) {
            window.takeScreenshot(screenshot_req.?.path);
            screenshot_target_ns = null;
            window.requestQuit();
        }
    }

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Declining: the per-frame work is one float div + cmp once screenshot_req is null (the common case), which the optimizer keeps near-free. The current code's units-in-seconds reads more cleanly against the after_sec field on the request struct, and tracking loop_start_ns makes the "since main-loop entry" semantics explicit in the variable name. Happy to revisit if profiling ever shows this matters.

Comment thread backends/sokol/src/window.zig Outdated
Comment on lines +105 to +108
std.debug.print(
"labelle: screenshot requested but not supported on sokol backend yet ({s})\n",
.{path},
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Use standard logging facilities (std.log.warn) instead of std.debug.print for user-facing warnings, as per the project's general rules.

    std.log.warn(
        "screenshot requested but not supported on sokol backend yet ({s})",
        .{path},
    );
References
  1. In CLI tools, use standard logging facilities (e.g., std.log.warn) or write to stderr for user-facing warnings, rather than using debug-specific print functions (e.g., std.debug.print).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 2eec524 — swapped to std.log.warn so the message respects the configured log level instead of always firing through std.debug.print.

Zig 0.16 removed std.time.nanoTimestamp; the engine helper now exposes
`nowNs()` which calls libc clock_gettime under the hood. Use it in
both the raylib and sokol screenshot timing blocks.
@apotema

apotema commented May 26, 2026

Copy link
Copy Markdown
Contributor Author

@copilot review

Copilot AI commented May 26, 2026

Copy link
Copy Markdown
Contributor

@copilot review

Please share the specific change(s) you want me to make in this PR (file/behavior), and I’ll update it right away.

Gemini flagged std.debug.print as not matching project convention for
user-facing warnings. Swap to std.log.warn so the message lands on the
standard log channel and respects the configured log level (debug.print
always fires, even in release modes where logs are filtered).

The message body is unchanged modulo the trailing newline (std.log adds
its own line break).
@apotema
apotema merged commit 1e0bdf0 into main May 26, 2026
4 checks passed
@apotema
apotema deleted the feat/227-screenshot-template branch May 26, 2026 14:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants