Commit6cde4fa9Recorded19 Sep 2026Repositorysigil-graphics

Make render targets work on the web build; add the test-shader-limits proof

Message

make-render-target created a depth-stencil image in the environment's default depth format. The web environment declares SG_PIXELFORMAT_NONE, so on WebGL that image failed (GL_TEXTURE_FORMAT_NOT_SUPPORTED) and every pass on the target was refused (BEGINPASS_ATTACHMENTS_ALIVE): render targets drew nothing in the browser. The depth attachment now exists only when the environment has a depth format, which is what the sgp pipelines expect on each target.

Destructors no longer call into sokol after gfx-shutdown (sg_isvalid), so a texture, target or shader collected after shutdown does not trip sokol's assert.

test/test-shader-limits runs a Game of Life stepped entirely on the GPU (with-shader inside with-render-target, ping-pong between two targets), draws vec4/float uniform arrays, and refills a pixel texture, on native and on the web config; verify.mjs reads the presented frame on both and compares every cell, strip and texel against a CPU model.

Changed
 src/c/graphics.c                                                |  79 +++++++++++++++----------
 test/test-shader-limits/dev-redirects.sgl                       |   6 ++
 test/test-shader-limits/index.template.html                     |  40 +++++++++++++
 test/test-shader-limits/package.sgl                             |  67 +++++++++++++++++++++
 test/test-shader-limits/src/test-shader-limits/main.sgl         |  22 +++++++
 test/test-shader-limits/src/test-shader-limits/scene.sgl        | 369 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 test/test-shader-limits/src/test-shader-limits/shell-native.sgl |  49 +++++++++++++++
 test/test-shader-limits/src/test-shader-limits/shell-web.sgl    |  52 ++++++++++++++++
 test/test-shader-limits/styles.css                              |   5 ++
 test/test-shader-limits/verify.mjs                              | 418 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 10 files changed, 1076 insertions(+), 31 deletions(-)
