[Fix] (MG_Backend/DirectVulkan, MG_Test/Pipeline): give 8/16-bit integer attributes their GL value in Magma

- the fetch format takes the input's signedness, so UNSIGNED_BYTE 240 read as int was -16
  (Sodium lightmaps into a_LightAndData: dark terrain under shaderpacks)
- a program variant keyed on two cached masks re-extends exactly those locations
- the vertex input resolve moves ahead of the variant pick; vertex-stage transforms get a helper
This commit is contained in:
2026-10-18 15:55:07 -04:00
parent 3528628681
commit cc0c2aa766
9 changed files with 440 additions and 75 deletions
@@ -0,0 +1,133 @@
// MobileGL - MobileGL/MG_Backend/DirectVulkan/Renderer/NarrowIntegerVertexFormats.h
// Copyright (c) 2025-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
#pragma once
#include "../VkIncludes.h"
#include <MG_Util/ShaderTranspiler/NarrowIntegerVertexInputs.h>
#include <Includes.h>
namespace MobileGL::MG_Backend::DirectVulkan {
using MG_Util::ShaderTranspiler::NarrowIntegerVertexInputs;
// Which vertex inputs need the ExtendNarrowIntegerVertexInputs program variant, decided from
// two halves that are each computed once and cached with the object they describe.
//
// The pipeline gives an integer attribute the fetch format of the shader input's signedness
// (VUID-VkGraphicsPipelineCreateInfo-Input-08733). For 32-bit data that swap only renames the
// bits; for 8/16-bit data it changes the extension rule, so 240 in GL_UNSIGNED_BYTE data
// reaches an `int` input as -16. These are exactly the (data, input) pairs that need it.
enum class NarrowIntegerVertexFormat : Uint8 {
None = 0,
Unsigned8,
Unsigned16,
Signed8,
Signed16,
};
constexpr NarrowIntegerVertexFormat ClassifyNarrowIntegerVertexFormat(VkFormat format) {
switch (format) {
case VK_FORMAT_R8_UINT:
case VK_FORMAT_R8G8_UINT:
case VK_FORMAT_R8G8B8_UINT:
case VK_FORMAT_R8G8B8A8_UINT:
return NarrowIntegerVertexFormat::Unsigned8;
case VK_FORMAT_R16_UINT:
case VK_FORMAT_R16G16_UINT:
case VK_FORMAT_R16G16B16_UINT:
case VK_FORMAT_R16G16B16A16_UINT:
return NarrowIntegerVertexFormat::Unsigned16;
case VK_FORMAT_R8_SINT:
case VK_FORMAT_R8G8_SINT:
case VK_FORMAT_R8G8B8_SINT:
case VK_FORMAT_R8G8B8A8_SINT:
return NarrowIntegerVertexFormat::Signed8;
case VK_FORMAT_R16_SINT:
case VK_FORMAT_R16G16_SINT:
case VK_FORMAT_R16G16B16_SINT:
case VK_FORMAT_R16G16B16A16_SINT:
return NarrowIntegerVertexFormat::Signed16;
default:
return NarrowIntegerVertexFormat::None;
}
}
// The vertex-input half: the locations whose attribute DATA is narrow integer, by width and
// signedness. Filled from the source formats when a vertex input layout is built, so a draw
// reads four words instead of walking its attributes.
struct NarrowIntegerAttributeMasks {
Uint32 unsigned8 = 0;
Uint32 unsigned16 = 0;
Uint32 signed8 = 0;
Uint32 signed16 = 0;
constexpr void Note(Uint32 location, VkFormat format) {
if (location >= 32) return;
const Uint32 bit = 1u << location;
switch (ClassifyNarrowIntegerVertexFormat(format)) {
case NarrowIntegerVertexFormat::Unsigned8:
unsigned8 |= bit;
break;
case NarrowIntegerVertexFormat::Unsigned16:
unsigned16 |= bit;
break;
case NarrowIntegerVertexFormat::Signed8:
signed8 |= bit;
break;
case NarrowIntegerVertexFormat::Signed16:
signed16 |= bit;
break;
case NarrowIntegerVertexFormat::None:
break;
}
}
};
// The program half: the locations whose shader input is a 32-bit int or uint scalar/vector.
// Both masks are zero for a program without integer vertex inputs, which is what keeps the
// common draw at four AND instructions.
struct IntegerVertexInputMasks {
Uint32 signedInputs = 0;
Uint32 unsignedInputs = 0;
constexpr void Note(Uint32 location, GLenum shaderInputType) {
if (location >= 32) return;
const Uint32 bit = 1u << location;
switch (shaderInputType) {
case GL_INT:
case GL_INT_VEC2:
case GL_INT_VEC3:
case GL_INT_VEC4:
signedInputs |= bit;
break;
case GL_UNSIGNED_INT:
case GL_UNSIGNED_INT_VEC2:
case GL_UNSIGNED_INT_VEC3:
case GL_UNSIGNED_INT_VEC4:
unsignedInputs |= bit;
break;
default:
break;
}
}
};
// Unsigned narrow data into an int input is fetched sign-extended and must be zero-extended;
// signed narrow data into a uint input is fetched zero-extended and must be sign-extended.
// Matching signedness, 32-bit data and float inputs need nothing.
constexpr NarrowIntegerVertexInputs ResolveNarrowIntegerVertexInputs(const IntegerVertexInputMasks& inputs,
const NarrowIntegerAttributeMasks& data) {
NarrowIntegerVertexInputs result;
result.zeroExtend8 = inputs.signedInputs & data.unsigned8;
result.zeroExtend16 = inputs.signedInputs & data.unsigned16;
result.signExtend8 = inputs.unsignedInputs & data.signed8;
result.signExtend16 = inputs.unsignedInputs & data.signed16;
return result;
}
} // namespace MobileGL::MG_Backend::DirectVulkan
@@ -2736,6 +2736,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
VkProgramObject& entry) const {
entry.activeVertexInputLocationMask = 0;
entry.vertexInputTypes.fill(0);
entry.integerVertexInputs = {};
entry.readsBaseVertexBuiltin = false;
for (SizeT moduleIndex = 0; moduleIndex < stages.size() && moduleIndex < spirv.size(); ++moduleIndex) {
@@ -2802,6 +2803,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
entry.activeVertexInputLocationMask |= (1u << expandedLocation);
entry.vertexInputTypes[expandedLocation] = locationType;
entry.integerVertexInputs.Note(expandedLocation, locationType);
}
}
}
@@ -3553,8 +3555,80 @@ namespace MobileGL::MG_Backend::DirectVulkan {
++m_cacheStructureEpoch;
}
const ProgramFactory::VkProgramObject& ProgramFactory::GetOrCreateProgram(const MagmaProgramSource& program,
CompileOptionFlags flags) {
namespace {
// The vertex stage's own transforms, in the order they have to run: the ZeroBaseVertex
// variant, the unconditional 64-bit input packing, then the narrow-integer variant.
void TransformVertexStageForVulkan(Vector<Uint>& module, ProgramFactory::CompileOptionFlags flags,
const NarrowIntegerVertexInputs& narrowIntegerInputs,
Bool enableSpirvValidation, Uint programIndex) {
// The non-indexed variant of a vertex stage that reads gl_BaseVertex: GL wants zero
// there, Vulkan's builtin would hand it the draw's firstVertex. Requested per draw
// through CompileOptionBit::ZeroBaseVertex, so the indexed variant of the same
// program keeps the native builtin and stays correct for glDrawElementsBaseVertex
// and for the baseVertex word of an indexed indirect command.
if (flags & ProgramFactory::CompileOptionBit::ZeroBaseVertex) {
Vector<Uint> zeroedSpirv;
if (MG_Util::ShaderTranspiler::ShaderCompiler::ZeroBaseVertexForVulkan(module, zeroedSpirv,
enableSpirvValidation)) {
module = std::move(zeroedSpirv);
} else {
// Failing open keeps the native builtin, which is the pre-fix behavior:
// gl_BaseVertex reads firstVertex on a DrawArrays instead of zero.
MGLOG_E("ProgramFactory: failed to zero gl_BaseVertex for program %u; non-indexed "
"draws will read the draw's first vertex from it instead of zero",
programIndex);
}
}
// A 64-bit vertex input has to arrive as its 32-bit word pair: VK_FORMAT_R64*_SFLOAT is
// optional and lavapipe advertises none of them at all. The pass is unconditional so it
// always agrees with the Float64 case in VertexInputStateFactory::ToVkVertexFormat, and
// ReflectVertexInputs then sees an ordinary uvec2/uvec4 input.
//
// Failure here is not recoverable and must not be swallowed: ToVkVertexFormat has already
// committed to R32G32{,B32A32}_UINT for the attribute, so a module still declaring
// `in double` would reconcile to Unknown and build a pipeline with a UINT format under a
// double input - garbage with no diagnostic anywhere.
{
Vector<Uint> packedSpirv;
const Bool packOk = MG_Util::ShaderTranspiler::ShaderCompiler::PackDoubleVertexInputsForVulkan(
module, packedSpirv, enableSpirvValidation);
MOBILEGL_ASSERT(packOk,
"ProgramFactory: 64-bit vertex input packing failed for program %u; the "
"vertex-input format and the shader input type now disagree",
programIndex);
if (packOk) {
module = std::move(packedSpirv);
} else {
MGLOG_E("ProgramFactory: failed to pack 64-bit vertex inputs for program %u; "
"double-typed vertex attributes will be fetched as uint32 words and not "
"reinterpreted",
programIndex);
}
}
// The narrow-integer variant: the pipeline fetches these attributes with the format of
// the input's signedness, and this restores the extension rule of the data's own (see
// ResolveNarrowIntegerVertexInputs). After the 64-bit packing so it sees the inputs in
// the shape the pipeline's vertex input state and ReflectVertexInputs describe.
if (narrowIntegerInputs.Any()) {
Vector<Uint> extendedSpirv;
if (MG_Util::ShaderTranspiler::ShaderCompiler::ExtendNarrowIntegerVertexInputsForVulkan(
module, extendedSpirv, narrowIntegerInputs, enableSpirvValidation)) {
module = std::move(extendedSpirv);
} else {
MGLOG_E("ProgramFactory: failed to extend narrow integer vertex inputs for program %u; "
"8/16-bit integer attributes read through an input of the other signedness keep "
"the fetch format's extension",
programIndex);
}
}
}
} // namespace
const ProgramFactory::VkProgramObject& ProgramFactory::GetOrCreateProgram(
const MagmaProgramSource& program, CompileOptionFlags flags,
const NarrowIntegerVertexInputs& narrowIntegerInputs) {
// Hashing the full SPIR-V of every stage is far too expensive to repeat per draw;
// reuse the program's memoized hash while its backend state version is unchanged.
// The memo keys on the flags word, which ComputeHash is no longer a pure function of:
@@ -3575,6 +3649,9 @@ namespace MobileGL::MG_Backend::DirectVulkan {
hash = ComputeHash(program, flags);
program.SetBackendHashMemo(memoKey, hash);
}
// Mixed in after the memo, which keys on the flags word alone: the memo keeps answering
// for the plain program, and the variant costs one 16-byte hash on top of it.
if (narrowIntegerInputs.Any()) hash = MixNarrowIntegerVertexInputs(hash, narrowIntegerInputs);
auto it = m_cache.find(hash);
if (it != m_cache.end()) {
// Every draw/dispatch funnels through this lookup (the renderer memos only
@@ -3827,50 +3904,9 @@ namespace MobileGL::MG_Backend::DirectVulkan {
}
}
// The non-indexed variant of a vertex stage that reads gl_BaseVertex: GL wants zero
// there, Vulkan's builtin would hand it the draw's firstVertex. Requested per draw
// through CompileOptionBit::ZeroBaseVertex, so the indexed variant of the same
// program keeps the native builtin and stays correct for glDrawElementsBaseVertex
// and for the baseVertex word of an indexed indirect command.
if (stages[i] == ShaderStage::Vertex && (flags & CompileOptionBit::ZeroBaseVertex)) {
Vector<Uint> zeroedSpirv;
if (MG_Util::ShaderTranspiler::ShaderCompiler::ZeroBaseVertexForVulkan(moduleSpirvs[i], zeroedSpirv,
enableSpirvValidation)) {
moduleSpirvs[i] = std::move(zeroedSpirv);
} else {
// Failing open keeps the native builtin, which is the pre-fix behavior:
// gl_BaseVertex reads firstVertex on a DrawArrays instead of zero.
MGLOG_E("ProgramFactory: failed to zero gl_BaseVertex for program %u; non-indexed "
"draws will read the draw's first vertex from it instead of zero",
program.GetExternalIndex());
}
}
// A 64-bit vertex input has to arrive as its 32-bit word pair: VK_FORMAT_R64*_SFLOAT is
// optional and lavapipe advertises none of them at all. The pass is unconditional so it
// always agrees with the Float64 case in VertexInputStateFactory::ToVkVertexFormat, and
// ReflectVertexInputs below then sees an ordinary uvec2/uvec4 input.
//
// Failure here is not recoverable and must not be swallowed: ToVkVertexFormat has already
// committed to R32G32{,B32A32}_UINT for the attribute, so a module still declaring
// `in double` would reconcile to Unknown and build a pipeline with a UINT format under a
// double input - garbage with no diagnostic anywhere.
if (stages[i] == ShaderStage::Vertex) {
Vector<Uint> packedSpirv;
const Bool packOk = MG_Util::ShaderTranspiler::ShaderCompiler::PackDoubleVertexInputsForVulkan(
moduleSpirvs[i], packedSpirv, enableSpirvValidation);
MOBILEGL_ASSERT(packOk,
"ProgramFactory: 64-bit vertex input packing failed for program %u; the "
"vertex-input format and the shader input type now disagree",
program.GetExternalIndex());
if (packOk) {
moduleSpirvs[i] = std::move(packedSpirv);
} else {
MGLOG_E("ProgramFactory: failed to pack 64-bit vertex inputs for program %u; "
"double-typed vertex attributes will be fetched as uint32 words and not "
"reinterpreted",
program.GetExternalIndex());
}
TransformVertexStageForVulkan(moduleSpirvs[i], flags, narrowIntegerInputs, enableSpirvValidation,
program.GetExternalIndex());
}
// When Vulkan can legally access storage images without a statically declared
@@ -12,6 +12,7 @@
#include "../MagmaSession.h"
#include "PipelineFactory.h"
#include "MagmaProgramSource.h"
#include "NarrowIntegerVertexFormats.h"
#include "MG_State/GLState/ProgramState/ShaderObject.h"
#include "MG_State/GLState/TextureState/TextureEnum.h"
@@ -200,6 +201,9 @@ namespace MobileGL::MG_Backend::DirectVulkan {
Int globalUboBinding = -1;
Uint32 activeVertexInputLocationMask = 0;
Array<GLenum, kMaxVertexInputLocations> vertexInputTypes{};
// The locations of vertexInputTypes that are int / uint, for the per-draw narrow-integer
// variant decision (ResolveNarrowIntegerVertexInputs). Identical in every variant.
IntegerVertexInputMasks integerVertexInputs;
Uint32 activeFragmentOutputLocationMask = 0;
Array<GLenum, kMaxVertexInputLocations> fragmentOutputTypes{};
ShaderStage rasterizationProducerStage = ShaderStage::Unknown;
@@ -328,6 +332,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
globalUboBinding = other.globalUboBinding;
activeVertexInputLocationMask = other.activeVertexInputLocationMask;
vertexInputTypes = other.vertexInputTypes;
integerVertexInputs = other.integerVertexInputs;
activeFragmentOutputLocationMask = other.activeFragmentOutputLocationMask;
fragmentOutputTypes = other.fragmentOutputTypes;
rasterizationProducerStage = other.rasterizationProducerStage;
@@ -351,6 +356,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
other.pointSizeCapabilityUnsupported = false;
other.globalUboBinding = -1;
other.activeVertexInputLocationMask = 0;
other.integerVertexInputs = {};
other.activeFragmentOutputLocationMask = 0;
other.rasterizationProducerStage = ShaderStage::Unknown;
other.producerOutputComponentCount = 0;
@@ -402,6 +408,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
globalUboBinding = other.globalUboBinding;
activeVertexInputLocationMask = other.activeVertexInputLocationMask;
vertexInputTypes = other.vertexInputTypes;
integerVertexInputs = other.integerVertexInputs;
activeFragmentOutputLocationMask = other.activeFragmentOutputLocationMask;
fragmentOutputTypes = other.fragmentOutputTypes;
rasterizationProducerStage = other.rasterizationProducerStage;
@@ -425,6 +432,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
other.pointSizeCapabilityUnsupported = false;
other.globalUboBinding = -1;
other.activeVertexInputLocationMask = 0;
other.integerVertexInputs = {};
other.activeFragmentOutputLocationMask = 0;
other.rasterizationProducerStage = ShaderStage::Unknown;
other.producerOutputComponentCount = 0;
@@ -518,7 +526,18 @@ namespace MobileGL::MG_Backend::DirectVulkan {
ProgramFactory(const ProgramFactory&) = delete;
HashType ComputeHash(const MagmaProgramSource& program, CompileOptionFlags flags) const;
const VkProgramObject& GetOrCreateProgram(const MagmaProgramSource& program, CompileOptionFlags flags);
// `narrowIntegerInputs` selects the variant whose vertex stage widens narrow integer
// attribute data GL's way (ExtendNarrowIntegerVertexInputsPass). It is draw-time state like
// the flags, and the draw path asks for it only when ResolveNarrowIntegerVertexInputs finds
// a location that needs it, so every other program keeps a single entry.
const VkProgramObject& GetOrCreateProgram(const MagmaProgramSource& program, CompileOptionFlags flags,
const NarrowIntegerVertexInputs& narrowIntegerInputs = {});
// The cache identity of a narrow-integer variant: the hash of the same program and flags
// without it, mixed with the corrected locations. Content in, content out, so the variant's
// pipelines key apart from the plain program's through programHash alone.
static HashType MixNarrowIntegerVertexInputs(HashType hash, const NarrowIntegerVertexInputs& inputs) {
return XXH64(&inputs, sizeof(inputs), hash);
}
// The default framebuffer's current image height, baked as a literal into every
// FragCoordYFlip variant (there is no push-constant or specialization channel here, and
@@ -214,6 +214,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
out.bindings.push_back(
{binding, stride, buffer.Divisor ? VK_VERTEX_INPUT_RATE_INSTANCE : VK_VERTEX_INPUT_RATE_VERTEX});
out.attributes.push_back({location, binding, format, 0});
out.narrowIntegerAttributes.Note(location, format);
out.bindingAttributeLocations.push_back(location);
out.bindingBaseOffsets.push_back(static_cast<SizeT>(offset));
out.bindingConversions.push_back(conversion);
@@ -14,6 +14,7 @@
#include "Config.h"
#include "MagmaPipeArms.h"
#include "NarrowIntegerVertexFormats.h"
#include "VertexInputStateBuilder.h"
#include "MG_State/GLState/VertexArrayState/VertexArrayObject.h"
#include <Includes.h>
@@ -63,6 +64,11 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// Bitmask of `attributes[i].location` - the draw path needs it up to
// three times per draw, so it is baked once at build time.
Uint32 attributeLocationMask = 0;
// The locations of `attributes` whose source data is 8/16-bit integer, by width and
// signedness - the vertex-input half of the narrow-integer program variant decision
// (NarrowIntegerVertexFormats.h). A pure function of `attributes`, so `layoutHash`
// already covers it.
NarrowIntegerAttributeMasks narrowIntegerAttributes;
// Per-binding glVertexAttribDivisor values other than 1. Vulkan's instance input
// rate advances once per instance and nothing else, so anything else has to be
// stated through VK_EXT_vertex_attribute_divisor. Empty when every instanced
@@ -918,6 +918,10 @@ namespace MobileGL::MG_Backend::DirectVulkan {
const Uint32 vertexInputAttribMask = vis.attributeLocationMask;
const Uint32 activeAttribMask = programObj.activeVertexInputLocationMask;
const Uint32 missingAttribMask = activeAttribMask & ~vertexInputAttribMask;
// The fetch format takes the shader input's signedness (VUID-VkGraphicsPipelineCreateInfo-
// Input-08733). That renames the bits of 32-bit data, but extends 8/16-bit data by the
// input's rule instead of the data's; the program variant SetupWireDraw picks for exactly
// those attributes (ResolveNarrowIntegerVertexInputs) puts GL's value back in the shader.
auto& patchedAttributes = m_patchedAttributesScratch;
patchedAttributes.assign(vis.attributes.begin(), vis.attributes.end());
Bool hasPatchedVertexAttributes = false;
@@ -739,6 +739,43 @@ namespace MobileGL::MG_Backend::DirectVulkan {
flags |= ProgramFactory::CompileOptionBit::ZeroBaseVertex;
programObject = &m_programFactory->GetOrCreateProgram(program, flags);
}
const auto vaoHandle = state.BoundVertexElements;
if (MG_Pipe::MGPipeHandleIsNull(vaoHandle)) MagmaWireFatal("vertex-elements-record");
if (vaoHandle.Slot >= state.VertexElementsCsos.size()) MagmaWireFatal("vertex-elements-record");
const auto& vao = state.VertexElementsCsos[vaoHandle.Slot];
if (!vao.Live || vao.Gen != vaoHandle.Gen) MagmaWireFatal("vertex-elements-generation");
// P7 wave 2 package C, CONTRACT-P7 §3.2: THE `vertex-layout` ROW IS RETIRED AND SPLIT.
//
// A false is now ONE reason and only one - `buffer-window`, an enabled attribute the
// program reads whose slot lies outside the window set_vertex_buffers published. That
// is a disagreement about what crossed, not about what this device can express, so it
// keeps a named Fatal through the hook (MGPipeSessionFail -> Session::Fail, which is
// what rule I asks of a refusal that must stay a refusal). It is the check that caught
// the Redmi VAO hash collision fixed in 376c04be, which is why it is not softened.
// The P7 marker is gone from the detail string with the abort: this is a permanent
// protocol error, not a shape the split path has yet to migrate, so it must not keep
// counting against the census of unmigrated refusals (scripts/ci/fatal_census.py
// counts the marker literally, which is also why no comment in this package spells it).
// P14: resolved through the record's own memo (ResolveWireVertexInput), except in a nested
// preparation, which builds into its own scratch rather than under the outer draw's feet.
const VertexInputStateFactory::BackendVertexInputState* resolvedInput = nullptr;
if (&scratch == &m_wireDrawScratch) {
resolvedInput = m_vertexInputStateFactory->ResolveWireVertexInput(
vao, state, programObject->activeVertexInputLocationMask);
} else if (m_vertexInputStateFactory->BuildWireVertexInput(
vao, state, programObject->activeVertexInputLocationMask, scratch.input)) {
resolvedInput = &scratch.input;
}
if (resolvedInput == nullptr) MagmaWireFatal("vertex-layout-buffer-window");
const auto& input = *resolvedInput;
// The narrow-integer variant (NarrowIntegerVertexFormats.h), chosen from two masks cached on
// the program entry and on the resolved layout: a program without integer vertex inputs pays
// four ANDs here. Every variant reflects the same inputs, so the layout resolved above with
// the plain program's active mask is this variant's too.
const NarrowIntegerVertexInputs narrowIntegerInputs =
ResolveNarrowIntegerVertexInputs(programObject->integerVertexInputs, input.narrowIntegerAttributes);
if (narrowIntegerInputs.Any())
programObject = &m_programFactory->GetOrCreateProgram(program, flags, narrowIntegerInputs);
if (programObject->invalidSpirvDeclined) {
MGL_WIRE_DECLINE_AT(ProgramInvalidSpirv,
"a module of the program fails SPIR-V validation and was never built");
@@ -776,35 +813,6 @@ namespace MobileGL::MG_Backend::DirectVulkan {
(void)ResolveWireImage(*fbo, fbo->Stencil, VK_IMAGE_ASPECT_STENCIL_BIT);
}
m_textureManager->FlushPendingUploads();
const auto vaoHandle = state.BoundVertexElements;
if (MG_Pipe::MGPipeHandleIsNull(vaoHandle)) MagmaWireFatal("vertex-elements-record");
if (vaoHandle.Slot >= state.VertexElementsCsos.size()) MagmaWireFatal("vertex-elements-record");
const auto& vao = state.VertexElementsCsos[vaoHandle.Slot];
if (!vao.Live || vao.Gen != vaoHandle.Gen) MagmaWireFatal("vertex-elements-generation");
// P7 wave 2 package C, CONTRACT-P7 §3.2: THE `vertex-layout` ROW IS RETIRED AND SPLIT.
//
// A false is now ONE reason and only one - `buffer-window`, an enabled attribute the
// program reads whose slot lies outside the window set_vertex_buffers published. That
// is a disagreement about what crossed, not about what this device can express, so it
// keeps a named Fatal through the hook (MGPipeSessionFail -> Session::Fail, which is
// what rule I asks of a refusal that must stay a refusal). It is the check that caught
// the Redmi VAO hash collision fixed in 376c04be, which is why it is not softened.
// The P7 marker is gone from the detail string with the abort: this is a permanent
// protocol error, not a shape the split path has yet to migrate, so it must not keep
// counting against the census of unmigrated refusals (scripts/ci/fatal_census.py
// counts the marker literally, which is also why no comment in this package spells it).
// P14: resolved through the record's own memo (ResolveWireVertexInput), except in a nested
// preparation, which builds into its own scratch rather than under the outer draw's feet.
const VertexInputStateFactory::BackendVertexInputState* resolvedInput = nullptr;
if (&scratch == &m_wireDrawScratch) {
resolvedInput =
m_vertexInputStateFactory->ResolveWireVertexInput(vao, state, programObj.activeVertexInputLocationMask);
} else if (m_vertexInputStateFactory->BuildWireVertexInput(vao, state, programObj.activeVertexInputLocationMask,
scratch.input)) {
resolvedInput = &scratch.input;
}
if (resolvedInput == nullptr) MagmaWireFatal("vertex-layout-buffer-window");
const auto& input = *resolvedInput;
// The seven device/format reasons are now MASKED OUT of the vertex input state, and
// this is the monolith arm's pre-flight for them, statement for statement
// (VulkanRenderer.cpp's SetupDraw: "Fail loudly rather than render wrong pixels"). An
+1
View File
@@ -14,6 +14,7 @@ add_executable(
PassthroughTessControlTest.cpp
ViewportIndexReflectionTest.cpp
DriverModuleScreenTest.cpp
NarrowIntegerVertexFormatsTest.cpp
)
target_include_directories(PipelineQuirkTest PRIVATE
@@ -0,0 +1,157 @@
// MobileGL - MobileGL/MG_Test/Pipeline/NarrowIntegerVertexFormatsTest.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
//
// Which vertex inputs take Magma's narrow-integer program variant. The pipeline fetches an integer
// attribute with the format of the shader input's signedness, which re-extends 8/16-bit data by the
// wrong rule exactly when the data and the input disagree in signedness; every other pairing -
// 32-bit data, matching signedness, float data or float inputs - must keep today's program.
#include <gtest/gtest.h>
#include <MG_Backend/DirectVulkan/Renderer/NarrowIntegerVertexFormats.h>
#include <MG_Backend/DirectVulkan/Renderer/ProgramFactory.h>
using namespace MobileGL;
using MobileGL::MG_Backend::DirectVulkan::ClassifyNarrowIntegerVertexFormat;
using MobileGL::MG_Backend::DirectVulkan::IntegerVertexInputMasks;
using MobileGL::MG_Backend::DirectVulkan::NarrowIntegerAttributeMasks;
using MobileGL::MG_Backend::DirectVulkan::NarrowIntegerVertexFormat;
using MobileGL::MG_Backend::DirectVulkan::NarrowIntegerVertexInputs;
using MobileGL::MG_Backend::DirectVulkan::ProgramFactory;
using MobileGL::MG_Backend::DirectVulkan::ResolveNarrowIntegerVertexInputs;
namespace {
// The correction one (data format, shader input type) pair gets, alone at `location`.
NarrowIntegerVertexInputs Resolve(VkFormat format, GLenum inputType, Uint32 location = 3) {
IntegerVertexInputMasks inputs;
inputs.Note(location, inputType);
NarrowIntegerAttributeMasks data;
data.Note(location, format);
return ResolveNarrowIntegerVertexInputs(inputs, data);
}
NarrowIntegerVertexInputs Only(Uint32 NarrowIntegerVertexInputs::* field, Uint32 location = 3) {
NarrowIntegerVertexInputs expected;
expected.*field = 1u << location;
return expected;
}
} // namespace
TEST(NarrowIntegerVertexFormats, ClassifiesExactlyThe8And16BitIntegerFormats) {
for (const VkFormat format :
{VK_FORMAT_R8_UINT, VK_FORMAT_R8G8_UINT, VK_FORMAT_R8G8B8_UINT, VK_FORMAT_R8G8B8A8_UINT})
EXPECT_EQ(ClassifyNarrowIntegerVertexFormat(format), NarrowIntegerVertexFormat::Unsigned8) << format;
for (const VkFormat format :
{VK_FORMAT_R16_UINT, VK_FORMAT_R16G16_UINT, VK_FORMAT_R16G16B16_UINT, VK_FORMAT_R16G16B16A16_UINT})
EXPECT_EQ(ClassifyNarrowIntegerVertexFormat(format), NarrowIntegerVertexFormat::Unsigned16) << format;
for (const VkFormat format :
{VK_FORMAT_R8_SINT, VK_FORMAT_R8G8_SINT, VK_FORMAT_R8G8B8_SINT, VK_FORMAT_R8G8B8A8_SINT})
EXPECT_EQ(ClassifyNarrowIntegerVertexFormat(format), NarrowIntegerVertexFormat::Signed8) << format;
for (const VkFormat format :
{VK_FORMAT_R16_SINT, VK_FORMAT_R16G16_SINT, VK_FORMAT_R16G16B16_SINT, VK_FORMAT_R16G16B16A16_SINT})
EXPECT_EQ(ClassifyNarrowIntegerVertexFormat(format), NarrowIntegerVertexFormat::Signed16) << format;
// 32-bit integers, float/normalized/scaled data and packed or reversed formats never qualify.
for (const VkFormat format :
{VK_FORMAT_R32_UINT, VK_FORMAT_R32G32B32A32_UINT, VK_FORMAT_R32_SINT, VK_FORMAT_R32G32B32A32_SINT,
VK_FORMAT_R8G8B8A8_UNORM, VK_FORMAT_R8G8B8A8_SNORM, VK_FORMAT_R8G8B8A8_USCALED, VK_FORMAT_R16G16_SSCALED,
VK_FORMAT_R16_SFLOAT, VK_FORMAT_R32G32_SFLOAT, VK_FORMAT_B8G8R8A8_UNORM, VK_FORMAT_A2B10G10R10_UINT_PACK32,
VK_FORMAT_A2B10G10R10_SNORM_PACK32, VK_FORMAT_UNDEFINED})
EXPECT_EQ(ClassifyNarrowIntegerVertexFormat(format), NarrowIntegerVertexFormat::None) << format;
}
TEST(NarrowIntegerVertexFormats, CorrectsOnlyTheSignednessMismatches) {
// Sodium 0.5's a_LightAndData: GL_UNSIGNED_BYTE x4 into `in ivec4`.
EXPECT_EQ(Resolve(VK_FORMAT_R8G8B8A8_UINT, GL_INT_VEC4), Only(&NarrowIntegerVertexInputs::zeroExtend8));
EXPECT_EQ(Resolve(VK_FORMAT_R8_UINT, GL_INT), Only(&NarrowIntegerVertexInputs::zeroExtend8));
EXPECT_EQ(Resolve(VK_FORMAT_R16G16_UINT, GL_INT_VEC2), Only(&NarrowIntegerVertexInputs::zeroExtend16));
EXPECT_EQ(Resolve(VK_FORMAT_R16G16B16_UINT, GL_INT_VEC3), Only(&NarrowIntegerVertexInputs::zeroExtend16));
EXPECT_EQ(Resolve(VK_FORMAT_R8G8B8A8_SINT, GL_UNSIGNED_INT_VEC4), Only(&NarrowIntegerVertexInputs::signExtend8));
EXPECT_EQ(Resolve(VK_FORMAT_R8G8_SINT, GL_UNSIGNED_INT_VEC2), Only(&NarrowIntegerVertexInputs::signExtend8));
EXPECT_EQ(Resolve(VK_FORMAT_R16_SINT, GL_UNSIGNED_INT), Only(&NarrowIntegerVertexInputs::signExtend16));
// Matching signedness: the fetch already extends by the data's rule.
EXPECT_FALSE(Resolve(VK_FORMAT_R8G8B8A8_UINT, GL_UNSIGNED_INT_VEC4).Any());
EXPECT_FALSE(Resolve(VK_FORMAT_R16_SINT, GL_INT).Any());
// 32-bit data: the format swap renames bits, which is GL's reinterpretation already.
EXPECT_FALSE(Resolve(VK_FORMAT_R32_UINT, GL_INT).Any());
EXPECT_FALSE(Resolve(VK_FORMAT_R32G32B32A32_SINT, GL_UNSIGNED_INT_VEC4).Any());
// Float data into an integer input and integer data into a float input are not this rule.
EXPECT_FALSE(Resolve(VK_FORMAT_R8G8B8A8_USCALED, GL_INT_VEC4).Any());
EXPECT_FALSE(Resolve(VK_FORMAT_R8_UNORM, GL_UNSIGNED_INT).Any());
EXPECT_FALSE(Resolve(VK_FORMAT_R8G8B8A8_UINT, GL_FLOAT_VEC4).Any());
EXPECT_FALSE(Resolve(VK_FORMAT_R16_SINT, GL_FLOAT).Any());
}
// Locations are independent bits, a location the program does not read as an integer is ignored
// even when its data is narrow, and the top location still fits.
TEST(NarrowIntegerVertexFormats, KeepsLocationsApart) {
IntegerVertexInputMasks inputs;
inputs.Note(0, GL_FLOAT_VEC3);
inputs.Note(1, GL_INT_VEC4);
inputs.Note(2, GL_UNSIGNED_INT_VEC2);
inputs.Note(5, GL_UNSIGNED_INT_VEC4);
inputs.Note(31, GL_INT);
inputs.Note(32, GL_INT); // beyond the masks: ignored, not wrapped
EXPECT_EQ(inputs.signedInputs, (1u << 1) | (1u << 31));
EXPECT_EQ(inputs.unsignedInputs, (1u << 2) | (1u << 5));
NarrowIntegerAttributeMasks data;
data.Note(0, VK_FORMAT_R8G8B8A8_UINT); // a float input: not corrected
data.Note(1, VK_FORMAT_R8G8B8A8_UINT);
data.Note(2, VK_FORMAT_R16G16_UINT); // matching signedness
data.Note(5, VK_FORMAT_R8G8B8A8_SINT);
data.Note(7, VK_FORMAT_R16_SINT); // no shader input
data.Note(31, VK_FORMAT_R16_UINT);
data.Note(40, VK_FORMAT_R8_UINT);
const NarrowIntegerVertexInputs resolved = ResolveNarrowIntegerVertexInputs(inputs, data);
EXPECT_EQ(resolved.zeroExtend8, 1u << 1);
EXPECT_EQ(resolved.zeroExtend16, 1u << 31);
EXPECT_EQ(resolved.signExtend8, 1u << 5);
EXPECT_EQ(resolved.signExtend16, 0u);
// A program without integer inputs resolves to nothing whatever the data is.
EXPECT_FALSE(ResolveNarrowIntegerVertexInputs(IntegerVertexInputMasks{}, data).Any());
}
// The variant's program-cache identity: distinct from the plain program and from every other
// correction set, and a pure function of its inputs.
TEST(NarrowIntegerVertexFormats, VariantHashKeysEveryCorrectionApart) {
constexpr ProgramFactory::HashType kBase = 0x0123456789abcdefull;
const NarrowIntegerVertexInputs sodium = Only(&NarrowIntegerVertexInputs::zeroExtend8, 3);
const auto hash = ProgramFactory::MixNarrowIntegerVertexInputs(kBase, sodium);
EXPECT_NE(hash, kBase);
EXPECT_EQ(ProgramFactory::MixNarrowIntegerVertexInputs(kBase, sodium), hash);
EXPECT_NE(ProgramFactory::MixNarrowIntegerVertexInputs(kBase + 1, sodium), hash);
for (const auto field : {&NarrowIntegerVertexInputs::zeroExtend16, &NarrowIntegerVertexInputs::signExtend8,
&NarrowIntegerVertexInputs::signExtend16})
EXPECT_NE(ProgramFactory::MixNarrowIntegerVertexInputs(kBase, Only(field, 3)), hash);
EXPECT_NE(ProgramFactory::MixNarrowIntegerVertexInputs(kBase, Only(&NarrowIntegerVertexInputs::zeroExtend8, 4)),
hash);
}
// The program half lives on the cache entry, and the cache's robin-hood insert moves entries
// around: a mask left behind by a move would pick another program's variant.
TEST(NarrowIntegerVertexFormats, IntegerInputMasksTravelWithTheProgramEntry) {
ProgramFactory::VkProgramObject source;
source.integerVertexInputs.signedInputs = 1u << 3;
source.integerVertexInputs.unsignedInputs = 1u << 7;
ProgramFactory::VkProgramObject moved(std::move(source));
EXPECT_EQ(moved.integerVertexInputs.signedInputs, 1u << 3);
EXPECT_EQ(moved.integerVertexInputs.unsignedInputs, 1u << 7);
EXPECT_EQ(source.integerVertexInputs.signedInputs, 0u);
EXPECT_EQ(source.integerVertexInputs.unsignedInputs, 0u);
ProgramFactory::VkProgramObject assigned;
assigned = std::move(moved);
EXPECT_EQ(assigned.integerVertexInputs.signedInputs, 1u << 3);
EXPECT_EQ(assigned.integerVertexInputs.unsignedInputs, 1u << 7);
EXPECT_EQ(moved.integerVertexInputs.signedInputs, 0u);
EXPECT_EQ(moved.integerVertexInputs.unsignedInputs, 0u);
}