Files
libakgl/tools/docs_screenshot.c

165 lines
5.4 KiB
C
Raw Normal View History

Compile, link and run every example in the documentation libakgl exports 157 functions and the only user-facing documentation was a FAQ in README.md whose examples did not compile: two unbalanced `PASS()` calls, a `sprite->frameids = [0, 1, 2, 3];` that is not C in any dialect, a stray `9` inside a bitmask expression, an `int screenwidth = NULL`, and -- in the first snippet a reader ever saw -- the exact `strncpy` call AGENTS.md forbids. That is not a reader routing around typos. It is what happens to samples that nothing executes. This is akbasic's documentation harness with the interpreter taken out and the ability to link and run put in. The contract is a set of fence info strings, so an example is ordinary markdown that still highlights on the forge: ```c compiled with -fsyntax-only -Wall -Werror ```c wrap=NAME the same, wrapped in tests/docs_preludes/NAME.pre/.post ```c run=NAME linked against akgl and run headless ```c excerpt=PATH must still appear verbatim in PATH ```c screenshot=NAME also the source of docs/images/NAME.png ```json kind=KIND loaded through the real akgl_*_load_json ```output the exact stdout of the runnable block above it `run=` is why this links at all: -fsyntax-only proves a call typechecks, not that the startup order works or that an ATTEMPT block gives back what it took. `json kind=` exists because the asset formats are documented in prose and read by four loaders with nothing tying the two together -- util/assets/littleguy.json is already invalid against the loader it ships with. A fence with no info string is a hard error, and so is an unknown one. The failure mode this exists to prevent is passing because it quietly ran nothing, so an unannotated block is a missing decision rather than a default. Exit status is the number of failed examples; 2 for a usage error, which is a different thing and has to be distinguishable. Proven to fail on all ten of its failure modes -- untagged fence, non-compiling snippet, stale excerpt, orphan output block, unknown info string, run= output mismatch, invalid JSON, missing figure, a -Werror warning, and a dangling preload= -- because a check that has never failed has not been tested. `-Werror` here although AKGL_WERROR stays off for the library: that option is off so a new compiler's diagnostic cannot break a consumer's build, and a doc snippet is not a consumer. A sample that warns is a sample that teaches the warning. Registered as `docs_examples` and `docs_screenshots`. No docs-path filter in CI, deliberately: documentation goes stale because the code moved, not because somebody edited a chapter. Co-Authored-By: Claude Code <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 20:58:11 -04:00
/**
* @file docs_screenshot.c
* @brief Bring libakgl up against an offscreen target, call one documented frame, save a PNG.
*
* The drawing, sprite and tilemap chapters describe what a call puts on the
* screen, and a sentence is a poor way to describe a picture. This is what turns
* the listing in a chapter into the image beside it: `tools/docs_screenshots.sh`
* extracts the listing from the markdown, compiles it against this file, and
* runs the result -- so the figure is generated from the exact code the reader
* is looking at rather than from a copy that can drift away from it.
*
* **It is a host**, and deliberately a small one: `akgl_game_init()` opens a
* real window, owns the frame loop and does not stop until the game quits, none
* of which a build step wants. What this does instead is the shortest path to
* pixels -- the dummy video driver, a software renderer, one frame, read the
* target back, write it out. `tests/sprite.c` and `tests/draw.c` already set
* that pattern.
*
* The frame itself is `docs_frame()`, supplied by the chapter's block through
* `tests/docs_preludes/akglframe.pre`. It is declared in `docs_prelude.h` so the
* host and the prelude cannot disagree about the signature.
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <SDL3/SDL.h>
#include <SDL3_image/SDL_image.h>
#include <akerror.h>
#include <docs_prelude.h>
/** @brief Set in HANDLE_DEFAULT and read after FINISH; see the note in main(). */
static int failed = 0;
static void usage(void)
{
fprintf(stderr,
"usage: akgl_docs_screenshot <output.png> [width] [height]\n"
"\n"
" Calls docs_frame() against an offscreen renderer of the given size\n"
" (default 320x240) and writes what it drew as a PNG.\n");
}
/**
* @brief Everything between SDL being up and the pixels being on disk.
*
* Split out so the ATTEMPT in main() has one thing to CATCH and the SDL teardown
* has one place to happen.
*/
static akerr_ErrorContext AKERR_NOIGNORE *draw_frame(const char *outpath, int w, int h)
{
PREPARE_ERROR(errctx);
SDL_Surface *shot = NULL;
FAIL_ZERO_RETURN(errctx, outpath, AKERR_NULLPOINTER, "Received null output path");
PASS(errctx, akgl_error_init());
akgl_renderer = &akgl_default_renderer;
FAIL_ZERO_RETURN(errctx, SDL_Init(SDL_INIT_VIDEO), AKGL_ERR_SDL,
"Couldn't initialize SDL: %s", SDL_GetError());
FAIL_ZERO_RETURN(
errctx,
SDL_CreateWindowAndRenderer(
"net/aklabs/libakgl/docs_screenshot", w, h, 0,
&akgl_window, &akgl_renderer->sdl_renderer),
AKGL_ERR_SDL,
"Couldn't create window/renderer: %s", SDL_GetError());
PASS(errctx, akgl_render_2d_bind(akgl_renderer));
PASS(errctx, akgl_heap_init());
PASS(errctx, akgl_registry_init());
akgl_camera = &akgl_default_camera;
akgl_camera->x = 0.0f;
akgl_camera->y = 0.0f;
akgl_camera->w = (float32_t)w;
akgl_camera->h = (float32_t)h;
/*
* Opaque black, so a figure has a defined background rather than whatever
* the driver left in the buffer. Clearing is the host's prerogative -- the
* library's draw calls put things on the target and do not own what is
* underneath them -- and this is the host doing it.
*/
FAIL_ZERO_RETURN(errctx, SDL_SetRenderDrawColor(akgl_renderer->sdl_renderer, 0, 0, 0, 0xff),
AKGL_ERR_SDL, "%s", SDL_GetError());
FAIL_ZERO_RETURN(errctx, SDL_RenderClear(akgl_renderer->sdl_renderer),
AKGL_ERR_SDL, "%s", SDL_GetError());
PASS(errctx, docs_frame());
shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL);
FAIL_ZERO_RETURN(errctx, (shot != NULL), AKGL_ERR_SDL,
"Couldn't read the target back: %s", SDL_GetError());
if ( !IMG_SavePNG(shot, outpath) ) {
SDL_DestroySurface(shot);
FAIL_RETURN(errctx, AKGL_ERR_SDL, "Couldn't write %s: %s", outpath, SDL_GetError());
}
SDL_DestroySurface(shot);
SUCCEED_RETURN(errctx);
}
int main(int argc, char **argv)
{
PREPARE_ERROR(errctx);
int w = 320;
int h = 240;
if ( argc < 2 ) {
usage();
return 2;
}
if ( argc > 2 ) {
w = atoi(argv[2]);
}
if ( argc > 3 ) {
h = atoi(argv[3]);
}
if ( w <= 0 || h <= 0 ) {
usage();
return 2;
}
/*
* Set here rather than left to the environment, so the tool answers the same
* on a developer's desktop as it does in CI. A figure regenerated over a
* real GPU could differ by a pixel of antialiasing and turn every rebuild
* into a binary diff.
*/
SDL_SetHint(SDL_HINT_VIDEO_DRIVER, "dummy");
SDL_SetHint(SDL_HINT_RENDER_DRIVER, "software");
SDL_SetHint(SDL_HINT_AUDIO_DRIVER, "dummy");
ATTEMPT {
CATCH(errctx, draw_frame(argv[1], w, h));
} CLEANUP {
if ( akgl_window != NULL ) {
SDL_DestroyWindow(akgl_window);
akgl_window = NULL;
}
SDL_Quit();
} PROCESS(errctx) {
} HANDLE_DEFAULT(errctx) {
LOG_ERROR_WITH_MESSAGE(errctx, "could not draw the figure");
/*
* A flag rather than a `return`: FINISH ends with RELEASE_ERROR, and
* leaving a HANDLE block early means the context never goes back to
* AKERR_ARRAY_ERROR -- one leaked slot per call, and AGENTS.md documents
* where that ends up.
*/
failed = 1;
/*
* FINISH_NORETURN rather than FINISH: FINISH expands a
* `return __err_context` that an int-returning function cannot compile,
* even where the branch is unreachable.
*/
} FINISH_NORETURN(errctx);
return failed;
}