[Fix, Test] (BackendLoader): drain and gate every capability probe whose pname is not ES core

This commit is contained in:
2026-08-20 15:30:11 -04:00
parent 4fc3531d0d
commit 6dfadeb7d2
2 changed files with 193 additions and 32 deletions
@@ -52,6 +52,18 @@ namespace {
GLint maxClipDistances = 8;
bool maxClipDistancesQueried = false;
bool clipDistanceQueryRaisesError = false;
// GL_MAX_VIEWPORTS / GL_VIEWPORT_SUBPIXEL_BITS / GL_VIEWPORT_BOUNDS_RANGE are
// GL_OES_viewport_array state and, like the clip-distance pname, exist nowhere in ES core.
GLint maxViewports = 32;
GLint viewportSubpixelBits = 8;
bool viewportArrayLimitsQueried = false;
// A driver rejecting one of the UNCONDITIONAL probes. GL_SMOOTH_LINE_WIDTH_RANGE is the
// realistic one - it is desktop-only state that every GLES driver refuses - and it stands
// in for the whole run: whatever it leaves behind must not reach the application.
bool smoothLineWidthQueryRaisesError = false;
// What the driver answers for the four multisample ceilings. Zero is the value that has
// to be floored away: the frontend would otherwise advertise a sample count it rejects.
GLint multisampleCeiling = 4;
GLfloat minFragmentInterpolationOffset = -0.75f;
GLfloat maxFragmentInterpolationOffset = 0.625f;
GLint fragmentInterpolationOffsetBits = 6;
@@ -175,6 +187,21 @@ namespace {
*data = g_fake.maxClipDistances;
}
break;
case GL_MAX_VIEWPORTS:
g_fake.viewportArrayLimitsQueried = true;
*data = g_fake.maxViewports;
break;
case GL_VIEWPORT_SUBPIXEL_BITS:
g_fake.viewportArrayLimitsQueried = true;
*data = g_fake.viewportSubpixelBits;
break;
case GL_MAX_COLOR_TEXTURE_SAMPLES:
case GL_MAX_DEPTH_TEXTURE_SAMPLES:
case GL_MAX_FRAMEBUFFER_SAMPLES:
case GL_MAX_INTEGER_SAMPLES:
case GL_MAX_SAMPLES:
*data = g_fake.multisampleCeiling;
break;
case GL_FRAGMENT_INTERPOLATION_OFFSET_BITS:
g_fake.fragmentInterpolationLimitsQueried = true;
if (g_fake.fragmentInterpolationQueryRaisesError) {
@@ -254,11 +281,22 @@ namespace {
data[0] = g_fake.maxFragmentInterpolationOffset;
}
break;
case GL_SMOOTH_LINE_WIDTH_RANGE:
if (g_fake.smoothLineWidthQueryRaisesError) {
g_fake.pendingError = GL_INVALID_ENUM;
} else {
data[0] = 0.0f;
data[1] = 0.0f;
}
break;
case GL_VIEWPORT_BOUNDS_RANGE:
g_fake.viewportArrayLimitsQueried = true;
data[0] = 0.0f;
data[1] = 0.0f;
break;
// Two-component range queries.
case GL_ALIASED_LINE_WIDTH_RANGE:
case GL_SMOOTH_LINE_WIDTH_RANGE:
case GL_ALIASED_POINT_SIZE_RANGE:
case GL_VIEWPORT_BOUNDS_RANGE:
data[0] = 0.0f;
data[1] = 0.0f;
break;
@@ -715,6 +753,72 @@ TEST(ClipDistanceCapabilities, ARejectedQueryIsDrainedAndReportsZero) {
EXPECT_EQ(funcs.glGetError(), GL_NO_ERROR) << "the failed query must not leave an error behind";
}
// The same defect one more time, for the three GL_OES_viewport_array pnames. Their advertised
// values do not come from the driver (GL_Getter answers GL_MAX_VIEWPORTS from the frontend state
// width and floors GL_SUBPIXEL_BITS at its own constant), so what this pins is the other half of
// the class defect: a pname that does not exist must not be asked for, because the GL_INVALID_ENUM
// it raises is then attributed to whatever the application calls next.
TEST(ViewportArrayCapabilities, TheLimitsAreOnlyAskedForWhenTheExtensionIsPresent) {
const auto funcs = MakeFakeGLESFunctions();
ResetFakeDriver();
g_fake.maxVertexSsboBlocks = 0;
MobileGL::MG_External::GLESCapabilities withoutCaps;
ASSERT_TRUE(MobileGL::MG_Util::BackendLoader::FillInGLESCapabilities(withoutCaps, funcs));
EXPECT_FALSE(withoutCaps.SupportsViewportArray);
EXPECT_FALSE(g_fake.viewportArrayLimitsQueried);
EXPECT_EQ(withoutCaps.MaxViewports, 16) << "the OpenGL core minimum, not a driver answer";
EXPECT_FLOAT_EQ(withoutCaps.ViewportBoundsRangeMin, -32768.0f);
EXPECT_FLOAT_EQ(withoutCaps.ViewportBoundsRangeMax, 32767.0f);
ResetFakeDriver();
g_fake.maxVertexSsboBlocks = 0;
g_fake.extensions.emplace_back("GL_OES_viewport_array");
MobileGL::MG_External::GLESCapabilities withCaps;
ASSERT_TRUE(MobileGL::MG_Util::BackendLoader::FillInGLESCapabilities(withCaps, funcs));
EXPECT_TRUE(withCaps.SupportsViewportArray);
EXPECT_TRUE(g_fake.viewportArrayLimitsQueried);
EXPECT_EQ(withCaps.MaxViewports, g_fake.maxViewports);
EXPECT_EQ(withCaps.ViewportSubpixelBits, g_fake.viewportSubpixelBits);
}
// The multisample ceilings are ES 3.1 state; a driver that answers zero - or an older context
// that answers nothing - must not have that reach GL_Getter, which would then reject the sample
// count it just advertised.
TEST(MultisampleCapabilities, TheAdvertisedSampleCountsNeverFallBelowOne) {
ResetFakeDriver();
g_fake.maxVertexSsboBlocks = 0;
g_fake.multisampleCeiling = 0;
const auto funcs = MakeFakeGLESFunctions();
MobileGL::MG_External::GLESCapabilities caps;
ASSERT_TRUE(MobileGL::MG_Util::BackendLoader::FillInGLESCapabilities(caps, funcs));
EXPECT_EQ(caps.MaxColorTextureSamples, 1);
EXPECT_EQ(caps.MaxDepthTextureSamples, 1);
EXPECT_EQ(caps.MaxFramebufferSamples, 1);
EXPECT_EQ(caps.MaxIntegerSamples, 1);
EXPECT_EQ(caps.MaxSamples, 1);
EXPECT_EQ(caps.MaxSampleMaskWords, 1);
}
// The whole point of the drain, stated once at the level that matters: capability init is the
// first thing that ever touches the driver, so an error it leaves behind surfaces at the
// APPLICATION's first glGetError and is blamed on an unrelated call. GL_SMOOTH_LINE_WIDTH_RANGE
// is the stand-in because it is desktop-only state that every real GLES driver refuses.
TEST(CapabilityProbeHygiene, ARejectedUnconditionalProbeLeavesNoErrorBehind) {
ResetFakeDriver();
g_fake.maxVertexSsboBlocks = 0;
g_fake.smoothLineWidthQueryRaisesError = true;
const auto funcs = MakeFakeGLESFunctions();
MobileGL::MG_External::GLESCapabilities caps;
ASSERT_TRUE(MobileGL::MG_Util::BackendLoader::FillInGLESCapabilities(caps, funcs));
EXPECT_EQ(funcs.glGetError(), GL_NO_ERROR)
<< "capability init must not hand the application an error it never caused";
}
TEST(FragmentInterpolationCapabilities, QueriesOnlyWhenSupportedAndPreservesDriverLimits) {
const auto funcs = MakeFakeGLESFunctions();
@@ -1082,11 +1082,39 @@ namespace MobileGL::MG_Util::BackendLoader {
GLint maxProgramTextureGatherOffset = 7;
GLint maxPatchVertices = 32;
GLint maxTessGenLevel = 64;
// Function-scope, and used by every probe group below rather than redeclared inside each
// one. Returns whether anything was drained, which is what lets a group tell "the driver
// answered" from "the driver rejected the pname and left my local alone".
const auto drainErrors = [&glesFuncs]() {
Bool hadError = false;
if (glesFuncs.glGetError) {
while (glesFuncs.glGetError() != GL_NO_ERROR) hadError = true;
}
return hadError;
};
// THE GENERATOR OF THIS WHOLE BUG FAMILY, closed here. A bare glGetIntegerv/glGetFloatv
// of a pname the driver does not have does two damaging things at once: it leaves the
// local at whatever the declaration initialised it to - an optimistic number the frontend
// then advertises as a capability - and it leaves a GL_INVALID_ENUM in the queue where
// the next unrelated probe's caller, or the application's first glGetError, gets blamed
// for it. The per-stage storage block, fragment interpolation and buffer texture probes
// below already drain and fall back; this unconditional run did neither, which is how
// GL_MAX_CLIP_DISTANCES came to be advertised as 8 on a driver with no clip distances at
// all. Every pname here that is not ES core is now either gated on the capability that
// makes it exist or floored at the value a rejected probe would have left, and the whole
// run is bracketed by a drain.
drainErrors();
glesFuncs.glGetFloatv(GL_ALIASED_LINE_WIDTH_RANGE, aliasedLineWidthRange);
// GL_SMOOTH_LINE_WIDTH_RANGE / GL_SMOOTH_LINE_WIDTH_GRANULARITY (0x0B22 / 0x0B23) are
// desktop-only - ES has never had an antialiased line width query - so on a real GLES
// driver these two raise GL_INVALID_ENUM. Kept as probes rather than dropped because the
// ANGLE and desktop-GL hosts MobileGL also runs on do answer them; the initialisers are
// the GL 4.6 table 23.55 minimum of [1, 1], which is both the honest answer for a driver
// that cannot say and what an untouched out-param already holds.
glesFuncs.glGetFloatv(GL_SMOOTH_LINE_WIDTH_RANGE, smoothLineWidthRange);
glesFuncs.glGetFloatv(GL_SMOOTH_LINE_WIDTH_GRANULARITY, &smoothLineWidthGranularity);
glesFuncs.glGetFloatv(GL_ALIASED_POINT_SIZE_RANGE, aliasedPointSizeRange);
glesFuncs.glGetFloatv(GL_VIEWPORT_BOUNDS_RANGE, viewportBoundsRange);
glesFuncs.glGetIntegerv(GL_MAX_3D_TEXTURE_SIZE, &max3DTextureSize);
glesFuncs.glGetIntegerv(GL_MAX_ARRAY_TEXTURE_LAYERS, &maxArrayTextureLayers);
glesFuncs.glGetIntegerv(GL_MAX_CUBE_MAP_TEXTURE_SIZE, &maxCubeMapTextureSize);
@@ -1110,8 +1138,25 @@ namespace MobileGL::MG_Util::BackendLoader {
// single test case. 1 is a spec-legal value (the minimum required), so cap
// to what is actually implemented instead of forwarding the raw driver limit.
maxSampleMaskWords = std::min(maxSampleMaskWords, 1);
// The multisample ceilings above are ES 3.1 state apart from GL_MAX_SAMPLES, which is ES
// 3.0, so a 3.0 context rejects five of the six and leaves whatever the out-param held.
// One sample is what a rejected probe leaves behind and is also the smallest legal
// answer, so clamp rather than trust: a zero reaching GL_Getter would have the frontend
// reject the very sample count it just advertised (see GetAdvertisedMaxSamples).
maxColorTextureSamples = std::max(maxColorTextureSamples, 1);
maxDepthTextureSamples = std::max(maxDepthTextureSamples, 1);
maxFramebufferSamples = std::max(maxFramebufferSamples, 1);
maxIntegerSamples = std::max(maxIntegerSamples, 1);
maxSamples = std::max(maxSamples, 1);
maxSampleMaskWords = std::max(maxSampleMaskWords, 1);
// ES 3.2 core, or EXT_tessellation_shader on 3.1. Probed rather than version-gated so a
// 3.1 driver that HAS the extension still gets to answer; the clamp below is what makes a
// rejected query safe, since GL 4.6 table 23.66 and ES 3.2 table 21.45 set the same
// minimums the initialisers carry and neither API permits less.
glesFuncs.glGetIntegerv(GL_MAX_PATCH_VERTICES, &maxPatchVertices);
glesFuncs.glGetIntegerv(GL_MAX_TESS_GEN_LEVEL, &maxTessGenLevel);
maxPatchVertices = std::max(maxPatchVertices, 32);
maxTessGenLevel = std::max(maxTessGenLevel, 64);
glesFuncs.glGetIntegerv(GL_MIN_PROGRAM_TEXTURE_GATHER_OFFSET, &minProgramTextureGatherOffset);
glesFuncs.glGetIntegerv(GL_MAX_PROGRAM_TEXTURE_GATHER_OFFSET, &maxProgramTextureGatherOffset);
// A driver that leaves the probe untouched (pre-ES 3.1, or an ignored enum) must not
@@ -1148,6 +1193,13 @@ namespace MobileGL::MG_Util::BackendLoader {
(caps.GLESVersion.Major == 3 && caps.GLESVersion.Minor >= 2)) {
glesFuncs.glGetIntegerv(GL_MAX_GEOMETRY_IMAGE_UNIFORMS, &maxGeometryImageUniforms);
}
// Closes the bracket opened before the run: every local above now holds either the
// driver's answer or a floor, and nothing this function asked for is left in the error
// queue for a later probe - or the application - to be blamed for.
if (drainErrors()) {
MGLOG_W("One or more capability queries were rejected by this driver; the affected "
"limits keep MobileGL's spec-minimum floors");
}
// Per-stage storage-block counts. Deliberately NOT batched with the unconditional probes
// above, for the reason GL_MAX_TEXTURE_BUFFER_SIZE is not: the vertex and fragment pnames
// are ES 3.1, but the tessellation and geometry ones only exist from ES 3.2 on (or under
@@ -1160,14 +1212,6 @@ namespace MobileGL::MG_Util::BackendLoader {
// stages is 0. That is the honest answer: DirectGLES emits ESSL 3.10 on an ES 3.1 context,
// where those stages do not exist at all.
{
const auto drainErrors = [&glesFuncs]() {
Bool hadError = false;
if (glesFuncs.glGetError) {
while (glesFuncs.glGetError() != GL_NO_ERROR) hadError = true;
}
return hadError;
};
// Isolate from errors raised by the preceding probes so the drain below reports on
// these queries only.
drainErrors();
@@ -1210,35 +1254,40 @@ namespace MobileGL::MG_Util::BackendLoader {
// exists; everywhere else the honest 0 stands and no GL_INVALID_ENUM is left behind for an
// unrelated query - or the application's first glGetError - to trip over.
if (caps.SupportsClipDistance) {
if (glesFuncs.glGetError) {
while (glesFuncs.glGetError() != GL_NO_ERROR) {
}
}
drainErrors();
glesFuncs.glGetIntegerv(GL_MAX_CLIP_DISTANCES, &maxClipDistances);
Bool queryFailed = false;
if (glesFuncs.glGetError) {
while (glesFuncs.glGetError() != GL_NO_ERROR) {
queryFailed = true;
}
}
if (queryFailed) {
if (drainErrors()) {
MGLOG_W("GL_EXT_clip_cull_distance is advertised but GL_MAX_CLIP_DISTANCES was "
"rejected; reporting no clip distances");
maxClipDistances = 0;
}
}
glesFuncs.glGetIntegerv(GL_MAX_VIEWPORTS, &maxViewports);
glesFuncs.glGetIntegerv(GL_MAX_VIEWPORT_DIMS, maxViewportDims);
glesFuncs.glGetIntegerv(GL_VIEWPORT_SUBPIXEL_BITS, &viewportSubpixelBits);
// GL_MAX_VIEWPORTS (0x825B), GL_VIEWPORT_SUBPIXEL_BITS (0x825C) and GL_VIEWPORT_BOUNDS_RANGE
// (0x825D) all arrive with GL_OES_viewport_array and exist nowhere in ES core, so on the
// drivers DirectGLES actually runs on all three raise GL_INVALID_ENUM. The values MobileGL
// advertises do not change by asking: GL_Getter answers GL_MAX_VIEWPORTS from the frontend
// state width (indexed viewport entry points validate against RenderStateParameters::
// MAX_VIEWPORTS, so a device answer of 1 would reject indices the state can legitimately
// hold), floors GL_SUBPIXEL_BITS at its own 4, and the bounds range is clamped to the core
// minimum below. What changes is that the errors stop being manufactured.
if (caps.SupportsViewportArray) {
drainErrors();
glesFuncs.glGetIntegerv(GL_MAX_VIEWPORTS, &maxViewports);
glesFuncs.glGetIntegerv(GL_VIEWPORT_SUBPIXEL_BITS, &viewportSubpixelBits);
if (glesFuncs.glGetFloatv) {
glesFuncs.glGetFloatv(GL_VIEWPORT_BOUNDS_RANGE, viewportBoundsRange);
}
if (drainErrors()) {
MGLOG_W("GL_OES_viewport_array is advertised but its viewport limit queries were "
"rejected; keeping the OpenGL core minimums");
maxViewports = 16;
viewportSubpixelBits = 0;
viewportBoundsRange[0] = -32768.0f;
viewportBoundsRange[1] = 32767.0f;
}
}
if (caps.SupportsShaderMultisampleInterpolation && glesFuncs.glGetFloatv) {
const auto drainErrors = [&glesFuncs]() {
Bool hadError = false;
if (glesFuncs.glGetError) {
while (glesFuncs.glGetError() != GL_NO_ERROR) hadError = true;
}
return hadError;
};
// Isolate these optional queries from errors raised by preceding capability
// probes, then consume any query error so initialization never leaks it into
// the application's first glGetError call.
@@ -1517,6 +1566,14 @@ namespace MobileGL::MG_Util::BackendLoader {
caps.AvoidSamplerMipmapMinFilter ? "true" : "false");
MGLOG_I(" Avoid explicit LOD bias: %s", caps.AvoidExplicitLodBias ? "true" : "false");
// Last line of defence. Capability init is the very first thing that touches the driver,
// so anything it leaves in the error queue surfaces at the APPLICATION's first
// glGetError and gets attributed to whatever call the app happened to make. Every group
// above drains its own, but a probe added later must not be able to reintroduce the leak.
if (drainErrors()) {
MGLOG_W("Capability initialization left a GL error behind; it has been consumed so it "
"cannot surface at the application's first glGetError");
}
return true;
}
} // namespace MobileGL::MG_Util::BackendLoader