Files
virtualjaguar-libretro/app/test/shader_tests.cpp
T

742 lines
34 KiB
C++

// Tests for the shader layer (issue #834). Usage:
// vj_shader_tests unit pure logic + the library-missing fallbacks (no GL, no library)
// vj_shader_tests library real librashader parses the bundled presets (exit 77 = skipped)
// vj_shader_tests gl renders pixels through the bundled presets (exit 77 = skipped)
// Exit code 77 is CTest's SKIP_RETURN_CODE: the machine lacks the library or a
// display, which is not a failure and must not be reported as a pass.
#ifdef _WIN32
#define NOMINMAX
#endif
#include <SDL3/SDL.h>
#include <algorithm>
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <filesystem>
#include <fstream>
#include <string>
#include <vector>
#include "imgui_impl_opengl3_loader.h"
#include "config.h"
#include "gl_context.h"
#include "paths.h"
#include "present_util.h"
#include "shaders.h"
#ifndef VJ_SHADER_DIR
#error VJ_SHADER_DIR must point at app/shaders
#endif
namespace fs = std::filesystem;
using namespace vj;
static int g_fail = 0;
// Progress markers on stderr (unbuffered): a hung CI job then shows where it stopped.
#define STEP(name) std::fprintf(stderr, "shader_tests: step %s\n", name)
#define CHECK(cond, ...) \
do { \
if (!(cond)) { \
g_fail++; \
std::fprintf(stderr, "shader_tests: FAIL %s:%d: %s -- ", __FILE__, __LINE__, #cond); \
std::fprintf(stderr, __VA_ARGS__); \
std::fprintf(stderr, "\n"); \
} \
} while (0)
static void setEnv(const char* k, const char* v) {
#ifdef _WIN32
_putenv_s(k, v ? v : "");
#else
if (v) setenv(k, v, 1);
else unsetenv(k);
#endif
}
static fs::path makeTemp(const char* tag) {
fs::path p = fs::temp_directory_path() / (std::string("vj-shader-test-") + tag + "-" +
std::to_string((unsigned long long)SDL_GetPerformanceCounter()));
fs::create_directories(p);
return p;
}
static void write(const fs::path& p, const std::string& text) {
fs::create_directories(p.parent_path());
std::ofstream(p, std::ios::binary) << text;
}
static std::string str(const fs::path& p) { return p.generic_string(); }
// ---------------------------------------------------------------------------------------
// unit
// ---------------------------------------------------------------------------------------
static void testRect() {
STEP("rect");
PresentOptions o;
// 640x480 source in a 1000x800 area, 4:3 core aspect: width-limited, centred.
PresentRect r = computePresentRect(0, 0, 1000, 800, 640, 480, 4.0f / 3.0f, o);
CHECK(std::fabs(r.w - 1000) < 0.5f && std::fabs(r.h - 750) < 0.5f, "fit %g x %g", r.w, r.h);
CHECK(r.x == 0 && r.y == 25, "centred at %g,%g", r.x, r.y);
// Integer scaling: whole multiples of the source height only.
o.integerScale = true;
r = computePresentRect(0, 0, 1000, 1000, 320, 240, 4.0f / 3.0f, o);
CHECK(r.h == 720 && std::fabs(r.w - 960) < 0.5f, "integer 3x -> %g x %g", r.w, r.h);
// A window narrower than 4:3 of the integer height steps down a multiple.
r = computePresentRect(0, 0, 700, 1000, 320, 240, 4.0f / 3.0f, o);
CHECK(r.h == 480 && std::fabs(r.w - 640) < 0.5f, "integer width-limited -> %g x %g", r.w, r.h);
// Never below 1x when the window is smaller than the source: falls back to fit.
r = computePresentRect(0, 0, 200, 150, 320, 240, 4.0f / 3.0f, o);
CHECK(r.w <= 200.5f && r.h <= 150.5f && r.w > 0, "tiny window -> %g x %g", r.w, r.h);
// Stretch ignores aspect; the override beats the core's aspect.
o = PresentOptions();
o.stretch = true;
r = computePresentRect(10, 20, 300, 100, 320, 240, 4.0f / 3.0f, o);
CHECK(r.w == 300 && r.h == 100 && r.x == 10 && r.y == 20, "stretch");
o = PresentOptions();
o.aspectOverride = 16.0f / 9.0f;
r = computePresentRect(0, 0, 1600, 1600, 320, 240, 4.0f / 3.0f, o);
CHECK(std::fabs(r.w / r.h - 16.0f / 9.0f) < 0.01f, "override aspect %g", r.w / r.h);
// No frame: an empty rectangle, never a division by zero.
r = computePresentRect(5, 6, 100, 100, 0, 0, 0, o);
CHECK(r.w == 0 && r.h == 0, "no source");
}
static void testPresetParsing() {
STEP("parse");
fs::path d = makeTemp("parse");
write(d / "a.slang", "x");
write(d / "sub/b.slang", "x");
write(d / "lut.png", "x");
CHECK(hasPresetExtension("x/y.slangp") && hasPresetExtension("Y.SLANGP"), "extension, case-insensitive");
CHECK(!hasPresetExtension("y.glslp") && !hasPresetExtension("slangp") && !hasPresetExtension("y.slang"), "extension");
write(d / "good.slangp",
"shaders = 2\n"
"# a comment\n"
"shader0 = \"a.slang\"\n"
"filter_linear0 = true\n"
"shader1 = sub/b.slang # trailing comment\n"
"textures = \"lut\"\n"
"lut = lut.png\n");
PresetInfo p = inspectPreset(str(d / "good.slangp"));
CHECK(p.ok, "good preset rejected: %s", p.error.c_str());
CHECK(p.shaders.size() == 2, "2 passes, got %zu", p.shaders.size());
CHECK(p.shaders.size() == 2 && p.shaders[0] == str(d / "a.slang") && p.shaders[1] == str(d / "sub/b.slang"),
"pass order / resolution: %s", p.shaders.empty() ? "(none)" : p.shaders.back().c_str());
CHECK(p.textures.size() == 1, "1 texture");
// #reference: the referencing file's keys win; paths resolve against the file that defines them.
write(d / "ref/over.slangp", "#reference \"../good.slangp\"\nshaders = 1\n");
p = inspectPreset(str(d / "ref/over.slangp"));
CHECK(p.ok && p.shaders.size() == 1 && p.references.size() == 1, "reference: %s", p.error.c_str());
write(d / "ref/inherit.slangp", "#reference \"../good.slangp\"\n");
p = inspectPreset(str(d / "ref/inherit.slangp"));
CHECK(p.ok && p.shaders.size() == 2, "inherits both passes: %s", p.error.c_str());
// Things that must be refused, each with a message that names the problem.
p = inspectPreset(str(d / "nope.slangp"));
CHECK(!p.ok && p.error.find("not found") != std::string::npos, "missing file: %s", p.error.c_str());
p = inspectPreset(str(d));
CHECK(!p.ok, "a directory is not a preset");
p = inspectPreset("");
CHECK(!p.ok, "empty path");
write(d / "old.glslp", "shaders = 1\nshader0 = a.slang\n");
p = inspectPreset(str(d / "old.glslp"));
CHECK(!p.ok && p.error.find("glslp") != std::string::npos, "glslp: %s", p.error.c_str());
write(d / "nocount.slangp", "shader0 = a.slang\n");
p = inspectPreset(str(d / "nocount.slangp"));
CHECK(!p.ok && p.error.find("shaders") != std::string::npos, "no count: %s", p.error.c_str());
write(d / "zero.slangp", "shaders = 0\n");
CHECK(!inspectPreset(str(d / "zero.slangp")).ok, "zero passes");
write(d / "gap.slangp", "shaders = 2\nshader0 = a.slang\n");
p = inspectPreset(str(d / "gap.slangp"));
CHECK(!p.ok && p.error.find("pass 1") != std::string::npos, "missing shader1: %s", p.error.c_str());
write(d / "missing.slangp", "shaders = 1\nshader0 = gone.slang\n");
p = inspectPreset(str(d / "missing.slangp"));
CHECK(!p.ok && p.error.find("gone.slang") != std::string::npos, "missing shader file: %s", p.error.c_str());
write(d / "lutless.slangp", "shaders = 1\nshader0 = a.slang\ntextures = \"lut\"\nlut = nothere.png\n");
p = inspectPreset(str(d / "lutless.slangp"));
CHECK(!p.ok && p.error.find("nothere.png") != std::string::npos, "missing texture: %s", p.error.c_str());
write(d / "loopA.slangp", "#reference \"loopB.slangp\"\nshaders = 1\n");
write(d / "loopB.slangp", "#reference \"loopA.slangp\"\n");
p = inspectPreset(str(d / "loopA.slangp"));
CHECK(!p.ok && p.error.find("loop") != std::string::npos, "reference loop: %s", p.error.c_str());
write(d / "badref.slangp", "#reference \"absent.slangp\"\n");
CHECK(!inspectPreset(str(d / "badref.slangp")).ok, "reference to a missing file");
STEP("scan");
// Folder scan: finds presets at depth, skips .git, sorted, relative with '/'.
write(d / ".git/hooks/x.slangp", "x");
write(d / "deep/er/crt.slangp", "shaders = 1\n");
bool trunc = true;
std::vector<std::string> l = scanPresets(str(d), 5000, 10, &trunc);
bool hasDeep = false, hasGit = false, sorted = std::is_sorted(l.begin(), l.end());
for (const std::string& s : l) {
if (s == "deep/er/crt.slangp") hasDeep = true;
if (s.find(".git") != std::string::npos) hasGit = true;
}
CHECK(hasDeep && !hasGit && sorted && !trunc, "scan: %zu found", l.size());
l = scanPresets(str(d), 2, 10, &trunc);
CHECK(l.size() == 2 && trunc, "result cap");
l = scanPresets(str(d / "nonexistent"), 10, 10, &trunc);
CHECK(l.empty(), "missing root");
l = scanPresets("", 10, 10, nullptr);
CHECK(l.empty(), "empty root");
std::error_code ec;
fs::remove_all(d, ec);
}
static void testFallback() {
STEP("fallback");
fs::path d = makeTemp("fallback");
write(d / "p.slang", "x");
write(d / "p.slangp", "shaders = 1\nshader0 = p.slang\n");
// 1. A library path that does not exist forces "missing", whatever is installed.
std::string bogus = str(d / "no-such-dir" / ShaderChain::libraryFileName());
setEnv("VJ_LIBRASHADER", bogus.c_str());
{
ShaderChain sc;
sc.enabled = true;
CHECK(!sc.probeLibrary(), "bogus VJ_LIBRASHADER must read as missing");
CHECK(!sc.libraryLoaded() && !sc.active(), "not loaded, not active");
CHECK(!sc.libraryError().empty(), "an error text is set");
CHECK(!ShaderChain::builtWithHeaders() || sc.libraryStatus() == ShaderChain::LibraryStatus::NotFound, "a path with no file reads as not found, not as a failed load");
std::string err;
CHECK(sc.usePreset(str(d / "p.slangp"), err), "a valid preset is accepted even without the library: %s", err.c_str());
CHECK(!sc.usePreset(str(d / "absent.slangp"), err) && !err.empty(), "an invalid preset is still refused");
CHECK(sc.preset == str(d / "p.slangp"), "refused preset leaves the previous one");
ShaderFrame f;
f.srcTex = 1;
f.srcW = f.srcH = 8;
f.outW = f.outH = 16;
// No GL is touched on this path: render() just says "draw the plain picture".
CHECK(sc.render(f) == 0 && sc.render(f) == 0 && sc.render(f) == 0, "render falls back to 0");
std::string note;
CHECK(sc.takeNotice(note), "a notice is queued");
CHECK(note.find("plain picture") != std::string::npos, "notice text: %s", note.c_str());
CHECK(!sc.takeNotice(note), "and only one, not one per frame");
sc.enabled = false;
CHECK(sc.render(f) == 0 && !sc.takeNotice(note), "disabled: silent");
sc.destroyGL(); // safe with nothing created
}
STEP("fallback-garbage-lib");
// 2. A file that is not a library at all.
write(d / "fake" / ShaderChain::libraryFileName(), "this is not a shared library\n");
setEnv("VJ_LIBRASHADER", str(d / "fake").c_str());
{
ShaderChain sc;
CHECK(!sc.probeLibrary(), "a text file must not load as a library");
CHECK(!sc.libraryError().empty(), "and says why: %s", sc.libraryError().c_str());
CHECK(!ShaderChain::builtWithHeaders() || sc.libraryStatus() == ShaderChain::LibraryStatus::LoadFailed, "a file that exists but cannot be used reads as a failed load");
CHECK(!ShaderChain::builtWithHeaders() || sc.libraryError().find(ShaderChain::libraryFileName()) != std::string::npos, "and names the file");
}
STEP("fallback-settings");
// 3. settings round trip
{
Config cfg;
cfg.setPath(str(d / "c.ini"));
ShaderChain a;
a.enabled = true;
a.preset = "/some/preset.slangp";
a.folder = "/some/folder/";
a.saveSettings(cfg);
ShaderChain b;
b.loadSettings(cfg, str(d) + "/");
CHECK(b.enabled && b.preset == a.preset && b.folder == a.folder, "settings round trip");
ShaderChain c;
Config empty;
c.loadSettings(empty, "");
CHECK(!c.enabled && c.preset.empty(), "defaults: off");
}
setEnv("VJ_LIBRASHADER", nullptr);
std::error_code ec;
fs::remove_all(d, ec);
}
// Optional (VJ_TEST_SLANG_DIR=<slang-shaders checkout>): the parser against ~1500
// real presets. Not part of the default run; prints what it finds.
static void testArraysOfArraysList() {
CHECK(presetNeedsArraysOfArrays("crt/crt-geom.slangp"), "relative path");
CHECK(presetNeedsArraysOfArrays("/x/slang-shaders/crt/crt-geom.slangp"), "clone path");
CHECK(presetNeedsArraysOfArrays("C:\\ss\\crt\\crt-geom.slangp"), "Windows separators");
CHECK(!presetNeedsArraysOfArrays("/x/mycrt/crt-geom.slangp"), "match stops at a separator");
CHECK(!presetNeedsArraysOfArrays("crt/crt-lottes.slangp"), "crt-lottes compiles on GL 4.1");
CHECK(!presetNeedsArraysOfArrays(""), "empty path");
}
static void testRealCheckout() {
const char* dir = std::getenv("VJ_TEST_SLANG_DIR");
if (!dir || !*dir) return;
Uint64 t0 = SDL_GetTicks();
bool trunc = false;
std::vector<std::string> l = scanPresets(dir, 100000, 12, &trunc);
Uint64 t1 = SDL_GetTicks();
std::string root = dir;
if (root.back() != '/') root += '/';
size_t bad = 0;
for (const std::string& rel : l) {
PresetInfo p = inspectPreset(root + rel);
if (!p.ok) {
if (bad++ < 12) std::printf(" not valid: %s: %s\n", rel.c_str(), p.error.c_str());
}
}
std::printf("slang-shaders: %zu presets found in %llu ms%s, %zu failed inspection (%llu ms total)\n", l.size(),
(unsigned long long)(t1 - t0), trunc ? " (truncated)" : "", bad, (unsigned long long)(SDL_GetTicks() - t0));
CHECK(l.size() > 100, "a slang-shaders checkout has hundreds of presets");
CHECK(inspectPreset(root + "crt/crt-easymode.slangp").ok, "crt-easymode.slangp");
CHECK(inspectPreset(root + "crt/crt-geom.slangp").ok, "crt-geom.slangp");
}
static int runUnit() {
testRect();
testPresetParsing();
testFallback();
testArraysOfArraysList();
testRealCheckout();
std::printf("shader_tests unit: %s (%s headers)\n", g_fail ? "FAIL" : "OK",
ShaderChain::builtWithHeaders() ? "with" : "without");
return g_fail ? 1 : 0;
}
// ---------------------------------------------------------------------------------------
// bundled slang-shaders subset
// ---------------------------------------------------------------------------------------
#ifdef VJ_SLANG_DIR
static const char* const kBundledMustWork[] = {
"crt/crt-easymode.slangp", "crt/crt-geom.slangp",
"crt/crt-royale.slangp", "crt/crt-lottes.slangp",
"pixel-art-scaling/sharp-bilinear-simple.slangp",
"interpolation/bicubic.slangp", "bilinear.slangp", "nearest.slangp",
};
static int runBundled() {
std::string root = VJ_SLANG_DIR;
if (root.empty() || !fs::is_directory(fs::u8path(root))) {
std::printf("shader_tests bundled: SKIP (no bundled subset)\n");
return 77;
}
if (root.back() != '/') root += '/';
bool trunc = false;
std::vector<std::string> l = scanPresets(root, 100000, 12, &trunc);
size_t bad = 0;
for (const std::string& rel : l) {
PresetInfo p = inspectPreset(root + rel);
if (!p.ok && bad++ < 15) std::printf(" not valid: %s: %s\n", rel.c_str(), p.error.c_str());
}
std::printf("bundled slang-shaders: %zu presets, %zu failed inspection\n", l.size(), bad);
CHECK(l.size() > 400, "expected hundreds of presets, got %zu", l.size());
// A handful may be broken upstream; the closure must still be essentially complete.
CHECK(bad * 50 <= l.size(), "too many presets with missing files: %zu of %zu", bad, l.size());
for (const char* rel : kBundledMustWork) {
PresetInfo p = inspectPreset(root + rel);
CHECK(p.ok, "%s: %s", rel, p.error.c_str());
}
// The needs-GL-4.3 list names bundled files: a slang-shaders bump that renames one
// must fail here, not quietly stop greying it out.
for (const char* rel : presetsNeedingArraysOfArrays()) {
CHECK(inspectPreset(root + rel).ok, "arrays-of-arrays list: %s is not a valid bundled preset", rel);
CHECK(presetNeedsArraysOfArrays(root + rel), "%s: full path should match", rel);
}
CHECK(fs::exists(fs::u8path(root + "NOTICE-virtualjaguar.txt")), "notice file");
CHECK(fs::exists(fs::u8path(root + "crt/shaders/crt-royale/LICENSE.TXT")), "crt-royale licence travels with it");
std::printf("shader_tests bundled: %s\n", g_fail ? "FAIL" : "OK");
return g_fail ? 1 : 0;
}
#else
static int runBundled() {
std::printf("shader_tests bundled: SKIP (no bundled subset)\n");
return 77;
}
#endif
// ---------------------------------------------------------------------------------------
// library
// ---------------------------------------------------------------------------------------
static int runLibrary() {
if (!ShaderChain::builtWithHeaders()) {
std::printf("shader_tests library: SKIP (built without librashader headers)\n");
return 77;
}
ShaderChain sc;
sc.setLibrarySearchDirs({});
if (!sc.probeLibrary()) {
// VJ_REQUIRE_LIBRASHADER=1 (CI, on the legs that ship a runtime): a runtime that does not load
// is a failure, never a skip.
const char* req = std::getenv("VJ_REQUIRE_LIBRASHADER");
if (req && *req && *req != '0') {
std::printf("shader_tests library: FAIL (VJ_REQUIRE_LIBRASHADER is set but the runtime did not load: %s)\n",
sc.libraryError().c_str());
return 1;
}
std::printf("shader_tests library: SKIP (%s)\n", sc.libraryError().c_str());
return 77;
}
std::printf("librashader %s, API version %u\n", sc.libraryPath().c_str(), sc.libraryApiVersion());
CHECK(sc.libraryApiVersion() >= 1, "API version");
for (const char* name : {"vj-sharp-bilinear.slangp", "vj-scanlines.slangp"}) {
std::string err;
CHECK(sc.usePreset(std::string(VJ_SHADER_DIR) + "/" + name, err), "%s: %s", name, err.c_str());
}
// The scanlines preset declares three parameters.
CHECK(sc.params().size() == 3, "scanlines parameters: %zu", sc.params().size());
bool found = false;
for (const ShaderParam& p : sc.params())
if (p.name == "SCAN_STRENGTH") found = p.initial > 0.4f && p.initial < 0.5f && p.maximum == 1.0f;
CHECK(found, "SCAN_STRENGTH range read from #pragma parameter");
// A preset librashader itself rejects (syntactically valid for us, nonsense for it).
fs::path d = makeTemp("lib");
write(d / "a.slang", "x");
write(d / "weird.slangp", "shaders = 1\nshader0 = a.slang\nscale_type0 = sideways\nscale0 = banana\n");
std::string err;
bool accepted = sc.usePreset(str(d / "weird.slangp"), err);
std::printf("librashader on a nonsense preset: %s%s\n", accepted ? "accepted" : "rejected: ", accepted ? "" : err.c_str());
CHECK(accepted || !err.empty(), "a rejection carries the library's message");
std::error_code ec;
fs::remove_all(d, ec);
std::printf("shader_tests library: %s\n", g_fail ? "FAIL" : "OK");
return g_fail ? 1 : 0;
}
// ---------------------------------------------------------------------------------------
// gl
// ---------------------------------------------------------------------------------------
typedef void (*PFNBindFb)(unsigned, unsigned);
typedef void (*PFNGenFb)(int, unsigned*);
typedef void (*PFNDelFb)(int, const unsigned*);
typedef void (*PFNFbTex)(unsigned, unsigned, unsigned, unsigned, int);
static std::vector<unsigned char> readTexture(unsigned tex, int w, int h) {
PFNBindFb bindFb = (PFNBindFb)SDL_GL_GetProcAddress("glBindFramebuffer");
PFNGenFb genFb = (PFNGenFb)SDL_GL_GetProcAddress("glGenFramebuffers");
PFNDelFb delFb = (PFNDelFb)SDL_GL_GetProcAddress("glDeleteFramebuffers");
PFNFbTex fbTex = (PFNFbTex)SDL_GL_GetProcAddress("glFramebufferTexture2D");
std::vector<unsigned char> px((size_t)w * h * 4, 0);
unsigned fb = 0;
genFb(1, &fb);
bindFb(0x8D40 /*GL_FRAMEBUFFER*/, fb);
fbTex(0x8D40, 0x8CE0 /*COLOR_ATTACHMENT0*/, GL_TEXTURE_2D, tex, 0);
glPixelStorei(GL_PACK_ALIGNMENT, 1);
glReadPixels(0, 0, w, h, 0x1908 /*GL_RGBA*/, 0x1401 /*GL_UNSIGNED_BYTE*/, px.data());
bindFb(0x8D40, 0);
delFb(1, &fb);
return px;
}
static unsigned makeTexture(int w, int h, const std::vector<unsigned char>& rgba) {
GLuint t = 0;
glGenTextures(1, &t);
glBindTexture(GL_TEXTURE_2D, t);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, 0x813D /*GL_TEXTURE_MAX_LEVEL*/, 0); // no mip chain; see video.cpp
glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
glTexImage2D(GL_TEXTURE_2D, 0, 0x8058 /*GL_RGBA8*/, w, h, 0, GL_RGBA, GL_UNSIGNED_BYTE, rgba.data());
return t;
}
static int runGl() {
if (!ShaderChain::builtWithHeaders()) {
std::printf("shader_tests gl: SKIP (built without librashader headers)\n");
return 77;
}
ShaderChain sc;
sc.setLibrarySearchDirs({});
if (!sc.probeLibrary()) {
std::printf("shader_tests gl: SKIP (%s)\n", sc.libraryError().c_str());
return 77;
}
if (!SDL_Init(SDL_INIT_VIDEO)) {
std::printf("shader_tests gl: SKIP (no video: %s)\n", SDL_GetError());
return 77;
}
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 3);
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 3);
SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, SDL_GL_CONTEXT_PROFILE_CORE);
#ifdef __APPLE__
SDL_GL_SetAttribute(SDL_GL_CONTEXT_FLAGS, SDL_GL_CONTEXT_FORWARD_COMPATIBLE_FLAG);
#endif
SDL_Window* win = SDL_CreateWindow("vj shader test", 64, 64, SDL_WINDOW_OPENGL | SDL_WINDOW_HIDDEN);
SDL_GLContext ctx = win ? SDL_GL_CreateContext(win) : nullptr;
if (!win || !ctx) {
std::printf("shader_tests gl: SKIP (no OpenGL 3.3 context: %s)\n", SDL_GetError());
if (win) SDL_DestroyWindow(win);
SDL_Quit();
return 77;
}
SDL_GL_MakeCurrent(win, ctx);
if (!vj::initOpenGLLoader()) {
std::printf("shader_tests gl: FAIL cannot load GL entry points\n");
return 1;
}
std::printf("GL: %s / %s\n", (const char*)glGetString(GL_VERSION), (const char*)glGetString(GL_RENDERER));
// Source picture, uploaded the way the app does: row 0 is the TOP of the image.
// Top half red, bottom half blue.
const int SW = 32, SH = 16, OW = 128, OH = 64;
std::vector<unsigned char> src((size_t)SW * SH * 4);
for (int y = 0; y < SH; y++)
for (int x = 0; x < SW; x++) {
unsigned char* p = &src[((size_t)y * SW + x) * 4];
bool top = y < SH / 2;
p[0] = top ? 255 : 0;
p[1] = 0;
p[2] = top ? 0 : 255;
p[3] = 255;
}
unsigned srcTex = makeTexture(SW, SH, src);
ShaderFrame f;
f.srcTex = srcTex;
f.srcW = SW;
f.srcH = SH;
f.outW = OW;
f.outH = OH;
f.aspect = 2.0f;
f.fps = 60.0f;
auto px = [&](const std::vector<unsigned char>& v, int x, int y) { return &v[((size_t)y * OW + x) * 4]; };
// ---- sharp bilinear: an integer 4x scale must reproduce the picture crisply ----
sc.enabled = true;
std::string err;
CHECK(sc.usePreset(std::string(VJ_SHADER_DIR) + "/vj-sharp-bilinear.slangp", err), "preset: %s", err.c_str());
f.frameCount = 1;
unsigned out = sc.render(f);
CHECK(out != 0, "render returned no texture: %s", sc.lastError().c_str());
if (out) {
std::vector<unsigned char> o = readTexture(out, OW, OH);
// The output texture keeps the input's row order (row 0 = top of the picture),
// which is why the presenter draws it with plain UVs: red on top, blue below.
const unsigned char* top = px(o, 64, 6);
const unsigned char* bottom = px(o, 64, OH - 7);
CHECK(top[0] > 240 && top[2] < 15, "top of the picture (row 6) should be red, got %d,%d,%d", top[0], top[1], top[2]);
CHECK(bottom[2] > 240 && bottom[0] < 15, "bottom of the picture should be blue, got %d,%d,%d", bottom[0], bottom[1], bottom[2]);
// crisp edge: the red/blue seam is on a 4-pixel source boundary; one row either
// side of it is pure, not blended.
const unsigned char* a = px(o, 64, OH / 2 - 1);
const unsigned char* b = px(o, 64, OH / 2 + 1);
CHECK((a[0] > 240) != (b[0] > 240), "seam rows differ: %d vs %d", a[0], b[0]);
// Resizing the output re-allocates cleanly.
ShaderFrame g = f;
g.outW = 96;
g.outH = 48;
CHECK(sc.render(g) != 0, "render after a size change");
CHECK(sc.render(f) != 0, "and back");
// The same emulated frame at the same size is not rendered twice; a new
// frame (or a changed size) is.
uint64_t r0 = sc.chainRuns(), u0 = sc.chainReuses();
unsigned again = sc.render(f);
CHECK(again != 0 && sc.chainRuns() == r0 && sc.chainReuses() == u0 + 1, "same frame, same size: output reused");
ShaderFrame nf = f;
nf.frameCount = f.frameCount + 1;
CHECK(sc.render(nf) != 0 && sc.chainRuns() == r0 + 1, "next frame: chain runs");
f.frameCount = nf.frameCount;
}
// ---- scanlines: a flat white source must come out with bright and dark rows ----
std::vector<unsigned char> white((size_t)SW * SH * 4, 255);
unsigned whiteTex = makeTexture(SW, SH, white);
f.srcTex = whiteTex;
CHECK(sc.usePreset(std::string(VJ_SHADER_DIR) + "/vj-scanlines.slangp", err), "preset: %s", err.c_str());
out = sc.render(f);
CHECK(out != 0, "scanlines render: %s", sc.lastError().c_str());
if (out) {
std::vector<unsigned char> o = readTexture(out, OW, OH);
int mn = 255, mx = 0;
for (int y = 0; y < OH; y++) {
int v = px(o, 64, y)[1];
mn = std::min(mn, v);
mx = std::max(mx, v);
}
CHECK(mx >= 245 && mn <= 200, "scanline rows should vary: min %d max %d", mn, mx);
// Turning the strength to 0 flattens it again, live, through the parameter API.
for (size_t i = 0; i < sc.params().size(); i++)
if (sc.params()[i].name == "SCAN_STRENGTH") {
sc.params()[i].value = 0.0f;
sc.paramChanged(i);
}
out = sc.render(f);
o = readTexture(out, OW, OH);
mn = 255;
mx = 0;
for (int y = 0; y < OH; y++) {
int v = px(o, 64, y)[1];
mn = std::min(mn, v);
mx = std::max(mx, v);
}
CHECK(mx - mn <= 2, "strength 0 should be flat: min %d max %d", mn, mx);
}
#ifdef VJ_SLANG_DIR
// ---- the bundled slang-shaders presets that must work on this GL ----
{
std::string root = VJ_SLANG_DIR;
if (!root.empty() && root.back() != '/') root += '/';
std::vector<const char*> rels(std::begin(kBundledMustWork), std::end(kBundledMustWork));
for (const char* rel : presetsNeedingArraysOfArrays())
if (std::find_if(rels.begin(), rels.end(), [&](const char* r) { return std::strcmp(r, rel) == 0; }) == rels.end())
rels.push_back(rel);
for (const char* rel : rels) {
if (!fs::exists(fs::u8path(root + rel))) continue; // bundling was skipped
// Presets that need arrays of arrays (GL 4.3) must render where the context has
// them, and elsewhere (macOS, GL 4.1) fail cleanly with that reason: the check
// that keeps presetsNeedingArraysOfArrays() honest in both directions.
bool optional = presetNeedsArraysOfArrays(rel);
ShaderChain ex;
ex.setLibrarySearchDirs({});
ex.enabled = true;
std::string e2;
bool used = ex.usePreset(root + rel, e2);
unsigned t = 0;
for (int i = 1; used && i <= 4; i++) {
f.srcTex = srcTex;
f.frameCount = (uint64_t)i;
t = ex.render(f);
}
long sum = 0;
if (t) {
std::vector<unsigned char> o = readTexture(t, OW, OH);
for (size_t i = 0; i < o.size(); i += 4) sum += o[i] + o[i + 1] + o[i + 2];
}
std::printf("bundled %s: %s (energy %ld)%s%s\n", rel, t && sum > 0 ? "renders" : "FAILS", sum,
t ? "" : " -- ", t ? "" : (used ? ex.lastError().c_str() : e2.c_str()));
if (optional && !ex.glHasArraysOfArrays()) {
CHECK(t == 0 && !ex.active(), "%s rendered on GL %s: drop it from the arrays-of-arrays list", rel,
ex.glVersionText().c_str());
CHECK(ex.lastError().find("needs OpenGL 4.3") != std::string::npos,
"%s: expected the arrays-of-arrays explanation, got: %s", rel, ex.lastError().c_str());
} else {
CHECK(t != 0 && sum > 0, "%s did not render a picture", rel);
}
ex.destroyGL();
}
}
#endif
// ---- any other preset on request (a slang-shaders checkout): VJ_TEST_PRESETS="a.slangp;b.slangp" ----
if (const char* extra = std::getenv("VJ_TEST_PRESETS")) {
f.srcTex = srcTex;
std::string list = extra;
size_t a0 = 0;
while (a0 < list.size()) {
size_t b0 = list.find(';', a0);
std::string one = list.substr(a0, b0 == std::string::npos ? std::string::npos : b0 - a0);
a0 = b0 == std::string::npos ? list.size() : b0 + 1;
if (one.empty()) continue;
ShaderChain ex;
ex.setLibrarySearchDirs({});
ex.enabled = true;
std::string e2;
bool used = ex.usePreset(one, e2);
unsigned t = 0;
for (int i = 1; used && i <= 3; i++) { // a few frames: history/feedback passes warm up
f.frameCount = (uint64_t)i;
t = ex.render(f);
}
long sum = 0;
if (t) {
std::vector<unsigned char> o = readTexture(t, OW, OH);
for (size_t i = 0; i < o.size(); i += 4) sum += o[i] + o[i + 1] + o[i + 2];
}
std::printf("preset %s: %s, %zu params, output energy %ld%s%s\n", one.c_str(),
t ? "rendered" : "NOT rendered", ex.params().size(), sum,
t ? "" : " -- ", t ? "" : (used ? ex.lastError().c_str() : e2.c_str()));
CHECK(t != 0 && sum > 0, "%s did not render a picture", one.c_str());
ex.destroyGL();
}
}
// ---- a shader that does not compile: clean fallback, one notice, no crash ----
fs::path d = makeTemp("gl");
write(d / "broken.slang", "#version 450\n#pragma stage vertex\nvoid main() { this is not glsl }\n#pragma stage fragment\nvoid main() {}\n");
write(d / "broken.slangp", "shaders = 1\nshader0 = broken.slang\n");
ShaderChain bad;
bad.setLibrarySearchDirs({});
bad.enabled = true;
bool accepted = bad.usePreset(str(d / "broken.slangp"), err); // may be refused up front
unsigned bo = accepted ? bad.render(f) : 0;
std::string note;
bool noted = bad.takeNotice(note);
CHECK(bo == 0, "a broken shader must not produce a texture");
CHECK(!accepted || (noted && note.find("plain picture") != std::string::npos), "notice: %s", note.c_str());
CHECK(!accepted || !bad.lastError().empty(), "error text recorded");
if (accepted) {
CHECK(bad.render(f) == 0 && !bad.takeNotice(note), "after a failure: plain, silent, no retry storm");
bad.retry(); // the user asking again tries again (and fails again, cleanly)
CHECK(bad.render(f) == 0 && bad.takeNotice(note), "retry reports again");
}
std::printf("broken shader -> %s: %s\n", accepted ? "chain build failed" : "refused up front",
accepted ? bad.lastError().c_str() : err.c_str());
std::error_code ec;
fs::remove_all(d, ec);
sc.destroyGL();
bad.destroyGL();
SDL_GL_DestroyContext(ctx);
SDL_DestroyWindow(win);
SDL_Quit();
std::printf("shader_tests gl: %s\n", g_fail ? "FAIL" : "OK");
return g_fail ? 1 : 0;
}
// ---------------------------------------------------------------------------------------
// probe (packaging smoke test, not a CTest entry)
// ---------------------------------------------------------------------------------------
//
// vj_shader_tests probe <dir>: finds the library the way the APP does (beside the executable,
// <base>/../Frameworks in a macOS bundle; no VJ_LIBRASHADER, no extra directories) and fails
// unless it loaded from inside <dir>. CI copies this executable into the unpacked AppImage,
// the installed .deb tree and the .app, where it proves the packaged layout is the probed one.
static int runProbe(const char* dir) {
if (!ShaderChain::builtWithHeaders()) {
std::printf("shader_tests probe: FAIL (built without librashader headers)\n");
return 1;
}
setEnv("VJ_LIBRASHADER", nullptr);
Config cfg;
ShaderChain sc;
sc.loadSettings(cfg, ""); // the same search list the app builds
if (!sc.probeLibrary()) {
std::printf("shader_tests probe: FAIL (%s)\n", sc.libraryError().c_str());
return 1;
}
std::error_code ec;
std::string got = fs::weakly_canonical(fs::path(sc.libraryPath()), ec).string();
std::string want = dir ? fs::weakly_canonical(fs::path(dir), ec).string() : "";
std::printf("shader_tests probe: found %s (API %u); expected inside %s\n", got.c_str(), sc.libraryApiVersion(), want.c_str());
if (want.empty() || got.find(want) != 0) {
std::printf("shader_tests probe: FAIL (not loaded from the package)\n");
return 1;
}
std::printf("shader_tests probe: OK\n");
return 0;
}
int main(int argc, char** argv) {
std::string mode = argc > 1 ? argv[1] : "unit";
if (mode == "probe") return runProbe(argc > 2 ? argv[2] : nullptr);
if (mode == "unit") return runUnit();
if (mode == "bundled") return runBundled();
if (mode == "library") return runLibrary();
if (mode == "gl") return runGl();
std::fprintf(stderr, "usage: %s unit|bundled|library|gl|probe <dir>\n", argv[0]);
return 2;
}