Make render targets work on the web build; add the test-shader-limits proof
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.
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(-)src/c/graphics.cmodified
{ GfxTexture *tex = (GfxTexture *)data; if (tex) { if (tex->owns_resources) { if (tex->owns_resources && sg_isvalid()) { if (tex->view.id != SG_INVALID_ID) { sg_destroy_view(tex->view); }static void rt_free_resources(GfxRenderTarget *rt){ if (!rt || rt->freed) return; /* After gfx-shutdown (sg_shutdown) the handles are already gone and * sokol asserts on any destroy; a finalizer running then has nothing * to release. */ if (!sg_isvalid()) { rt->freed = true; return; } if (rt->color_att_view.id != SG_INVALID_ID) { sg_destroy_view(rt->color_att_view); } /* Depth-stencil attachment — needed only for pipeline-validation * compatibility with sgp's default pipelines, which bake in the * swap chain's depth-stencil format. */ sg_image_desc depth_desc = { .usage = { .depth_stencil_attachment = true }, .width = w, .height = h, }; sg_image depth_img = sg_make_image(&depth_desc); if (depth_img.id == SG_INVALID_ID) { sg_destroy_view(tex_view); sg_destroy_view(att_view); sg_destroy_image(img); sigil__vm_set_error(vm, SIGIL_ERR_RUNTIME, "make-render-target: failed to create depth image"); return SIGIL_FALSE; } * environment's default depth format. Natively that is the swap * chain's depth-stencil format, so the pass must carry a matching * attachment. On the web build sig_gfx_environment() declares * depth_format = SG_PIXELFORMAT_NONE: the pipelines expect NO depth * attachment, and an image made with that format fails * (GL_TEXTURE_FORMAT_NOT_SUPPORTED), which left every web render * target's pass refused with BEGINPASS_ATTACHMENTS_ALIVE before * v0.11.3. So: only when the environment has a depth format. */ sg_image depth_img = { SG_INVALID_ID }; sg_view depth_att_view = { SG_INVALID_ID }; bool want_depth = sg_query_desc().environment.defaults.depth_format != SG_PIXELFORMAT_NONE; if (want_depth) { sg_image_desc depth_desc = { .usage = { .depth_stencil_attachment = true }, .width = w, .height = h, }; depth_img = sg_make_image(&depth_desc); if (depth_img.id == SG_INVALID_ID) { sg_destroy_view(tex_view); sg_destroy_view(att_view); sg_destroy_image(img); sigil__vm_set_error(vm, SIGIL_ERR_RUNTIME, "make-render-target: failed to create depth image"); return SIGIL_FALSE; } sg_view_desc depth_att_desc = { .depth_stencil_attachment = { .image = depth_img }, .label = "sigil-rt-depth-attachment", }; sg_view depth_att_view = sg_make_view(&depth_att_desc); if (depth_att_view.id == SG_INVALID_ID) { sg_destroy_image(depth_img); sg_destroy_view(tex_view); sg_destroy_view(att_view); sg_destroy_image(img); sigil__vm_set_error(vm, SIGIL_ERR_RUNTIME, "make-render-target: failed to create depth attachment view"); return SIGIL_FALSE; sg_view_desc depth_att_desc = { .depth_stencil_attachment = { .image = depth_img }, .label = "sigil-rt-depth-attachment", }; depth_att_view = sg_make_view(&depth_att_desc); if (depth_att_view.id == SG_INVALID_ID) { sg_destroy_image(depth_img); sg_destroy_view(tex_view); sg_destroy_view(att_view); sg_destroy_image(img); sigil__vm_set_error(vm, SIGIL_ERR_RUNTIME, "make-render-target: failed to create depth attachment view"); return SIGIL_FALSE; } } sg_sampler_desc smp_desc = { GfxRenderTarget *rt = malloc(sizeof(GfxRenderTarget)); if (!rt) { sg_destroy_sampler(smp); sg_destroy_view(depth_att_view); sg_destroy_image(depth_img); if (depth_att_view.id != SG_INVALID_ID) sg_destroy_view(depth_att_view); if (depth_img.id != SG_INVALID_ID) sg_destroy_image(depth_img); sg_destroy_view(tex_view); sg_destroy_view(att_view); sg_destroy_image(img);static void shader_free_resources(GfxShader *sh){ if (!sh || sh->freed) return; if (!sg_isvalid()) { sh->freed = true; active_shader = (active_shader == sh) ? NULL : active_shader; return; } if (active_shader == sh) { active_shader = NULL; }test/test-shader-limits/dev-redirects.sgladded
;; Local sigil-graphics is the in-progress version under development.(redirects repos: (list (for-repo url: "codeberg:sigil/sigil-graphics" use: (from-path dir: "../.."))))test/test-shader-limits/index.template.htmladded
<!doctype html><html lang="en"><head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>test-shader-limits</title> <link rel="icon" href="data:,"> <link rel="stylesheet" href="styles.css"></head><body> <main> <!-- 640x480 CSS pixels, the scene's virtual viewport, so the buffer (CSS size times devicePixelRatio) has the same aspect and no letterbox: verify.mjs maps virtual pixels by one scale. --> <canvas id="stage" width="640" height="480"></canvas> <div id="status"></div> </main> <script> // The page's parameters, dispatched to the app before its first frame // (a dispatch made before the runtime starts is queued by the loader): // grid=N cells per side (64) // steps=N generations before the ready line (8) // bench never settle; print frame-ms every 120 frames (function () { "use strict"; function withApp(fn) { if (globalThis.SigilWebApp) { fn(globalThis.SigilWebApp); return; } document.addEventListener("sigil-web-app-ready", function (e) { fn(e.detail); }, { once: true }); } withApp(function (app) { var params = new URLSearchParams(location.search); if (params.has("grid")) app.dispatch("grid", params.get("grid")); if (params.has("steps")) app.dispatch("steps", params.get("steps")); if (params.has("bench")) app.dispatch("bench", "on"); }); })(); </script> <!--sigil:app--></body></html>test/test-shader-limits/package.sgladded
;;; test-shader-limits — the measured proof for sigil-graphics v0.11.3's;;; shader additions, on native and on the web build.;;;;;; One scene, two shells (the (crash main) seam): a Game of Life stepped;;; entirely on the GPU by render-target ping-pong with a shader bound;;; inside with-render-target, a palette drawn from `vec4 palette[8]` and;;; `float levels[4]` uniform arrays, and a 4x4 texture made from pixels;;; and refilled with update-texture. verify.mjs reads the presented frame;;; on both targets and asserts every cell, strip and texel.;;;;;; sigil deps install --redirects dev-redirects.sgl;;; sigil build --redirects dev-redirects.sgl native;;; sigil build --redirects dev-redirects.sgl --config web browser;;; node verify.mjs --native | --web (see README)(define sigil-repo "codeberg:sigil/sigil")(package name: "test-shader-limits" version: "0.0.1" sigil: "0.22" description: "Proof: uniform arrays, pixel textures, GPU ping-pong automaton on native and web" license: "BSD-3-Clause" authors: (list "David Wilson <david@daviwil.com>") entry: '(test-shader-limits main) bundle-name: "test-shader-limits" configs: (list (config name: 'dev output-dir: "build/dev" debug?: #t optimize: 0 bundle?: #t) ;; The browser build: wasm32-wasi through zig, native codegen, the ;; shape of crash-the-stack's web config. (config name: 'web output-dir: "build/web" toolchain: 'zig target: "wasm32-wasi" backend: 'native debug?: #f optimize: 2 bundle?: #t features: '(web wasm graphics))) dependencies: (list ;; Windowing, native only (sokol_app + X11 cannot build for wasm). (from-git url: "codeberg:sigil/sigil-app" version: "^0.9.0" configs: '(dev)) ;; The package under test; dev-redirects.sgl points it at ../.. (from-git url: "codeberg:sigil/sigil-graphics" version: "^0.11.2") ;; Web only: the wasm runtime (loader + entry ABI) and the raw-WebGL2 ;; binding sigil-graphics' sokol backend draws through. (from-git url: sigil-repo package: "sigil-wasm-runtime" version: ">=0.22" configs: '(web)) (from-git url: sigil-repo package: "sigil-wasm-gles3" version: ">=0.22" configs: '(web))) ;; The web build's task; the dev config takes sigil-build's default. tasks: (list (task name: 'build configs: '(web) description: "Compile the scene and link the browser app" steps: (list (compile-sigil-modules sources: "src/**/*.sgl") (link-web-application name: "test-shader-limits" html-mount-id: "status")))))test/test-shader-limits/src/test-shader-limits/main.sgladded
;;; (test-shader-limits main) - The entry, and the seam between the two;;; targets (the shape of crash-the-stack's (crash main)).(define-library (test-shader-limits main) (cond-expand (wasm (import (test-shader-limits shell-web))) (else (import (test-shader-limits shell-native)))) (export main sigil-web-dispatch-event) (begin (cond-expand (wasm (define (main . args) (web-main)) (define (sigil-web-dispatch-event type payload) (web-dispatch type payload))) (else (define (main . args) (apply native-main args)) (define (sigil-web-dispatch-event type payload) 0)))))test/test-shader-limits/src/test-shader-limits/scene.sgladded
;;; (test-shader-limits scene) - The scene both shells draw.;;;;;; Three exhibits in a 640x480 virtual viewport, each read back by;;; verify.mjs from the presented frame:;;;;;; FIELD a Game of Life on a GRIDxGRID torus, stepped on the GPU:;;; two render targets, the step shader bound with with-shader;;; INSIDE with-render-target, sampling the other target through;;; draw-render-target (channel 0), swapped every step. Seeded;;; by draw-filled-rect, never touched by the CPU again. Drawn;;; at CELL px per cell at (FIELD-X, FIELD-Y) with the target's;;; 'nearest filter, so each cell is a flat CELLxCELL block.;;; The glider starts near the bottom-right corner so it wraps;;; across both edges within STEPS: the wrap uses u_resolution,;;; which inside the pass must be the target's size.;;; PALETTE eight 24x24 strips whose colour is palette[i] from;;; `uniform vec4 palette[8]`, and four grey strips from;;; `uniform float levels[4]`, i chosen per draw by u_index.;;; PIXELS a 4x4 texture from make-texture-from-pixels drawn 64x64;;; with 'nearest, refilled by update-texture on the second;;; frame; the frame the verifier reads must show the second;;; pattern.;;;;;; Trace lines (stdout natively, console.log on the web):;;; shader-limits: probe NAME ok|FAIL DETAIL the refusals at init;;; shader-limits: update ok|FAIL update-texture landed on frame 2;;; shader-limits: ready grid=G steps=N the field has stepped N times and;;; the frame showing it was presented;;; shader-limits: frame-ms MEAN MAX tick-ms MEAN MAX (bench mode, every;;; BENCH-EVERY frames): wall dt;;; between frames and the tick's;;; own step+present CPU time(define-library (test-shader-limits scene) (import (sigil core) (sigil math) (sigil string) (sigil time) (sigil graphics)) (export VW VH FIELD-X FIELD-Y FIELD-PX scene-init! scene-tick! scene-settled? say) (begin (define VW 640) (define VH 480) (define FIELD-X 8) (define FIELD-Y 8) (define FIELD-PX 384) ; the field is drawn FIELD-PX square whatever GRID is (define PAL-X 408) (define LEV-X 440) (define STRIP 24) (define STRIP-GAP 28) (define PIX-X 408) (define PIX-Y 260) (define PIX-SCALE 16) (define BENCH-EVERY 120) ;; --- GLSL ------------------------------------------------------------- ;; One source, two dialects: the desktop core profile natively, GLSL ES ;; 3.00 on the web build (the version line and the precision defaults ;; are the only differences the sources need). (cond-expand (wasm (define GLSL-PREFIX "#version 300 es\nprecision highp float;\nprecision highp int;\nprecision mediump sampler2D;\n")) (else (define GLSL-PREFIX "#version 410\n"))) ;; sgp's vertex layout: coord = (position.xy, texcoord.zw), color at 1. (define VERT-SOURCE (string-append GLSL-PREFIX "layout(location = 0) in vec4 coord;\n" "layout(location = 1) in vec4 color;\n" "out vec2 texUV;\n" "out vec4 iColor;\n" "void main() {\n" " gl_Position = vec4(coord.xy, 0.0, 1.0);\n" " texUV = coord.zw;\n" " iColor = color;\n" "}\n")) ;; The Life step. Reads the previous generation from channel 0 by ;; integer texel (texelFetch: no sampler filtering, no UV flip to get ;; wrong), wrapping on u_resolution, which is the target's size when ;; the shader is bound inside with-render-target. (define LIFE-SOURCE (string-append GLSL-PREFIX "uniform sampler2D iTexChannel0_iSmpChannel0;\n" "uniform vec2 u_resolution;\n" "in vec2 texUV;\n" "in vec4 iColor;\n" "out vec4 fragColor;\n" "int cell(ivec2 q, ivec2 size) {\n" " q = (q + size) % size;\n" " return texelFetch(iTexChannel0_iSmpChannel0, q, 0).r > 0.5 ? 1 : 0;\n" "}\n" "void main() {\n" " ivec2 size = ivec2(u_resolution);\n" " ivec2 p = ivec2(gl_FragCoord.xy);\n" " int n = 0;\n" " for (int dy = -1; dy <= 1; dy++) {\n" " for (int dx = -1; dx <= 1; dx++) {\n" " if (dx != 0 || dy != 0) n += cell(p + ivec2(dx, dy), size);\n" " }\n" " }\n" " int alive = cell(p, size);\n" " float next = (n == 3 || (n == 2 && alive == 1)) ? 1.0 : 0.0;\n" " fragColor = vec4(next, next, next, 1.0);\n" "}\n")) ;; The palette exhibit: colour from a vec4 array or a float array, ;; the entry picked by u_index (a float, as every uniform here is). (define PALETTE-SOURCE (string-append GLSL-PREFIX "uniform sampler2D iTexChannel0_iSmpChannel0;\n" "uniform vec4 palette[8];\n" "uniform float levels[4];\n" "uniform float u_index;\n" "uniform float u_mode;\n" "in vec2 texUV;\n" "in vec4 iColor;\n" "out vec4 fragColor;\n" "void main() {\n" " int i = int(u_index + 0.5);\n" " vec4 c = (u_mode < 0.5) ? palette[i] : vec4(vec3(levels[i]), 1.0);\n" " // the white texture sgp binds for filled shapes: identity\n" " fragColor = c * texture(iTexChannel0_iSmpChannel0, texUV) * iColor;\n" "}\n")) ;; The eight palette entries and four levels the verifier expects, ;; chosen so every strip is a distinct, saturated colour. (define PALETTE '((1.0 0.0 0.0 1.0) (0.0 1.0 0.0 1.0) (0.0 0.0 1.0 1.0) (1.0 1.0 0.0 1.0) (0.0 1.0 1.0 1.0) (1.0 0.0 1.0 1.0) (1.0 0.5 0.0 1.0) (0.5 0.0 1.0 1.0))) (define LEVELS #(0.0 0.25 0.5 1.0)) ;; --- the pixel texture ----------------------------------------------- ;; A 4x4 RGBA8 bytevector from a list of 16 (r g b) triples, top row ;; first (the orientation load-texture gives and the verifier reads). (define (pixels-4x4 triples) (let ((bv (make-bytevector (* 4 4 4) 255))) (let loop ((i 0) (ts triples)) (when (and (< i 16) (pair? ts)) (let ((t (car ts))) (bytevector-u8-set! bv (* i 4) (car t)) (bytevector-u8-set! bv (+ (* i 4) 1) (cadr t)) (bytevector-u8-set! bv (+ (* i 4) 2) (caddr t))) (loop (+ i 1) (cdr ts)))) bv)) ;; Frame 1 shows PATTERN-A (a red/blue checker); update-texture on ;; frame 2 replaces it with PATTERN-B (green/yellow, with one white ;; and one black texel so a row/column swap or a stale texture reads ;; as a failure, not a coincidence). (define PATTERN-A '((255 0 0) (0 0 255) (255 0 0) (0 0 255) (0 0 255) (255 0 0) (0 0 255) (255 0 0) (255 0 0) (0 0 255) (255 0 0) (0 0 255) (0 0 255) (255 0 0) (0 0 255) (255 0 0))) (define PATTERN-B '((0 255 0) (255 255 0) (0 255 0) (255 255 0) (255 255 0) (255 255 255) (255 255 0) (0 255 0) (0 255 0) (255 255 0) (0 0 0) (255 255 0) (255 255 0) (0 255 0) (255 255 0) (0 255 0))) ;; --- state ------------------------------------------------------------ (define *grid* 64) (define *steps* 8) (define *bench* #f) (define *rt-a* #f) (define *rt-b* #f) (define *cur* #f) (define *next* #f) (define *life* #f) (define *palette* #f) (define *tex* #f) (define *done* 0) ; steps taken (define *frame* 0) ; frames presented (define *settled* #f) ; the ready line has been said (define *sum-dt* 0.0) (define *max-dt* 0.0) (define *sum-tick* 0.0) (define *max-tick* 0.0) (define *n* 0) (define (say . parts) (println "~a" (apply str "shader-limits:" (map (lambda (p) (str " " p)) parts)))) ;; --- probes: the refusals -------------------------------------------- ;; Run thunk expecting it to raise; ok when it does. (define (expect-error name thunk) (let ((raised (guard (e (#t (if (error-object? e) (error-object-message e) (str e)))) (thunk) #f))) (if raised (say "probe" name "ok" raised) (say "probe" name "FAIL" "no error was raised")))) ;; Run thunk expecting it NOT to raise (the positive control beside ;; each refusal). (define (expect-ok name thunk) (let ((err (guard (e (#t (if (error-object? e) (error-object-message e) (str e)))) (thunk) #f))) (if err (say "probe" name "FAIL" err) (say "probe" name "ok")))) (define (run-probes!) (expect-error "make-texture-size" (lambda () (make-texture-from-pixels 4 4 (make-bytevector 10 0)))) (expect-error "update-texture-size" (lambda () (update-texture *tex* (make-bytevector 3 0)))) ;; make-texture-from-pixels spent this frame's upload (expect-error "update-same-frame" (lambda () (update-texture *tex* (pixels-4x4 PATTERN-B)))) (expect-error "update-immutable" (lambda () (update-texture (render-target->texture *rt-a*) (make-bytevector (* *grid* *grid* 4) 0)))) (expect-error "uniform-count" (lambda () (set-shader-uniform *palette* 'palette '(1 2 3)))) (expect-error "uniform-scalar-for-array" (lambda () (set-shader-uniform *palette* 'levels 0.5))) (expect-error "filter-name" (lambda () (make-render-target 4 4 'cubic))) (expect-ok "uniform-nested" (lambda () (set-shader-uniform *palette* 'palette PALETTE))) (expect-ok "uniform-flat-vector" (lambda () (set-shader-uniform *palette* 'levels LEVELS)))) ;; --- the field ---------------------------------------------------------- (define (seed-cell! x y) (draw-filled-rect (inexact x) (inexact y) 1.0 1.0)) ;; A glider (moves +1,+1 every four generations) with its trailing row ;; on the last row and column, so within eight steps it crosses both ;; torus edges (a wrong wrap, or a wrong u_resolution inside the pass, ;; deforms it); a blinker (period 2) and a block (still life). (define (seed!) (let ((g (- *grid* 3))) (with-render-target *cur* (set-color 1.0 1.0 1.0 1.0) (seed-cell! (+ g 1) g) (seed-cell! (+ g 2) (+ g 1)) (seed-cell! g (+ g 2)) (seed-cell! (+ g 1) (+ g 2)) (seed-cell! (+ g 2) (+ g 2)) (seed-cell! 9 10) (seed-cell! 10 10) (seed-cell! 11 10) (seed-cell! 20 20) (seed-cell! 21 20) (seed-cell! 20 21) (seed-cell! 21 21)))) ;; One generation: the step shader, bound inside the pass on *next*, ;; samples *cur* through draw-render-target (channel 0). Then swap. (define (step!) (with-render-target *next* (with-shader *life* (draw-render-target *cur* 0.0 0.0 (inexact *grid*) (inexact *grid*)))) (let ((t *cur*)) (set! *cur* *next*) (set! *next* t)) (set! *done* (+ *done* 1))) ;; --- the frame ------------------------------------------------------------ (define (draw-strips!) (with-shader *palette* (set-shader-uniform *palette* 'u_mode 0.0) (let loop ((i 0)) (when (< i 8) (set-shader-uniform *palette* 'u_index (inexact i)) (draw-filled-rect (inexact PAL-X) (inexact (+ 8 (* i STRIP-GAP))) (inexact STRIP) (inexact STRIP)) (loop (+ i 1)))) (set-shader-uniform *palette* 'u_mode 1.0) (let loop ((i 0)) (when (< i 4) (set-shader-uniform *palette* 'u_index (inexact i)) (draw-filled-rect (inexact LEV-X) (inexact (+ 8 (* i STRIP-GAP))) (inexact STRIP) (inexact STRIP)) (loop (+ i 1)))))) (define (present!) (clear-screen 0.05 0.05 0.08 1.0) (set-color 1.0 1.0 1.0 1.0) (draw-render-target *cur* (inexact FIELD-X) (inexact FIELD-Y) (inexact FIELD-PX) (inexact FIELD-PX)) (draw-strips!) (set-color 1.0 1.0 1.0 1.0) (draw-texture *tex* (inexact PIX-X) (inexact PIX-Y) (inexact (* 4 PIX-SCALE)) (inexact (* 4 PIX-SCALE)))) ;; --- entry points ------------------------------------------------------- ;; After gfx-setup and set-viewport. grid: cells per side; steps: the ;; generations to run before saying ready; bench?: never stop, and ;; report timing every BENCH-EVERY frames. (define (scene-init! grid steps bench?) (set! *grid* grid) (set! *steps* steps) (set! *bench* bench?) (set! *rt-a* (make-render-target grid grid 'nearest)) (set! *rt-b* (make-render-target grid grid 'nearest)) (set! *cur* *rt-a*) (set! *next* *rt-b*) (set! *life* (load-shader VERT-SOURCE LIFE-SOURCE)) (set! *palette* (load-shader VERT-SOURCE PALETTE-SOURCE)) (set! *tex* (make-texture-from-pixels 4 4 (pixels-4x4 PATTERN-A) 'nearest)) (unless (and (shader? *life*) (shader? *palette*) (texture? *tex*)) (say "probe" "resources" "FAIL" "a shader or texture did not load")) (seed!) (run-probes!) (say "init" (str "grid=" grid) (str "steps=" steps))) (define (scene-settled?) *settled*) ;; One frame: begin-frame/end-frame are the shell's (present! draws ;; between them), so the tick takes a thunk that wraps the frame. (define (scene-tick! dt with-frame-thunk) (let ((t0 (current-jiffy))) (when (or *bench* (< *done* *steps*)) (step!)) ;; frame 2 (one end-frame after creation): the real update (when (= *frame* 1) (let ((err (guard (e (#t (if (error-object? e) (error-object-message e) (str e)))) (update-texture *tex* (pixels-4x4 PATTERN-B)) #f))) (if err (say "update" "FAIL" err) (say "update" "ok")))) (with-frame-thunk present!) (set! *frame* (+ *frame* 1)) (let ((tick-ms (/ (* 1000.0 (- (current-jiffy) t0)) (jiffies-per-second))) (dt-ms (* 1000.0 dt))) (when *bench* (set! *sum-dt* (+ *sum-dt* dt-ms)) (set! *max-dt* (max *max-dt* dt-ms)) (set! *sum-tick* (+ *sum-tick* tick-ms)) (set! *max-tick* (max *max-tick* tick-ms)) (set! *n* (+ *n* 1)) (when (= *n* BENCH-EVERY) (say "frame-ms" (round-1 (/ *sum-dt* BENCH-EVERY)) (round-1 *max-dt*) "tick-ms" (round-1 (/ *sum-tick* BENCH-EVERY)) (round-1 *max-tick*)) (set! *sum-dt* 0.0) (set! *max-dt* 0.0) (set! *sum-tick* 0.0) (set! *max-tick* 0.0) (set! *n* 0)))) ;; The frame that shows generation *steps* has just been presented ;; (and the update landed a frame earlier): say so, once. (when (and (not *bench*) (not *settled*) (>= *done* *steps*) (>= *frame* 3)) (set! *settled* #t) (say "ready" (str "grid=" *grid*) (str "steps=" *done*))))) (define (round-1 x) (/ (round (* x 10.0)) 10.0))))test/test-shader-limits/src/test-shader-limits/shell-native.sgladded
;;; (test-shader-limits shell-native) - The sigil-app window.;;;;;; test-shader-limits [--grid N] [--steps N] [--bench] [--hold SECONDS];;;;;; Steps the scene once per frame until --steps generations are done,;;; says the ready line, and keeps presenting that frame for --hold;;; seconds (default 20) so the verifier can capture the window; quits;;; after that, or on Escape. --bench never settles: it steps every;;; frame and prints timing every 120 frames until --hold runs out.(define-library (test-shader-limits shell-native) (import (sigil core) (sigil process) (sigil app) (sigil graphics) (test-shader-limits scene)) (export native-main) (begin (define (arg-value args flag default) (cond ((null? args) default) ((and (string=? (car args) flag) (pair? (cdr args))) (cadr args)) (else (arg-value (cdr args) flag default)))) (define (game-loop grid steps bench? hold) (lambda () (gfx-setup) (set-viewport VW VH) (scene-init! grid steps bench?) (let loop ((elapsed 0.0)) (let ((dt (wait-frame))) (scene-tick! dt (lambda (draw) (with-frame (draw)))) (cond ((key-pressed? 'escape) (request-quit)) ((quit-requested?) #t) ((> elapsed hold) (request-quit)) (else (loop (+ elapsed dt)))))) (gfx-shutdown))) (define (native-main . _args) (let* ((args (cdr (command-line))) (grid (or (string->number (arg-value args "--grid" "64")) 64)) (steps (or (string->number (arg-value args "--steps" "8")) 8)) (bench? (and (member "--bench" args) #t)) (hold (or (string->number (arg-value args "--hold" "20")) 20))) (run-game "test-shader-limits" VW VH (game-loop grid steps bench? hold)) 0))))test/test-shader-limits/src/test-shader-limits/shell-web.sgladded
;;; (test-shader-limits shell-web) - The browser shell.;;;;;; The gles3 app shell dispatches ("frame", ms) once per;;; requestAnimationFrame; the page dispatches the query parameters;;; ("grid", N) ("steps", N) ("bench", "on") before the first frame. The;;; scene is initialised on the first frame so those have landed.(define-library (test-shader-limits shell-web) (import (sigil core) (sigil graphics) (sigil browser gles3) (test-shader-limits scene)) (export web-main web-dispatch) (begin (define *grid* 64) (define *steps* 8) (define *bench* #f) (define *booted* #f) (define *last-ms* #f) (define (frame-dt payload) (let* ((ms (or (string->number payload) 0.0)) (dt (if *last-ms* (/ (- ms *last-ms*) 1000.0) (/ 1.0 60.0)))) (set! *last-ms* ms) (if (or (< dt 0.0) (> dt 1.0)) (/ 1.0 60.0) dt))) (define (web-tick dt) (unless *booted* (set! *booted* #t) (scene-init! *grid* *steps* *bench*)) (gles3-resize-to-display) (scene-tick! dt (lambda (draw) (begin-frame) (draw) (end-frame)))) (define (web-main) (unless (gles3-use-canvas "#stage") (error "test-shader-limits: no WebGL2 context for #stage")) (gles3-resize-to-display) (gfx-setup) (set-viewport VW VH) (set-letterbox-color 0.0 0.0 0.0) (gles3-start-loop "test-shader-limits-tick") 0) (define (web-dispatch type payload) (cond ((string=? type "frame") (web-tick (frame-dt payload)) 0) ((string=? type "grid") (set! *grid* (or (string->number payload) 64)) 0) ((string=? type "steps") (set! *steps* (or (string->number payload) 8)) 0) ((string=? type "bench") (set! *bench* (string=? payload "on")) 0) (else 0)))))test/test-shader-limits/styles.cssadded
/* test-shader-limits: the canvas at exactly its virtual size. */html, body { margin: 0; background: #06080f; color: #9aa4b8; font: 14px/1.4 monospace; }main { display: flex; flex-direction: column; align-items: flex-start; gap: 8px; padding: 8px; }#stage { width: 640px; height: 480px; display: block; background: #000; }#status:empty { display: none; }test/test-shader-limits/verify.mjsadded
// verify.mjs - reads the presented frame of test-shader-limits on either// target and asserts every exhibit against what the scene must show.//// node verify.mjs --native [--bin build/dev/bin/test-shader-limits]// [--grid 64] [--steps 8] [--expect-steps N] [--shot PATH]// node verify.mjs --web [build/web] [--port 8097] [--cdp 9237]// [--grid 64] [--steps 8] [--expect-steps N] [--shot PATH]// node verify.mjs --native|--web --bench [--grid 512] [--reports 3]//// Native: runs the binary on $DISPLAY (an Xvfb the caller owns), waits for// its "shader-limits: ready" line, captures the window with ImageMagick's// `import -window` (so `import`, `identify`, `convert` and `xdotool` must be// on PATH: run inside `guix shell -m ../../manifest.scm imagemagick xdotool`,// with LD_LIBRARY_PATH set for libGL, see README.md).// Web: serves the build dir on loopback, launches google-chrome headless with// software WebGL (SwiftShader), drives it over the DevTools Protocol (Node// 22's built-in WebSocket), and reads the canvas inside an animation frame.//// Sub-arms, each PASS/FAIL <name>: <detail>; any FAIL exits 1; a wait that// runs out prints TIMED-OUT and exits 2:// imports (web) the wasm imports only gl, sigil_wasm_gles3 and wasi// probes every refusal probe the scene runs said ok, none FAIL// update update-texture landed on frame 2 ("shader-limits: update ok")// ready the ready line names the expected grid and step count// field every cell of the GRIDxGRID Life field matches a CPU Life run// of the same seed for --expect-steps generations on a torus// palette the eight vec4 palette[8] strips and the four float levels[4]// strips show the array entries// pixels the 4x4 uploaded texture shows the post-update pattern// console (web) zero error-level console entries and zero exceptions//// --expect-steps defaults to --steps; passing a different value is the// assertion-layer sabotage: the field must then FAIL.// --bench runs the app in bench mode and prints its frame-ms lines; no// verdict, it is the measurement for the Session Log.import http from "node:http";import fs from "node:fs";import path from "node:path";import { spawn, execFileSync } from "node:child_process";const args = process.argv.slice(2);const flag = (name) => args.includes(name);const opt = (name, dflt) => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : dflt; };const VALUED = ["--bin", "--grid", "--steps", "--expect-steps", "--shot", "--port", "--cdp", "--reports", "--hold"];const positional = args.filter((a, i) => !a.startsWith("--") && !(i > 0 && VALUED.includes(args[i - 1])));const NATIVE = flag("--native");const WEB = flag("--web");const BENCH = flag("--bench");if (NATIVE === WEB) { console.log("usage: node verify.mjs --native | --web [options]"); process.exit(2); }const GRID = parseInt(opt("--grid", BENCH ? "512" : "64"), 10);const STEPS = parseInt(opt("--steps", "8"), 10);const EXPECT_STEPS = parseInt(opt("--expect-steps", String(STEPS)), 10);const SHOT = opt("--shot", `/tmp/test-shader-limits-${NATIVE ? "native" : "web"}.png`);const REPORTS = parseInt(opt("--reports", "3"), 10);const HOLD = parseInt(opt("--hold", "60"), 10);// ---- the scene's geometry, mirrored from scene.sgl ---------------------------const VW = 640, VH = 480;const FIELD_X = 8, FIELD_Y = 8, FIELD_PX = 384;const PAL_X = 408, LEV_X = 440, STRIP = 24, STRIP_GAP = 28;const PIX_X = 408, PIX_Y = 260, PIX_SCALE = 16;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]];const LEVELS = [0, 64, 128, 255];const PATTERN_B = [ [0, 255, 0], [255, 255, 0], [0, 255, 0], [255, 255, 0], [255, 255, 0], [255, 255, 255], [255, 255, 0], [0, 255, 0], [0, 255, 0], [255, 255, 0], [0, 0, 0], [255, 255, 0], [255, 255, 0], [0, 255, 0], [255, 255, 0], [0, 255, 0],];const PROBES = ["make-texture-size", "update-texture-size", "update-same-frame", "update-immutable", "uniform-count", "uniform-scalar-for-array", "filter-name", "uniform-nested", "uniform-flat-vector"];// The seed, as scene.sgl draws it, and a CPU Life on a torus.function seedGrid(n) { const g = new Uint8Array(n * n); const set = (x, y) => { g[((y + n) % n) * n + ((x + n) % n)] = 1; }; const s = n - 3; set(s + 1, s); set(s + 2, s + 1); set(s, s + 2); set(s + 1, s + 2); set(s + 2, s + 2); set(9, 10); set(10, 10); set(11, 10); set(20, 20); set(21, 20); set(20, 21); set(21, 21); return g;}function lifeStep(g, n) { const out = new Uint8Array(n * n); for (let y = 0; y < n; y++) for (let x = 0; x < n; x++) { let c = 0; for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) { if (dx || dy) c += g[((y + dy + n) % n) * n + ((x + dx + n) % n)]; } const alive = g[y * n + x]; out[y * n + x] = (c === 3 || (c === 2 && alive)) ? 1 : 0; } return out;}function expectedField(n, steps) { let g = seedGrid(n); for (let i = 0; i < steps; i++) g = lifeStep(g, n); return g; }// ---- verdicts -------------------------------------------------------------------const results = [];const planned = (WEB ? ["imports"] : []).concat(["probes", "update", "ready", "field", "palette", "pixels", "gl-log"]).concat(WEB ? ["console"] : []);function pass(name, detail) { results.push([name, "PASS"]); console.log(`PASS ${name}${detail ? ": " + detail : ""}`); }function fail(name, detail) { results.push([name, "FAIL"]); console.log(`FAIL ${name}: ${detail}`); }function notRun() { const done = new Set(results.map((r) => r[0])); return planned.filter((p) => !done.has(p)); }const sleep = (ms) => new Promise((r) => setTimeout(r, ms));// A captured frame: RGBA bytes, buffer size, and the virtual-to-buffer scale.// px(vx, vy) samples the buffer pixel under the centre of virtual pixel (vx, vy).function makeFrame(data, w, h) { const sx = w / VW, sy = h / VH; return { w, h, sx, sy, px(vx, vy) { const bx = Math.min(w - 1, Math.floor((vx + 0.5) * sx)), by = Math.min(h - 1, Math.floor((vy + 0.5) * sy)); const i = (by * w + bx) * 4; return [data[i], data[i + 1], data[i + 2]]; }, };}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;function checkLines(lines) { // probes const seen = new Map(); 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]); } const missing = PROBES.filter((p) => !seen.has(p)); const failed = [...seen].filter(([, v]) => v.startsWith("FAIL")).map(([k, v]) => `${k}${v.slice(4)}`); if (!missing.length && !failed.length) pass("probes", `${PROBES.length} probes ok`); else fail("probes", `${missing.length ? "missing " + missing.join(",") + "; " : ""}${failed.length ? "failed: " + failed.join(" | ") : ""}`); // update const upd = lines.find((l) => /^shader-limits: update /.test(l)); if (upd === "shader-limits: update ok") pass("update", "update-texture landed on frame 2"); else fail("update", upd || "no update line"); // ready const rdy = lines.map((l) => l.match(/^shader-limits: ready grid=(\d+) steps=(\d+)$/)).find(Boolean); if (rdy && parseInt(rdy[1], 10) === GRID && parseInt(rdy[2], 10) === EXPECT_STEPS) pass("ready", rdy[0]); else fail("ready", rdy ? `${rdy[0]} (expected grid=${GRID} steps=${EXPECT_STEPS})` : "no ready line");}function checkFrame(frame) { // field const exp = expectedField(GRID, EXPECT_STEPS); const cell = FIELD_PX / GRID; let mismatches = 0, live = 0, seenLive = 0; const firstBad = []; for (let y = 0; y < GRID; y++) for (let x = 0; x < GRID; x++) { const c = frame.px(FIELD_X + x * cell + (cell - 1) / 2, FIELD_Y + y * cell + (cell - 1) / 2); const lit = c[0] > 128 ? 1 : 0; live += exp[y * GRID + x]; seenLive += lit; if (lit !== exp[y * GRID + x]) { mismatches++; if (firstBad.length < 6) firstBad.push(`(${x},${y}) got ${lit} want ${exp[y * GRID + x]}`); } } 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)`); else fail("field", `${mismatches} of ${GRID * GRID} cells differ (expected ${live} live, saw ${seenLive}); first: ${firstBad.join("; ")}`); // palette const bad = []; for (let i = 0; i < 8; i++) { const c = frame.px(PAL_X + STRIP / 2, 8 + i * STRIP_GAP + STRIP / 2); if (!near(c, PALETTE[i], 12)) bad.push(`palette[${i}] got ${c} want ${PALETTE[i]}`); } for (let i = 0; i < 4; i++) { const c = frame.px(LEV_X + STRIP / 2, 8 + i * STRIP_GAP + STRIP / 2); if (!near(c, [LEVELS[i], LEVELS[i], LEVELS[i]], 12)) bad.push(`levels[${i}] got ${c} want ${LEVELS[i]}`); } if (!bad.length) pass("palette", "8 vec4 palette entries and 4 float levels read back through the shader"); else fail("palette", bad.join("; ")); // pixels const badPx = []; for (let t = 0; t < 16; t++) { const tx = t % 4, ty = Math.floor(t / 4); const c = frame.px(PIX_X + tx * PIX_SCALE + PIX_SCALE / 2, PIX_Y + ty * PIX_SCALE + PIX_SCALE / 2); if (!near(c, PATTERN_B[t], 12)) badPx.push(`texel ${tx},${ty} got ${c} want ${PATTERN_B[t]}`); } if (!badPx.length) pass("pixels", "16 texels show the update-texture pattern"); else fail("pixels", badPx.join("; "));}// sokol logs through slog_func: natively sigil-app's format `[sg][error][id:N] ...`// on stderr, on the web sigil-graphics' own `sokol[level=L] file:line [tag] msg`// on the console (level 0 panic, 1 error, 2 warning, 3 info; the web build// has no SOKOL_DEBUG so the message text is empty and the line number is// the pointer). A render target whose pass sokol refuses draws nothing and// says so only here, so any error-level line is a failure.function checkGlLog(lines) { const bad = lines.filter((l) => /\[(sg|sgp|sapp)\]\[(error|panic)\]/.test(l) || /^sokol\[level=[01]\]/.test(l)); const warn = lines.filter((l) => /\[(sg|sgp)\]\[warning\]/.test(l) || /^sokol\[level=2\]/.test(l)); if (!bad.length) pass("gl-log", `no sokol error lines${warn.length ? "; " + warn.length + " warning(s): " + warn.slice(0, 3).join(" | ") : ""}`); else fail("gl-log", `${bad.length} sokol error line(s): ${bad.slice(0, 4).join(" | ")}`);}function finish() { const failed = results.filter((r) => r[1] === "FAIL").length; const nr = notRun(); console.log(`${failed ? "RED" : "GREEN"}: ${results.length - failed} pass, ${failed} fail${nr.length ? "; did not run: " + nr.join(" ") : ""}`); return failed || nr.length ? 1 : 0;}// =============================================================================// NATIVE ARM// =============================================================================if (NATIVE) { const bin = path.resolve(opt("--bin", "build/dev/bin/test-shader-limits")); if (!fs.existsSync(bin)) { console.log(`SETUP-FAILED: ${bin} missing; build first`); process.exit(2); } if (!process.env.DISPLAY) { console.log("SETUP-FAILED: DISPLAY is not set; point it at a worker-owned Xvfb"); process.exit(2); } for (const tool of ["xdotool", "import", "convert", "identify"]) { try { execFileSync("sh", ["-c", `command -v ${tool}`], { stdio: "ignore" }); } catch { console.log(`SETUP-FAILED: ${tool} is not on PATH (run inside guix shell -m ../../manifest.scm imagemagick xdotool)`); process.exit(2); } } const appArgs = ["--grid", String(GRID), "--steps", String(STEPS), "--hold", String(HOLD)].concat(BENCH ? ["--bench"] : []); const child = spawn(bin, appArgs, { stdio: ["ignore", "pipe", "pipe"], detached: true }); const lines = []; let buf = ""; 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); } }); const stderrLines = []; child.stderr.on("data", (d) => { for (const l of d.toString().split("\n")) if (l.trim()) stderrLines.push(l); }); let exited = null; child.on("exit", (code, sig) => { exited = { code, sig }; }); const stop = () => { try { process.kill(-child.pid, "SIGTERM"); } catch { /* gone */ } }; process.on("exit", stop); const waitLine = async (re, ms) => { const t0 = Date.now(); while (Date.now() - t0 < ms) { const hit = lines.find((l) => re.test(l)); if (hit) return hit; if (exited) return null; await sleep(100); } return null; }; if (BENCH) { const t0 = Date.now(); while (Date.now() - t0 < HOLD * 1000 + 5000) { const reports = lines.filter((l) => /^shader-limits: frame-ms /.test(l)); if (reports.length >= REPORTS || exited) break; await sleep(200); } console.log(`bench native grid=${GRID} (renderer: ${glRenderer()})`); for (const l of lines.filter((l) => /^shader-limits: (init|frame-ms) /.test(l))) console.log(" " + l); stop(); process.exit(0); } const ready = await waitLine(/^shader-limits: ready /, 30000); if (!ready) { console.log(`TIMED-OUT ready: no ready line in 30 s${exited ? ` (exited ${JSON.stringify(exited)})` : ""}; did not run: ${notRun().join(" ")}`); console.log("stdout so far:\n " + lines.join("\n ")); console.log("stderr so far:\n " + stderrLines.join("\n ")); stop(); process.exit(2); } checkLines(lines); // The frame that shows the settled state was presented before the line // was printed, and the app keeps presenting it; one more frame's worth of // time makes sure the X server has it before the capture. await sleep(300); let xid = ""; for (let i = 0; i < 50 && !xid; i++) { try { xid = execFileSync("xdotool", ["search", "--name", "test-shader-limits"]).toString().trim().split("\n")[0]; } catch { /* not yet */ } if (!xid) await sleep(100); } if (!xid) { console.log("SETUP-FAILED: no X window named test-shader-limits; did not run: " + notRun().join(" ")); stop(); process.exit(2); } execFileSync("import", ["-window", xid, SHOT]); const [w, h] = execFileSync("identify", ["-format", "%w %h", SHOT]).toString().trim().split(" ").map(Number); const raw = execFileSync("convert", [SHOT, "-depth", "8", "rgba:-"], { maxBuffer: 64 * 1024 * 1024 }); if (raw.length !== w * h * 4) { console.log(`SETUP-FAILED: raw dump is ${raw.length} bytes for ${w}x${h}`); stop(); process.exit(2); } console.log(`note: captured ${w}x${h} window to ${SHOT}`); checkFrame(makeFrame(raw, w, h)); checkGlLog(stderrLines.concat(lines)); stop(); process.exit(finish());}function glRenderer() { try { return execFileSync("sh", ["-c", "glxinfo -B 2>/dev/null | grep -i 'renderer string' | head -1"]).toString().trim() || "glxinfo unavailable"; } catch { return "glxinfo unavailable"; }}// =============================================================================// WEB ARM// =============================================================================const ROOT = path.resolve(positional[0] || "build/web");const PORT = parseInt(opt("--port", "8097"), 10);const CDP = parseInt(opt("--cdp", "9237"), 10);const TYPES = { ".html": "text/html;charset=utf-8", ".js": "text/javascript;charset=utf-8", ".wasm": "application/wasm", ".json": "application/json;charset=utf-8", ".css": "text/css;charset=utf-8", ".png": "image/png" };// ---- imports, from the file on disk ------------------------------------------{ const wasmPath = path.join(ROOT, "test-shader-limits.wasm"); if (!fs.existsSync(wasmPath)) { console.log(`SETUP-FAILED imports: ${wasmPath} missing; build --config web first`); process.exit(2); } const mod = new WebAssembly.Module(fs.readFileSync(wasmPath)); const byModule = {}; for (const imp of WebAssembly.Module.imports(mod)) (byModule[imp.module] = byModule[imp.module] || []).push(imp.name); const modules = Object.keys(byModule).sort(); const expected = ["gl", "sigil_wasm_gles3", "wasi_snapshot_preview1"]; const listing = modules.map((m) => `${m}(${byModule[m].length})`).join(" "); if (JSON.stringify(modules) === JSON.stringify(expected)) pass("imports", listing); else fail("imports", `expected modules ${expected.join(",")} got ${listing}`);}const server = http.createServer((req, res) => { const urlPath = decodeURIComponent((req.url || "/").split("?")[0]); const fp = path.join(ROOT, urlPath === "/" ? "/index.html" : urlPath); if (fp !== ROOT && !fp.startsWith(ROOT + path.sep)) { res.writeHead(403).end(); return; } fs.readFile(fp, (err, buf) => { if (err) { res.writeHead(404).end("not found: " + urlPath); return; } res.writeHead(200, { "Content-Type": TYPES[path.extname(fp)] || "application/octet-stream", "Cache-Control": "no-store" }); res.end(buf); });});await new Promise((r) => server.listen(PORT, "127.0.0.1", r));const udd = fs.mkdtempSync("/tmp/test-shader-limits-chrome-");const chrome = spawn("google-chrome", [ "--headless=new", "--no-sandbox", "--disable-dev-shm-usage", "--use-gl=angle", "--use-angle=swiftshader", "--enable-unsafe-swiftshader", "--enable-webgl", "--ignore-gpu-blocklist", `--remote-debugging-port=${CDP}`, `--user-data-dir=${udd}`, "--window-size=800,600", "about:blank",], { stdio: "ignore", detached: true });function killChromeGroup(sig) { try { process.kill(-chrome.pid, sig); } catch { /* gone */ } }let exiting = false;function shutdown(code) { if (exiting) return; exiting = true; try { server.close(); } catch { /* not listening */ } killChromeGroup("SIGTERM"); setTimeout(() => { killChromeGroup("SIGKILL"); try { fs.rmSync(udd, { recursive: true, force: true }); } catch { /* scratch */ } process.exit(code); }, 1500).unref(); const done = () => { try { fs.rmSync(udd, { recursive: true, force: true }); } catch { /* scratch */ } process.exit(code); }; if (chrome.exitCode !== null) done(); else chrome.once("exit", done);}process.on("exit", () => { killChromeGroup("SIGKILL"); try { fs.rmSync(udd, { recursive: true, force: true }); } catch { /* scratch */ } });process.on("SIGINT", () => shutdown(130));process.on("SIGTERM", () => shutdown(143));process.on("unhandledRejection", (err) => { console.log("EXCEPTION: " + (err && err.stack || err)); shutdown(2); });process.on("uncaughtException", (err) => { console.log("EXCEPTION: " + (err && err.stack || err)); shutdown(2); });setTimeout(() => { console.log(`TIMED-OUT whole run after 120 s; did not run: ${notRun().join(" ")}`); shutdown(2); }, 120000).unref();let pageWs = null;for (let i = 0; i < 80 && !pageWs; i++) { 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 */ } await sleep(250);}if (!pageWs) { console.log("SETUP-FAILED: no chrome page target after 20 s; did not run: " + notRun().join(" ")); shutdown(2); }const ws = new WebSocket(pageWs);let msgId = 0; const pending = new Map();const consoleLines = []; const consoleErrors = [];function send(method, params = {}) { return new Promise((res, rej) => { const id = ++msgId; pending.set(id, { res, rej }); ws.send(JSON.stringify({ id, method, params })); });}ws.addEventListener("message", (ev) => { const msg = JSON.parse(ev.data); 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; } if (msg.method === "Runtime.consoleAPICalled") { const text = (msg.params.args || []).map((a) => a.value ?? a.description ?? "").join(" "); consoleLines.push(text); if (msg.params.type === "error") consoleErrors.push("console.error: " + text); } if (msg.method === "Log.entryAdded" && msg.params.entry.level === "error") consoleErrors.push("log: " + msg.params.entry.text); if (msg.method === "Runtime.exceptionThrown") consoleErrors.push("exception: " + (msg.params.exceptionDetails?.exception?.description || msg.params.exceptionDetails?.text));});await new Promise((res, rej) => { ws.addEventListener("open", res); ws.addEventListener("error", rej); });await send("Page.enable"); await send("Runtime.enable"); await send("Log.enable");// A devicePixelRatio of 2 so the canvas buffer (1280x960) is not the virtual// size: the sampler's scale mapping is then exercised, not the identity.await send("Emulation.setDeviceMetricsOverride", { width: 800, height: 600, deviceScaleFactor: 2, mobile: false });async function evalJS(expr) { const r = await send("Runtime.evaluate", { expression: expr, returnByValue: true, awaitPromise: true }); if (r.exceptionDetails) throw new Error(JSON.stringify(r.exceptionDetails)); return r.result.value;}async function waitLine(re, ms) { const t0 = Date.now(); while (Date.now() - t0 < ms) { const hit = consoleLines.find((l) => re.test(l)); if (hit) return hit; await sleep(100); } return null;}const query = `?grid=${GRID}&steps=${STEPS}${BENCH ? "&bench" : ""}`;await send("Page.navigate", { url: `http://127.0.0.1:${PORT}/index.html${query}` });if (BENCH) { const t0 = Date.now(); while (Date.now() - t0 < 90000) { if (consoleLines.filter((l) => /^shader-limits: frame-ms /.test(l)).length >= REPORTS) break; await sleep(200); } 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"; })()`); console.log(`bench web grid=${GRID} (renderer: ${renderer})`); for (const l of consoleLines.filter((l) => /^shader-limits: (init|frame-ms) /.test(l))) console.log(" " + l); shutdown(0);} else { const ready = await waitLine(/^shader-limits: ready /, 30000); if (!ready) { console.log(`TIMED-OUT ready: no ready line in 30 s; did not run: ${notRun().join(" ")}`); console.log("console so far:\n " + consoleLines.join("\n ") + "\nerrors:\n " + consoleErrors.join("\n ")); shutdown(2); } else { checkLines(consoleLines); if (flag("--verbose")) console.log("console:\n " + consoleLines.join("\n ")); // Read the canvas inside an animation frame: the app's tick registered // its next frame first, so a callback registered now runs after the draw // and before the (non-preserved) buffer is discarded. const px = await evalJS(`new Promise((resolve) => requestAnimationFrame(() => { const c = document.getElementById("stage"); const off = document.createElement("canvas"); off.width = c.width; off.height = c.height; const g = off.getContext("2d"); g.drawImage(c, 0, 0); const d = g.getImageData(0, 0, c.width, c.height).data; resolve({ w: c.width, h: c.height, data: Array.from(d) }); }))`); const shot = await send("Page.captureScreenshot", { format: "png" }); fs.writeFileSync(SHOT, Buffer.from(shot.data, "base64")); console.log(`note: canvas buffer ${px.w}x${px.h}; page screenshot at ${SHOT}`); checkFrame(makeFrame(Uint8Array.from(px.data), px.w, px.h)); checkGlLog(consoleLines); if (!consoleErrors.length) pass("console", "zero error entries, zero exceptions"); else fail("console", consoleErrors.slice(0, 5).join(" | ")); shutdown(finish()); }}