Diff
src/c/graphics.cmodified
@@ -883,7 +883,7 @@ static void texture_destructor(void *data)
883
{
884
GfxTexture *tex = (GfxTexture *)data;
885
if (tex) {
−886
if (tex->owns_resources) {
+886
if (tex->owns_resources && sg_isvalid()) {
887
if (tex->view.id != SG_INVALID_ID) {
888
sg_destroy_view(tex->view);
889
}
@@ -1310,6 +1310,10 @@ static GfxRenderTarget *get_rt(SigilVM *vm, Value v)
1310
static void rt_free_resources(GfxRenderTarget *rt)
1311
{
1312
if (!rt || rt->freed) return;
+1313
/* After gfx-shutdown (sg_shutdown) the handles are already gone and
+1314
* sokol asserts on any destroy; a finalizer running then has nothing
+1315
* to release. */
+1316
if (!sg_isvalid()) { rt->freed = true; return; }
1317
if (rt->color_att_view.id != SG_INVALID_ID) {
1318
sg_destroy_view(rt->color_att_view);
1319
}
@@ -1412,35 +1416,47 @@ static Value native_make_render_target(SigilVM *vm, int argc, Value *args)
1416
1417
/* Depth-stencil attachment — needed only for pipeline-validation
1418
* compatibility with sgp's default pipelines, which bake in the
−1415
* swap chain's depth-stencil format. */
−1416
sg_image_desc depth_desc = {
−1417
.usage = { .depth_stencil_attachment = true },
−1418
.width = w,
−1419
.height = h,
−1420
};
−1421
sg_image depth_img = sg_make_image(&depth_desc);
−1422
if (depth_img.id == SG_INVALID_ID) {
−1423
sg_destroy_view(tex_view);
−1424
sg_destroy_view(att_view);
−1425
sg_destroy_image(img);
−1426
sigil__vm_set_error(vm, SIGIL_ERR_RUNTIME,
−1427
"make-render-target: failed to create depth image");
−1428
return SIGIL_FALSE;
−1429
}
+1419
* environment's default depth format. Natively that is the swap
+1420
* chain's depth-stencil format, so the pass must carry a matching
+1421
* attachment. On the web build sig_gfx_environment() declares
+1422
* depth_format = SG_PIXELFORMAT_NONE: the pipelines expect NO depth
+1423
* attachment, and an image made with that format fails
+1424
* (GL_TEXTURE_FORMAT_NOT_SUPPORTED), which left every web render
+1425
* target's pass refused with BEGINPASS_ATTACHMENTS_ALIVE before
+1426
* v0.11.3. So: only when the environment has a depth format. */
+1427
sg_image depth_img = { SG_INVALID_ID };
+1428
sg_view depth_att_view = { SG_INVALID_ID };
+1429
bool want_depth = sg_query_desc().environment.defaults.depth_format != SG_PIXELFORMAT_NONE;
+1430
if (want_depth) {
+1431
sg_image_desc depth_desc = {
+1432
.usage = { .depth_stencil_attachment = true },
+1433
.width = w,
+1434
.height = h,
+1435
};
+1436
depth_img = sg_make_image(&depth_desc);
+1437
if (depth_img.id == SG_INVALID_ID) {
+1438
sg_destroy_view(tex_view);
+1439
sg_destroy_view(att_view);
+1440
sg_destroy_image(img);
+1441
sigil__vm_set_error(vm, SIGIL_ERR_RUNTIME,
+1442
"make-render-target: failed to create depth image");
+1443
return SIGIL_FALSE;
+1444
}
1445
−1431
sg_view_desc depth_att_desc = {
−1432
.depth_stencil_attachment = { .image = depth_img },
−1433
.label = "sigil-rt-depth-attachment",
−1434
};
−1435
sg_view depth_att_view = sg_make_view(&depth_att_desc);
−1436
if (depth_att_view.id == SG_INVALID_ID) {
−1437
sg_destroy_image(depth_img);
−1438
sg_destroy_view(tex_view);
−1439
sg_destroy_view(att_view);
−1440
sg_destroy_image(img);
−1441
sigil__vm_set_error(vm, SIGIL_ERR_RUNTIME,
−1442
"make-render-target: failed to create depth attachment view");
−1443
return SIGIL_FALSE;
+1446
sg_view_desc depth_att_desc = {
+1447
.depth_stencil_attachment = { .image = depth_img },
+1448
.label = "sigil-rt-depth-attachment",
+1449
};
+1450
depth_att_view = sg_make_view(&depth_att_desc);
+1451
if (depth_att_view.id == SG_INVALID_ID) {
+1452
sg_destroy_image(depth_img);
+1453
sg_destroy_view(tex_view);
+1454
sg_destroy_view(att_view);
+1455
sg_destroy_image(img);
+1456
sigil__vm_set_error(vm, SIGIL_ERR_RUNTIME,
+1457
"make-render-target: failed to create depth attachment view");
+1458
return SIGIL_FALSE;
+1459
}
1460
}
1461
1462
sg_sampler_desc smp_desc = {
@@ -1454,8 +1470,8 @@ static Value native_make_render_target(SigilVM *vm, int argc, Value *args)
1470
GfxRenderTarget *rt = malloc(sizeof(GfxRenderTarget));
1471
if (!rt) {
1472
sg_destroy_sampler(smp);
−1457
sg_destroy_view(depth_att_view);
−1458
sg_destroy_image(depth_img);
+1473
if (depth_att_view.id != SG_INVALID_ID) sg_destroy_view(depth_att_view);
+1474
if (depth_img.id != SG_INVALID_ID) sg_destroy_image(depth_img);
1475
sg_destroy_view(tex_view);
1476
sg_destroy_view(att_view);
1477
sg_destroy_image(img);
@@ -1721,6 +1737,7 @@ static GfxShader *get_shader(SigilVM *vm, Value v)
1737
static void shader_free_resources(GfxShader *sh)
1738
{
1739
if (!sh || sh->freed) return;
+1740
if (!sg_isvalid()) { sh->freed = true; active_shader = (active_shader == sh) ? NULL : active_shader; return; }
1741
if (active_shader == sh) {
1742
active_shader = NULL;
1743
}
test/test-shader-limits/dev-redirects.sgladded
@@ -0,0 +1,6 @@
+1
;; Local sigil-graphics is the in-progress version under development.
+2
(redirects
+3
repos: (list
+4
(for-repo
+5
url: "codeberg:sigil/sigil-graphics"
+6
use: (from-path dir: "../.."))))
test/test-shader-limits/index.template.htmladded
@@ -0,0 +1,40 @@
+1
<!doctype html>
+2
<html lang="en">
+3
<head>
+4
<meta charset="utf-8">
+5
<meta name="viewport" content="width=device-width, initial-scale=1">
+6
<title>test-shader-limits</title>
+7
<link rel="icon" href="data:,">
+8
<link rel="stylesheet" href="styles.css">
+9
</head>
+10
<body>
+11
<main>
+12
<!-- 640x480 CSS pixels, the scene's virtual viewport, so the buffer
+13
(CSS size times devicePixelRatio) has the same aspect and no
+14
letterbox: verify.mjs maps virtual pixels by one scale. -->
+15
<canvas id="stage" width="640" height="480"></canvas>
+16
<div id="status"></div>
+17
</main>
+18
<script>
+19
// The page's parameters, dispatched to the app before its first frame
+20
// (a dispatch made before the runtime starts is queued by the loader):
+21
// grid=N cells per side (64)
+22
// steps=N generations before the ready line (8)
+23
// bench never settle; print frame-ms every 120 frames
+24
(function () {
+25
"use strict";
+26
function withApp(fn) {
+27
if (globalThis.SigilWebApp) { fn(globalThis.SigilWebApp); return; }
+28
document.addEventListener("sigil-web-app-ready", function (e) { fn(e.detail); }, { once: true });
+29
}
+30
withApp(function (app) {
+31
var params = new URLSearchParams(location.search);
+32
if (params.has("grid")) app.dispatch("grid", params.get("grid"));
+33
if (params.has("steps")) app.dispatch("steps", params.get("steps"));
+34
if (params.has("bench")) app.dispatch("bench", "on");
+35
});
+36
})();
+37
</script>
+38
<!--sigil:app-->
+39
</body>
+40
</html>
test/test-shader-limits/package.sgladded
@@ -0,0 +1,67 @@
+1
;;; test-shader-limits — the measured proof for sigil-graphics v0.11.3's
+2
;;; shader additions, on native and on the web build.
+3
;;;
+4
;;; One scene, two shells (the (crash main) seam): a Game of Life stepped
+5
;;; entirely on the GPU by render-target ping-pong with a shader bound
+6
;;; inside with-render-target, a palette drawn from `vec4 palette[8]` and
+7
;;; `float levels[4]` uniform arrays, and a 4x4 texture made from pixels
+8
;;; and refilled with update-texture. verify.mjs reads the presented frame
+9
;;; on both targets and asserts every cell, strip and texel.
+10
;;;
+11
;;; sigil deps install --redirects dev-redirects.sgl
+12
;;; sigil build --redirects dev-redirects.sgl native
+13
;;; sigil build --redirects dev-redirects.sgl --config web browser
+14
;;; node verify.mjs --native | --web (see README)
+15
+16
(define sigil-repo "codeberg:sigil/sigil")
+17
+18
(package
+19
name: "test-shader-limits"
+20
version: "0.0.1"
+21
sigil: "0.22"
+22
description: "Proof: uniform arrays, pixel textures, GPU ping-pong automaton on native and web"
+23
license: "BSD-3-Clause"
+24
authors: (list "David Wilson <david@daviwil.com>")
+25
+26
entry: '(test-shader-limits main)
+27
bundle-name: "test-shader-limits"
+28
+29
configs: (list
+30
(config
+31
name: 'dev
+32
output-dir: "build/dev"
+33
debug?: #t
+34
optimize: 0
+35
bundle?: #t)
+36
;; The browser build: wasm32-wasi through zig, native codegen, the
+37
;; shape of crash-the-stack's web config.
+38
(config
+39
name: 'web
+40
output-dir: "build/web"
+41
toolchain: 'zig
+42
target: "wasm32-wasi"
+43
backend: 'native
+44
debug?: #f
+45
optimize: 2
+46
bundle?: #t
+47
features: '(web wasm graphics)))
+48
+49
dependencies: (list
+50
;; Windowing, native only (sokol_app + X11 cannot build for wasm).
+51
(from-git url: "codeberg:sigil/sigil-app" version: "^0.9.0" configs: '(dev))
+52
;; The package under test; dev-redirects.sgl points it at ../..
+53
(from-git url: "codeberg:sigil/sigil-graphics" version: "^0.11.2")
+54
;; Web only: the wasm runtime (loader + entry ABI) and the raw-WebGL2
+55
;; binding sigil-graphics' sokol backend draws through.
+56
(from-git url: sigil-repo package: "sigil-wasm-runtime" version: ">=0.22" configs: '(web))
+57
(from-git url: sigil-repo package: "sigil-wasm-gles3" version: ">=0.22" configs: '(web)))
+58
+59
;; The web build's task; the dev config takes sigil-build's default.
+60
tasks: (list
+61
(task
+62
name: 'build
+63
configs: '(web)
+64
description: "Compile the scene and link the browser app"
+65
steps: (list
+66
(compile-sigil-modules sources: "src/**/*.sgl")
+67
(link-web-application name: "test-shader-limits" html-mount-id: "status")))))
test/test-shader-limits/src/test-shader-limits/main.sgladded
@@ -0,0 +1,22 @@
+1
;;; (test-shader-limits main) - The entry, and the seam between the two
+2
;;; targets (the shape of crash-the-stack's (crash main)).
+3
+4
(define-library (test-shader-limits main)
+5
(cond-expand
+6
(wasm (import (test-shader-limits shell-web)))
+7
(else (import (test-shader-limits shell-native))))
+8
+9
(export main sigil-web-dispatch-event)
+10
+11
(begin
+12
(cond-expand
+13
(wasm
+14
(define (main . args)
+15
(web-main))
+16
(define (sigil-web-dispatch-event type payload)
+17
(web-dispatch type payload)))
+18
(else
+19
(define (main . args)
+20
(apply native-main args))
+21
(define (sigil-web-dispatch-event type payload)
+22
0)))))
test/test-shader-limits/src/test-shader-limits/scene.sgladded
@@ -0,0 +1,369 @@
+1
;;; (test-shader-limits scene) - The scene both shells draw.
+2
;;;
+3
;;; Three exhibits in a 640x480 virtual viewport, each read back by
+4
;;; verify.mjs from the presented frame:
+5
;;;
+6
;;; FIELD a Game of Life on a GRIDxGRID torus, stepped on the GPU:
+7
;;; two render targets, the step shader bound with with-shader
+8
;;; INSIDE with-render-target, sampling the other target through
+9
;;; draw-render-target (channel 0), swapped every step. Seeded
+10
;;; by draw-filled-rect, never touched by the CPU again. Drawn
+11
;;; at CELL px per cell at (FIELD-X, FIELD-Y) with the target's
+12
;;; 'nearest filter, so each cell is a flat CELLxCELL block.
+13
;;; The glider starts near the bottom-right corner so it wraps
+14
;;; across both edges within STEPS: the wrap uses u_resolution,
+15
;;; which inside the pass must be the target's size.
+16
;;; PALETTE eight 24x24 strips whose colour is palette[i] from
+17
;;; `uniform vec4 palette[8]`, and four grey strips from
+18
;;; `uniform float levels[4]`, i chosen per draw by u_index.
+19
;;; PIXELS a 4x4 texture from make-texture-from-pixels drawn 64x64
+20
;;; with 'nearest, refilled by update-texture on the second
+21
;;; frame; the frame the verifier reads must show the second
+22
;;; pattern.
+23
;;;
+24
;;; Trace lines (stdout natively, console.log on the web):
+25
;;; shader-limits: probe NAME ok|FAIL DETAIL the refusals at init
+26
;;; shader-limits: update ok|FAIL update-texture landed on frame 2
+27
;;; shader-limits: ready grid=G steps=N the field has stepped N times and
+28
;;; the frame showing it was presented
+29
;;; shader-limits: frame-ms MEAN MAX tick-ms MEAN MAX (bench mode, every
+30
;;; BENCH-EVERY frames): wall dt
+31
;;; between frames and the tick's
+32
;;; own step+present CPU time
+33
+34
(define-library (test-shader-limits scene)
+35
(import (sigil core)
+36
(sigil math)
+37
(sigil string)
+38
(sigil time)
+39
(sigil graphics))
+40
+41
(export VW VH
+42
FIELD-X FIELD-Y FIELD-PX
+43
scene-init! scene-tick! scene-settled?
+44
say)
+45
+46
(begin
+47
+48
(define VW 640)
+49
(define VH 480)
+50
(define FIELD-X 8)
+51
(define FIELD-Y 8)
+52
(define FIELD-PX 384) ; the field is drawn FIELD-PX square whatever GRID is
+53
(define PAL-X 408)
+54
(define LEV-X 440)
+55
(define STRIP 24)
+56
(define STRIP-GAP 28)
+57
(define PIX-X 408)
+58
(define PIX-Y 260)
+59
(define PIX-SCALE 16)
+60
(define BENCH-EVERY 120)
+61
+62
;; --- GLSL -------------------------------------------------------------
+63
+64
;; One source, two dialects: the desktop core profile natively, GLSL ES
+65
;; 3.00 on the web build (the version line and the precision defaults
+66
;; are the only differences the sources need).
+67
(cond-expand
+68
(wasm
+69
(define GLSL-PREFIX
+70
"#version 300 es\nprecision highp float;\nprecision highp int;\nprecision mediump sampler2D;\n"))
+71
(else
+72
(define GLSL-PREFIX "#version 410\n")))
+73
+74
;; sgp's vertex layout: coord = (position.xy, texcoord.zw), color at 1.
+75
(define VERT-SOURCE
+76
(string-append
+77
GLSL-PREFIX
+78
"layout(location = 0) in vec4 coord;\n"
+79
"layout(location = 1) in vec4 color;\n"
+80
"out vec2 texUV;\n"
+81
"out vec4 iColor;\n"
+82
"void main() {\n"
+83
" gl_Position = vec4(coord.xy, 0.0, 1.0);\n"
+84
" texUV = coord.zw;\n"
+85
" iColor = color;\n"
+86
"}\n"))
+87
+88
;; The Life step. Reads the previous generation from channel 0 by
+89
;; integer texel (texelFetch: no sampler filtering, no UV flip to get
+90
;; wrong), wrapping on u_resolution, which is the target's size when
+91
;; the shader is bound inside with-render-target.
+92
(define LIFE-SOURCE
+93
(string-append
+94
GLSL-PREFIX
+95
"uniform sampler2D iTexChannel0_iSmpChannel0;\n"
+96
"uniform vec2 u_resolution;\n"
+97
"in vec2 texUV;\n"
+98
"in vec4 iColor;\n"
+99
"out vec4 fragColor;\n"
+100
"int cell(ivec2 q, ivec2 size) {\n"
+101
" q = (q + size) % size;\n"
+102
" return texelFetch(iTexChannel0_iSmpChannel0, q, 0).r > 0.5 ? 1 : 0;\n"
+103
"}\n"
+104
"void main() {\n"
+105
" ivec2 size = ivec2(u_resolution);\n"
+106
" ivec2 p = ivec2(gl_FragCoord.xy);\n"
+107
" int n = 0;\n"
+108
" for (int dy = -1; dy <= 1; dy++) {\n"
+109
" for (int dx = -1; dx <= 1; dx++) {\n"
+110
" if (dx != 0 || dy != 0) n += cell(p + ivec2(dx, dy), size);\n"
+111
" }\n"
+112
" }\n"
+113
" int alive = cell(p, size);\n"
+114
" float next = (n == 3 || (n == 2 && alive == 1)) ? 1.0 : 0.0;\n"
+115
" fragColor = vec4(next, next, next, 1.0);\n"
+116
"}\n"))
+117
+118
;; The palette exhibit: colour from a vec4 array or a float array,
+119
;; the entry picked by u_index (a float, as every uniform here is).
+120
(define PALETTE-SOURCE
+121
(string-append
+122
GLSL-PREFIX
+123
"uniform sampler2D iTexChannel0_iSmpChannel0;\n"
+124
"uniform vec4 palette[8];\n"
+125
"uniform float levels[4];\n"
+126
"uniform float u_index;\n"
+127
"uniform float u_mode;\n"
+128
"in vec2 texUV;\n"
+129
"in vec4 iColor;\n"
+130
"out vec4 fragColor;\n"
+131
"void main() {\n"
+132
" int i = int(u_index + 0.5);\n"
+133
" vec4 c = (u_mode < 0.5) ? palette[i] : vec4(vec3(levels[i]), 1.0);\n"
+134
" // the white texture sgp binds for filled shapes: identity\n"
+135
" fragColor = c * texture(iTexChannel0_iSmpChannel0, texUV) * iColor;\n"
+136
"}\n"))
+137
+138
;; The eight palette entries and four levels the verifier expects,
+139
;; chosen so every strip is a distinct, saturated colour.
+140
(define PALETTE
+141
'((1.0 0.0 0.0 1.0)
+142
(0.0 1.0 0.0 1.0)
+143
(0.0 0.0 1.0 1.0)
+144
(1.0 1.0 0.0 1.0)
+145
(0.0 1.0 1.0 1.0)
+146
(1.0 0.0 1.0 1.0)
+147
(1.0 0.5 0.0 1.0)
+148
(0.5 0.0 1.0 1.0)))
+149
(define LEVELS #(0.0 0.25 0.5 1.0))
+150
+151
;; --- the pixel texture -----------------------------------------------
+152
+153
;; A 4x4 RGBA8 bytevector from a list of 16 (r g b) triples, top row
+154
;; first (the orientation load-texture gives and the verifier reads).
+155
(define (pixels-4x4 triples)
+156
(let ((bv (make-bytevector (* 4 4 4) 255)))
+157
(let loop ((i 0) (ts triples))
+158
(when (and (< i 16) (pair? ts))
+159
(let ((t (car ts)))
+160
(bytevector-u8-set! bv (* i 4) (car t))
+161
(bytevector-u8-set! bv (+ (* i 4) 1) (cadr t))
+162
(bytevector-u8-set! bv (+ (* i 4) 2) (caddr t)))
+163
(loop (+ i 1) (cdr ts))))
+164
bv))
+165
+166
;; Frame 1 shows PATTERN-A (a red/blue checker); update-texture on
+167
;; frame 2 replaces it with PATTERN-B (green/yellow, with one white
+168
;; and one black texel so a row/column swap or a stale texture reads
+169
;; as a failure, not a coincidence).
+170
(define PATTERN-A
+171
'((255 0 0) (0 0 255) (255 0 0) (0 0 255)
+172
(0 0 255) (255 0 0) (0 0 255) (255 0 0)
+173
(255 0 0) (0 0 255) (255 0 0) (0 0 255)
+174
(0 0 255) (255 0 0) (0 0 255) (255 0 0)))
+175
+176
(define PATTERN-B
+177
'((0 255 0) (255 255 0) (0 255 0) (255 255 0)
+178
(255 255 0) (255 255 255) (255 255 0) (0 255 0)
+179
(0 255 0) (255 255 0) (0 0 0) (255 255 0)
+180
(255 255 0) (0 255 0) (255 255 0) (0 255 0)))
+181
+182
;; --- state ------------------------------------------------------------
+183
+184
(define *grid* 64)
+185
(define *steps* 8)
+186
(define *bench* #f)
+187
(define *rt-a* #f)
+188
(define *rt-b* #f)
+189
(define *cur* #f)
+190
(define *next* #f)
+191
(define *life* #f)
+192
(define *palette* #f)
+193
(define *tex* #f)
+194
(define *done* 0) ; steps taken
+195
(define *frame* 0) ; frames presented
+196
(define *settled* #f) ; the ready line has been said
+197
(define *sum-dt* 0.0)
+198
(define *max-dt* 0.0)
+199
(define *sum-tick* 0.0)
+200
(define *max-tick* 0.0)
+201
(define *n* 0)
+202
+203
(define (say . parts)
+204
(println "~a" (apply str "shader-limits:" (map (lambda (p) (str " " p)) parts))))
+205
+206
;; --- probes: the refusals --------------------------------------------
+207
+208
;; Run thunk expecting it to raise; ok when it does.
+209
(define (expect-error name thunk)
+210
(let ((raised (guard (e (#t (if (error-object? e) (error-object-message e) (str e))))
+211
(thunk)
+212
#f)))
+213
(if raised
+214
(say "probe" name "ok" raised)
+215
(say "probe" name "FAIL" "no error was raised"))))
+216
+217
;; Run thunk expecting it NOT to raise (the positive control beside
+218
;; each refusal).
+219
(define (expect-ok name thunk)
+220
(let ((err (guard (e (#t (if (error-object? e) (error-object-message e) (str e))))
+221
(thunk)
+222
#f)))
+223
(if err
+224
(say "probe" name "FAIL" err)
+225
(say "probe" name "ok"))))
+226
+227
(define (run-probes!)
+228
(expect-error "make-texture-size"
+229
(lambda () (make-texture-from-pixels 4 4 (make-bytevector 10 0))))
+230
(expect-error "update-texture-size"
+231
(lambda () (update-texture *tex* (make-bytevector 3 0))))
+232
;; make-texture-from-pixels spent this frame's upload
+233
(expect-error "update-same-frame"
+234
(lambda () (update-texture *tex* (pixels-4x4 PATTERN-B))))
+235
(expect-error "update-immutable"
+236
(lambda () (update-texture (render-target->texture *rt-a*)
+237
(make-bytevector (* *grid* *grid* 4) 0))))
+238
(expect-error "uniform-count"
+239
(lambda () (set-shader-uniform *palette* 'palette '(1 2 3))))
+240
(expect-error "uniform-scalar-for-array"
+241
(lambda () (set-shader-uniform *palette* 'levels 0.5)))
+242
(expect-error "filter-name"
+243
(lambda () (make-render-target 4 4 'cubic)))
+244
(expect-ok "uniform-nested"
+245
(lambda () (set-shader-uniform *palette* 'palette PALETTE)))
+246
(expect-ok "uniform-flat-vector"
+247
(lambda () (set-shader-uniform *palette* 'levels LEVELS))))
+248
+249
;; --- the field ----------------------------------------------------------
+250
+251
(define (seed-cell! x y)
+252
(draw-filled-rect (inexact x) (inexact y) 1.0 1.0))
+253
+254
;; A glider (moves +1,+1 every four generations) with its trailing row
+255
;; on the last row and column, so within eight steps it crosses both
+256
;; torus edges (a wrong wrap, or a wrong u_resolution inside the pass,
+257
;; deforms it); a blinker (period 2) and a block (still life).
+258
(define (seed!)
+259
(let ((g (- *grid* 3)))
+260
(with-render-target *cur*
+261
(set-color 1.0 1.0 1.0 1.0)
+262
(seed-cell! (+ g 1) g)
+263
(seed-cell! (+ g 2) (+ g 1))
+264
(seed-cell! g (+ g 2))
+265
(seed-cell! (+ g 1) (+ g 2))
+266
(seed-cell! (+ g 2) (+ g 2))
+267
(seed-cell! 9 10) (seed-cell! 10 10) (seed-cell! 11 10)
+268
(seed-cell! 20 20) (seed-cell! 21 20) (seed-cell! 20 21) (seed-cell! 21 21))))
+269
+270
;; One generation: the step shader, bound inside the pass on *next*,
+271
;; samples *cur* through draw-render-target (channel 0). Then swap.
+272
(define (step!)
+273
(with-render-target *next*
+274
(with-shader *life*
+275
(draw-render-target *cur* 0.0 0.0 (inexact *grid*) (inexact *grid*))))
+276
(let ((t *cur*))
+277
(set! *cur* *next*)
+278
(set! *next* t))
+279
(set! *done* (+ *done* 1)))
+280
+281
;; --- the frame ------------------------------------------------------------
+282
+283
(define (draw-strips!)
+284
(with-shader *palette*
+285
(set-shader-uniform *palette* 'u_mode 0.0)
+286
(let loop ((i 0))
+287
(when (< i 8)
+288
(set-shader-uniform *palette* 'u_index (inexact i))
+289
(draw-filled-rect (inexact PAL-X) (inexact (+ 8 (* i STRIP-GAP)))
+290
(inexact STRIP) (inexact STRIP))
+291
(loop (+ i 1))))
+292
(set-shader-uniform *palette* 'u_mode 1.0)
+293
(let loop ((i 0))
+294
(when (< i 4)
+295
(set-shader-uniform *palette* 'u_index (inexact i))
+296
(draw-filled-rect (inexact LEV-X) (inexact (+ 8 (* i STRIP-GAP)))
+297
(inexact STRIP) (inexact STRIP))
+298
(loop (+ i 1))))))
+299
+300
(define (present!)
+301
(clear-screen 0.05 0.05 0.08 1.0)
+302
(set-color 1.0 1.0 1.0 1.0)
+303
(draw-render-target *cur* (inexact FIELD-X) (inexact FIELD-Y)
+304
(inexact FIELD-PX) (inexact FIELD-PX))
+305
(draw-strips!)
+306
(set-color 1.0 1.0 1.0 1.0)
+307
(draw-texture *tex* (inexact PIX-X) (inexact PIX-Y)
+308
(inexact (* 4 PIX-SCALE)) (inexact (* 4 PIX-SCALE))))
+309
+310
;; --- entry points -------------------------------------------------------
+311
+312
;; After gfx-setup and set-viewport. grid: cells per side; steps: the
+313
;; generations to run before saying ready; bench?: never stop, and
+314
;; report timing every BENCH-EVERY frames.
+315
(define (scene-init! grid steps bench?)
+316
(set! *grid* grid)
+317
(set! *steps* steps)
+318
(set! *bench* bench?)
+319
(set! *rt-a* (make-render-target grid grid 'nearest))
+320
(set! *rt-b* (make-render-target grid grid 'nearest))
+321
(set! *cur* *rt-a*)
+322
(set! *next* *rt-b*)
+323
(set! *life* (load-shader VERT-SOURCE LIFE-SOURCE))
+324
(set! *palette* (load-shader VERT-SOURCE PALETTE-SOURCE))
+325
(set! *tex* (make-texture-from-pixels 4 4 (pixels-4x4 PATTERN-A) 'nearest))
+326
(unless (and (shader? *life*) (shader? *palette*) (texture? *tex*))
+327
(say "probe" "resources" "FAIL" "a shader or texture did not load"))
+328
(seed!)
+329
(run-probes!)
+330
(say "init" (str "grid=" grid) (str "steps=" steps)))
+331
+332
(define (scene-settled?) *settled*)
+333
+334
;; One frame: begin-frame/end-frame are the shell's (present! draws
+335
;; between them), so the tick takes a thunk that wraps the frame.
+336
(define (scene-tick! dt with-frame-thunk)
+337
(let ((t0 (current-jiffy)))
+338
(when (or *bench* (< *done* *steps*))
+339
(step!))
+340
;; frame 2 (one end-frame after creation): the real update
+341
(when (= *frame* 1)
+342
(let ((err (guard (e (#t (if (error-object? e) (error-object-message e) (str e))))
+343
(update-texture *tex* (pixels-4x4 PATTERN-B))
+344
#f)))
+345
(if err (say "update" "FAIL" err) (say "update" "ok"))))
+346
(with-frame-thunk present!)
+347
(set! *frame* (+ *frame* 1))
+348
(let ((tick-ms (/ (* 1000.0 (- (current-jiffy) t0)) (jiffies-per-second)))
+349
(dt-ms (* 1000.0 dt)))
+350
(when *bench*
+351
(set! *sum-dt* (+ *sum-dt* dt-ms))
+352
(set! *max-dt* (max *max-dt* dt-ms))
+353
(set! *sum-tick* (+ *sum-tick* tick-ms))
+354
(set! *max-tick* (max *max-tick* tick-ms))
+355
(set! *n* (+ *n* 1))
+356
(when (= *n* BENCH-EVERY)
+357
(say "frame-ms" (round-1 (/ *sum-dt* BENCH-EVERY)) (round-1 *max-dt*)
+358
"tick-ms" (round-1 (/ *sum-tick* BENCH-EVERY)) (round-1 *max-tick*))
+359
(set! *sum-dt* 0.0) (set! *max-dt* 0.0)
+360
(set! *sum-tick* 0.0) (set! *max-tick* 0.0)
+361
(set! *n* 0))))
+362
;; The frame that shows generation *steps* has just been presented
+363
;; (and the update landed a frame earlier): say so, once.
+364
(when (and (not *bench*) (not *settled*) (>= *done* *steps*) (>= *frame* 3))
+365
(set! *settled* #t)
+366
(say "ready" (str "grid=" *grid*) (str "steps=" *done*)))))
+367
+368
(define (round-1 x)
+369
(/ (round (* x 10.0)) 10.0))))
test/test-shader-limits/src/test-shader-limits/shell-native.sgladded
@@ -0,0 +1,49 @@
+1
;;; (test-shader-limits shell-native) - The sigil-app window.
+2
;;;
+3
;;; test-shader-limits [--grid N] [--steps N] [--bench] [--hold SECONDS]
+4
;;;
+5
;;; Steps the scene once per frame until --steps generations are done,
+6
;;; says the ready line, and keeps presenting that frame for --hold
+7
;;; seconds (default 20) so the verifier can capture the window; quits
+8
;;; after that, or on Escape. --bench never settles: it steps every
+9
;;; frame and prints timing every 120 frames until --hold runs out.
+10
+11
(define-library (test-shader-limits shell-native)
+12
(import (sigil core)
+13
(sigil process)
+14
(sigil app)
+15
(sigil graphics)
+16
(test-shader-limits scene))
+17
+18
(export native-main)
+19
+20
(begin
+21
+22
(define (arg-value args flag default)
+23
(cond ((null? args) default)
+24
((and (string=? (car args) flag) (pair? (cdr args))) (cadr args))
+25
(else (arg-value (cdr args) flag default))))
+26
+27
(define (game-loop grid steps bench? hold)
+28
(lambda ()
+29
(gfx-setup)
+30
(set-viewport VW VH)
+31
(scene-init! grid steps bench?)
+32
(let loop ((elapsed 0.0))
+33
(let ((dt (wait-frame)))
+34
(scene-tick! dt (lambda (draw) (with-frame (draw))))
+35
(cond
+36
((key-pressed? 'escape) (request-quit))
+37
((quit-requested?) #t)
+38
((> elapsed hold) (request-quit))
+39
(else (loop (+ elapsed dt))))))
+40
(gfx-shutdown)))
+41
+42
(define (native-main . _args)
+43
(let* ((args (cdr (command-line)))
+44
(grid (or (string->number (arg-value args "--grid" "64")) 64))
+45
(steps (or (string->number (arg-value args "--steps" "8")) 8))
+46
(bench? (and (member "--bench" args) #t))
+47
(hold (or (string->number (arg-value args "--hold" "20")) 20)))
+48
(run-game "test-shader-limits" VW VH (game-loop grid steps bench? hold))
+49
0))))
test/test-shader-limits/src/test-shader-limits/shell-web.sgladded
@@ -0,0 +1,52 @@
+1
;;; (test-shader-limits shell-web) - The browser shell.
+2
;;;
+3
;;; The gles3 app shell dispatches ("frame", ms) once per
+4
;;; requestAnimationFrame; the page dispatches the query parameters
+5
;;; ("grid", N) ("steps", N) ("bench", "on") before the first frame. The
+6
;;; scene is initialised on the first frame so those have landed.
+7
+8
(define-library (test-shader-limits shell-web)
+9
(import (sigil core)
+10
(sigil graphics)
+11
(sigil browser gles3)
+12
(test-shader-limits scene))
+13
+14
(export web-main web-dispatch)
+15
+16
(begin
+17
+18
(define *grid* 64)
+19
(define *steps* 8)
+20
(define *bench* #f)
+21
(define *booted* #f)
+22
(define *last-ms* #f)
+23
+24
(define (frame-dt payload)
+25
(let* ((ms (or (string->number payload) 0.0))
+26
(dt (if *last-ms* (/ (- ms *last-ms*) 1000.0) (/ 1.0 60.0))))
+27
(set! *last-ms* ms)
+28
(if (or (< dt 0.0) (> dt 1.0)) (/ 1.0 60.0) dt)))
+29
+30
(define (web-tick dt)
+31
(unless *booted*
+32
(set! *booted* #t)
+33
(scene-init! *grid* *steps* *bench*))
+34
(gles3-resize-to-display)
+35
(scene-tick! dt (lambda (draw) (begin-frame) (draw) (end-frame))))
+36
+37
(define (web-main)
+38
(unless (gles3-use-canvas "#stage")
+39
(error "test-shader-limits: no WebGL2 context for #stage"))
+40
(gles3-resize-to-display)
+41
(gfx-setup)
+42
(set-viewport VW VH)
+43
(set-letterbox-color 0.0 0.0 0.0)
+44
(gles3-start-loop "test-shader-limits-tick")
+45
0)
+46
+47
(define (web-dispatch type payload)
+48
(cond ((string=? type "frame") (web-tick (frame-dt payload)) 0)
+49
((string=? type "grid") (set! *grid* (or (string->number payload) 64)) 0)
+50
((string=? type "steps") (set! *steps* (or (string->number payload) 8)) 0)
+51
((string=? type "bench") (set! *bench* (string=? payload "on")) 0)
+52
(else 0)))))
test/test-shader-limits/styles.cssadded
@@ -0,0 +1,5 @@
+1
/* test-shader-limits: the canvas at exactly its virtual size. */
+2
html, body { margin: 0; background: #06080f; color: #9aa4b8; font: 14px/1.4 monospace; }
+3
main { display: flex; flex-direction: column; align-items: flex-start; gap: 8px; padding: 8px; }
+4
#stage { width: 640px; height: 480px; display: block; background: #000; }
+5
#status:empty { display: none; }
test/test-shader-limits/verify.mjsadded
@@ -0,0 +1,418 @@
+1
// verify.mjs - reads the presented frame of test-shader-limits on either
+2
// target and asserts every exhibit against what the scene must show.
+3
//
+4
// node verify.mjs --native [--bin build/dev/bin/test-shader-limits]
+5
// [--grid 64] [--steps 8] [--expect-steps N] [--shot PATH]
+6
// node verify.mjs --web [build/web] [--port 8097] [--cdp 9237]
+7
// [--grid 64] [--steps 8] [--expect-steps N] [--shot PATH]
+8
// node verify.mjs --native|--web --bench [--grid 512] [--reports 3]
+9
//
+10
// Native: runs the binary on $DISPLAY (an Xvfb the caller owns), waits for
+11
// its "shader-limits: ready" line, captures the window with ImageMagick's
+12
// `import -window` (so `import`, `identify`, `convert` and `xdotool` must be
+13
// on PATH: run inside `guix shell -m ../../manifest.scm imagemagick xdotool`,
+14
// with LD_LIBRARY_PATH set for libGL, see README.md).
+15
// Web: serves the build dir on loopback, launches google-chrome headless with
+16
// software WebGL (SwiftShader), drives it over the DevTools Protocol (Node
+17
// 22's built-in WebSocket), and reads the canvas inside an animation frame.
+18
//
+19
// Sub-arms, each PASS/FAIL <name>: <detail>; any FAIL exits 1; a wait that
+20
// runs out prints TIMED-OUT and exits 2:
+21
// imports (web) the wasm imports only gl, sigil_wasm_gles3 and wasi
+22
// probes every refusal probe the scene runs said ok, none FAIL
+23
// update update-texture landed on frame 2 ("shader-limits: update ok")
+24
// ready the ready line names the expected grid and step count
+25
// field every cell of the GRIDxGRID Life field matches a CPU Life run
+26
// of the same seed for --expect-steps generations on a torus
+27
// palette the eight vec4 palette[8] strips and the four float levels[4]
+28
// strips show the array entries
+29
// pixels the 4x4 uploaded texture shows the post-update pattern
+30
// console (web) zero error-level console entries and zero exceptions
+31
//
+32
// --expect-steps defaults to --steps; passing a different value is the
+33
// assertion-layer sabotage: the field must then FAIL.
+34
// --bench runs the app in bench mode and prints its frame-ms lines; no
+35
// verdict, it is the measurement for the Session Log.
+36
+37
import http from "node:http";
+38
import fs from "node:fs";
+39
import path from "node:path";
+40
import { spawn, execFileSync } from "node:child_process";
+41
+42
const args = process.argv.slice(2);
+43
const flag = (name) => args.includes(name);
+44
const opt = (name, dflt) => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : dflt; };
+45
const VALUED = ["--bin", "--grid", "--steps", "--expect-steps", "--shot", "--port", "--cdp", "--reports", "--hold"];
+46
const positional = args.filter((a, i) => !a.startsWith("--") && !(i > 0 && VALUED.includes(args[i - 1])));
+47
+48
const NATIVE = flag("--native");
+49
const WEB = flag("--web");
+50
const BENCH = flag("--bench");
+51
if (NATIVE === WEB) { console.log("usage: node verify.mjs --native | --web [options]"); process.exit(2); }
+52
const GRID = parseInt(opt("--grid", BENCH ? "512" : "64"), 10);
+53
const STEPS = parseInt(opt("--steps", "8"), 10);
+54
const EXPECT_STEPS = parseInt(opt("--expect-steps", String(STEPS)), 10);
+55
const SHOT = opt("--shot", `/tmp/test-shader-limits-${NATIVE ? "native" : "web"}.png`);
+56
const REPORTS = parseInt(opt("--reports", "3"), 10);
+57
const HOLD = parseInt(opt("--hold", "60"), 10);
+58
+59
// ---- the scene's geometry, mirrored from scene.sgl ---------------------------
+60
const VW = 640, VH = 480;
+61
const FIELD_X = 8, FIELD_Y = 8, FIELD_PX = 384;
+62
const PAL_X = 408, LEV_X = 440, STRIP = 24, STRIP_GAP = 28;
+63
const PIX_X = 408, PIX_Y = 260, PIX_SCALE = 16;
+64
const PALETTE = [[255, 0, 0], [0, 255, 0], [0, 0, 255], [255, 255, 0], [0, 255, 255], [255, 0, 255], [255, 128, 0], [128, 0, 255]];
+65
const LEVELS = [0, 64, 128, 255];
+66
const PATTERN_B = [
+67
[0, 255, 0], [255, 255, 0], [0, 255, 0], [255, 255, 0],
+68
[255, 255, 0], [255, 255, 255], [255, 255, 0], [0, 255, 0],
+69
[0, 255, 0], [255, 255, 0], [0, 0, 0], [255, 255, 0],
+70
[255, 255, 0], [0, 255, 0], [255, 255, 0], [0, 255, 0],
+71
];
+72
const PROBES = ["make-texture-size", "update-texture-size", "update-same-frame", "update-immutable",
+73
"uniform-count", "uniform-scalar-for-array", "filter-name", "uniform-nested", "uniform-flat-vector"];
+74
+75
// The seed, as scene.sgl draws it, and a CPU Life on a torus.
+76
function seedGrid(n) {
+77
const g = new Uint8Array(n * n);
+78
const set = (x, y) => { g[((y + n) % n) * n + ((x + n) % n)] = 1; };
+79
const s = n - 3;
+80
set(s + 1, s); set(s + 2, s + 1); set(s, s + 2); set(s + 1, s + 2); set(s + 2, s + 2);
+81
set(9, 10); set(10, 10); set(11, 10);
+82
set(20, 20); set(21, 20); set(20, 21); set(21, 21);
+83
return g;
+84
}
+85
function lifeStep(g, n) {
+86
const out = new Uint8Array(n * n);
+87
for (let y = 0; y < n; y++) for (let x = 0; x < n; x++) {
+88
let c = 0;
+89
for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) {
+90
if (dx || dy) c += g[((y + dy + n) % n) * n + ((x + dx + n) % n)];
+91
}
+92
const alive = g[y * n + x];
+93
out[y * n + x] = (c === 3 || (c === 2 && alive)) ? 1 : 0;
+94
}
+95
return out;
+96
}
+97
function expectedField(n, steps) { let g = seedGrid(n); for (let i = 0; i < steps; i++) g = lifeStep(g, n); return g; }
+98
+99
// ---- verdicts -------------------------------------------------------------------
+100
const results = [];
+101
const planned = (WEB ? ["imports"] : []).concat(["probes", "update", "ready", "field", "palette", "pixels", "gl-log"]).concat(WEB ? ["console"] : []);
+102
function pass(name, detail) { results.push([name, "PASS"]); console.log(`PASS ${name}${detail ? ": " + detail : ""}`); }
+103
function fail(name, detail) { results.push([name, "FAIL"]); console.log(`FAIL ${name}: ${detail}`); }
+104
function notRun() { const done = new Set(results.map((r) => r[0])); return planned.filter((p) => !done.has(p)); }
+105
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+106
+107
// A captured frame: RGBA bytes, buffer size, and the virtual-to-buffer scale.
+108
// px(vx, vy) samples the buffer pixel under the centre of virtual pixel (vx, vy).
+109
function makeFrame(data, w, h) {
+110
const sx = w / VW, sy = h / VH;
+111
return {
+112
w, h, sx, sy,
+113
px(vx, vy) {
+114
const bx = Math.min(w - 1, Math.floor((vx + 0.5) * sx)), by = Math.min(h - 1, Math.floor((vy + 0.5) * sy));
+115
const i = (by * w + bx) * 4;
+116
return [data[i], data[i + 1], data[i + 2]];
+117
},
+118
};
+119
}
+120
const near = (a, b, tol) => Math.abs(a[0] - b[0]) <= tol && Math.abs(a[1] - b[1]) <= tol && Math.abs(a[2] - b[2]) <= tol;
+121
+122
function checkLines(lines) {
+123
// probes
+124
const seen = new Map();
+125
for (const l of lines) { const m = l.match(/^shader-limits: probe (\S+) (ok|FAIL)(.*)$/); if (m) seen.set(m[1], m[2] + m[3]); }
+126
const missing = PROBES.filter((p) => !seen.has(p));
+127
const failed = [...seen].filter(([, v]) => v.startsWith("FAIL")).map(([k, v]) => `${k}${v.slice(4)}`);
+128
if (!missing.length && !failed.length) pass("probes", `${PROBES.length} probes ok`);
+129
else fail("probes", `${missing.length ? "missing " + missing.join(",") + "; " : ""}${failed.length ? "failed: " + failed.join(" | ") : ""}`);
+130
// update
+131
const upd = lines.find((l) => /^shader-limits: update /.test(l));
+132
if (upd === "shader-limits: update ok") pass("update", "update-texture landed on frame 2");
+133
else fail("update", upd || "no update line");
+134
// ready
+135
const rdy = lines.map((l) => l.match(/^shader-limits: ready grid=(\d+) steps=(\d+)$/)).find(Boolean);
+136
if (rdy && parseInt(rdy[1], 10) === GRID && parseInt(rdy[2], 10) === EXPECT_STEPS) pass("ready", rdy[0]);
+137
else fail("ready", rdy ? `${rdy[0]} (expected grid=${GRID} steps=${EXPECT_STEPS})` : "no ready line");
+138
}
+139
+140
function checkFrame(frame) {
+141
// field
+142
const exp = expectedField(GRID, EXPECT_STEPS);
+143
const cell = FIELD_PX / GRID;
+144
let mismatches = 0, live = 0, seenLive = 0; const firstBad = [];
+145
for (let y = 0; y < GRID; y++) for (let x = 0; x < GRID; x++) {
+146
const c = frame.px(FIELD_X + x * cell + (cell - 1) / 2, FIELD_Y + y * cell + (cell - 1) / 2);
+147
const lit = c[0] > 128 ? 1 : 0;
+148
live += exp[y * GRID + x]; seenLive += lit;
+149
if (lit !== exp[y * GRID + x]) { mismatches++; if (firstBad.length < 6) firstBad.push(`(${x},${y}) got ${lit} want ${exp[y * GRID + x]}`); }
+150
}
+151
if (mismatches === 0 && live >= 12) pass("field", `${GRID}x${GRID} cells match a CPU Life after ${EXPECT_STEPS} steps; ${live} live cells (glider, blinker, block)`);
+152
else fail("field", `${mismatches} of ${GRID * GRID} cells differ (expected ${live} live, saw ${seenLive}); first: ${firstBad.join("; ")}`);
+153
// palette
+154
const bad = [];
+155
for (let i = 0; i < 8; i++) {
+156
const c = frame.px(PAL_X + STRIP / 2, 8 + i * STRIP_GAP + STRIP / 2);
+157
if (!near(c, PALETTE[i], 12)) bad.push(`palette[${i}] got ${c} want ${PALETTE[i]}`);
+158
}
+159
for (let i = 0; i < 4; i++) {
+160
const c = frame.px(LEV_X + STRIP / 2, 8 + i * STRIP_GAP + STRIP / 2);
+161
if (!near(c, [LEVELS[i], LEVELS[i], LEVELS[i]], 12)) bad.push(`levels[${i}] got ${c} want ${LEVELS[i]}`);
+162
}
+163
if (!bad.length) pass("palette", "8 vec4 palette entries and 4 float levels read back through the shader");
+164
else fail("palette", bad.join("; "));
+165
// pixels
+166
const badPx = [];
+167
for (let t = 0; t < 16; t++) {
+168
const tx = t % 4, ty = Math.floor(t / 4);
+169
const c = frame.px(PIX_X + tx * PIX_SCALE + PIX_SCALE / 2, PIX_Y + ty * PIX_SCALE + PIX_SCALE / 2);
+170
if (!near(c, PATTERN_B[t], 12)) badPx.push(`texel ${tx},${ty} got ${c} want ${PATTERN_B[t]}`);
+171
}
+172
if (!badPx.length) pass("pixels", "16 texels show the update-texture pattern");
+173
else fail("pixels", badPx.join("; "));
+174
}
+175
+176
// sokol logs through slog_func: natively sigil-app's format `[sg][error][id:N] ...`
+177
// on stderr, on the web sigil-graphics' own `sokol[level=L] file:line [tag] msg`
+178
// on the console (level 0 panic, 1 error, 2 warning, 3 info; the web build
+179
// has no SOKOL_DEBUG so the message text is empty and the line number is
+180
// the pointer). A render target whose pass sokol refuses draws nothing and
+181
// says so only here, so any error-level line is a failure.
+182
function checkGlLog(lines) {
+183
const bad = lines.filter((l) => /\[(sg|sgp|sapp)\]\[(error|panic)\]/.test(l) || /^sokol\[level=[01]\]/.test(l));
+184
const warn = lines.filter((l) => /\[(sg|sgp)\]\[warning\]/.test(l) || /^sokol\[level=2\]/.test(l));
+185
if (!bad.length) pass("gl-log", `no sokol error lines${warn.length ? "; " + warn.length + " warning(s): " + warn.slice(0, 3).join(" | ") : ""}`);
+186
else fail("gl-log", `${bad.length} sokol error line(s): ${bad.slice(0, 4).join(" | ")}`);
+187
}
+188
+189
function finish() {
+190
const failed = results.filter((r) => r[1] === "FAIL").length;
+191
const nr = notRun();
+192
console.log(`${failed ? "RED" : "GREEN"}: ${results.length - failed} pass, ${failed} fail${nr.length ? "; did not run: " + nr.join(" ") : ""}`);
+193
return failed || nr.length ? 1 : 0;
+194
}
+195
+196
// =============================================================================
+197
// NATIVE ARM
+198
// =============================================================================
+199
if (NATIVE) {
+200
const bin = path.resolve(opt("--bin", "build/dev/bin/test-shader-limits"));
+201
if (!fs.existsSync(bin)) { console.log(`SETUP-FAILED: ${bin} missing; build first`); process.exit(2); }
+202
if (!process.env.DISPLAY) { console.log("SETUP-FAILED: DISPLAY is not set; point it at a worker-owned Xvfb"); process.exit(2); }
+203
for (const tool of ["xdotool", "import", "convert", "identify"]) {
+204
try { execFileSync("sh", ["-c", `command -v ${tool}`], { stdio: "ignore" }); }
+205
catch { console.log(`SETUP-FAILED: ${tool} is not on PATH (run inside guix shell -m ../../manifest.scm imagemagick xdotool)`); process.exit(2); }
+206
}
+207
const appArgs = ["--grid", String(GRID), "--steps", String(STEPS), "--hold", String(HOLD)].concat(BENCH ? ["--bench"] : []);
+208
const child = spawn(bin, appArgs, { stdio: ["ignore", "pipe", "pipe"], detached: true });
+209
const lines = [];
+210
let buf = "";
+211
child.stdout.on("data", (d) => { buf += d.toString(); let i; while ((i = buf.indexOf("\n")) >= 0) { lines.push(buf.slice(0, i)); buf = buf.slice(i + 1); } });
+212
const stderrLines = [];
+213
child.stderr.on("data", (d) => { for (const l of d.toString().split("\n")) if (l.trim()) stderrLines.push(l); });
+214
let exited = null;
+215
child.on("exit", (code, sig) => { exited = { code, sig }; });
+216
const stop = () => { try { process.kill(-child.pid, "SIGTERM"); } catch { /* gone */ } };
+217
process.on("exit", stop);
+218
+219
const waitLine = async (re, ms) => {
+220
const t0 = Date.now();
+221
while (Date.now() - t0 < ms) {
+222
const hit = lines.find((l) => re.test(l));
+223
if (hit) return hit;
+224
if (exited) return null;
+225
await sleep(100);
+226
}
+227
return null;
+228
};
+229
+230
if (BENCH) {
+231
const t0 = Date.now();
+232
while (Date.now() - t0 < HOLD * 1000 + 5000) {
+233
const reports = lines.filter((l) => /^shader-limits: frame-ms /.test(l));
+234
if (reports.length >= REPORTS || exited) break;
+235
await sleep(200);
+236
}
+237
console.log(`bench native grid=${GRID} (renderer: ${glRenderer()})`);
+238
for (const l of lines.filter((l) => /^shader-limits: (init|frame-ms) /.test(l))) console.log(" " + l);
+239
stop();
+240
process.exit(0);
+241
}
+242
+243
const ready = await waitLine(/^shader-limits: ready /, 30000);
+244
if (!ready) {
+245
console.log(`TIMED-OUT ready: no ready line in 30 s${exited ? ` (exited ${JSON.stringify(exited)})` : ""}; did not run: ${notRun().join(" ")}`);
+246
console.log("stdout so far:\n " + lines.join("\n ")); console.log("stderr so far:\n " + stderrLines.join("\n "));
+247
stop(); process.exit(2);
+248
}
+249
checkLines(lines);
+250
// The frame that shows the settled state was presented before the line
+251
// was printed, and the app keeps presenting it; one more frame's worth of
+252
// time makes sure the X server has it before the capture.
+253
await sleep(300);
+254
let xid = "";
+255
for (let i = 0; i < 50 && !xid; i++) {
+256
try { xid = execFileSync("xdotool", ["search", "--name", "test-shader-limits"]).toString().trim().split("\n")[0]; } catch { /* not yet */ }
+257
if (!xid) await sleep(100);
+258
}
+259
if (!xid) { console.log("SETUP-FAILED: no X window named test-shader-limits; did not run: " + notRun().join(" ")); stop(); process.exit(2); }
+260
execFileSync("import", ["-window", xid, SHOT]);
+261
const [w, h] = execFileSync("identify", ["-format", "%w %h", SHOT]).toString().trim().split(" ").map(Number);
+262
const raw = execFileSync("convert", [SHOT, "-depth", "8", "rgba:-"], { maxBuffer: 64 * 1024 * 1024 });
+263
if (raw.length !== w * h * 4) { console.log(`SETUP-FAILED: raw dump is ${raw.length} bytes for ${w}x${h}`); stop(); process.exit(2); }
+264
console.log(`note: captured ${w}x${h} window to ${SHOT}`);
+265
checkFrame(makeFrame(raw, w, h));
+266
checkGlLog(stderrLines.concat(lines));
+267
stop();
+268
process.exit(finish());
+269
}
+270
+271
function glRenderer() {
+272
try { return execFileSync("sh", ["-c", "glxinfo -B 2>/dev/null | grep -i 'renderer string' | head -1"]).toString().trim() || "glxinfo unavailable"; }
+273
catch { return "glxinfo unavailable"; }
+274
}
+275
+276
// =============================================================================
+277
// WEB ARM
+278
// =============================================================================
+279
const ROOT = path.resolve(positional[0] || "build/web");
+280
const PORT = parseInt(opt("--port", "8097"), 10);
+281
const CDP = parseInt(opt("--cdp", "9237"), 10);
+282
const TYPES = { ".html": "text/html;charset=utf-8", ".js": "text/javascript;charset=utf-8", ".wasm": "application/wasm",
+283
".json": "application/json;charset=utf-8", ".css": "text/css;charset=utf-8", ".png": "image/png" };
+284
+285
// ---- imports, from the file on disk ------------------------------------------
+286
{
+287
const wasmPath = path.join(ROOT, "test-shader-limits.wasm");
+288
if (!fs.existsSync(wasmPath)) { console.log(`SETUP-FAILED imports: ${wasmPath} missing; build --config web first`); process.exit(2); }
+289
const mod = new WebAssembly.Module(fs.readFileSync(wasmPath));
+290
const byModule = {};
+291
for (const imp of WebAssembly.Module.imports(mod)) (byModule[imp.module] = byModule[imp.module] || []).push(imp.name);
+292
const modules = Object.keys(byModule).sort();
+293
const expected = ["gl", "sigil_wasm_gles3", "wasi_snapshot_preview1"];
+294
const listing = modules.map((m) => `${m}(${byModule[m].length})`).join(" ");
+295
if (JSON.stringify(modules) === JSON.stringify(expected)) pass("imports", listing);
+296
else fail("imports", `expected modules ${expected.join(",")} got ${listing}`);
+297
}
+298
+299
const server = http.createServer((req, res) => {
+300
const urlPath = decodeURIComponent((req.url || "/").split("?")[0]);
+301
const fp = path.join(ROOT, urlPath === "/" ? "/index.html" : urlPath);
+302
if (fp !== ROOT && !fp.startsWith(ROOT + path.sep)) { res.writeHead(403).end(); return; }
+303
fs.readFile(fp, (err, buf) => {
+304
if (err) { res.writeHead(404).end("not found: " + urlPath); return; }
+305
res.writeHead(200, { "Content-Type": TYPES[path.extname(fp)] || "application/octet-stream", "Cache-Control": "no-store" });
+306
res.end(buf);
+307
});
+308
});
+309
await new Promise((r) => server.listen(PORT, "127.0.0.1", r));
+310
+311
const udd = fs.mkdtempSync("/tmp/test-shader-limits-chrome-");
+312
const chrome = spawn("google-chrome", [
+313
"--headless=new", "--no-sandbox", "--disable-dev-shm-usage",
+314
"--use-gl=angle", "--use-angle=swiftshader", "--enable-unsafe-swiftshader",
+315
"--enable-webgl", "--ignore-gpu-blocklist",
+316
`--remote-debugging-port=${CDP}`, `--user-data-dir=${udd}`,
+317
"--window-size=800,600", "about:blank",
+318
], { stdio: "ignore", detached: true });
+319
function killChromeGroup(sig) { try { process.kill(-chrome.pid, sig); } catch { /* gone */ } }
+320
let exiting = false;
+321
function shutdown(code) {
+322
if (exiting) return; exiting = true;
+323
try { server.close(); } catch { /* not listening */ }
+324
killChromeGroup("SIGTERM");
+325
setTimeout(() => { killChromeGroup("SIGKILL"); try { fs.rmSync(udd, { recursive: true, force: true }); } catch { /* scratch */ } process.exit(code); }, 1500).unref();
+326
const done = () => { try { fs.rmSync(udd, { recursive: true, force: true }); } catch { /* scratch */ } process.exit(code); };
+327
if (chrome.exitCode !== null) done(); else chrome.once("exit", done);
+328
}
+329
process.on("exit", () => { killChromeGroup("SIGKILL"); try { fs.rmSync(udd, { recursive: true, force: true }); } catch { /* scratch */ } });
+330
process.on("SIGINT", () => shutdown(130));
+331
process.on("SIGTERM", () => shutdown(143));
+332
process.on("unhandledRejection", (err) => { console.log("EXCEPTION: " + (err && err.stack || err)); shutdown(2); });
+333
process.on("uncaughtException", (err) => { console.log("EXCEPTION: " + (err && err.stack || err)); shutdown(2); });
+334
setTimeout(() => { console.log(`TIMED-OUT whole run after 120 s; did not run: ${notRun().join(" ")}`); shutdown(2); }, 120000).unref();
+335
+336
let pageWs = null;
+337
for (let i = 0; i < 80 && !pageWs; i++) {
+338
try { const ts = await (await fetch(`http://127.0.0.1:${CDP}/json`)).json(); const p = ts.find((t) => t.type === "page"); if (p && p.webSocketDebuggerUrl) pageWs = p.webSocketDebuggerUrl; } catch { /* not up yet */ }
+339
await sleep(250);
+340
}
+341
if (!pageWs) { console.log("SETUP-FAILED: no chrome page target after 20 s; did not run: " + notRun().join(" ")); shutdown(2); }
+342
+343
const ws = new WebSocket(pageWs);
+344
let msgId = 0; const pending = new Map();
+345
const consoleLines = []; const consoleErrors = [];
+346
function send(method, params = {}) {
+347
return new Promise((res, rej) => { const id = ++msgId; pending.set(id, { res, rej }); ws.send(JSON.stringify({ id, method, params })); });
+348
}
+349
ws.addEventListener("message", (ev) => {
+350
const msg = JSON.parse(ev.data);
+351
if (msg.id && pending.has(msg.id)) { const { res, rej } = pending.get(msg.id); pending.delete(msg.id); msg.error ? rej(new Error(JSON.stringify(msg.error))) : res(msg.result); return; }
+352
if (msg.method === "Runtime.consoleAPICalled") {
+353
const text = (msg.params.args || []).map((a) => a.value ?? a.description ?? "").join(" ");
+354
consoleLines.push(text);
+355
if (msg.params.type === "error") consoleErrors.push("console.error: " + text);
+356
}
+357
if (msg.method === "Log.entryAdded" && msg.params.entry.level === "error") consoleErrors.push("log: " + msg.params.entry.text);
+358
if (msg.method === "Runtime.exceptionThrown") consoleErrors.push("exception: " + (msg.params.exceptionDetails?.exception?.description || msg.params.exceptionDetails?.text));
+359
});
+360
await new Promise((res, rej) => { ws.addEventListener("open", res); ws.addEventListener("error", rej); });
+361
await send("Page.enable"); await send("Runtime.enable"); await send("Log.enable");
+362
// A devicePixelRatio of 2 so the canvas buffer (1280x960) is not the virtual
+363
// size: the sampler's scale mapping is then exercised, not the identity.
+364
await send("Emulation.setDeviceMetricsOverride", { width: 800, height: 600, deviceScaleFactor: 2, mobile: false });
+365
+366
async function evalJS(expr) {
+367
const r = await send("Runtime.evaluate", { expression: expr, returnByValue: true, awaitPromise: true });
+368
if (r.exceptionDetails) throw new Error(JSON.stringify(r.exceptionDetails));
+369
return r.result.value;
+370
}
+371
async function waitLine(re, ms) {
+372
const t0 = Date.now();
+373
while (Date.now() - t0 < ms) { const hit = consoleLines.find((l) => re.test(l)); if (hit) return hit; await sleep(100); }
+374
return null;
+375
}
+376
+377
const query = `?grid=${GRID}&steps=${STEPS}${BENCH ? "&bench" : ""}`;
+378
await send("Page.navigate", { url: `http://127.0.0.1:${PORT}/index.html${query}` });
+379
+380
if (BENCH) {
+381
const t0 = Date.now();
+382
while (Date.now() - t0 < 90000) {
+383
if (consoleLines.filter((l) => /^shader-limits: frame-ms /.test(l)).length >= REPORTS) break;
+384
await sleep(200);
+385
}
+386
const renderer = await evalJS(`(() => { const c = document.createElement("canvas"); const gl = c.getContext("webgl2"); const d = gl && gl.getExtension("WEBGL_debug_renderer_info"); return d ? gl.getParameter(d.UNMASKED_RENDERER_WEBGL) : "unknown"; })()`);
+387
console.log(`bench web grid=${GRID} (renderer: ${renderer})`);
+388
for (const l of consoleLines.filter((l) => /^shader-limits: (init|frame-ms) /.test(l))) console.log(" " + l);
+389
shutdown(0);
+390
} else {
+391
const ready = await waitLine(/^shader-limits: ready /, 30000);
+392
if (!ready) {
+393
console.log(`TIMED-OUT ready: no ready line in 30 s; did not run: ${notRun().join(" ")}`);
+394
console.log("console so far:\n " + consoleLines.join("\n ") + "\nerrors:\n " + consoleErrors.join("\n "));
+395
shutdown(2);
+396
} else {
+397
checkLines(consoleLines);
+398
if (flag("--verbose")) console.log("console:\n " + consoleLines.join("\n "));
+399
// Read the canvas inside an animation frame: the app's tick registered
+400
// its next frame first, so a callback registered now runs after the draw
+401
// and before the (non-preserved) buffer is discarded.
+402
const px = await evalJS(`new Promise((resolve) => requestAnimationFrame(() => {
+403
const c = document.getElementById("stage");
+404
const off = document.createElement("canvas"); off.width = c.width; off.height = c.height;
+405
const g = off.getContext("2d"); g.drawImage(c, 0, 0);
+406
const d = g.getImageData(0, 0, c.width, c.height).data;
+407
resolve({ w: c.width, h: c.height, data: Array.from(d) });
+408
}))`);
+409
const shot = await send("Page.captureScreenshot", { format: "png" });
+410
fs.writeFileSync(SHOT, Buffer.from(shot.data, "base64"));
+411
console.log(`note: canvas buffer ${px.w}x${px.h}; page screenshot at ${SHOT}`);
+412
checkFrame(makeFrame(Uint8Array.from(px.data), px.w, px.h));
+413
checkGlLog(consoleLines);
+414
if (!consoleErrors.length) pass("console", "zero error entries, zero exceptions");
+415
else fail("console", consoleErrors.slice(0, 5).join(" | "));
+416
shutdown(finish());
+417
}
+418
}