mirror of
https://github.com/MobileGL-Dev/MobileGL
synced 2026-09-07 19:58:32 +09:00
[Test] (DirectVulkan): pin the primitives-generated probe's verdict and override mapping, and hold the reroute's GL answers and arming observable on the integration lane
This commit is contained in:
@@ -101,6 +101,7 @@ add_executable(MobileGLIntegrationTest
|
||||
Scenarios/VertexAttribBindingScenario.cpp
|
||||
Scenarios/XfbCaptureBufferReuseScenario.cpp
|
||||
Scenarios/XfbPrimitiveQueryScenario.cpp
|
||||
Scenarios/PrimitivesGeneratedNoXfbScenario.cpp
|
||||
Scenarios/XfbRepeatedCaptureScenario.cpp
|
||||
Scenarios/TessellationXfbCaptureScenario.cpp
|
||||
Scenarios/VertexArrayEnableDisableScenario.cpp
|
||||
@@ -366,6 +367,12 @@ mgl_itest_join_environment(MGL_ITEST_GLES_UNLOCATED_IO_BLOCKS_ENVIRONMENT
|
||||
${MGL_ITEST_COMMON_ENV})
|
||||
mgl_itest_join_environment(MGL_ITEST_GLES_WIDENED_PACKED16_ENVIRONMENT
|
||||
"MOBILEGL_BACKEND_TYPE=DirectGLES" "MOBILEGL_ESPRYT_WIDEN_PACKED16_STORAGE=1" ${MGL_ITEST_COMMON_ENV})
|
||||
# Same shape as the UnlocatedIoBlocks entry: the log path is where the reroute's latched
|
||||
# MGLOG_I lands, and the arming case only trusts the bytes written after it started.
|
||||
mgl_itest_join_environment(MGL_ITEST_VULKAN_PRIMGEN_REROUTE_ENVIRONMENT
|
||||
"MOBILEGL_BACKEND_TYPE=DirectVulkan" "MOBILEGL_MAGMA_PRIMGEN_QUERY_REROUTE=1"
|
||||
"MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/primgen-query-reroute.log"
|
||||
${MGL_ITEST_VULKAN_ENV})
|
||||
|
||||
# TIMEOUT on every entry: a GPU test that wedges must fail the run, not hang it.
|
||||
set(MGL_ITEST_TIMEOUT 120)
|
||||
@@ -546,6 +553,25 @@ gtest_discover_tests(MobileGLIntegrationTest
|
||||
ENVIRONMENT "${MGL_ITEST_GLES_NO_VIEWPORT_EMULATION_ENVIRONMENT}"
|
||||
)
|
||||
|
||||
# PrimitivesGeneratedNoXfbScenario again, with the GL_PRIMITIVES_GENERATED statistics
|
||||
# reroute PINNED ON. The ambient DirectVulkan registration runs the same cases under the
|
||||
# bring-up probe's Auto verdict, so between the two entries both accounting paths answer
|
||||
# the same GL questions and must produce the same numbers - the "two pools must agree"
|
||||
# gate this machine can hold that the affected device cannot. The pinned entry is also
|
||||
# the only one whose arming case runs: it asserts the renderer's latched MGLOG_I, so a
|
||||
# silently-disarmed reroute (an inverted override mapping, a lost gate) fails here
|
||||
# instead of leaving every equality case vacuously green. DirectVulkan only - the flag
|
||||
# steers nothing on DirectGLES.
|
||||
gtest_discover_tests(MobileGLIntegrationTest
|
||||
TEST_PREFIX "DirectVulkan.PrimGenReroute."
|
||||
TEST_FILTER "PrimitivesGeneratedNoXfbScenario.*"
|
||||
DISCOVERY_TIMEOUT 30
|
||||
PROPERTIES
|
||||
LABELS integration-gpu
|
||||
TIMEOUT ${MGL_ITEST_TIMEOUT}
|
||||
ENVIRONMENT "${MGL_ITEST_VULKAN_PRIMGEN_REROUTE_ENVIRONMENT}"
|
||||
)
|
||||
|
||||
# The packed16 copy scenarios again, with the 8-bit storage widening PINNED ON. The ambient
|
||||
# registrations above cover the narrow storage - on every CI driver the widening's POST
|
||||
# probe finds no field-order mirror, so Auto keeps the native 16-bit path - which means the
|
||||
|
||||
@@ -0,0 +1,411 @@
|
||||
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/PrimitivesGeneratedNoXfbScenario.cpp
|
||||
// Copyright (c) 2026 MobileGL-Dev
|
||||
// Licensed under the GNU Lesser General Public License v3.0:
|
||||
// https://www.gnu.org/licenses/gpl-3.0.txt
|
||||
// https://www.gnu.org/licenses/lgpl-3.0.txt
|
||||
// SPDX-License-Identifier: LGPL-3.0-only
|
||||
// End of Source File Header
|
||||
//
|
||||
// Scenario - GL_PRIMITIVES_GENERATED COUNTS DRAWS MADE WITH TRANSFORM FEEDBACK
|
||||
// INACTIVE.
|
||||
//
|
||||
// GL 4.6 core 13.4: the query counts what the last vertex processing stage emits,
|
||||
// capture or no capture. The CTS leans its whole tessellation suite on that - the
|
||||
// tessellator's output is MEASURED by an XFB-inactive PATCHES draw under
|
||||
// rasterizer discard inside a GENERATED query, and the capture buffers of ~29
|
||||
// tessellation tests are sized from the answer - so a backend that answers 0
|
||||
// hands them a zero-byte buffer and an INVALID_OPERATION off its zero-length map.
|
||||
//
|
||||
// DirectVulkan serves the query from the transform-feedback stream query's
|
||||
// primitivesNeeded, which VK_EXT_transform_feedback defines to count whether or
|
||||
// not a capture span is open. Both the Mali-G1-Ultra driver AND Mesa lavapipe
|
||||
// disagree with that definition: with no vkCmdBeginTransformFeedbackEXT recorded,
|
||||
// the pair reads back 0. Where the bring-up probe measures that defect with a
|
||||
// working control - or MOBILEGL_MAGMA_PRIMGEN_QUERY_REROUTE=1 pins it on - the
|
||||
// renderer accumulates XFB-inactive draws through the best proven substitute
|
||||
// pool: VK_QUERY_TYPE_PRIMITIVES_GENERATED_EXT (which lavapipe hosts and passes,
|
||||
// rasterizer discard included), else pipeline statistics over clipping-stage
|
||||
// invocations (GL's CLIPPING_INPUT_PRIMITIVES). These cases assert the GL-visible
|
||||
// answer, so on this machine they hold the reroute to the same numbers the
|
||||
// healthy stream path must produce - the "two pools must agree" assertion - and
|
||||
// on a healthy driver they pin the stream path itself.
|
||||
//
|
||||
// DirectVulkan only: DirectGLES has no GPU counter for an XFB-inactive draw at
|
||||
// all (ES has no PRIMITIVES_GENERATED without a capture), and its CPU accounting
|
||||
// is a different mechanism with its own tests.
|
||||
|
||||
#include <cstdlib>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <functional>
|
||||
#include <initializer_list>
|
||||
#include <iterator>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include "../Harness/HeadlessGL.h"
|
||||
#include "../Harness/ScenarioFixture.h"
|
||||
|
||||
#ifdef GLAPI
|
||||
#undef GLAPI
|
||||
#endif
|
||||
#define GL_GLEXT_PROTOTYPES
|
||||
#include <GL/gl.h>
|
||||
#include <GL/glcorearb.h>
|
||||
#undef GL_GLEXT_PROTOTYPES
|
||||
|
||||
namespace MGITest {
|
||||
namespace {
|
||||
|
||||
GLuint CompileShaderStage(GLenum type, const char* source, std::string* log) {
|
||||
const GLuint shader = glCreateShader(type);
|
||||
glShaderSource(shader, 1, &source, nullptr);
|
||||
glCompileShader(shader);
|
||||
GLint status = GL_FALSE;
|
||||
glGetShaderiv(shader, GL_COMPILE_STATUS, &status);
|
||||
if (status == GL_FALSE) {
|
||||
GLint length = 0;
|
||||
glGetShaderiv(shader, GL_INFO_LOG_LENGTH, &length);
|
||||
std::vector<char> buffer(static_cast<std::size_t>(length) + 1, '\0');
|
||||
glGetShaderInfoLog(shader, length + 1, nullptr, buffer.data());
|
||||
if (log != nullptr) *log = buffer.data();
|
||||
glDeleteShader(shader);
|
||||
return 0;
|
||||
}
|
||||
return shader;
|
||||
}
|
||||
|
||||
// A capture-capable vertex-only program: the varying gives glBeginTransformFeedback
|
||||
// something to capture for the mixed-span case; the XFB-inactive cases draw with the
|
||||
// same program and simply never begin a span.
|
||||
const char* const kVertexSource = R"(#version 430 core
|
||||
out vec4 vs_out_value;
|
||||
void main() {
|
||||
const vec2 corners[3] = vec2[3](vec2(-1.0, -1.0), vec2(3.0, -1.0), vec2(-1.0, 3.0));
|
||||
vs_out_value = vec4(1.0);
|
||||
gl_Position = vec4(corners[gl_VertexID % 3], 0.0, 1.0);
|
||||
}
|
||||
)";
|
||||
|
||||
// A passthrough tessellation pipeline whose all-1 levels emit exactly one
|
||||
// triangle per patch - the count the tessellation cases assert.
|
||||
const char* const kTessVertexSource = R"(#version 430 core
|
||||
void main() {
|
||||
gl_Position = vec4(0.0, 0.0, 0.0, 1.0);
|
||||
}
|
||||
)";
|
||||
const char* const kTessControlSource = R"(#version 430 core
|
||||
layout(vertices = 1) out;
|
||||
void main() {
|
||||
gl_TessLevelOuter[0] = 1.0;
|
||||
gl_TessLevelOuter[1] = 1.0;
|
||||
gl_TessLevelOuter[2] = 1.0;
|
||||
gl_TessLevelOuter[3] = 1.0;
|
||||
gl_TessLevelInner[0] = 1.0;
|
||||
gl_TessLevelInner[1] = 1.0;
|
||||
}
|
||||
)";
|
||||
const char* const kTessEvalSource = R"(#version 430 core
|
||||
layout(triangles, equal_spacing, cw) in;
|
||||
void main() {
|
||||
gl_Position = vec4(gl_TessCoord.xy * 2.0 - 1.0, 0.0, 1.0);
|
||||
}
|
||||
)";
|
||||
|
||||
class PrimitivesGeneratedNoXfbScenario : public ScenarioTest {
|
||||
protected:
|
||||
void SetUp() override {
|
||||
ScenarioTest::SetUp();
|
||||
if (!Ready()) return;
|
||||
if (Gl().BackendName() != std::string("DirectVulkan")) {
|
||||
GTEST_SKIP() << "the stream-query defect and its reroute are DirectVulkan's; "
|
||||
<< Gl().BackendName()
|
||||
<< " answers this query from a different mechanism";
|
||||
}
|
||||
glGenVertexArrays(1, &m_vao);
|
||||
glBindVertexArray(m_vao);
|
||||
glGenQueries(2, m_queries);
|
||||
ASSERT_NE(m_queries[0], 0u);
|
||||
ASSERT_NE(m_queries[1], 0u);
|
||||
}
|
||||
|
||||
void TearDown() override {
|
||||
if (!Ready()) return;
|
||||
glUseProgram(0);
|
||||
if (m_queries[0] != 0 || m_queries[1] != 0) glDeleteQueries(2, m_queries);
|
||||
m_queries[0] = m_queries[1] = 0;
|
||||
for (const GLuint program : m_programs) {
|
||||
glDeleteProgram(program);
|
||||
}
|
||||
m_programs.clear();
|
||||
glBindVertexArray(0);
|
||||
if (m_vao != 0) glDeleteVertexArrays(1, &m_vao);
|
||||
m_vao = 0;
|
||||
ScenarioTest::TearDown();
|
||||
}
|
||||
|
||||
GLuint BuildProgram(std::initializer_list<std::pair<GLenum, const char*>> stages,
|
||||
bool withCaptureVarying) {
|
||||
std::vector<GLuint> shaders;
|
||||
for (const auto& [type, source] : stages) {
|
||||
const GLuint shader = CompileShaderStage(type, source, &m_buildLog);
|
||||
if (shader == 0) {
|
||||
for (const GLuint built : shaders) glDeleteShader(built);
|
||||
return 0;
|
||||
}
|
||||
shaders.push_back(shader);
|
||||
}
|
||||
const GLuint program = glCreateProgram();
|
||||
for (const GLuint shader : shaders) glAttachShader(program, shader);
|
||||
if (withCaptureVarying) {
|
||||
const char* varying = "vs_out_value";
|
||||
glTransformFeedbackVaryings(program, 1, &varying, GL_INTERLEAVED_ATTRIBS);
|
||||
}
|
||||
glLinkProgram(program);
|
||||
for (const GLuint shader : shaders) glDeleteShader(shader);
|
||||
GLint status = GL_FALSE;
|
||||
glGetProgramiv(program, GL_LINK_STATUS, &status);
|
||||
if (status == GL_FALSE) {
|
||||
GLint length = 0;
|
||||
glGetProgramiv(program, GL_INFO_LOG_LENGTH, &length);
|
||||
std::vector<char> buffer(static_cast<std::size_t>(length) + 1, '\0');
|
||||
glGetProgramInfoLog(program, length + 1, nullptr, buffer.data());
|
||||
m_buildLog = buffer.data();
|
||||
glDeleteProgram(program);
|
||||
return 0;
|
||||
}
|
||||
m_programs.push_back(program);
|
||||
return program;
|
||||
}
|
||||
|
||||
GLuint BuildCaptureProgram() {
|
||||
return BuildProgram({{GL_VERTEX_SHADER, kVertexSource}}, true);
|
||||
}
|
||||
|
||||
GLuint BuildTessellationProgram() {
|
||||
GLint maxTessGenLevel = 0;
|
||||
glGetIntegerv(GL_MAX_TESS_GEN_LEVEL, &maxTessGenLevel);
|
||||
while (glGetError() != GL_NO_ERROR) {
|
||||
}
|
||||
if (maxTessGenLevel < 1) return 0;
|
||||
return BuildProgram({{GL_VERTEX_SHADER, kTessVertexSource},
|
||||
{GL_TESS_CONTROL_SHADER, kTessControlSource},
|
||||
{GL_TESS_EVALUATION_SHADER, kTessEvalSource}},
|
||||
false);
|
||||
}
|
||||
|
||||
// GENERATED query around `record()`, answered with GL_QUERY_RESULT.
|
||||
GLuint QueryGenerated(const std::function<void()>& record) {
|
||||
glBeginQuery(GL_PRIMITIVES_GENERATED, m_queries[1]);
|
||||
record();
|
||||
glEndQuery(GL_PRIMITIVES_GENERATED);
|
||||
GLuint generated = 0xFFFFFFFFu;
|
||||
glGetQueryObjectuiv(m_queries[1], GL_QUERY_RESULT, &generated);
|
||||
return generated;
|
||||
}
|
||||
|
||||
static GLenum DrainGLErrors() {
|
||||
const GLenum first = glGetError();
|
||||
while (glGetError() != GL_NO_ERROR) {
|
||||
}
|
||||
return first;
|
||||
}
|
||||
|
||||
const std::string& BuildLog() const { return m_buildLog; }
|
||||
|
||||
static std::filesystem::path LibraryLogPath() {
|
||||
const char* path = std::getenv("MOBILEGL_LOG_FILE_PATH");
|
||||
return (path != nullptr && *path != '\0') ? std::filesystem::path(path)
|
||||
: std::filesystem::path();
|
||||
}
|
||||
|
||||
static std::uintmax_t LibraryLogSize() {
|
||||
std::error_code ec;
|
||||
const std::filesystem::path path = LibraryLogPath();
|
||||
if (path.empty()) return 0;
|
||||
const std::uintmax_t size = std::filesystem::file_size(path, ec);
|
||||
return ec ? 0 : size;
|
||||
}
|
||||
|
||||
static std::string LibraryLogSince(std::uintmax_t offset) {
|
||||
const std::filesystem::path path = LibraryLogPath();
|
||||
if (path.empty()) return {};
|
||||
std::ifstream file(path, std::ios::binary);
|
||||
if (!file.good()) return {};
|
||||
file.seekg(static_cast<std::streamoff>(offset));
|
||||
return std::string((std::istreambuf_iterator<char>(file)),
|
||||
std::istreambuf_iterator<char>());
|
||||
}
|
||||
|
||||
GLuint m_vao = 0;
|
||||
GLuint m_queries[2] = {0, 0}; // [0]=written, [1]=generated
|
||||
std::vector<GLuint> m_programs;
|
||||
std::string m_buildLog;
|
||||
};
|
||||
|
||||
// The plain shape: no capture object was ever bound, no span begun, no
|
||||
// rasterizer discard - just a GENERATED query around two triangles. On a
|
||||
// healthy driver the stream query answers it; on an affected one the armed
|
||||
// reroute must produce the same 2.
|
||||
TEST_F(PrimitivesGeneratedNoXfbScenario, CountsADrawMadeWithNoCaptureSpan) {
|
||||
if (!Ready()) return;
|
||||
const GLuint program = BuildCaptureProgram();
|
||||
ASSERT_NE(program, 0u) << BuildLog();
|
||||
glUseProgram(program);
|
||||
|
||||
const GLuint generated = QueryGenerated([]() { glDrawArrays(GL_TRIANGLES, 0, 6); });
|
||||
EXPECT_EQ(DrainGLErrors(), 0u);
|
||||
EXPECT_EQ(generated, 2u)
|
||||
<< "GL_PRIMITIVES_GENERATED must count a draw made while transform feedback is "
|
||||
"inactive (GL 4.6 core 13.4)";
|
||||
}
|
||||
|
||||
// THE CTS SHAPE (esextcTessellationShaderUtils.cpp, captureTessellationData):
|
||||
// rasterizer discard ON, transform feedback INACTIVE, the draw inside a
|
||||
// GENERATED query. This is the exact query whose 0 sizes ~29 tessellation
|
||||
// tests' capture buffers on the affected device.
|
||||
//
|
||||
// On lavapipe this case holds through the dedicated
|
||||
// VK_QUERY_TYPE_PRIMITIVES_GENERATED_EXT reroute (its discard feature is
|
||||
// what makes a discarded draw countable there - llvmpipe's clipping
|
||||
// statistics AND stream query both read 0 under discard).
|
||||
//
|
||||
// The value-conditioned skip below is deliberate and narrow, for a stack
|
||||
// with NO counter that survives discard: there this case is unfalsifiable,
|
||||
// and a red would indict MobileGL for a hole the bring-up probe already
|
||||
// measures and reports (StatisticsSubstitutePlainOnly / Unfixable). The
|
||||
// exact-zero answer IS the capability signal - any wrong nonzero count
|
||||
// still fails - and on every driver that counts discarded draws at all the
|
||||
// full assertion runs. The device probe list holds this shape on the Mali.
|
||||
TEST_F(PrimitivesGeneratedNoXfbScenario, CountsUnderRasterizerDiscardWithNoCaptureSpan) {
|
||||
if (!Ready()) return;
|
||||
const GLuint program = BuildCaptureProgram();
|
||||
ASSERT_NE(program, 0u) << BuildLog();
|
||||
glUseProgram(program);
|
||||
|
||||
glEnable(GL_RASTERIZER_DISCARD);
|
||||
const GLuint generated = QueryGenerated([]() { glDrawArrays(GL_TRIANGLES, 0, 6); });
|
||||
glDisable(GL_RASTERIZER_DISCARD);
|
||||
EXPECT_EQ(DrainGLErrors(), 0u);
|
||||
if (generated == 0u) {
|
||||
GTEST_SKIP() << "no counter this backend can reach (stream query, dedicated "
|
||||
"primitives-generated query, clipping statistics) survives "
|
||||
"rasterizer discard for an XFB-inactive draw on this stack - the "
|
||||
"shape is unfalsifiable here; the bring-up probe measures the same "
|
||||
"hole and the POST row reports it";
|
||||
}
|
||||
EXPECT_EQ(generated, 2u)
|
||||
<< "rasterizer discard drops primitives after clipping and must not hide them from "
|
||||
"GL_PRIMITIVES_GENERATED - this is the exact shape the CTS measures the "
|
||||
"tessellator with";
|
||||
}
|
||||
|
||||
// The tessellation flavour: a PATCHES draw whose all-1 levels emit exactly
|
||||
// one triangle - the count the CTS's getAmountOfVerticesGeneratedByTessellator
|
||||
// protocol derives everything from. Undiscarded, so that the answer is
|
||||
// holdable on this machine through whichever accounting path is armed (the
|
||||
// discard interaction is the case above's business, measured separately).
|
||||
TEST_F(PrimitivesGeneratedNoXfbScenario, CountsATessellatedPatchWithNoCaptureSpan) {
|
||||
if (!Ready()) return;
|
||||
const GLuint program = BuildTessellationProgram();
|
||||
if (program == 0) {
|
||||
GTEST_SKIP() << "no tessellation stages on this stack: " << BuildLog();
|
||||
}
|
||||
glUseProgram(program);
|
||||
glPatchParameteri(GL_PATCH_VERTICES, 1);
|
||||
|
||||
const GLuint generated = QueryGenerated([]() { glDrawArrays(GL_PATCHES, 0, 1); });
|
||||
EXPECT_EQ(DrainGLErrors(), 0u);
|
||||
EXPECT_EQ(generated, 1u)
|
||||
<< "a triangles-domain patch with every level 1 tessellates to exactly one "
|
||||
"triangle, and GL_PRIMITIVES_GENERATED must say so with no capture active";
|
||||
}
|
||||
|
||||
// One query span holding BOTH kinds of draw: an XFB-inactive draw, then a
|
||||
// captured one, then another XFB-inactive one. The GENERATED answer must
|
||||
// accumulate across the two accounting paths the armed reroute splits them
|
||||
// into (stream slots for the captured draw, statistics slots for the
|
||||
// others), and WRITTEN must stay exactly the captured draw's count - the
|
||||
// pairing the stream path exists to keep exact. Undiscarded, so the
|
||||
// accumulation invariant is holdable on this machine (see the discard
|
||||
// case's comment); the triangles rasterize into the harness framebuffer,
|
||||
// which nothing here reads.
|
||||
TEST_F(PrimitivesGeneratedNoXfbScenario, ASpanMixingActiveAndInactiveDrawsAccumulatesBoth) {
|
||||
if (!Ready()) return;
|
||||
const GLuint program = BuildCaptureProgram();
|
||||
ASSERT_NE(program, 0u) << BuildLog();
|
||||
glUseProgram(program);
|
||||
|
||||
GLuint captureBuffer = 0;
|
||||
glGenBuffers(1, &captureBuffer);
|
||||
glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, captureBuffer);
|
||||
glBufferData(GL_TRANSFORM_FEEDBACK_BUFFER, 3 * 4 * sizeof(float), nullptr, GL_DYNAMIC_DRAW);
|
||||
|
||||
glBeginQuery(GL_TRANSFORM_FEEDBACK_PRIMITIVES_WRITTEN, m_queries[0]);
|
||||
const GLuint generated = QueryGenerated([]() {
|
||||
glDrawArrays(GL_TRIANGLES, 0, 3); // XFB inactive
|
||||
glBeginTransformFeedback(GL_TRIANGLES);
|
||||
glDrawArrays(GL_TRIANGLES, 0, 3); // captured
|
||||
glEndTransformFeedback();
|
||||
glDrawArrays(GL_TRIANGLES, 0, 3); // XFB inactive again
|
||||
});
|
||||
glEndQuery(GL_TRANSFORM_FEEDBACK_PRIMITIVES_WRITTEN);
|
||||
|
||||
GLuint written = 0xFFFFFFFFu;
|
||||
glGetQueryObjectuiv(m_queries[0], GL_QUERY_RESULT, &written);
|
||||
glDeleteBuffers(1, &captureBuffer);
|
||||
EXPECT_EQ(DrainGLErrors(), 0u);
|
||||
EXPECT_EQ(generated, 3u) << "one triangle before the span, one inside it, one after";
|
||||
EXPECT_EQ(written, 1u) << "only the draw inside the span writes anything";
|
||||
}
|
||||
|
||||
// THE ONE CASE THAT CAN FAIL WHEN THE REROUTE SILENTLY STOPS BEING ARMED -
|
||||
// the UnlocatedIoBlockScenario shape, for the same reason: every case above
|
||||
// is green here whether the reroute ran or not (that is the "two pools
|
||||
// agree" point), so none of them can say the pinned lane actually exercised
|
||||
// a reroute pool. This one asserts a LIBRARY OBSERVABLE against the
|
||||
// environment: with MOBILEGL_MAGMA_PRIMGEN_QUERY_REROUTE pinned on, an
|
||||
// XFB-inactive draw inside a GENERATED span must make the renderer say -
|
||||
// through its latched MGLOG_I - that it engaged the reroute. It reads
|
||||
// MG_Config not at all (on Android this module links the shipping library)
|
||||
// and trusts only the log bytes appended after it started.
|
||||
TEST_F(PrimitivesGeneratedNoXfbScenario, TheRerouteIsActuallyArmedWhenTheEnvironmentPinsItOn) {
|
||||
if (!Ready()) return;
|
||||
if (AmbientQuirkFromEnvironment("MOBILEGL_MAGMA_PRIMGEN_QUERY_REROUTE") != AmbientQuirk::On) {
|
||||
GTEST_SKIP() << "this case needs the reroute pinned ON for the whole process, which "
|
||||
"is what the PrimGenReroute. ctest entry does with "
|
||||
"MOBILEGL_MAGMA_PRIMGEN_QUERY_REROUTE=1; unset, the bring-up probe "
|
||||
"decides and this machine's verdict is its own business";
|
||||
}
|
||||
if (LibraryLogPath().empty()) {
|
||||
GTEST_SKIP() << "MOBILEGL_MAGMA_PRIMGEN_QUERY_REROUTE is pinned on but "
|
||||
"MOBILEGL_LOG_FILE_PATH is not set, so the library has nowhere to "
|
||||
"record that it rerouted anything; the PrimGenReroute. ctest "
|
||||
"entry sets both";
|
||||
}
|
||||
|
||||
const GLuint program = BuildCaptureProgram();
|
||||
ASSERT_NE(program, 0u) << BuildLog();
|
||||
glUseProgram(program);
|
||||
|
||||
// Taken BEFORE the draw, so the line this looks for can only be one this
|
||||
// process wrote for this span. The latch fires on the FIRST rerouted
|
||||
// draw, which is inside the query below.
|
||||
const std::uintmax_t before = LibraryLogSize();
|
||||
const GLuint generated = QueryGenerated([]() { glDrawArrays(GL_TRIANGLES, 0, 3); });
|
||||
EXPECT_EQ(DrainGLErrors(), 0u);
|
||||
EXPECT_EQ(generated, 1u) << "the pinned-on lane did not even count correctly";
|
||||
|
||||
const std::string appended = LibraryLogSince(before);
|
||||
EXPECT_NE(appended.find("PRIMITIVES_GENERATED reroute engaged"), std::string::npos)
|
||||
<< "MOBILEGL_MAGMA_PRIMGEN_QUERY_REROUTE is pinned ON, an XFB-inactive draw ran inside "
|
||||
"a GENERATED query, and the renderer never reported engaging the reroute. The "
|
||||
"quirk is not armed - check the override mapping "
|
||||
"(ChoosePrimitivesGeneratedReroute) and the arming gate in "
|
||||
"VulkanRenderer::BeginXfbQueryForDraw. Log appended by this test:\n"
|
||||
<< appended;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
} // namespace MGITest
|
||||
@@ -30,6 +30,22 @@ target_link_libraries(DriverBugProbesTest PRIVATE
|
||||
${LINK_LIBRARIES}
|
||||
)
|
||||
|
||||
add_executable(
|
||||
PrimitivesGeneratedNoXfbProbeTest
|
||||
PrimitivesGeneratedNoXfbProbeTest.cpp
|
||||
)
|
||||
|
||||
target_include_directories(PrimitivesGeneratedNoXfbProbeTest PRIVATE
|
||||
${MGL_ROOT}/include
|
||||
${MGL_ROOT}/MobileGL
|
||||
)
|
||||
|
||||
target_link_libraries(PrimitivesGeneratedNoXfbProbeTest PRIVATE
|
||||
GTest::gtest_main
|
||||
${LINK_LIBRARIES}
|
||||
)
|
||||
|
||||
include(GoogleTest)
|
||||
gtest_discover_tests(DriverPostIterationRPWitnessTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
|
||||
gtest_discover_tests(DriverBugProbesTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
|
||||
gtest_discover_tests(PrimitivesGeneratedNoXfbProbeTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
// MobileGL - MobileGL/MG_Test/SelfTest/PrimitivesGeneratedNoXfbProbeTest.cpp
|
||||
// Copyright (c) 2026 MobileGL-Dev
|
||||
// Licensed under the GNU Lesser General Public License v3.0:
|
||||
// https://www.gnu.org/licenses/gpl-3.0.txt
|
||||
// https://www.gnu.org/licenses/lgpl-3.0.txt
|
||||
// SPDX-License-Identifier: LGPL-3.0-only
|
||||
// End of Source File Header
|
||||
//
|
||||
// The primitives-generated-without-transform-feedback probe's VERDICT and ARMING
|
||||
// logic, pinned over synthetic measurements. The Vulkan plumbing needs a GPU; the
|
||||
// two pure functions are where the cheap mistakes live - a verdict that reads a
|
||||
// half-broken driver as healthy, an override arm swapped so ForceOn disarms, a
|
||||
// substitute ranked below a worse one - and every driver the campaign has
|
||||
// characterised is written down here as a fake measurement so the mapping cannot
|
||||
// drift without a red:
|
||||
// - a conforming driver (stream counts everywhere),
|
||||
// - Mesa lavapipe as measured 2026-08: stream silent everywhere, the dedicated
|
||||
// VK_EXT_primitives_generated_query exact everywhere (discard included), and
|
||||
// the statistics control exact on the plain shape but dead under rasterizer
|
||||
// discard (llvmpipe's discard short-circuit),
|
||||
// - the same driver without the dedicated query - the statistics tiers,
|
||||
// - a device with the defect and no working substitute,
|
||||
// - and the refuse-to-guess shapes (half counts, missing mandatory shapes).
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <MG_Util/SelfTest/PrimitivesGeneratedNoXfbProbe.h>
|
||||
|
||||
using MobileGL::Bool;
|
||||
using MobileGL::Uint64;
|
||||
using MobileGL::MG_Config::QuirkOverride;
|
||||
using MobileGL::MG_Util::SelfTest::EvaluatePrimitivesGeneratedNoXfbVerdict;
|
||||
using MobileGL::MG_Util::SelfTest::ChoosePrimitivesGeneratedReroute;
|
||||
using MobileGL::MG_Util::SelfTest::PrimGenRerouteKind;
|
||||
using MobileGL::MG_Util::SelfTest::PrimitivesGeneratedNoXfbMeasurement;
|
||||
using MobileGL::MG_Util::SelfTest::PrimitivesGeneratedNoXfbShapeMeasurement;
|
||||
using MobileGL::MG_Util::SelfTest::PrimitivesGeneratedNoXfbVerdict;
|
||||
|
||||
namespace {
|
||||
struct ShapeAnswers {
|
||||
Uint64 stream = 0;
|
||||
// Negative-free encoding: measured flags separate from values.
|
||||
Bool pgqMeasured = false;
|
||||
Uint64 pgq = 0;
|
||||
Bool statMeasured = false;
|
||||
Uint64 stat = 0;
|
||||
};
|
||||
|
||||
PrimitivesGeneratedNoXfbShapeMeasurement Shape(const ShapeAnswers& answers) {
|
||||
PrimitivesGeneratedNoXfbShapeMeasurement shape;
|
||||
shape.drawn = true;
|
||||
shape.expectedPrimitives = 1;
|
||||
shape.streamGenerated = answers.stream;
|
||||
shape.primitivesGeneratedExtMeasured = answers.pgqMeasured;
|
||||
shape.primitivesGeneratedExt = answers.pgq;
|
||||
shape.statisticsMeasured = answers.statMeasured;
|
||||
shape.statisticsClippingInput = answers.stat;
|
||||
return shape;
|
||||
}
|
||||
|
||||
PrimitivesGeneratedNoXfbMeasurement Measurement(PrimitivesGeneratedNoXfbShapeMeasurement plain,
|
||||
PrimitivesGeneratedNoXfbShapeMeasurement discard,
|
||||
PrimitivesGeneratedNoXfbShapeMeasurement patches) {
|
||||
PrimitivesGeneratedNoXfbMeasurement measurement;
|
||||
measurement.ran = true;
|
||||
measurement.trianglesPlain = plain;
|
||||
measurement.trianglesDiscard = discard;
|
||||
measurement.patchesDiscard = patches;
|
||||
return measurement;
|
||||
}
|
||||
|
||||
PrimitivesGeneratedNoXfbShapeMeasurement NotDrawn() {
|
||||
return PrimitivesGeneratedNoXfbShapeMeasurement{};
|
||||
}
|
||||
|
||||
constexpr ShapeAnswers kHealthy{1, true, 1, true, 1};
|
||||
// The lavapipe measurement: stream silent, dedicated query exact, statistics
|
||||
// exact only where nothing is discarded.
|
||||
constexpr ShapeAnswers kLavapipePlain{0, true, 1, true, 1};
|
||||
constexpr ShapeAnswers kLavapipeDiscard{0, true, 1, true, 0};
|
||||
} // namespace
|
||||
|
||||
// A conforming driver: the stream query counts every capture-less shape exactly.
|
||||
// Controls agreeing changes nothing - health is decided by the subject.
|
||||
TEST(PrimitivesGeneratedNoXfbVerdictTest, AConformingDriverReadsStreamCounts) {
|
||||
const auto measurement = Measurement(Shape(kHealthy), Shape(kHealthy), Shape(kHealthy));
|
||||
EXPECT_EQ(EvaluatePrimitivesGeneratedNoXfbVerdict(measurement),
|
||||
PrimitivesGeneratedNoXfbVerdict::StreamCounts);
|
||||
}
|
||||
|
||||
// ...and stays healthy with no tessellation stage to draw the patches shape with,
|
||||
// and with no control at all - a control is only required to QUALIFY a
|
||||
// substitute, never to certify health.
|
||||
TEST(PrimitivesGeneratedNoXfbVerdictTest, HealthNeedsNeitherTessellationNorAControl) {
|
||||
EXPECT_EQ(EvaluatePrimitivesGeneratedNoXfbVerdict(
|
||||
Measurement(Shape(kHealthy), Shape(kHealthy), NotDrawn())),
|
||||
PrimitivesGeneratedNoXfbVerdict::StreamCounts);
|
||||
EXPECT_EQ(EvaluatePrimitivesGeneratedNoXfbVerdict(
|
||||
Measurement(Shape({1}), Shape({1}), Shape({1}))),
|
||||
PrimitivesGeneratedNoXfbVerdict::StreamCounts);
|
||||
}
|
||||
|
||||
// Mesa lavapipe as measured (2026-08): stream silent for every capture-less
|
||||
// draw, the dedicated primitives-generated query exact on every shape (discard
|
||||
// included), the statistics control dead under discard. The dedicated query must
|
||||
// win - it is the only substitute that covers the CTS shape there.
|
||||
TEST(PrimitivesGeneratedNoXfbVerdictTest, LavapipeShapedMeasurementTakesTheDedicatedQuery) {
|
||||
const auto measurement =
|
||||
Measurement(Shape(kLavapipePlain), Shape(kLavapipeDiscard), Shape(kLavapipeDiscard));
|
||||
EXPECT_EQ(EvaluatePrimitivesGeneratedNoXfbVerdict(measurement),
|
||||
PrimitivesGeneratedNoXfbVerdict::PrimitivesGeneratedExtSubstitute);
|
||||
}
|
||||
|
||||
// The affected-device hypothesis with no dedicated query: statistics exact on
|
||||
// every shape, the CTS's discarded shapes included.
|
||||
TEST(PrimitivesGeneratedNoXfbVerdictTest, StatisticsExactEverywhereIsTheFullStatisticsSubstitute) {
|
||||
const auto measurement = Measurement(Shape({0, false, 0, true, 1}), Shape({0, false, 0, true, 1}),
|
||||
Shape({0, false, 0, true, 1}));
|
||||
EXPECT_EQ(EvaluatePrimitivesGeneratedNoXfbVerdict(measurement),
|
||||
PrimitivesGeneratedNoXfbVerdict::StatisticsSubstitute);
|
||||
}
|
||||
|
||||
// A dedicated query that is silent in the same way the stream query is must not
|
||||
// be armed - the statistics tier decides instead.
|
||||
TEST(PrimitivesGeneratedNoXfbVerdictTest, ASilentDedicatedQueryFallsThroughToStatistics) {
|
||||
const auto measurement = Measurement(Shape({0, true, 0, true, 1}), Shape({0, true, 0, true, 1}),
|
||||
Shape({0, true, 0, true, 1}));
|
||||
EXPECT_EQ(EvaluatePrimitivesGeneratedNoXfbVerdict(measurement),
|
||||
PrimitivesGeneratedNoXfbVerdict::StatisticsSubstitute);
|
||||
}
|
||||
|
||||
// The llvmpipe statistics hole without the dedicated query to rescue it: exact on
|
||||
// the plain shape, dead under discard. Repairs undiscarded queries only, and the
|
||||
// verdict must say so.
|
||||
TEST(PrimitivesGeneratedNoXfbVerdictTest, StatisticsDeadUnderDiscardIsThePlainOnlySubstitute) {
|
||||
const auto measurement = Measurement(Shape({0, false, 0, true, 1}), Shape({0, false, 0, true, 0}),
|
||||
Shape({0, false, 0, true, 0}));
|
||||
EXPECT_EQ(EvaluatePrimitivesGeneratedNoXfbVerdict(measurement),
|
||||
PrimitivesGeneratedNoXfbVerdict::StatisticsSubstitutePlainOnly);
|
||||
}
|
||||
|
||||
// The defect with no substitute: no control, controls silent, or a control that
|
||||
// OVERCOUNTS the plain shape (as disqualifying as one that reads 0 - an exact
|
||||
// match is what qualifies a substitute).
|
||||
TEST(PrimitivesGeneratedNoXfbVerdictTest, StreamSilentWithoutAWorkingPlainControlIsUnfixable) {
|
||||
EXPECT_EQ(EvaluatePrimitivesGeneratedNoXfbVerdict(
|
||||
Measurement(Shape({0}), Shape({0}), Shape({0}))),
|
||||
PrimitivesGeneratedNoXfbVerdict::Unfixable);
|
||||
EXPECT_EQ(EvaluatePrimitivesGeneratedNoXfbVerdict(
|
||||
Measurement(Shape({0, true, 0, true, 0}), Shape({0, true, 0, true, 0}),
|
||||
Shape({0, true, 0, true, 0}))),
|
||||
PrimitivesGeneratedNoXfbVerdict::Unfixable);
|
||||
EXPECT_EQ(EvaluatePrimitivesGeneratedNoXfbVerdict(
|
||||
Measurement(Shape({0, true, 2, true, 2}), Shape({0, true, 1, true, 1}),
|
||||
Shape({0, true, 1, true, 1}))),
|
||||
PrimitivesGeneratedNoXfbVerdict::Unfixable);
|
||||
}
|
||||
|
||||
// Refuse-to-guess shapes. A nonzero-but-wrong stream answer fits neither the
|
||||
// defect (exact silence) nor health (the exact count), whichever shape carries
|
||||
// it; and a probe that never ran, or lost its mandatory shapes, says nothing.
|
||||
TEST(PrimitivesGeneratedNoXfbVerdictTest, AnswersFittingNeitherHealthNorTheDefectAreInconclusive) {
|
||||
EXPECT_EQ(EvaluatePrimitivesGeneratedNoXfbVerdict(
|
||||
Measurement(Shape({2, true, 1, true, 1}), Shape({0, true, 1, true, 1}),
|
||||
Shape({0, true, 1, true, 1}))),
|
||||
PrimitivesGeneratedNoXfbVerdict::Inconclusive);
|
||||
EXPECT_EQ(EvaluatePrimitivesGeneratedNoXfbVerdict(
|
||||
Measurement(Shape({0, true, 1, true, 1}), Shape({3, true, 1, true, 1}),
|
||||
Shape({0, true, 1, true, 1}))),
|
||||
PrimitivesGeneratedNoXfbVerdict::Inconclusive);
|
||||
|
||||
PrimitivesGeneratedNoXfbMeasurement neverRan;
|
||||
neverRan.ran = false;
|
||||
EXPECT_EQ(EvaluatePrimitivesGeneratedNoXfbVerdict(neverRan),
|
||||
PrimitivesGeneratedNoXfbVerdict::Inconclusive);
|
||||
|
||||
const auto missingMandatoryShape = Measurement(Shape({0, true, 1, true, 1}), NotDrawn(), NotDrawn());
|
||||
EXPECT_EQ(EvaluatePrimitivesGeneratedNoXfbVerdict(missingMandatoryShape),
|
||||
PrimitivesGeneratedNoXfbVerdict::Inconclusive);
|
||||
}
|
||||
|
||||
// A partial silence is still the defect: the plain shape counts but the discarded
|
||||
// ones read 0 (a driver that gates the stream counter on rasterization rather
|
||||
// than on the capture). With a whole control the substitute is whole.
|
||||
TEST(PrimitivesGeneratedNoXfbVerdictTest, SilenceOnOnlyTheDiscardShapesIsStillTheDefect) {
|
||||
const auto measurement = Measurement(Shape({1, true, 1, true, 1}), Shape({0, true, 1, true, 1}),
|
||||
Shape({0, true, 1, true, 1}));
|
||||
EXPECT_EQ(EvaluatePrimitivesGeneratedNoXfbVerdict(measurement),
|
||||
PrimitivesGeneratedNoXfbVerdict::PrimitivesGeneratedExtSubstitute);
|
||||
}
|
||||
|
||||
// ===================== THE OVERRIDE MAPPING =====================
|
||||
//
|
||||
// The one-line swap this exists to catch: ForceOn and ForceOff exchanging arms,
|
||||
// Auto arming on a verdict that never qualified a substitute, or the pool ranking
|
||||
// inverting. Every cell of the (override x verdict) table is written out.
|
||||
|
||||
namespace {
|
||||
constexpr PrimitivesGeneratedNoXfbVerdict kAllVerdicts[] = {
|
||||
PrimitivesGeneratedNoXfbVerdict::Inconclusive,
|
||||
PrimitivesGeneratedNoXfbVerdict::StreamCounts,
|
||||
PrimitivesGeneratedNoXfbVerdict::PrimitivesGeneratedExtSubstitute,
|
||||
PrimitivesGeneratedNoXfbVerdict::StatisticsSubstitute,
|
||||
PrimitivesGeneratedNoXfbVerdict::StatisticsSubstitutePlainOnly,
|
||||
PrimitivesGeneratedNoXfbVerdict::Unfixable,
|
||||
};
|
||||
}
|
||||
|
||||
TEST(PrimitivesGeneratedNoXfbArmingTest, ForceOffNeverReroutes) {
|
||||
for (const auto verdict : kAllVerdicts) {
|
||||
for (const Bool pgqUsable : {false, true}) {
|
||||
for (const Bool statsUsable : {false, true}) {
|
||||
EXPECT_EQ(ChoosePrimitivesGeneratedReroute(QuirkOverride::ForceOff, verdict, pgqUsable,
|
||||
statsUsable),
|
||||
PrimGenRerouteKind::None);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TEST(PrimitivesGeneratedNoXfbArmingTest, ForceOnBypassesTheVerdictButNeverTheStructuralChecks) {
|
||||
for (const auto verdict : kAllVerdicts) {
|
||||
// The dedicated query wins where the device can host it...
|
||||
EXPECT_EQ(ChoosePrimitivesGeneratedReroute(QuirkOverride::ForceOn, verdict, true, true),
|
||||
PrimGenRerouteKind::PrimitivesGeneratedExt);
|
||||
EXPECT_EQ(ChoosePrimitivesGeneratedReroute(QuirkOverride::ForceOn, verdict, true, false),
|
||||
PrimGenRerouteKind::PrimitivesGeneratedExt);
|
||||
// ...statistics stand in where only they exist...
|
||||
EXPECT_EQ(ChoosePrimitivesGeneratedReroute(QuirkOverride::ForceOn, verdict, false, true),
|
||||
PrimGenRerouteKind::ClippingStatistics);
|
||||
// ...and no pool means no reroute, forced or not.
|
||||
EXPECT_EQ(ChoosePrimitivesGeneratedReroute(QuirkOverride::ForceOn, verdict, false, false),
|
||||
PrimGenRerouteKind::None);
|
||||
}
|
||||
}
|
||||
|
||||
TEST(PrimitivesGeneratedNoXfbArmingTest, AutoFollowsExactlyTheSubstituteVerdicts) {
|
||||
EXPECT_EQ(ChoosePrimitivesGeneratedReroute(
|
||||
QuirkOverride::Auto, PrimitivesGeneratedNoXfbVerdict::PrimitivesGeneratedExtSubstitute,
|
||||
true, true),
|
||||
PrimGenRerouteKind::PrimitivesGeneratedExt);
|
||||
EXPECT_EQ(ChoosePrimitivesGeneratedReroute(
|
||||
QuirkOverride::Auto, PrimitivesGeneratedNoXfbVerdict::StatisticsSubstitute, false, true),
|
||||
PrimGenRerouteKind::ClippingStatistics);
|
||||
EXPECT_EQ(ChoosePrimitivesGeneratedReroute(
|
||||
QuirkOverride::Auto, PrimitivesGeneratedNoXfbVerdict::StatisticsSubstitutePlainOnly,
|
||||
false, true),
|
||||
PrimGenRerouteKind::ClippingStatistics);
|
||||
// The statistics verdicts never take the dedicated pool: that verdict only
|
||||
// exists when the dedicated query did NOT qualify.
|
||||
EXPECT_EQ(ChoosePrimitivesGeneratedReroute(
|
||||
QuirkOverride::Auto, PrimitivesGeneratedNoXfbVerdict::StatisticsSubstitute, true, true),
|
||||
PrimGenRerouteKind::ClippingStatistics);
|
||||
for (const auto verdict :
|
||||
{PrimitivesGeneratedNoXfbVerdict::Inconclusive, PrimitivesGeneratedNoXfbVerdict::StreamCounts,
|
||||
PrimitivesGeneratedNoXfbVerdict::Unfixable}) {
|
||||
EXPECT_EQ(ChoosePrimitivesGeneratedReroute(QuirkOverride::Auto, verdict, true, true),
|
||||
PrimGenRerouteKind::None);
|
||||
}
|
||||
// The structural checks bind Auto too.
|
||||
EXPECT_EQ(ChoosePrimitivesGeneratedReroute(
|
||||
QuirkOverride::Auto, PrimitivesGeneratedNoXfbVerdict::PrimitivesGeneratedExtSubstitute,
|
||||
false, true),
|
||||
PrimGenRerouteKind::None);
|
||||
EXPECT_EQ(ChoosePrimitivesGeneratedReroute(
|
||||
QuirkOverride::Auto, PrimitivesGeneratedNoXfbVerdict::StatisticsSubstitute, false, false),
|
||||
PrimGenRerouteKind::None);
|
||||
}
|
||||
@@ -57,8 +57,12 @@ namespace MobileGL::MG_Util::SelfTest {
|
||||
// (when the device has pipelineStatisticsQuery): one invocation of the
|
||||
// primitive clipping stage per primitive reaching it - GL's
|
||||
// CLIPPING_INPUT_PRIMITIVES - which sits AFTER every vertex processing
|
||||
// stage and, per spec, BEFORE rasterizer discard, so for an XFB-inactive
|
||||
// draw it is definitionally the number PRIMITIVES_GENERATED must answer.
|
||||
// stage (post-tess, post-GS) and, per spec, BEFORE rasterizer discard, so
|
||||
// for an XFB-inactive draw it is definitionally the number
|
||||
// PRIMITIVES_GENERATED must answer. (A geometry stage's non-zero vertex
|
||||
// streams never reach clipping, but non-indexed GL_PRIMITIVES_GENERATED
|
||||
// counts stream 0 alone, so the sets still agree. The stage's OUTPUT
|
||||
// count - CLIPPING_PRIMITIVES - would not: clipping drops and splits.)
|
||||
//
|
||||
// THE CONTROL DISCIPLINE (DriverBugProbes.h): the substitute slots are the
|
||||
// probe's controls, and the DISCARD dimension is measured separately because
|
||||
|
||||
Reference in New Issue
Block a user