// SPDX-License-Identifier: MIT // SPDX-FileCopyrightText: 2026 Martino Ferrari #include "harness.hpp" #include "egl_headless.hpp" #include // eglGetProcAddress: the context-aware entry point resolver #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include namespace { /// `eglGetProcAddress` is the context-aware resolver of the EGL context: in a /// surfaceless context there is no GLX/WGL dispatch to fall back on, so this is /// the path that must work. plot::gl::ProcLoader egl_loader() noexcept { return reinterpret_cast(eglGetProcAddress); } constexpr const char* kVertexSrc = R"(#version 330 core layout(location = 0) in vec2 a_pos; uniform vec2 u_offset; void main() { gl_Position = vec4(a_pos + u_offset, 0.0, 1.0); } )"; constexpr const char* kFragmentSrc = R"(#version 330 core uniform float u_alpha; out vec4 o_color; void main() { o_color = vec4(1.0, 0.0, 0.0, u_alpha); } )"; /// Deliberately unparseable GLSL, to exercise the failure path of /// `Program::from_source`. constexpr const char* kBrokenFragmentSrc = R"(#version 330 core out vec4 o_color; void main() { o_color = this is not glsl; } )"; /// Valid GLSL on both sides but an interface that cannot link: the fragment /// reads a `vec3` where the vertex writes a `vec2`, so the link has to fail and /// carry the driver's log through `Status::message`. constexpr const char* kMismatchedVertexSrc = R"(#version 330 core layout(location = 0) in vec2 a_pos; out vec2 v_value; void main() { v_value = a_pos; gl_Position = vec4(a_pos, 0.0, 1.0); } )"; constexpr const char* kMismatchedFragmentSrc = R"(#version 330 core in vec3 v_value; out vec4 o_color; void main() { o_color = vec4(v_value, 1.0); } )"; /// Holds one GL entry point at a stub for as long as it lives, the render-smoke /// stubs' save/restore pattern made generic over the slot's function-pointer /// type so this file needs one class rather than one per entry point. The /// pointer is restored by the destructor, which also runs while a failed /// `PLOT_CHECK` unwinds, so a stub can never leak into the next case. template class EntryGuard { public: EntryGuard(Fn& slot, Fn value) noexcept : slot_(slot), saved_(slot) { slot_ = value; } ~EntryGuard() { slot_ = saved_; } EntryGuard(const EntryGuard&) = delete; EntryGuard& operator=(const EntryGuard&) = delete; private: Fn& slot_; Fn saved_; }; // --- the caps probe, driven by fake strings ---------------------------------- /// Reported by the stubbed `glGetString`; each case sets them before probing. const char* g_fake_version = "2.1"; const char* g_fake_extensions = ""; bool g_fake_version_missing = false; const plot::gl::GLubyte* fake_get_string(plot::gl::GLenum name) noexcept { if (name == plot::gl::GL_VERSION) { return g_fake_version_missing ? nullptr : reinterpret_cast(g_fake_version); } if (name == plot::gl::GL_EXTENSIONS) { return g_fake_extensions == nullptr ? nullptr : reinterpret_cast(g_fake_extensions); } if (name == plot::gl::GL_RENDERER) { return reinterpret_cast("fake renderer"); } if (name == plot::gl::GL_VENDOR) { return reinterpret_cast("fake vendor"); } return nullptr; } /// The indexed list's length and the profile mask the fake `glGetIntegerv` /// reports; only the 3.x indexed path reads them. void fake_get_integer_v(plot::gl::GLenum name, plot::gl::GLint* out) noexcept { *out = name == plot::gl::GL_NUM_EXTENSIONS ? 2 : 0; // 0 = no core-profile bit } /// What the stubbed `glGetStringi` reports: 0 = a null pointer, 1 = the /// extension the probe looks for, 2 = some other one. int g_fake_stringi_mode = 0; const plot::gl::GLubyte* fake_get_string_i(plot::gl::GLenum, plot::gl::GLuint) noexcept { if (g_fake_stringi_mode == 0) { return nullptr; } return reinterpret_cast( g_fake_stringi_mode == 1 ? "GL_ARB_buffer_storage" : "GL_ARB_something_else"); } // --- entry points that hand out no name, for the constructors' failure paths -- void zero_gen_buffers(plot::gl::GLsizei, plot::gl::GLuint* out) noexcept { *out = 0; } void zero_gen_vertex_arrays(plot::gl::GLsizei, plot::gl::GLuint* out) noexcept { *out = 0; } void zero_gen_textures(plot::gl::GLsizei, plot::gl::GLuint* out) noexcept { *out = 0; } void zero_gen_framebuffers(plot::gl::GLsizei, plot::gl::GLuint* out) noexcept { *out = 0; } plot::gl::GLuint zero_create_shader(plot::gl::GLenum) noexcept { return 0; } plot::gl::GLuint zero_create_program() noexcept { return 0; } /// Probes caps once with the fake string table installed and `GL_EXTENSIONS` /// reporting `extensions` (null meaning the query returns no string). The /// `load()` first clears the cached probe *and* restores the real entry point /// -- the previous probe's guard has already run -- so each call reads the stub. /// Returning a copy keeps the probe valid after the guard restores the table. plot::gl::Caps probe_legacy_caps(const char* extensions) noexcept { plot::gl::load(nullptr); EntryGuard guard(plot::gl::glGetString, &fake_get_string); g_fake_version = "2.1"; g_fake_version_missing = false; g_fake_extensions = extensions; return plot::gl::caps(); } /// Probes caps once on a 3.2+ context, where the indexed list supplies the /// names: `GL_NUM_EXTENSIONS` and the profile mask come from the fake /// `glGetIntegerv`, each name from the fake `glGetStringi` (`stringi_mode`: /// 0 = null pointer, 1 = the extension sought, 2 = another). plot::gl::Caps probe_indexed_caps(int stringi_mode) noexcept { plot::gl::load(nullptr); EntryGuard string_guard(plot::gl::glGetString, &fake_get_string); EntryGuard int_guard(plot::gl::glGetIntegerv, &fake_get_integer_v); EntryGuard stringi_guard(plot::gl::glGetStringi, &fake_get_string_i); g_fake_version = "3.3"; g_fake_version_missing = false; g_fake_stringi_mode = stringi_mode; return plot::gl::caps(); } } // namespace // Must stay the first GL case in this translation unit: `caps()` caches after // the first successful query, so "before any load()" is observable only once. PLOT_TEST(caps_before_load_is_zeroed) { const plot::gl::Caps& caps = plot::gl::caps(); PLOT_CHECK(caps.major == 0 && caps.minor == 0); PLOT_CHECK(!caps.compute && !caps.buffer_storage && !caps.persistent_map); PLOT_CHECK(!caps.core_profile); PLOT_CHECK(caps.renderer != nullptr && caps.renderer[0] == '\0'); PLOT_CHECK(caps.vendor != nullptr && caps.vendor[0] == '\0'); } // The `kNoLoader` family and the `load()` failure branch need `available()` to // be false, and `load()` is one-way and process-wide: once this suite has // resolved the table -- the very next case does -- a failure cannot be produced // in-process. So the whole case runs in a forked child, where poisoning the // table poisons only its own copy. On a host that ships libGL.so.1 the only way // left to make a required entry point unresolvable is to block `dlopen` by // exhausting the file descriptors, which is what the child does first. // // It must run before `headless_context_provides_gl33`: after that case the // process has opened libGL.so.1, and the excluded descriptors would no longer // stop `dlopen` from handing the loader the table it wants. PLOT_TEST(a_gl_failure_without_a_loader_is_safe_in_a_forked_child) { std::fflush(nullptr); // so the child's exit() cannot re-flush the parent's stdout const pid_t child = ::fork(); PLOT_CHECK(child >= 0); if (child == 0) { int rc = 0; try { struct rlimit original {}; PLOT_CHECK(::getrlimit(RLIMIT_NOFILE, &original) == 0); struct rlimit lowered = original; lowered.rlim_cur = original.rlim_max < 64 ? original.rlim_max : 64; PLOT_CHECK(::setrlimit(RLIMIT_NOFILE, &lowered) == 0); std::vector held; int fd = -1; while ((fd = ::open("/dev/null", O_RDONLY)) >= 0) { held.push_back(fd); } // Every descriptor is spent, so dlopen("libGL.so.1") fails and no required // entry point can be resolved: load() reports the first one it could not. PLOT_CHECK(!plot::gl::load(nullptr)); PLOT_CHECK(!plot::gl::available()); plot::Status st; plot::gl::Buffer buffer; PLOT_CHECK(!buffer.create(&st)); PLOT_CHECK(st.code == plot::ErrorCode::Unsupported); PLOT_CHECK(st.message != nullptr); PLOT_CHECK(buffer.id() == 0); plot::gl::VertexArray vao; PLOT_CHECK(!vao.create(&st)); PLOT_CHECK(st.code == plot::ErrorCode::Unsupported); PLOT_CHECK(vao.id() == 0); plot::gl::Program program = plot::gl::Program::from_source(kVertexSrc, kFragmentSrc, &st); PLOT_CHECK(!st); PLOT_CHECK(st.code == plot::ErrorCode::Unsupported); PLOT_CHECK(program.id() == 0); plot::gl::Texture rgba; PLOT_CHECK(!rgba.create_rgba(4, 4, &st)); PLOT_CHECK(st.code == plot::ErrorCode::Unsupported); PLOT_CHECK(rgba.id() == 0); plot::gl::Texture r8; PLOT_CHECK(!r8.create_r8(4, 4, &st)); PLOT_CHECK(st.code == plot::ErrorCode::Unsupported); PLOT_CHECK(r8.id() == 0); plot::gl::Framebuffer fbo; PLOT_CHECK(!fbo.create(&st)); PLOT_CHECK(st.code == plot::ErrorCode::Unsupported); PLOT_CHECK(fbo.id() == 0); plot::gl::Fence fence; PLOT_CHECK(!fence.create(&st)); PLOT_CHECK(st.code == plot::ErrorCode::Unsupported); PLOT_CHECK(fence.id() == nullptr); // An empty object's guarded operations must not reach the null table. plot::gl::Buffer empty_buffer; empty_buffer.storage(16, plot::gl::GL_MAP_WRITE_BIT); // entry point null: no-op plot::gl::Program empty_program; empty_program.set_uniform_int("u_value", 1); // id 0: no-op PLOT_CHECK(fence.wait(0)); // nothing in flight // Hand the descriptors back so the coverage dump at exit can open its file. for (const int held_fd : held) { ::close(held_fd); } PLOT_CHECK(::setrlimit(RLIMIT_NOFILE, &original) == 0); } catch (...) { rc = 1; } std::exit(rc); } int status = 0; PLOT_CHECK(::waitpid(child, &status, 0) == child); PLOT_CHECK(WIFEXITED(status)); PLOT_CHECK(WEXITSTATUS(status) == 0); } // `resolve()`'s second source, `dlsym(RTLD_DEFAULT, name)`, only answers when a // GL library sits in the global symbol scope -- the usual arrangement on a host // without glvnd's local dispatch. This host is the other kind, so the case // promotes libGL into that scope itself. The promotion is one-way and // process-wide, which is exactly why it happens in a forked child: the parent's // namespace is untouched, and the child's exit() flushes the counters for the // branch it walked. PLOT_TEST(a_globally_visible_gl_library_satisfies_the_fallback_resolver) { std::fflush(nullptr); const pid_t child = ::fork(); PLOT_CHECK(child >= 0); if (child == 0) { int rc = 0; try { void* handle = ::dlopen("libGL.so.1", RTLD_NOW | RTLD_GLOBAL); // Not every host ships a GL library to promote; where there is none the // case simply cannot reach the branch rather than pretending it did. if (handle != nullptr && ::dlsym(RTLD_DEFAULT, "glGetString") != nullptr) { PLOT_CHECK(plot::gl::load(nullptr)); PLOT_CHECK(plot::gl::available()); } } catch (...) { rc = 1; } std::exit(rc); } int status = 0; PLOT_CHECK(::waitpid(child, &status, 0) == child); PLOT_CHECK(WIFEXITED(status)); PLOT_CHECK(WEXITSTATUS(status) == 0); } PLOT_TEST(headless_context_provides_gl33) { plottest::HeadlessContext ctx; plot::Status st; PLOT_CHECK(ctx.create(&st)); // fails loudly if surfaceless EGL is unavailable // Primary path: resolve through the current context. PLOT_CHECK(plot::gl::load(egl_loader())); const plot::gl::Caps& caps = plot::gl::caps(); PLOT_CHECK(caps.major > 3 || (caps.major == 3 && caps.minor >= 3)); PLOT_CHECK(caps.renderer != nullptr && caps.renderer[0] != '\0'); PLOT_CHECK(caps.vendor != nullptr && caps.vendor[0] != '\0'); // EGL_CONTEXT_OPENGL_CORE_PROFILE_BIT_KHR is requested, so this checks our profile-mask parsing. PLOT_CHECK(caps.core_profile); const char* version = reinterpret_cast(plot::gl::glGetString(plot::gl::GL_VERSION)); PLOT_CHECK(version != nullptr); const int major = caps.major; const int minor = caps.minor; std::printf("[info] GL %d.%d, renderer \"%s\", vendor \"%s\", %s profile, EGL path \"%s\"\n", major, minor, caps.renderer, caps.vendor, caps.core_profile ? "core" : "compat", ctx.egl_path); std::printf("[info] caps: compute=%d buffer_storage=%d persistent_map=%d core_profile=%d\n", caps.compute ? 1 : 0, caps.buffer_storage ? 1 : 0, caps.persistent_map ? 1 : 0, caps.core_profile ? 1 : 0); // The dlsym(RTLD_DEFAULT) / libGL.so.1 fallback must resolve the whole table // too, and its pointers must still dispatch to the current EGL context. Only // a host that ships desktop libGL can exercise it; on a GLES-only host there // is nothing to assert. if (dlopen("libGL.so.1", RTLD_LAZY) != nullptr) { PLOT_CHECK(plot::gl::load(nullptr)); PLOT_CHECK(plot::gl::glGetError() == plot::gl::GL_NO_ERROR); const char* fallback_version = reinterpret_cast(plot::gl::glGetString(plot::gl::GL_VERSION)); PLOT_CHECK(fallback_version != nullptr); PLOT_CHECK(std::strcmp(fallback_version, version) == 0); // same context, same driver const plot::gl::Caps& reprobed = plot::gl::caps(); // load() invalidated the cache PLOT_CHECK(reprobed.major == major && reprobed.minor == minor); PLOT_CHECK(reprobed.renderer != nullptr && reprobed.renderer[0] != '\0'); } // Hand the table back to the context-aware resolver so teardown (deleting the // framebuffer) runs through the entry points of the context it belongs to. PLOT_CHECK(plot::gl::load(egl_loader())); ctx.destroy(); } PLOT_TEST(headless_framebuffer_reads_back_clear_color) { plottest::HeadlessContext ctx; plot::Status st; PLOT_CHECK(ctx.create(&st)); PLOT_CHECK(plot::gl::load(egl_loader())); plot::gl::glClearColor(0.25f, 0.5f, 0.75f, 1.0f); plot::gl::glClear(plot::gl::GL_COLOR_BUFFER_BIT); std::uint8_t px[4] = {0, 0, 0, 0}; PLOT_CHECK(ctx.read_pixel(0, 0, px)); PLOT_CHECK_NEAR(px[0], 64, 2); // round(0.25 * 255) PLOT_CHECK_NEAR(px[1], 128, 2); PLOT_CHECK_NEAR(px[2], 191, 2); PLOT_CHECK_NEAR(px[3], 255, 2); // A second corner proves the whole attachment is readable. PLOT_CHECK(ctx.read_pixel(ctx.width - 1, ctx.height - 1, px)); PLOT_CHECK_NEAR(px[2], 191, 2); PLOT_CHECK(plot::gl::glGetError() == plot::gl::GL_NO_ERROR); ctx.destroy(); } PLOT_TEST(raii_handles_round_trip) { plottest::HeadlessContext ctx; plot::Status st; PLOT_CHECK(ctx.create(&st)); PLOT_CHECK(plot::gl::load(egl_loader())); const float verts[6] = {0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 1.0f}; plot::gl::Buffer vbo; PLOT_CHECK(vbo.create(&st)); PLOT_CHECK(vbo.id() != 0); vbo.upload(verts, sizeof verts, plot::gl::GL_STATIC_DRAW); vbo.upload_sub(verts, sizeof verts, 0); plot::gl::VertexArray vao; PLOT_CHECK(vao.create(&st)); vao.bind(); vbo.bind(); vao.attrib(0, 2, 2 * sizeof(float), 0); const std::uint8_t texels[8 * 8 * 4] = {0}; plot::gl::Texture color; PLOT_CHECK(color.create_rgba(8, 8, &st)); color.upload_rgba(8, 8, texels); const std::uint8_t coverage[8 * 8] = {0}; plot::gl::Texture mask; PLOT_CHECK(mask.create_r8(8, 8, &st)); mask.upload_r8(8, 8, coverage); mask.set_filter(plot::gl::GL_NEAREST, plot::gl::GL_NEAREST); mask.bind(); plot::gl::Framebuffer fbo; PLOT_CHECK(fbo.create(&st)); fbo.attach_color(color); PLOT_CHECK(fbo.complete()); plot::gl::Fence fence; PLOT_CHECK(fence.create(&st)); plot::gl::glFinish(); PLOT_CHECK(fence.wait(0)); // Move-only: the source is emptied and the GL name survives. plot::gl::Buffer moved = std::move(vbo); PLOT_CHECK(moved.id() != 0); PLOT_CHECK(vbo.id() == 0); moved.reset(); PLOT_CHECK(moved.id() == 0); PLOT_CHECK(plot::gl::glGetError() == plot::gl::GL_NO_ERROR); ctx.destroy(); } PLOT_TEST(program_compiles_trivial_shaders) { plottest::HeadlessContext ctx; plot::Status st; PLOT_CHECK(ctx.create(&st)); PLOT_CHECK(plot::gl::load(egl_loader())); plot::Status ok; plot::gl::Program program = plot::gl::Program::from_source(kVertexSrc, kFragmentSrc, &ok); PLOT_CHECK(bool(ok)); PLOT_CHECK(program.id() != 0); program.use(); program.set_uniform("u_offset", plot::Vec2{1.0f, 2.0f}); program.set_uniform("u_alpha", 0.5f); program.set_uniform("u_missing", 0.25f); // unknown name: silent no-op PLOT_CHECK(plot::gl::glGetError() == plot::gl::GL_NO_ERROR); plot::Status bad; plot::gl::Program broken = plot::gl::Program::from_source(kVertexSrc, kBrokenFragmentSrc, &bad); PLOT_CHECK(!bad); PLOT_CHECK(bad.code == plot::ErrorCode::GLFailure); PLOT_CHECK(bad.message != nullptr && bad.message[0] != '\0'); PLOT_CHECK(broken.id() == 0); ctx.destroy(); } // The GLSL of both pipelines has to be accepted by the real driver: a typo that // no CPU test can catch (a missing varying, a reserved word) fails here. PLOT_TEST(shaders_compile_and_link) { plottest::HeadlessContext ctx; plot::Status st; PLOT_CHECK(ctx.create(&st)); PLOT_CHECK(plot::gl::load(nullptr)); plot::Status s0; plot::Status s1; plot::gl::Program line = plot::gl::Program::from_source(plot::render::kLineVS, plot::render::kLineFS, &s0); plot::gl::Program text = plot::gl::Program::from_source(plot::render::kTextVS, plot::render::kTextFS, &s1); PLOT_CHECK(bool(s0)); PLOT_CHECK(bool(s1)); PLOT_CHECK(line.id() != 0); PLOT_CHECK(text.id() != 0); ctx.destroy(); } // A renderer wrapper that was never successfully created must be a harmless // no-op: Task 7 checks `create()`'s bool and then keeps drawing with whatever // else it has, so the setters must not reach an empty program. PLOT_TEST(render_programs_are_inert_until_created) { plottest::HeadlessContext ctx; plot::Status st; PLOT_CHECK(ctx.create(&st)); PLOT_CHECK(plot::gl::load(nullptr)); plot::render::LineProgram line; plot::render::TextProgram text; PLOT_CHECK(!line.valid()); PLOT_CHECK(!text.valid()); line.bind(); line.set_scale({1.0f, 1.0f}); line.set_offset({0.0f, 0.0f}); line.set_viewport({256.0f, 256.0f}); line.set_width(2.0f); line.set_color(plot::Color::hex(0xFFFFFF)); text.bind(); text.set_viewport({256.0f, 256.0f}); text.set_px_range({8.0f, 8.0f}); text.set_glyph_uv({1.0f / 16.0f, 1.0f / 7.0f}); text.set_texture_size({128.0f, 56.0f}); text.set_color(plot::Color::hex(0xFFFFFF)); PLOT_CHECK(plot::gl::glGetError() == plot::gl::GL_NO_ERROR); ctx.destroy(); } // End to end on the real driver: the instanced attribute layout, the // triangle-strip expansion, the single y flip between Rect pixels and GL, and // the AA ramp that makes a thin trace legible. PLOT_TEST(line_pipeline_draws_an_aa_strip) { plottest::HeadlessContext ctx(256, 256); plot::Status st; PLOT_CHECK(ctx.create(&st)); PLOT_CHECK(plot::gl::load(nullptr)); plot::render::LineProgram program; PLOT_CHECK(program.create(&st)); plot::render::LineVertexArray lines; PLOT_CHECK(lines.create(&st)); // Three instances: two segments at different y, so a wrong attribute divisor // cannot pass (with a divisor of zero the second quad would be assembled from // the first instance's leftover attribute values), plus a degenerate one at // Rect (64, 140) — the shape a dense trace's min == max bucket stages, which // must still leave a mark instead of collapsing to zero area. const f32 segments[12] = {0.0f, 0.0f, 40.0f, 0.0f, 0.0f, 20.0f, 40.0f, 20.0f, 0.0f, 40.0f, 0.0f, 40.0f}; lines.upload(segments, 3); PLOT_CHECK(lines.instance_count() == 3); plot::gl::glViewport(0, 0, static_cast(ctx.width), static_cast(ctx.height)); plot::gl::glClearColor(0.0f, 0.0f, 0.0f, 1.0f); plot::gl::glClear(plot::gl::GL_COLOR_BUFFER_BIT); plot::gl::glEnable(plot::gl::GL_BLEND); plot::gl::glBlendFunc(plot::gl::GL_SRC_ALPHA, plot::gl::GL_ONE_MINUS_SRC_ALPHA); program.bind(); program.set_scale({1.0f, 1.0f}); program.set_offset({64.0f, 100.0f}); program.set_viewport({static_cast(ctx.width), static_cast(ctx.height)}); program.set_width(3.0f); program.set_color(plot::Color::hex(0xFF00FF)); lines.bind(); plot::gl::glDrawArraysInstanced(plot::gl::GL_TRIANGLE_STRIP, 0, 4, 3); plot::u8 px[4] = {}; // Rect y = 100 is GL row 155.5, so rows 155 and 156 straddle the axis // (d = 0.5), rows 154 and 157 are the half-covered AA fringe (d = 1.5), and // row 152 is outside the quad. Getting the flip wrong, or applying it twice, // moves the whole strip to the other half of the target. PLOT_CHECK(ctx.read_pixel(84, 155, px)); PLOT_CHECK(px[0] > 200 && px[1] < 60 && px[2] > 200); PLOT_CHECK(ctx.read_pixel(84, 156, px)); PLOT_CHECK(px[0] > 200 && px[2] > 200); PLOT_CHECK(ctx.read_pixel(84, 157, px)); PLOT_CHECK(px[0] > 60 && px[0] < 200); PLOT_CHECK(ctx.read_pixel(84, 152, px)); PLOT_CHECK(px[0] + px[2] < 40); // The second instance is where its own segment says: Rect y = 120 is rows // 135/136, and the gap between the two strips stays background. PLOT_CHECK(ctx.read_pixel(84, 135, px)); PLOT_CHECK(px[0] > 200 && px[2] > 200); PLOT_CHECK(ctx.read_pixel(84, 136, px)); PLOT_CHECK(px[0] > 200 && px[2] > 200); PLOT_CHECK(ctx.read_pixel(84, 145, px)); PLOT_CHECK(px[0] + px[2] < 40); // Past the end of the segment (flat cap) and far from both strips. PLOT_CHECK(ctx.read_pixel(110, 155, px)); PLOT_CHECK(px[0] + px[2] < 40); // The degenerate instance: Rect y = 140 is rows 115/116 at x = 64, and the // mark must be visible rather than a zero-area quad, while staying inside its // own stroke width + fringe (columns 62..66). PLOT_CHECK(ctx.read_pixel(64, 115, px)); PLOT_CHECK(px[0] > 200 && px[2] > 200); PLOT_CHECK(ctx.read_pixel(64, 116, px)); PLOT_CHECK(px[0] > 200 && px[2] > 200); PLOT_CHECK(ctx.read_pixel(64, 121, px)); PLOT_CHECK(px[0] + px[2] < 40); PLOT_CHECK(ctx.read_pixel(70, 115, px)); PLOT_CHECK(px[0] + px[2] < 40); PLOT_CHECK(ctx.read_pixel(128, 0, px)); PLOT_CHECK(px[0] + px[2] < 40); PLOT_CHECK(plot::gl::glGetError() == plot::gl::GL_NO_ERROR); ctx.destroy(); } // The text pipeline end to end: the R8 atlas upload, the per-instance glyph UV // rect, the NEAREST texel-centre sampling, the tint and the same single y flip. // Every texel of the cell is checked against the backend's own coverage byte, so // a wrong atlas cell, a wrong UV or a transposed axis cannot pass. PLOT_TEST(text_pipeline_draws_a_glyph) { using plot::render::EmbeddedMonoFont; plottest::HeadlessContext ctx(256, 256); plot::Status st; PLOT_CHECK(ctx.create(&st)); PLOT_CHECK(plot::gl::load(nullptr)); // The backend's atlas, through the owner the renderer uses: single channel, // GL_NEAREST, straight alpha, uploaded from the backend's own bytes. EmbeddedMonoFont font; plot::render::TextAtlas atlas; PLOT_CHECK(atlas.sync(font, &st)); PLOT_CHECK(!atlas.empty()); PLOT_CHECK_NEAR(atlas.texel_size().x, static_cast(font.atlas_width()), 1e-6); PLOT_CHECK_NEAR(atlas.texel_size().y, static_cast(font.atlas_height()), 1e-6); plot::render::TextProgram program; PLOT_CHECK(program.create(&st)); plot::render::TextVertexArray glyphs; PLOT_CHECK(glyphs.create(&st)); plot::render::TextBatch batch; batch.begin(); // One cell at Rect (64, 100), at the backend's own size: `px` of 1 means one // font pixel per pixel, which is what makes the texel-to-pixel mapping below // an identity. batch.add_text("A", {64.0f, 100.0f}, 1.0f, font); PLOT_CHECK(batch.glyph_count() == 1); PLOT_CHECK(batch.data().size() == 6); glyphs.upload(batch.data().data(), batch.glyph_count()); plot::gl::glViewport(0, 0, static_cast(ctx.width), static_cast(ctx.height)); plot::gl::glClearColor(0.0f, 0.0f, 0.0f, 1.0f); plot::gl::glClear(plot::gl::GL_COLOR_BUFFER_BIT); plot::gl::glEnable(plot::gl::GL_BLEND); plot::gl::glBlendFunc(plot::gl::GL_SRC_ALPHA, plot::gl::GL_ONE_MINUS_SRC_ALPHA); program.bind(); program.set_viewport({static_cast(ctx.width), static_cast(ctx.height)}); // The backend's own cell, on screen at 1:1: the draw's one rect size. const plot::f32 cell_w = static_cast(EmbeddedMonoFont::cell_width()); const plot::f32 cell_h = static_cast(EmbeddedMonoFont::cell_height()); program.set_px_range({cell_w, cell_h}); program.set_glyph_uv(font.cell_uv()); program.set_texture_size(atlas.texel_size()); // White, so the blended pixel value *is* the coverage byte: the shader emits // `vec4(rgb, a * coverage)`, and a white tint over the black clear lands at // `1.0 * coverage` in every channel. program.set_color(plot::Color::hex(0xFFFFFF)); atlas.bind(); glyphs.bind(); plot::gl::glDrawArraysInstanced(plot::gl::GL_TRIANGLE_STRIP, 0, 4, 1); // The cell is cell_w x cell_h texels and u_px_range is the same, so texel // (gx, gy) lands on exactly one target pixel: Rect x = 64 + gx, and Rect y = // 100 + gy is GL row 155 - gy. A transposed axis, a wrong cell or a wrong UV // all move ink away from where the coverage byte says it is. const plot::u32 slot = EmbeddedMonoFont::glyph_index(static_cast('A')); const plot::Vec2 origin = EmbeddedMonoFont::atlas_origin(slot); const plot::u8* atlas_bytes = font.atlas_pixels(); plot::u32 antialiased = 0; plot::u8 px[4] = {}; for (plot::u32 gy = 0; gy < EmbeddedMonoFont::cell_height(); ++gy) { for (plot::u32 gx = 0; gx < EmbeddedMonoFont::cell_width(); ++gx) { const plot::u32 ax = static_cast(origin.x) + gx; const plot::u32 ay = static_cast(origin.y) + gy; const plot::u8 want = atlas_bytes[static_cast(ay) * font.atlas_width() + ax]; PLOT_CHECK(ctx.read_pixel(64u + gx, 155u - gy, px)); PLOT_CHECK_NEAR(px[0], static_cast(want), 2.0); PLOT_CHECK_NEAR(px[1], static_cast(want), 2.0); PLOT_CHECK_NEAR(px[2], static_cast(want), 2.0); if (want > 0u && want < 255u) ++antialiased; } } // 'A' has grey edge texels and they reached the framebuffer: a 1-bit atlas // could only ever have produced 0 or 255 here. PLOT_CHECK(antialiased > 0u); PLOT_CHECK(plot::gl::glGetError() == plot::gl::GL_NO_ERROR); ctx.destroy(); } // The loader's legacy scanner: a 2.1 version string routes `caps()` down the // pre-3.0 path, the only one that reads `GL_EXTENSIONS` as one space-separated // string, so these probes go through `has_token` exactly as a real 2.1 driver // would. The near-misses are the assertions that matter -- a `strstr`-only // scanner would set the cap for both of them. PLOT_TEST(legacy_extension_string_is_scanned_a_whole_token_at_a_time) { PLOT_CHECK(probe_legacy_caps("GL_ARB_other GL_ARB_buffer_storage GL_ARB_more").buffer_storage); // At the very start and the very end: the boundaries the scanner exists for. PLOT_CHECK(probe_legacy_caps("GL_ARB_buffer_storage GL_ARB_more").buffer_storage); PLOT_CHECK(probe_legacy_caps("GL_ARB_more GL_ARB_buffer_storage").buffer_storage); // Prefix-only near-misses must not count. PLOT_CHECK(!probe_legacy_caps("GL_ARB_buffer_storageX").buffer_storage); PLOT_CHECK(!probe_legacy_caps("XGL_ARB_buffer_storage").buffer_storage); // No string at all: the null guard. PLOT_CHECK(!probe_legacy_caps(nullptr).buffer_storage); const plot::gl::Caps caps = probe_legacy_caps("GL_ARB_buffer_storage"); PLOT_CHECK(caps.major == 2 && caps.minor == 1); // the stub really was read PLOT_CHECK(caps.buffer_storage && caps.persistent_map); PLOT_CHECK(std::strcmp(caps.renderer, "fake renderer") == 0); } // The 3.0+ indexed path: `GL_NUM_EXTENSIONS` and the profile mask come from // `glGetIntegerv`, each name from `glGetStringi`. A null `glGetStringi` means // the list is unavailable, and the probe must then refuse -- the legacy string // is illegal on 3.0+ core -- rather than quietly report no support. PLOT_TEST(indexed_extension_list_is_probed_on_a_3x_context) { PLOT_CHECK(probe_indexed_caps(1).buffer_storage); PLOT_CHECK(!probe_indexed_caps(2).buffer_storage); // other names only PLOT_CHECK(!probe_indexed_caps(0).buffer_storage); // the entries are null { plot::gl::load(nullptr); EntryGuard string_guard(plot::gl::glGetString, &fake_get_string); EntryGuard int_guard(plot::gl::glGetIntegerv, &fake_get_integer_v); EntryGuard stringi_guard(plot::gl::glGetStringi, nullptr); g_fake_version = "3.3"; g_fake_version_missing = false; g_fake_extensions = "GL_ARB_buffer_storage"; // present, but must not be consulted PLOT_CHECK(!plot::gl::caps().buffer_storage); } const plot::gl::Caps caps = probe_indexed_caps(1); PLOT_CHECK(caps.major == 3 && caps.minor == 3); } // Neither a context that answers no version string nor one whose version does // not parse may leave stale caps behind: both must report the zeroed probe. PLOT_TEST(caps_are_zeroed_when_the_version_is_missing_or_unparsable) { { plot::gl::load(nullptr); EntryGuard guard(plot::gl::glGetString, &fake_get_string); g_fake_version_missing = true; const plot::gl::Caps caps = plot::gl::caps(); PLOT_CHECK(caps.major == 0 && caps.minor == 0); PLOT_CHECK(caps.renderer != nullptr && caps.renderer[0] == '\0'); } { plot::gl::load(nullptr); EntryGuard guard(plot::gl::glGetString, &fake_get_string); g_fake_version_missing = false; g_fake_version = "not a version"; const plot::gl::Caps caps = plot::gl::caps(); PLOT_CHECK(caps.major == 0 && caps.minor == 0); } g_fake_version = "2.1"; g_fake_version_missing = false; g_fake_extensions = ""; } // Every fallible constructor's refusal branches: a driver that hands out name 0 // (`GLFailure`), a table with the entry point absent (`Unsupported`), and the // argument checks. Each must report the documented code and leave an object // that is empty yet safe to destroy. The stubs save and restore the slot, so a // failure cannot leak into the next block. PLOT_TEST(fallible_handles_report_a_refused_driver_name_and_stay_empty) { plottest::HeadlessContext ctx; plot::Status st; PLOT_CHECK(ctx.create(&st)); PLOT_CHECK(plot::gl::load(egl_loader())); { EntryGuard guard(plot::gl::glGenBuffers, &zero_gen_buffers); plot::gl::Buffer buffer; PLOT_CHECK(!buffer.create(&st)); PLOT_CHECK(st.code == plot::ErrorCode::GLFailure); PLOT_CHECK(std::strcmp(st.message, "glGenBuffers returned 0") == 0); PLOT_CHECK(buffer.id() == 0); } { EntryGuard guard(plot::gl::glGenBuffers, nullptr); plot::gl::Buffer buffer; PLOT_CHECK(!buffer.create(&st)); PLOT_CHECK(st.code == plot::ErrorCode::Unsupported); PLOT_CHECK(buffer.id() == 0); } { EntryGuard guard(plot::gl::glGenVertexArrays, &zero_gen_vertex_arrays); plot::gl::VertexArray vao; PLOT_CHECK(!vao.create(&st)); PLOT_CHECK(st.code == plot::ErrorCode::GLFailure); PLOT_CHECK(std::strcmp(st.message, "glGenVertexArrays returned 0") == 0); PLOT_CHECK(vao.id() == 0); } { EntryGuard guard(plot::gl::glGenVertexArrays, nullptr); plot::gl::VertexArray vao; PLOT_CHECK(!vao.create(&st)); PLOT_CHECK(st.code == plot::ErrorCode::Unsupported); PLOT_CHECK(vao.id() == 0); } { EntryGuard guard(plot::gl::glGenTextures, &zero_gen_textures); plot::gl::Texture tex; PLOT_CHECK(!tex.create_rgba(4, 4, &st)); PLOT_CHECK(st.code == plot::ErrorCode::GLFailure); PLOT_CHECK(std::strcmp(st.message, "glGenTextures returned 0") == 0); PLOT_CHECK(tex.id() == 0); PLOT_CHECK(!tex.create_r8(4, 4, &st)); PLOT_CHECK(st.code == plot::ErrorCode::GLFailure); PLOT_CHECK(tex.id() == 0); } { EntryGuard guard(plot::gl::glGenTextures, nullptr); plot::gl::Texture tex; PLOT_CHECK(!tex.create_rgba(4, 4, &st)); PLOT_CHECK(st.code == plot::ErrorCode::Unsupported); PLOT_CHECK(!tex.create_r8(4, 4, &st)); PLOT_CHECK(st.code == plot::ErrorCode::Unsupported); } { plot::gl::Texture tex; PLOT_CHECK(!tex.create_rgba(0, 4, &st)); PLOT_CHECK(st.code == plot::ErrorCode::InvalidArgument); PLOT_CHECK(!tex.create_r8(4, 0, &st)); PLOT_CHECK(st.code == plot::ErrorCode::InvalidArgument); PLOT_CHECK(tex.id() == 0); } { EntryGuard guard(plot::gl::glGenFramebuffers, &zero_gen_framebuffers); plot::gl::Framebuffer fbo; PLOT_CHECK(!fbo.create(&st)); PLOT_CHECK(st.code == plot::ErrorCode::GLFailure); PLOT_CHECK(std::strcmp(st.message, "glGenFramebuffers returned 0") == 0); PLOT_CHECK(fbo.id() == 0); } { EntryGuard guard(plot::gl::glGenFramebuffers, nullptr); plot::gl::Framebuffer fbo; PLOT_CHECK(!fbo.create(&st)); PLOT_CHECK(st.code == plot::ErrorCode::Unsupported); PLOT_CHECK(fbo.id() == 0); } { EntryGuard guard(plot::gl::glFenceSync, nullptr); plot::gl::Fence fence; PLOT_CHECK(!fence.create(&st)); PLOT_CHECK(st.code == plot::ErrorCode::Unsupported); PLOT_CHECK(fence.id() == nullptr); } // Program: a table without glCreateShader, a null source, a shader the driver // refuses to create and a program it refuses to create. { EntryGuard guard(plot::gl::glCreateShader, nullptr); plot::Status bad; plot::gl::Program program = plot::gl::Program::from_source(kVertexSrc, kFragmentSrc, &bad); PLOT_CHECK(!bad); PLOT_CHECK(bad.code == plot::ErrorCode::Unsupported); PLOT_CHECK(program.id() == 0); } { plot::Status bad; plot::gl::Program program = plot::gl::Program::from_source(nullptr, kFragmentSrc, &bad); PLOT_CHECK(!bad); PLOT_CHECK(bad.code == plot::ErrorCode::InvalidArgument); PLOT_CHECK(program.id() == 0); } { EntryGuard guard(plot::gl::glCreateShader, &zero_create_shader); plot::Status bad; plot::gl::Program program = plot::gl::Program::from_source(kVertexSrc, kFragmentSrc, &bad); PLOT_CHECK(!bad); PLOT_CHECK(bad.code == plot::ErrorCode::GLFailure); PLOT_CHECK(std::strcmp(bad.message, "glCreateShader returned 0") == 0); PLOT_CHECK(program.id() == 0); } { EntryGuard guard(plot::gl::glCreateProgram, &zero_create_program); plot::Status bad; plot::gl::Program program = plot::gl::Program::from_source(kVertexSrc, kFragmentSrc, &bad); PLOT_CHECK(!bad); PLOT_CHECK(bad.code == plot::ErrorCode::GLFailure); PLOT_CHECK(std::strcmp(bad.message, "glCreateProgram returned 0") == 0); PLOT_CHECK(program.id() == 0); } plot::gl::Program empty; empty.set_uniform_int("u_value", 1); // an empty program's setter is a no-op PLOT_CHECK(plot::gl::glGetError() == plot::gl::GL_NO_ERROR); ctx.destroy(); } // A program whose stages each compile but cannot be linked together: the // constructor has to delete the program and report the driver's own log, not a // bare failure. This is the path the link-failure branch implements. PLOT_TEST(a_program_that_cannot_link_reports_the_driver_log) { plottest::HeadlessContext ctx; plot::Status st; PLOT_CHECK(ctx.create(&st)); PLOT_CHECK(plot::gl::load(egl_loader())); plot::Status bad; plot::gl::Program program = plot::gl::Program::from_source(kMismatchedVertexSrc, kMismatchedFragmentSrc, &bad); PLOT_CHECK(!bad); PLOT_CHECK(bad.code == plot::ErrorCode::GLFailure); PLOT_CHECK(bad.message != nullptr); PLOT_CHECK(std::strncmp(bad.message, "program link failed", std::strlen("program link failed")) == 0); // The driver's log follows the prefix: a real reason, not an empty tail. Mesa's // linker names the mismatched varying. PLOT_CHECK(std::strlen(bad.message) > std::strlen("program link failed: ")); PLOT_CHECK(std::strstr(bad.message, "v_value") != nullptr); PLOT_CHECK(program.id() == 0); ctx.destroy(); } // A vertex array that could not be fully created must not be left behind: the // pair fails when the vertex array itself is refused, and again when the // instance buffer is refused after the vertex array exists, and in both cases // the object reports nothing to draw. PLOT_TEST(vertex_arrays_leave_nothing_behind_when_create_fails) { plottest::HeadlessContext ctx; plot::Status st; PLOT_CHECK(ctx.create(&st)); PLOT_CHECK(plot::gl::load(egl_loader())); { EntryGuard guard(plot::gl::glGenVertexArrays, &zero_gen_vertex_arrays); plot::Status bad; plot::render::LineVertexArray lines; PLOT_CHECK(!lines.create(&bad)); PLOT_CHECK(!lines.valid()); plot::render::TextVertexArray glyphs; PLOT_CHECK(!glyphs.create(&bad)); PLOT_CHECK(!glyphs.valid()); } { EntryGuard guard(plot::gl::glGenBuffers, &zero_gen_buffers); plot::Status bad; plot::render::LineVertexArray lines; PLOT_CHECK(!lines.create(&bad)); PLOT_CHECK(!lines.valid()); plot::render::TextVertexArray glyphs; PLOT_CHECK(!glyphs.create(&bad)); PLOT_CHECK(!glyphs.valid()); } PLOT_CHECK(plot::gl::glGetError() == plot::gl::GL_NO_ERROR); ctx.destroy(); } // An empty batch -- a null pointer or a count of zero, with no vertex array // created -- stages nothing and draws nothing, without touching the GL table. PLOT_TEST(vertex_arrays_stage_nothing_for_an_empty_batch) { plot::render::LineVertexArray lines; lines.upload(nullptr, 3); PLOT_CHECK(lines.instance_count() == 0); const f32 segments[4] = {0.0f, 0.0f, 1.0f, 1.0f}; lines.upload(segments, 0); PLOT_CHECK(lines.instance_count() == 0); plot::render::TextVertexArray glyphs; glyphs.upload(nullptr, 4); PLOT_CHECK(glyphs.instance_count() == 0); const f32 glyph_data[6] = {0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f}; glyphs.upload(glyph_data, 0); PLOT_CHECK(glyphs.instance_count() == 0); }