#!/usr/bin/env python3 """contrib/shadertoy.html evaluates the same expressions we do. The toy reimplements src/expr.c in JavaScript so it can run in a browser with no build and no server. That duplication is the whole hazard: a preview that disagrees with the real thing is worse than no preview, and it would disagree quietly, in the third digit of a colour. So both implementations are handed the same expressions at the same points and have to produce the same numbers. The JS is lifted straight out of the HTML — if someone edits the page, this tests what the page actually contains. Skipped, loudly, when node is missing: it is a checker for a contrib toy, not a reason nobody can run the suite. """ import json import os import random import shutil import subprocess import sys from harness import check, report HERE = os.path.dirname(os.path.abspath(__file__)) HTML = os.path.join(HERE, "..", "contrib", "shadertoy.html") EVAL = os.path.join(HERE, "..", "build", "expr_eval") SCRIPT = open(HTML).read().split("")[0] # Environments to evaluate at: corners, the middle, and a moved cursor. ENVS = [ dict(x=0, y=0, cols=80, rows=24, curx=0, cury=0, cursor=1, focused=1, t=0, since=0), dict( x=13, y=7, cols=80, rows=24, curx=40, cury=12, cursor=1, focused=1, t=1234, since=120, ), dict( x=79, y=23, cols=80, rows=24, curx=3, cury=21, cursor=0, focused=0, t=99999, since=8000, ), dict( x=40, y=12, cols=100, rows=40, curx=99, cury=39, cursor=1, focused=1, t=7, since=250, ), ] def page_presets(): """Every preset the page ships, lifted out of the page. Read rather than listed, so a preset added later is checked without anyone remembering to add it here -- which nobody would.""" prefix = SCRIPT[: SCRIPT.index("/* ---- the chain UI")] out = subprocess.run( ["node", "-e", prefix + "\nconsole.log(JSON.stringify(PRESETS));"], capture_output=True, text=True, ) groups = json.loads(out.stdout) return [ (f"{group}/{name}", i, p["amount"]) for group, entries in groups.items() for name, chain in entries.items() for i, p in enumerate(chain) ] EXPRS = [ # the corners of the language "1 + 2 * 3", "(1 + 2) * 3", "10 - 3 - 2", "100 / 7", "7 % 3", "-5 + 1", "5 / 0", "5 % 0", "x / 0 + 3", "3 < 4", "4 <= 4", "3 == 3", "3 != 3", "x < curx", "1 && 0", "0 || x", "!0", "!x", "min(x, y)", "max(x, y)", "abs(y - rows)", "clamp(x * 9, 10, 200)", "dist(0, 0, 4, 0)", "dist(0, 0, 0, 2)", "dist(x, y, curx, cury)", "x ? 10 : 20", "cursor ? 255 : 0", "focused ? x * 2 : y * 3", "0 ? 1 / 0 : 7", "t % 255", "since", "(since < 250) * 255", # a flash "abs(since / 8 % 510 - 255)", # ...and a breathe "(x + y) % 7 * 30", "cols - x", "rows * 2 - y", # bitwise, where the two implementations agree by construction: int32, a # five-bit shift count, an arithmetic `>>`, and -- unlike C -- bits binding # tighter than comparison. "x & 7", "x | 8", "x ^ y", "~x", "~0", "x << 3", "x >> 1", "-8 >> 1", "-1 >> 28", "1 << 31", "1 << 33", "x << 40", "x >> 40", "0 - 5 & 3", "x & 7 == 5", "x ^ y | 1", "x | y & 1", "x ^ y ^ x", "(x ^ y) % 16 < 2", "(x & 1) ^ (y & 1)", "~x & 255", "t >> 6 & 15", "(x * 7 ^ y * 13) & 31", # trig: degrees in, -255..255 out, from a table both sides share "sin(0)", "sin(30)", "sin(45)", "sin(90)", "sin(180)", "sin(270)", "sin(359)", "sin(360)", "sin(450)", "sin(0 - 30)", "sin(0 - 400)", "cos(0)", "cos(90)", "cos(180)", "cos(270)", "cos(0 - 90)", "sin(x * 40)", "cos(y * 37)", "sin(t / 4)", "sin(t) + cos(t)", "128 + sin(t / 4) / 2", "abs(sin(x * 9 + t / 6))", "clamp(sin(t / 5) + 128, 0, 255)", "sin(sin(t / 3))", # pi as an angle: a half turn, in the degrees this language counts in "PI", "TAU", "PI * 2 == TAU", "sin(PI)", "sin(PI / 2)", "sin(PI / 6)", "cos(TAU)", "sin(TAU * x / cols)", "sin(TAU * y / rows + t / 5)", "128 + sin(TAU * t / 4000) / 2", ] def rand_exprs(n, seed=20260814): """A few generated ones too, so the checks are not only what I thought of.""" rng = random.Random(seed) atoms = [ "x", "y", "cols", "rows", "curx", "cury", "t", "since", "3", "7", "40", "255", ] out = [] for _ in range(n): a, b, c = (rng.choice(atoms) for _ in range(3)) out.append( rng.choice( [ f"({a} {rng.choice(['+', '-', '*', '/', '%', '&', '|', '^'])} {b}) * 2", f"{a} {rng.choice(['<<', '>>'])} ({b} % 6)", f"({a} ^ {b}) & {c}", f"sin({a} * 3 + {b})", f"cos({a} - {b})", f"128 + sin({a} * {rng.choice(['2', '5', '11'])}) / 2", f"clamp({a} * {rng.choice([2, 3, 9])} - {b}, 0, 255)", f"({a} {rng.choice(['<', '>', '==', '<=', '!='])} {b}) * {c}", f"min({a}, {b}) + max({b}, {c})", f"{a} ? {b} : {c}", f"dist({a}, {b}, curx, cury) * 4", f"abs({a} - {b}) % ({c} + 1)", ] ) ) return out def c_values(pairs): """Every (env, expr) through the compiler slosh actually uses.""" lines = [] for env, expr in pairs: vals = " ".join( str(env[k]) for k in ( "x", "y", "cols", "rows", "curx", "cury", "cursor", "focused", "t", "since", ) ) lines.append(f"{vals}\t{expr}") out = subprocess.run( [EVAL], input="\n".join(lines) + "\n", capture_output=True, text=True ) return out.stdout.strip().split("\n") JS_HARNESS = """ const fs = require("fs"); const html = fs.readFileSync(process.argv[2], "utf8"); // The page's script, run as-is except for the parts that need a document. const script = html.split("")[0]; const body = script.slice(0, script.indexOf("/* ---- the fake pane")); const compile = new Function(body + "; return compile;")(); const cases = JSON.parse(fs.readFileSync(process.argv[3], "utf8")); const out = []; for (const [env, expr] of cases) { try { const prog = compile(expr); let v = prog.fn(env); out.push(String(Math.trunc(v))); } catch (e) { out.push("error: " + e.message); } } console.log(out.join("\\n")); """ def main(): if not shutil.which("node"): print("SKIP no node: contrib/shadertoy.html cannot be checked here") return 0 if not os.path.exists(EVAL): check("the expression evaluator is built", False, EVAL) return report() presets = page_presets() check("the page ships presets to check", len(presets) >= 20, str(len(presets))) exprs = EXPRS + [e for _, _, e in presets] + rand_exprs(40) pairs = [(env, e) for e in exprs for env in ENVS] import tempfile cases = tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) json.dump([[env, e] for env, e in pairs], cases) cases.close() harness = tempfile.NamedTemporaryFile("w", suffix=".js", delete=False) harness.write(JS_HARNESS) harness.close() js = subprocess.run( ["node", harness.name, HTML, cases.name], capture_output=True, text=True ) os.unlink(cases.name) os.unlink(harness.name) if js.returncode != 0: check("the page's evaluator runs", False, js.stderr[-400:]) return report() got = js.stdout.strip().split("\n") want = c_values(pairs) check( "both sides answered every case", len(got) == len(pairs) and len(want) == len(pairs), f"js {len(got)}, c {len(want)}, cases {len(pairs)}", ) bad = [(e, env, w, g) for (env, e), w, g in zip(pairs, want, got) if w != g] check( f"the toy agrees with the compiler on all {len(pairs)} evaluations", not bad, "\n".join(f" {e!r} at {env} -> C {w}, JS {g}" for e, env, w, g in bad[:8]), ) # A preset that computes 0 everywhere is a preset that does nothing, which # is a broken example rather than a subtle one: it looks like the shader # system is not working. Checked over a whole pane and four points in time, # cell by cell -- sampling every other row made three `y % 2` effects look # dead when they were fine. grid = [ dict( x=x, y=y, cols=64, rows=24, curx=30, cury=12, cursor=1, focused=1, t=t, since=t, ) for t in (0, 500, 1500, 4000) for y in range(24) for x in range(64) ] flat = [(env, e) for _, _, e in presets for env in grid] vals = c_values(flat) per = len(grid) for idx, (label, i, expr) in enumerate(presets): window = vals[idx * per : (idx + 1) * per] nums = [int(v) for v in window if not v.startswith("error")] check( f"{label} pass {i} does something", nums and max(0, min(255, max(nums))) > 0, f"{expr!r} is 0 everywhere", ) # And that a refusal is a refusal on both sides, since a page that quietly # accepts what the config rejects teaches the wrong language. refused = ["nosuchvar + 1", "wobble(1, 2)", "(1 + 2", "1 +", "1 + 2 )", "1 ? 2"] rpairs = [(ENVS[0], e) for e in refused] cw = c_values(rpairs) cases = tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) json.dump([[env, e] for env, e in rpairs], cases) cases.close() harness = tempfile.NamedTemporaryFile("w", suffix=".js", delete=False) harness.write(JS_HARNESS) harness.close() rjs = ( subprocess.run( ["node", harness.name, HTML, cases.name], capture_output=True, text=True ) .stdout.strip() .split("\n") ) os.unlink(cases.name) os.unlink(harness.name) for expr, c, j in zip(refused, cw, rjs): check( f"both refuse {expr!r}", c.startswith("error") and j.startswith("error"), f"C {c!r}, JS {j!r}", ) return report() sys.exit(main())