// MobileGL - MobileGL/MG_Backend/DirectGLES/Utils.cpp
// 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

#include "DirectGLES.h"
#include "Utils.h"
#include "Managers.h"
#include "MG_Backend/BackendObjects.h"
#include "MG_Util/Converters/GLToMG/FramebufferEnumConverter.h"
#include "MG_Util/SelfTest/DriverBugProbes.h"
#include "MG_Util/Texture/TextureFormatProcessor.h"
#include "MG_Util/ShaderTranspiler/ShaderCompiler.h"
#include <Config.h>

#include <MG_State/GLState/Core.h>
#include <MG_Util/BackendLoaders/OpenGL/Loader.h>
#include <MG_Util/Converters/GLToStr/GLEnumConverter.h>
#include <MG_Util/Converters/MGToGL/TextureEnumConverter.h>
#include <MG_Util/Converters/MGToGL/FramebufferEnumConverter.h>
#include <MG_Util/Math/HalfFloat.h>
#include <MG_Util/Math/SmallFloat.h>

#include <algorithm>
#include <cmath>
#include <cctype>
#include <cstring>
#include <format>
#include <regex>

namespace MobileGL::MG_Backend::DirectGLES {
    namespace {
        Flags<PixelFormatNormalizeOptionBit> GetForcedPixelFormatNormalizeOptions() {
            Flags<PixelFormatNormalizeOptionBit> options;
            if (g_GLESCapabilities.IsAngleRenderer) {
                options |= PixelFormatNormalizeOptionBit::NoRgb16;
                options |= PixelFormatNormalizeOptionBit::NoSnorm16;
                options |= PixelFormatNormalizeOptionBit::NoSnorm8;
            }
            return options;
        }

        Flags<PixelFormatNormalizeOptionBit> GetDriverPixelFormatNormalizeOptions() {
            Flags<PixelFormatNormalizeOptionBit> options = PixelFormatNormalizeOptionBit::NoDepthComponent32;
            options |= PixelFormatNormalizeOptionBit::NoRGBA8Snorm;
            options |= PixelFormatNormalizeOptionBit::NoRGB16Snorm;
            if (!g_GLESCapabilities.SupportsNorm16Texture) {
                options |= PixelFormatNormalizeOptionBit::NoNorm16;
            }
            return options;
        }

        Flags<PixelFormatNormalizeOptionBit>
        GetRuntimeFallbackNormalizeOptions(GLenum requestedInternalFormat,
                                           Flags<PixelFormatNormalizeOptionBit> extraOptions) {
            using namespace MG_Util::TextureFormatProcessor;
            const Flags<PixelFormatNormalizeOptionBit> forcedOptions = GetApplicablePixelFormatNormalizeOptions(
                requestedInternalFormat, GetForcedPixelFormatNormalizeOptions() | extraOptions);
            if (forcedOptions) {
                return forcedOptions;
            }
            return GetApplicablePixelFormatNormalizeOptions(
                requestedInternalFormat, GetDriverPixelFormatNormalizeOptions() | extraOptions);
        }

        Bool HasCachedFormatCapability(TextureInternalFormat internalFormat,
                                       SizeT targetIndex,
                                       Bool caveat,
                                       FormatCapability capability) {
            if (!pActiveBackendObject || targetIndex >= kFormatCapabilityTargetCount) {
                return false;
            }
            const SizeT formatIndex = static_cast<SizeT>(internalFormat);
            if (formatIndex >= kFormatCapabilityFormatCount) {
                return false;
            }

            const FormatCapabilityCache& cache = pActiveBackendObject->GetFormatCapabilities();
            const FormatCapabilityFlags caps =
                caveat ? cache.CaveatCaps[targetIndex][formatIndex] : cache.FullCaps[targetIndex][formatIndex];
            return HasFormatCapability(caps, capability);
        }

        Bool HasAnyCachedFormatCapability(TextureInternalFormat internalFormat,
                                          Bool caveat,
                                          FormatCapability capability) {
            for (SizeT targetIndex = 0; targetIndex < kFormatCapabilityTargetCount; ++targetIndex) {
                if (HasCachedFormatCapability(internalFormat, targetIndex, caveat, capability)) {
                    return true;
                }
            }
            return false;
        }

        Bool ShouldUseCaveatFormat(TextureInternalFormat internalFormat, SizeT targetIndex) {
            if (targetIndex < kFormatCapabilityTargetCount) {
                const Bool fullCreatable =
                    HasCachedFormatCapability(internalFormat, targetIndex, false, FormatCapability::Creatable);
                const Bool caveatCreatable =
                    HasCachedFormatCapability(internalFormat, targetIndex, true, FormatCapability::Creatable);
                const Bool fullRenderable =
                    HasCachedFormatCapability(internalFormat, targetIndex, false, FormatCapability::FramebufferRenderable);
                const Bool caveatRenderable =
                    HasCachedFormatCapability(internalFormat, targetIndex, true, FormatCapability::FramebufferRenderable);
                return (!fullCreatable && caveatCreatable) || (!fullRenderable && caveatRenderable);
            }

            if (HasAnyCachedFormatCapability(internalFormat, false, FormatCapability::Creatable)) {
                return false;
            }
            return HasAnyCachedFormatCapability(internalFormat, true, FormatCapability::Creatable);
        }

        void GenerateFormatInfo(TextureInternalFormat internalFormat,
                                SizeT targetIndex,
                                GLenum* outInternalFormat,
                                GLenum* outFormat,
                                GLenum* outType) {
            using namespace MobileGL::MG_Util::TextureFormatProcessor;
            const GLenum requestedInternalFormat = MG_Util::ConvertTextureInternalFormatToGLEnum(internalFormat);
            Flags<PixelFormatNormalizeOptionBit> options;
            if (!pActiveBackendObject || ShouldUseCaveatFormat(internalFormat, targetIndex)) {
                options = GetRuntimeFallbackNormalizeOptions(
                    requestedInternalFormat,
                    TextureImpl::GetRenderTargetNormalizeOptions(g_GLESCapabilities, targetIndex));
            }
            // Outside the caveat branch on purpose: the driver CAN create the native narrow
            // storage - the capability probes say so - it just cannot be trusted as a raw-copy
            // endpoint. Texture and renderbuffer targets both come through here, which is what
            // keeps a renderbuffer -> texture copy of these formats same-ES-format when the
            // widening engages.
            if (TextureImpl::UsesWidenedPacked16NormStorage(internalFormat)) {
                options |= PixelFormatNormalizeOptionBit::WidenPacked16Norm;
            }
            NormalizePixelFormat(requestedInternalFormat, options, outInternalFormat, outFormat, outType);
        }
    } // namespace

    namespace TextureImpl {
        // Every image that can back a colour attachment needs a colour-renderable storage format,
        // and ES has no renderable three-channel format at all: a three-channel float fallback is
        // a legal ES texture but neither legal multisample storage nor a legal attachment, so
        // GL_RGB8_SNORM / GL_RGB16F / ... have to be widened to four channels for any of them.
        // This used to cover the multisample pair alone, on the grounds that only those can never
        // be uploaded to; the transfer paths now expand three-channel client data themselves
        // (Managers.cpp PrepareFallbackUpload) and hide the added alpha again on sample and
        // readback, so the same substitution is available everywhere.
        //
        // The widening only ever *happens* where the driver refuses the native form (see
        // PopulateFormatCapabilitiesImpl: outside multisample storage it rides the driver branch,
        // behind the native probe), so a driver that does render to a three-channel image keeps
        // allocating it byte for byte.
        //
        // Do NOT read that as "nothing changes off-device". Measured on Mesa 26.1.6 llvmpipe
        // (the headless CI driver), an ES 3.2 GL_TEXTURE_2D colour attachment is COMPLETE for
        // GL_RGB8 and GL_RGB16F but INCOMPLETE_ATTACHMENT for GL_RGB8_SNORM, GL_SRGB8 and every
        // RGB integer format, and UNSUPPORTED for GL_RGB32F. Those eight formats therefore DO
        // take the widened path on llvmpipe, which is where the retrace fixtures and the glcts
        // green suites run - the substitution is driver-conditional, not desktop-exempt.
        //
        // A buffer texture is the one image that can never be an attachment; its storage is the
        // buffer object's, and widening it would misdescribe the application's data.
        Bool TargetRequiresRenderableFormat(SizeT targetIndex) {
            if (targetIndex >= kFormatCapabilityTargetCount) {
                return false;
            }
            if (targetIndex == kFormatCapabilityRenderbufferTargetIndex) {
                return true;
            }
            return static_cast<TextureTarget>(targetIndex) != TextureTarget::TextureBuffer;
        }

        Flags<PixelFormatNormalizeOptionBit> GetRenderTargetNormalizeOptions(
            const MG_External::GLESCapabilities& capabilities, SizeT targetIndex) {
            Flags<PixelFormatNormalizeOptionBit> options;
            if (!TargetRequiresRenderableFormat(targetIndex)) {
                return options;
            }
            options |= PixelFormatNormalizeOptionBit::NoThreeChannelRenderTarget;
            if (!capabilities.SupportsRenderSnorm || !capabilities.SupportsNorm16Texture) {
                options |= PixelFormatNormalizeOptionBit::NoSnorm16RenderTarget;
            }
            // 8-bit signed-normalized storage is core ES, so only the rendering half is in
            // question here; the 16-bit bit above additionally needs EXT_texture_norm16 for the
            // encoding to exist at all.
            if (!capabilities.SupportsRenderSnorm) {
                options |= PixelFormatNormalizeOptionBit::NoSnorm8RenderTarget;
            }
            return options;
        }

        Bool UsesWidenedPacked16NormStorage(TextureInternalFormat internalFormat) {
            switch (internalFormat) {
            // TextureInternalFormat::RGB5 is both GL_RGB5 and GL_RGB565 - the GL-to-MG
            // converter folds the two spellings onto one logical format.
            case TextureInternalFormat::RGB5:
            case TextureInternalFormat::RGB5A1:
            case TextureInternalFormat::RGBA4:
                break;
            default:
                return false;
            }
            switch (MG_Config::Features.EsprytWidenPacked16Storage) {
            case MG_Config::QuirkOverride::ForceOn:
                return true;
            case MG_Config::QuirkOverride::ForceOff:
                return false;
            case MG_Config::QuirkOverride::Auto:
                break;
            }
            // Behind the backend gate on purpose: the memoized probe latches its first answer
            // for the whole process, and before the backend is up the GL function table may
            // not be resolved yet - a probe run then would latch "cannot tell" as "clean"
            // forever. Once the backend exists, the first narrow-format image this process
            // creates runs the probe on a live context.
            if (pActiveBackendObject == nullptr) {
                return false;
            }
            return MG_Util::SelfTest::CopyImageMirrorsPacked16FieldOrder(g_GLESFuncs);
        }

        void GenerateTextureFormatInfo(TextureInternalFormat internalFormat, GLenum* outInternalFormat,
                                       GLenum* outFormat, GLenum* outType, TextureTarget target) {
#ifdef TRACY_ENABLE
            ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
#endif
            const SizeT targetIndex =
                target == TextureTarget::Unknown ? kFormatCapabilityTargetCount : GetFormatCapabilityTargetIndex(target);
            GenerateFormatInfo(internalFormat, targetIndex, outInternalFormat, outFormat, outType);
        }

        void GenerateRenderbufferFormatInfo(TextureInternalFormat internalFormat, GLenum* outInternalFormat,
                                            GLenum* outFormat, GLenum* outType) {
#ifdef TRACY_ENABLE
            ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
#endif
            GenerateFormatInfo(internalFormat, GetRenderbufferFormatCapabilityTargetIndex(), outInternalFormat,
                               outFormat, outType);
        }

        Bool ShouldUseCaveatTextureFormat(TextureInternalFormat internalFormat, TextureTarget target) {
            const SizeT targetIndex =
                target == TextureTarget::Unknown ? kFormatCapabilityTargetCount : GetFormatCapabilityTargetIndex(target);
            return ShouldUseCaveatFormat(internalFormat, targetIndex);
        }

        Bool ShouldUseCaveatRenderbufferFormat(TextureInternalFormat internalFormat) {
            return ShouldUseCaveatFormat(internalFormat, GetRenderbufferFormatCapabilityTargetIndex());
        }

        namespace {
            Bool BackendFormatAddsAlpha(TextureInternalFormat internalFormat, SizeT targetIndex) {
                if (!TargetRequiresRenderableFormat(targetIndex)) {
                    return false;
                }
                if (pActiveBackendObject && !ShouldUseCaveatFormat(internalFormat, targetIndex)) {
                    return false;
                }
                const GLenum requestedInternalFormat = MG_Util::ConvertTextureInternalFormatToGLEnum(internalFormat);
                const Flags<PixelFormatNormalizeOptionBit> options = GetRuntimeFallbackNormalizeOptions(
                    requestedInternalFormat, GetRenderTargetNormalizeOptions(g_GLESCapabilities, targetIndex));
                return static_cast<Bool>(options & PixelFormatNormalizeOptionBit::NoThreeChannelRenderTarget);
            }
        } // namespace

        Bool BackendTextureFormatAddsAlpha(TextureInternalFormat internalFormat, TextureTarget target) {
            const SizeT targetIndex =
                target == TextureTarget::Unknown ? kFormatCapabilityTargetCount : GetFormatCapabilityTargetIndex(target);
            return BackendFormatAddsAlpha(internalFormat, targetIndex);
        }

        Bool BackendRenderbufferFormatAddsAlpha(TextureInternalFormat internalFormat) {
            return BackendFormatAddsAlpha(internalFormat, GetRenderbufferFormatCapabilityTargetIndex());
        }

        ImageBindableStorageWidening GetImageBindableStorageWidening(TextureInternalFormat internalFormat) {
            const GLenum requested = MG_Util::ConvertTextureInternalFormatToGLEnum(internalFormat);
            const auto carrier = static_cast<GLenum>(
                MG_Util::ShaderTranspiler::ShaderCompiler::WidenedCoreEsslImageFormat(requested));
            if (carrier == 0) {
                return {};
            }
            // EXACTLY the arming WidenImageFormatsForEssl uses, and it has to be: the shader, the
            // storage and the bind must all widen or none of them may, or the shader addresses a
            // texel size the storage does not have (which every driver tested accepts silently,
            // reading and writing out of bounds).
            //
            // A driver WITH GL_NV_image_formats can spell the narrow format - but only for the
            // formats SPIRV-Cross will actually print. It throws for its is_desktop_only_format
            // set instead of emitting a token, and the throw loses the stage whatever the driver
            // would have accepted: on Mesa, which advertises the extension, `layout(r8ui)
            // uimage2D` still lost its whole program until the widening ran for it too.
            if (g_GLESCapabilities.SupportsExtendedImageFormats &&
                MG_Util::ShaderTranspiler::ShaderCompiler::SpirvCrossCanPrintEsslImageFormat(requested)) {
                return {};
            }
            ImageBindableStorageWidening widening;
            widening.InternalFormat = carrier;
            widening.SourceChannels =
                MG_Util::ShaderTranspiler::ShaderCompiler::ImageFormatChannelCount(requested);
            switch (carrier) {
            case GL_RGBA32UI:
            case GL_RGBA16UI:
            case GL_RGBA8UI:
            case GL_RGBA32I:
            case GL_RGBA16I:
            case GL_RGBA8I:
                widening.IntegerData = true;
                break;
            default:
                widening.IntegerData = false;
                break;
            }
            // The carrier is a core ES format in every case, so it needs no fallback options of
            // its own; this call is only here to spell the transfer pair that describes it.
            MG_Util::TextureFormatProcessor::NormalizePixelFormat(carrier, Flags<PixelFormatNormalizeOptionBit>{},
                                                                  nullptr, &widening.Format, &widening.Type);
            // The two carriers that are not channel widenings, whose transfer pair has to say so.
            // Every other entry keeps the frontend format's own component type - a GL_RG16F shadow
            // is halves and so is its GL_RGBA16F carrier, so padding the channels is the whole
            // conversion. These two shadows are a PACKED 32-bit word per texel
            // (TextureFormatProcessor::NormalizePixelFormat), and no ES driver accepts either
            // packed type for the carrier's level, so the transfer names the carrier's own layout
            // and PrepareImageWidenedUpload splits the word into it.
            switch (internalFormat) {
            case TextureInternalFormat::R11FG11FB10F:
                // GL_UNSIGNED_INT_10F_11F_11F_REV -> GL_RGBA / GL_FLOAT, legal for GL_RGBA16F.
                widening.Format = GL_RGBA;
                widening.Type = GL_FLOAT;
                widening.SourceEncoding = ImageWidenSourceEncoding::PackedFloat11f11f10f;
                break;
            case TextureInternalFormat::RGB10A2UI:
            case TextureInternalFormat::RGB10A2:
                // GL_UNSIGNED_INT_2_10_10_10_REV -> the GL_RGBA_INTEGER / GL_UNSIGNED_SHORT the
                // GL_RGBA16UI carrier already asked for above; only the split is new. The two
                // formats share it: rgb10_a2's channel codes are the same fields rgb10_a2ui's are,
                // and what the shader divides them by is not the transfer's business.
                widening.SourceEncoding = ImageWidenSourceEncoding::PackedInt2101010Rev;
                break;
            default:
                break;
            }
            // The seven normalized formats whose carrier holds CODES rather than values. Both
            // halves of the transfer need to know: a missing alpha is padded with the saturated
            // code rather than the integer 1, and glGetTexImage has to divide the codes back out.
            bool signedNormalized = false;
            Uint32 channelMax[4] = {0u, 0u, 0u, 0u};
            if (MG_Util::ShaderTranspiler::ShaderCompiler::NormalizedImageCarrierCodes(requested, channelMax,
                                                                                       signedNormalized)) {
                for (SizeT channel = 0; channel < 4; ++channel) {
                    widening.ChannelMax[channel] = channelMax[channel];
                }
                widening.SignedNormalized = signedNormalized;
            }
            return widening;
        }

        GLenum GetImageBindableBufferSplitFormat(TextureInternalFormat internalFormat) {
            const GLenum requested = MG_Util::ConvertTextureInternalFormatToGLEnum(internalFormat);
            const auto base = static_cast<GLenum>(
                MG_Util::ShaderTranspiler::ShaderCompiler::SplitCoreEsslBufferImageFormat(requested));
            if (base == 0) {
                return GL_UNKNOWN_MGL;
            }
            // EXACTLY the arming WidenImageFormatsForEssl uses, for the reason the widening's is:
            // the shader, the glTexBuffer view and the glBindImageTexture argument must all split
            // or none of them may, or the shader subscripts a view the buffer is not described as.
            if (g_GLESCapabilities.SupportsExtendedImageFormats &&
                MG_Util::ShaderTranspiler::ShaderCompiler::SpirvCrossCanPrintEsslImageFormat(requested)) {
                return GL_UNKNOWN_MGL;
            }
            return base;
        }
    } // namespace TextureImpl
    namespace PrgramImpl {
        String ProcessOutColorLocations(const String& glslCode) {
#ifdef TRACY_ENABLE
            ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
#endif
            const static std::regex pattern(R"(\n(out highp vec4 outColor)(\d+);)");
            const String replacement = "\nlayout(location=$2) $1$2;";
            return std::regex_replace(glslCode, pattern, replacement);
        }

        String ForceSupporterOutput(const String& glslCode) {
#ifdef TRACY_ENABLE
            ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
#endif
            Bool hasPrecisionFloat =
                glslCode.find("precision ") != String::npos && glslCode.find("float;") != String::npos;
            Bool hasPrecisionInt = glslCode.find("precision ") != String::npos && glslCode.find("int;") != String::npos;

            String result = glslCode;
            String precisionFloat;
            String precisionInt;

            if (hasPrecisionFloat && hasPrecisionInt) {
                std::istringstream iss(result);
                std::vector<String> lines;
                String line;
                while (std::getline(iss, line)) {
                    Bool isPrecisionLine = (line.find("precision ") != String::npos) &&
                                           (line.find("float;") != String::npos || line.find("int;") != String::npos);
                    if (!isPrecisionLine) {
                        lines.push_back(line);
                    }
                }
                result.clear();
                for (SizeT i = 0; i < lines.size(); ++i) {
                    if (i != 0) result += '\n';
                    result += lines[i];
                }
                precisionFloat = "precision highp float;\n";
                precisionInt = "precision highp int;\n";
            } else {
                precisionFloat = hasPrecisionFloat ? "" : "precision highp float;\n";
                precisionInt = hasPrecisionInt ? "" : "precision highp int;\n";
            }

            SizeT lastExtensionPos = result.rfind("#extension");
            SizeT insertionPos = 0;

            if (lastExtensionPos != String::npos) {
                SizeT nextNewline = result.find('\n', lastExtensionPos);
                if (nextNewline != String::npos) {
                    insertionPos = nextNewline + 1;
                } else {
                    insertionPos = result.length();
                }
            } else {
                SizeT firstNewline = result.find('\n');
                if (firstNewline != String::npos) {
                    insertionPos = firstNewline + 1;
                } else {
                    result = precisionFloat + precisionInt + result;
                    return result;
                }
            }

            result.insert(insertionPos, precisionFloat + precisionInt);
            return result;
        }

        String ClampNormFallbackOutputs(String glslCode, GLenum shaderType, Uint32 snormOutputMask,
                                        Uint32 unormOutputMask) {
#ifdef TRACY_ENABLE
            ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
#endif
            const Uint32 outputMask = snormOutputMask | unormOutputMask;
            if (shaderType != GL_FRAGMENT_SHADER || outputMask == 0) {
                return glslCode;
            }

            const std::regex outputPattern(
                R"(layout\s*\(\s*location\s*=\s*([0-9]+)\s*\)\s*out\s+(?:(?:lowp|mediump|highp)\s+)?vec4\s+([A-Za-z_][A-Za-z0-9_]*)\s*;)");
            std::sregex_iterator outputIt(glslCode.begin(), glslCode.end(), outputPattern);
            std::sregex_iterator outputEnd;
            struct OutputClamp {
                String Name;
                Bool Signed;
            };
            Vector<OutputClamp> outputClamps;
            for (; outputIt != outputEnd; ++outputIt) {
                const Uint location = static_cast<Uint>(std::stoul((*outputIt)[1].str()));
                if (location < 32 && (outputMask & (1u << location))) {
                    outputClamps.push_back({(*outputIt)[2].str(), static_cast<Bool>(snormOutputMask & (1u << location))});
                }
            }
            if (outputClamps.empty()) {
                return glslCode;
            }

            const std::regex mainPattern(R"(void\s+main\s*\([^)]*\)\s*\{)");
            std::smatch mainMatch;
            if (!std::regex_search(glslCode, mainMatch, mainPattern)) {
                return glslCode;
            }

            SizeT bracePos = static_cast<SizeT>(mainMatch.position(0) + mainMatch.length(0) - 1);
            Int depth = 0;
            for (SizeT pos = bracePos; pos < glslCode.size(); ++pos) {
                if (glslCode[pos] == '{') {
                    ++depth;
                } else if (glslCode[pos] == '}') {
                    --depth;
                    if (depth == 0) {
                        String clampLine;
                        for (const OutputClamp& outputClamp : outputClamps) {
                            const String minValue = outputClamp.Signed ? "-1.0" : "0.0";
                            clampLine += "\n    " + outputClamp.Name + " = clamp(" + outputClamp.Name +
                                         ", vec4(" + minValue + "), vec4(1.0));";
                        }
                        clampLine += "\n";
                        glslCode.insert(pos, clampLine);
                        return glslCode;
                    }
                }
            }
            return glslCode;
        }

        String BroadcastLegacyFragColor(String glslCode, GLenum shaderType, Uint drawBufferCount) {
#ifdef TRACY_ENABLE
            ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
#endif
            // The name is the marker: ShaderSourceProcessor only emits it when the source
            // wrote gl_FragColor, and such a shader can have no other output.
            static const char* const kLoweredName = "mg_FragColor";
            if (shaderType != GL_FRAGMENT_SHADER || drawBufferCount <= 1) {
                return glslCode;
            }
            static const std::regex declRegex(
                R"(layout\s*\(\s*location\s*=\s*0\s*\)\s*out\s+((?:lowp|mediump|highp)\s+)?vec4\s+mg_FragColor\s*;)");
            std::smatch declMatch;
            if (!std::regex_search(glslCode, declMatch, declRegex)) {
                return glslCode;
            }
            const String precision = declMatch[1].matched ? declMatch[1].str() : String();

            String replicaDecls;
            String replicaCopies;
            for (Uint location = 1; location < drawBufferCount; ++location) {
                const String name = String(kLoweredName) + "_" + std::to_string(location);
                replicaDecls += "\nlayout(location = " + std::to_string(location) + ") out " + precision + "vec4 " +
                                name + ";";
                replicaCopies += "\n    " + name + " = " + kLoweredName + ";";
            }

            static const std::regex mainRegex(R"(void\s+main\s*\([^)]*\)\s*\{)");
            std::smatch mainMatch;
            if (!std::regex_search(glslCode, mainMatch, mainRegex)) {
                return glslCode;
            }
            SizeT bracePos = static_cast<SizeT>(mainMatch.position(0) + mainMatch.length(0) - 1);
            Int depth = 0;
            for (SizeT pos = bracePos; pos < glslCode.size(); ++pos) {
                if (glslCode[pos] == '{') {
                    ++depth;
                } else if (glslCode[pos] == '}') {
                    --depth;
                    if (depth == 0) {
                        glslCode.insert(pos, replicaCopies + "\n");
                        break;
                    }
                }
            }
            glslCode.insert(static_cast<SizeT>(declMatch.position(0)) + declMatch[0].str().size(), replicaDecls);
            return glslCode;
        }

        String ForceFlatIntegerVaryings(const String& glslCode, GLenum shaderType) {
#ifdef TRACY_ENABLE
            ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
#endif
            String result = glslCode;
            const String integerType = R"((?:(?:lowp|mediump|highp)\s+)?(?:u?int|[iu]vec[234])\b)";

            auto addFlatQualifier = [&result, &integerType](const String& qualifier) {
                const std::regex pattern("(layout\\s*\\([^)]*\\)\\s*)(?!(?:flat|smooth|noperspective)\\s)(" +
                                         qualifier + "\\s+" + integerType + ")");
                result = std::regex_replace(result, pattern, "$1flat $2");
            };

            // Every stage that has an integer interface at all, on BOTH sides. Interpolation is
            // only ever consumed at a fragment input, so the qualifier is semantically inert on
            // a tessellation or geometry interface - but an ES linker still compares the two
            // sides of every interface and rejects a program whose producer says `flat` and
            // whose consumer does not. Covering only the stages that "need" it left exactly two
            // holes, and a program that used tessellation fell into both:
            //   vertex `flat out uint` -> tess-control `in uint`   (producer flat, consumer not)
            //   tess-eval `out uint`   -> geometry `flat in uint`  (consumer flat, producer not)
            // Adreno answers "output ... interpolation mismatch with other stage" and the whole
            // program fails to link, which is a draw that silently paints nothing.
            //
            // Adding rather than stripping, because a fragment input's `flat` is load-bearing
            // (ESSL forbids an interpolated integer) and would have to be put back for the last
            // stage before the fragment shader anyway - so "everything integer is flat" is the
            // one rule that is consistent no matter which stages a program happens to have.
            switch (shaderType) {
            case GL_VERTEX_SHADER:
                addFlatQualifier("out");
                break;
            case GL_TESS_CONTROL_SHADER:
            case GL_TESS_EVALUATION_SHADER:
            case GL_GEOMETRY_SHADER:
                addFlatQualifier("in");
                addFlatQualifier("out");
                break;
            case GL_FRAGMENT_SHADER:
                addFlatQualifier("in");
                break;
            default:
                break;
            }

            return result;
        }

        String RetargetTextureBufferExtension(String glslCode,
                                              MG_External::GLESCapabilities::TextureBufferTier tier) {
#ifdef TRACY_ENABLE
            ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
#endif
            // SPIRV-Cross hardcodes the EXT spelling: CompilerGLSL::type_to_glsl emits
            // require_extension_internal("GL_EXT_texture_buffer") for any Dim=Buffer image
            // whenever it targets ESSL below 320, with no OES alternative and no way to
            // configure it. GL_OES_texture_buffer is functionally identical but is a separate
            // directive, and `#extension <name> : require` on a name the driver does not
            // advertise is a hard compile error - so on an OES-only driver the emitted shader
            // fails to compile for the sake of one token.
            //
            // Line comments are excluded by the directive check below; a `#extension` line inside
            // a /* */ block is not, and would be rewritten. That is harmless (it stays a comment)
            // and is not worth a preprocessor-aware scan here.
            //
            // Deliberately a directive rewrite and nothing more. The alternative - teaching the
            // SPIR-V to stop asking for the extension - is not available: the requirement is
            // synthesized by SPIRV-Cross from the image type itself, not carried in the module,
            // so there is nothing upstream to strip. Everything about the shader body that
            // actually uses the buffer texture is identical between the two extensions.
            using Tier = MG_External::GLESCapabilities::TextureBufferTier;
            if (tier != Tier::ExtensionOES) {
                return glslCode;
            }
            static constexpr const char* kExtName = "GL_EXT_texture_buffer";
            static constexpr const char* kOesName = "GL_OES_texture_buffer";
            constexpr SizeT kExtNameLength = 21; // strlen("GL_EXT_texture_buffer")
            static_assert(sizeof("GL_EXT_texture_buffer") - 1 == kExtNameLength, "name length drifted");
            static_assert(sizeof("GL_OES_texture_buffer") - 1 == kExtNameLength,
                          "the two spellings must be the same length for the in-place replace");

            // Only rewrite the name where it is the whole subject of an #extension directive.
            // Two separate guards, both load-bearing:
            //   * the directive check, so a line-comment mentioning the name is left alone;
            //   * the identifier-boundary check, because GL_EXT_texture_buffer is a PREFIX of
            //     GL_EXT_texture_buffer_object - a different, real extension that SPIRV-Cross
            //     emits from the same `case DimBuffer:` on its legacy-desktop branch. Without
            //     the boundary this pass would silently rewrite a request for that extension
            //     into a request for a GL_OES_texture_buffer_object that does not exist.
            const auto isIdentifierChar = [](char c) {
                return std::isalnum(static_cast<unsigned char>(c)) != 0 || c == '_';
            };
            SizeT searchFrom = 0;
            while (true) {
                const SizeT hit = glslCode.find(kExtName, searchFrom);
                if (hit == String::npos) {
                    break;
                }
                searchFrom = hit + kExtNameLength;

                // Identifier boundary on both sides, so the name is not a fragment of a longer one.
                if (hit > 0 && isIdentifierChar(glslCode[hit - 1])) {
                    continue;
                }
                if (hit + kExtNameLength < glslCode.size() && isIdentifierChar(glslCode[hit + kExtNameLength])) {
                    continue;
                }

                // Walk back to the start of the line and require that it is an #extension
                // directive, allowing whitespace between '#' and the keyword.
                SizeT lineStart = glslCode.rfind('\n', hit);
                lineStart = (lineStart == String::npos) ? 0 : lineStart + 1;
                SizeT cursor = lineStart;
                while (cursor < hit && std::isspace(static_cast<unsigned char>(glslCode[cursor]))) {
                    ++cursor;
                }
                if (cursor >= hit || glslCode[cursor] != '#') {
                    continue;
                }
                ++cursor;
                while (cursor < hit && std::isspace(static_cast<unsigned char>(glslCode[cursor]))) {
                    ++cursor;
                }
                if (glslCode.compare(cursor, 9, "extension") != 0) {
                    continue;
                }
                glslCode.replace(hit, kExtNameLength, kOesName);
            }
            return glslCode;
        }

        String RequestExtendedImageFormats(String glslCode, Bool needed) {
#ifdef TRACY_ENABLE
            ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
#endif
            // GLSL ES core has thirteen image formats; GL has forty. SPIRV-Cross prints whatever
            // format the OpTypeImage carries and asks for no extension for it, so an r8ui or
            // rg16f image - declared as such, or baked from the bound one - reaches the driver as
            // a format its core language does not know. GL_NV_image_formats is the only thing
            // that adds them, and it has to be requested by name.
            //
            // The caller decides `needed`: it knows which formats are in play (from the uniform
            // reflection and the image-unit bindings) and whether the driver advertises the
            // extension at all - `#extension` on an unadvertised name is itself a hard error, so
            // this must never be emitted speculatively.
            static constexpr const char* kDirective = "#extension GL_NV_image_formats : require\n";
            static constexpr const char* kExtName = "GL_NV_image_formats";
            if (!needed || glslCode.find(kExtName) != String::npos) {
                return glslCode;
            }
            // After the #version line, which must stay first. Everything else about the header is
            // order-insensitive, and ForceSupporterOutput's scan for the LAST #extension
            // directive still finds whichever one that is.
            const SizeT versionPos = glslCode.find("#version");
            if (versionPos == String::npos) {
                return kDirective + glslCode;
            }
            const SizeT lineEnd = glslCode.find('\n', versionPos);
            if (lineEnd == String::npos) {
                return glslCode + "\n" + kDirective;
            }
            glslCode.insert(lineEnd + 1, kDirective);
            return glslCode;
        }

        String RequestViewportArrayExtension(String glslCode, Bool needed) {
#ifdef TRACY_ENABLE
            ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
#endif
            // gl_ViewportIndex is desktop GL 4.1 core and is in ESSL only under
            // GL_OES_viewport_array. SPIRV-Cross prints the identifier as-is and requests no
            // extension for it - three lines away from the BuiltInLayer case, which DOES ask for
            // one on ES - so an untouched decompile reaches the driver naming a builtin its core
            // language has never heard of. The stage then fails to compile, the program is marked
            // unusable and every draw made with it renders nothing while raising no GL error.
            //
            // Same `needed` contract as RequestExtendedImageFormats, and the same hard rule:
            // `#extension` on a name the driver does not advertise is itself a compile error
            // (ARM's compiler is strict about it), so this must never be emitted speculatively.
            // A driver without the extension does not come through here at all - its module took
            // the LowerViewportIndexPass fallback and the emitted source no longer names the
            // builtin.
            static constexpr const char* kDirective = "#extension GL_OES_viewport_array : require\n";
            static constexpr const char* kExtName = "GL_OES_viewport_array";
            if (!needed || glslCode.find(kExtName) != String::npos) {
                return glslCode;
            }
            // Right after the #version line, for the reason spelled out above: it is the only
            // position that must stay first, and ForceSupporterOutput's scan for the LAST
            // #extension directive still finds whichever one that ends up being.
            const SizeT versionPos = glslCode.find("#version");
            if (versionPos == String::npos) {
                return kDirective + glslCode;
            }
            const SizeT lineEnd = glslCode.find('\n', versionPos);
            if (lineEnd == String::npos) {
                return glslCode + "\n" + kDirective;
            }
            glslCode.insert(lineEnd + 1, kDirective);
            return glslCode;
        }

        const char* PointSizeExtensionName(MG_External::GLESCapabilities::PointSizeTier tier, Bool tessellation) {
            using Tier = MG_External::GLESCapabilities::PointSizeTier;
            switch (tier) {
                case Tier::ExtensionEXT:
                    return tessellation ? "GL_EXT_tessellation_point_size" : "GL_EXT_geometry_point_size";
                case Tier::ExtensionOES:
                    return tessellation ? "GL_OES_tessellation_point_size" : "GL_OES_geometry_point_size";
                default:
                    return nullptr;
            }
        }

        String RequestPointSizeExtension(String glslCode, const char* extensionName) {
#ifdef TRACY_ENABLE
            ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
#endif
            // The gl_ViewportIndex story, one built-in over: ESSL 320 makes the tessellation and
            // geometry STAGES core but leaves gl_PointSize out of their gl_PerVertex entirely,
            // and SPIRV-Cross - which only ever sees a SPIR-V BuiltIn PointSize decoration -
            // prints the identifier with no directive behind it. Same hard rule as the two
            // neighbours: never emitted speculatively, because `#extension` on a name the driver
            // does not advertise is a compile error of its own.
            if (extensionName == nullptr || glslCode.find(extensionName) != String::npos) {
                return glslCode;
            }
            const String directive = String("#extension ") + extensionName + " : require\n";
            // Right after the #version line, the one position that must stay first;
            // ForceSupporterOutput's scan for the LAST #extension directive still finds
            // whichever one that ends up being.
            const SizeT versionPos = glslCode.find("#version");
            if (versionPos == String::npos) {
                return directive + glslCode;
            }
            const SizeT lineEnd = glslCode.find('\n', versionPos);
            if (lineEnd == String::npos) {
                return glslCode + "\n" + directive;
            }
            glslCode.insert(lineEnd + 1, directive);
            return glslCode;
        }

        String BakeImageFormatQualifiers(String glslCode,
                                         const UnorderedMap<String, String>& esslFormatByUniformName) {
#ifdef TRACY_ENABLE
            ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
#endif
            if (esslFormatByUniformName.empty() || glslCode.find("image") == String::npos) {
                return glslCode;
            }
            // Same declaration shape RebindImageUniformsToFrontendUnits matches, and for the same
            // reason: one line, one image uniform, the name in group 3.
            static const std::regex imageDeclRegex(
                R"((layout\s*\(([^)]*)\)\s*)?uniform\s+(?:(?:readonly|writeonly|coherent|volatile|restrict|highp|mediump|lowp)\s+)*[iu]?image[A-Za-z0-9]+\s+([A-Za-z_][A-Za-z0-9_]*)\s*(\[[^\]]*\])?\s*;)");
            // Every image format spelling GLSL has, so a declaration that already carries one is
            // recognised whatever it says - the caller's map is consulted only for declarations
            // with NO format, never to override a written one.
            static const std::regex existingFormatRegex(
                R"(\b(rgba32f|rgba16f|rg32f|rg16f|r11f_g11f_b10f|r32f|r16f|rgba16|rgb10_a2|rg16|rg8|r16|r8|rgba16_snorm|rgba8_snorm|rg16_snorm|rg8_snorm|r16_snorm|r8_snorm|rgba32i|rgba16i|rgba8i|rg32i|rg16i|rg8i|r32i|r16i|r8i|rgba32ui|rgba16ui|rgba8ui|rgb10_a2ui|rg32ui|rg16ui|rg8ui|r32ui|r16ui|r8ui)\b)");

            String result;
            result.reserve(glslCode.size());
            SizeT lineStart = 0;
            while (lineStart <= glslCode.size()) {
                const SizeT lineEnd = glslCode.find('\n', lineStart);
                const Bool lastLine = lineEnd == String::npos;
                String line = glslCode.substr(lineStart, lastLine ? String::npos : lineEnd - lineStart);

                std::smatch match;
                if (std::regex_search(line, match, imageDeclRegex)) {
                    const String name = match[3].str();
                    const auto formatIt = esslFormatByUniformName.find(name);
                    const String layoutContents = match[2].matched ? match[2].str() : String();
                    if (formatIt != esslFormatByUniformName.end() && !formatIt->second.empty() &&
                        !std::regex_search(layoutContents, existingFormatRegex)) {
                        if (match[1].matched) {
                            const SizeT layoutOpen = line.find('(', match.position(1));
                            line.insert(layoutOpen + 1, formatIt->second + ", ");
                        } else {
                            line.insert(match.position(0), "layout(" + formatIt->second + ") ");
                        }
                    }
                }

                result += line;
                if (lastLine) {
                    break;
                }
                result += '\n';
                lineStart = lineEnd + 1;
            }
            return result;
        }

        String RemoveLayoutBinding(const String& glslCode) {
#ifdef TRACY_ENABLE
            ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
#endif
            // Sampler and uniform-block bindings are re-established at draw time through the
            // API, so their layout qualifiers are stripped (they may exceed ES limits). SSBO
            // blocks and image uniforms are different: ES has no glShaderStorageBlockBinding,
            // and image units cannot be set with glUniform1i, so for those declarations the
            // binding qualifier is the only binding mechanism and must be preserved.
            static std::regex bindingRegex(R"(layout\s*\(\s*binding\s*=\s*\d+\s*\)\s*)");
            static std::regex bindingRegex2(R"(layout\s*\(\s*binding\s*=\s*\d+\s*,)");
            static std::regex keepBindingRegex(R"(\b(buffer|[iu]?image[A-Za-z0-9]*)\b)");

            String result;
            result.reserve(glslCode.size());
            SizeT lineStart = 0;
            while (lineStart <= glslCode.size()) {
                SizeT lineEnd = glslCode.find('\n', lineStart);
                const Bool lastLine = lineEnd == String::npos;
                String line = glslCode.substr(lineStart, lastLine ? String::npos : lineEnd - lineStart);

                if (!std::regex_search(line, keepBindingRegex)) {
                    line = std::regex_replace(line, bindingRegex, "");
                    line = std::regex_replace(line, bindingRegex2, "layout(");
                }

                result += line;
                if (lastLine) {
                    break;
                }
                result += '\n';
                lineStart = lineEnd + 1;
            }
            return result;
        }

        std::optional<String> ExtractPerVertexBlockMembers(const String& essl, const Bool input) {
#ifdef TRACY_ENABLE
            ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
#endif
            // Deliberately a scan for the DECLARATION rather than a regex over the whole text:
            // "gl_PerVertex" also appears inside the block's own body in some emissions, and the
            // direction keyword has to be the one immediately preceding the name for the match to
            // mean what this needs it to mean.
            const auto isIdentifierChar = [](char c) {
                return std::isalnum(static_cast<unsigned char>(c)) != 0 || c == '_';
            };
            const String keyword = input ? String("in") : String("out");
            SizeT pos = 0;
            while ((pos = essl.find("gl_PerVertex", pos)) != String::npos) {
                // Walk back over whitespace to the direction keyword.
                SizeT before = pos;
                while (before > 0 && std::isspace(static_cast<unsigned char>(essl[before - 1]))) --before;
                const Bool matches = before >= keyword.size() &&
                                     essl.compare(before - keyword.size(), keyword.size(), keyword) == 0 &&
                                     (before == keyword.size() ||
                                      !isIdentifierChar(essl[before - keyword.size() - 1]));
                if (!matches) {
                    pos += 1;
                    continue;
                }
                const SizeT open = essl.find('{', pos);
                if (open == String::npos) return std::nullopt;
                const SizeT close = essl.find('}', open);
                if (close == String::npos) return std::nullopt;
                return essl.substr(open + 1, close - open - 1);
            }
            return std::nullopt;
        }

        String BuildPassthroughTessControlEssl(const Uint esslVersion, const Uint patchVertices,
                                               const String& inPerVertexMembers,
                                               const String& outPerVertexMembers,
                                               const FloatVec4& defaultOuterLevel,
                                               const FloatVec2& defaultInnerLevel) {
#ifdef TRACY_ENABLE
            ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
#endif
            // Tessellation is core in ES 3.2 and reachable in 3.1 only through
            // GL_EXT_tessellation_shader. The caller has already established that the driver runs
            // the evaluation stage at all, so the only question here is which spelling to use.
            const Bool core = esslVersion >= 320;
            String source = "#version " + std::to_string(core ? 320u : 310u) + " es\n";
            if (!core) {
                source += "#extension GL_EXT_tessellation_shader : require\n";
            }
            source += "precision highp float;\n";
            source += "precision highp int;\n";
            source += "layout(vertices = " + std::to_string(patchVertices) + ") out;\n";
            // Mirrored, never invented. An empty member list means the neighbouring stage did not
            // redeclare the block either, and the driver's own built-in declaration is then what
            // both sides agree on - redeclaring here would be the thing that broke the match.
            if (!inPerVertexMembers.empty()) {
                source += "in gl_PerVertex {" + inPerVertexMembers + "} gl_in[gl_MaxPatchVertices];\n";
            }
            if (!outPerVertexMembers.empty()) {
                source += "out gl_PerVertex {" + outPerVertexMembers + "} gl_out[];\n";
            }
            source += "void main() {\n";
            // Only gl_Position is forwarded. That is the whole of what the pass-through owes the
            // evaluation stage: a program whose evaluation stage reads anything else per-vertex
            // was declined before this was ever called (ModuleReadsLocatedInput), and gl_PointSize
            // from a tessellation stage is a separate capability on both targets.
            source += "    gl_out[gl_InvocationID].gl_Position = gl_in[gl_InvocationID].gl_Position;\n";
            for (Uint i = 0; i < 4; ++i) {
                source += "    gl_TessLevelOuter[" + std::to_string(i) +
                          "] = " + MG_Util::ShaderTranspiler::TessellationLevelLiteral(defaultOuterLevel[i]) + ";\n";
            }
            for (Uint i = 0; i < 2; ++i) {
                source += "    gl_TessLevelInner[" + std::to_string(i) +
                          "] = " + MG_Util::ShaderTranspiler::TessellationLevelLiteral(defaultInnerLevel[i]) + ";\n";
            }
            source += "}\n";
            return source;
        }

        namespace {
            Bool IsImagePassIdentifierChar(char c) {
                return std::isalnum(static_cast<unsigned char>(c)) || c == '_';
            }

            // Occurrences of `identifier` in `code` that are whole identifiers, i.e. not the
            // tail or head of a longer one. "goku" must not find "goku_hd" or "my_goku".
            SizeT CountIdentifierOccurrences(const String& code, const String& identifier) {
                if (identifier.empty()) return 0;
                SizeT count = 0;
                for (SizeT pos = code.find(identifier); pos != String::npos;
                     pos = code.find(identifier, pos + 1)) {
                    if (pos > 0 && IsImagePassIdentifierChar(code[pos - 1])) continue;
                    const SizeT after = pos + identifier.size();
                    if (after < code.size() && IsImagePassIdentifierChar(code[after])) continue;
                    ++count;
                }
                return count;
            }

            Bool ContainsIdentifier(const String& code, const String& identifier) {
                return CountIdentifierOccurrences(code, identifier) > 0;
            }

            // The image format layout qualifiers ESSL accepts (GLSL ES 3.20 4.4.7 table 4.6 -
            // the ES-legal subset of what SPIRV-Cross's format_to_glsl can print). The
            // readonly/writeonly rule only applies to a declaration that carries one of them.
            Bool IsImageFormatQualifier(const String& token) {
                static constexpr StringView FORMATS[] = {
                    "rgba32f",      "rgba16f",      "rg32f",       "rg16f",        "r11f_g11f_b10f",
                    "r32f",         "r16f",         "rgba16",      "rgb10_a2",     "rgba8",
                    "rg16",         "rg8",          "r16",         "r8",           "rgba16_snorm",
                    "rgba8_snorm",  "rg16_snorm",   "rg8_snorm",   "r16_snorm",    "r8_snorm",
                    "rgba32i",      "rgba16i",      "rgba8i",      "rg32i",        "rg16i",
                    "rg8i",         "r32i",         "r16i",        "r8i",          "rgba32ui",
                    "rgba16ui",     "rgb10_a2ui",   "rgba8ui",     "rg32ui",       "rg16ui",
                    "rg8ui",        "r32ui",        "r16ui",       "r8ui",
                };
                for (const StringView format : FORMATS) {
                    if (token == format) return true;
                }
                return false;
            }

            // "Except for image variables qualified with the format qualifiers r32f, r32i, and
            // r32ui, image variables must specify either memory qualifier readonly or the
            // memory qualifier writeonly." (GLSL ES 3.20 4.10)
            Bool IsMemoryQualifierExemptImageFormat(const String& token) {
                return token == "r32f" || token == "r32i" || token == "r32ui";
            }

            // Comma-separated contents of a layout(...) list, each entry trimmed.
            Vector<String> SplitLayoutQualifierList(const String& layout) {
                Vector<String> tokens;
                SizeT start = 0;
                while (start <= layout.size()) {
                    SizeT comma = layout.find(',', start);
                    const Bool last = comma == String::npos;
                    String token = layout.substr(start, last ? String::npos : comma - start);
                    const SizeT first = token.find_first_not_of(" \t\r\n");
                    if (first == String::npos) {
                        token.clear();
                    } else {
                        token = token.substr(first, token.find_last_not_of(" \t\r\n") - first + 1);
                    }
                    if (!token.empty()) tokens.push_back(Move(token));
                    if (last) break;
                    start = comma + 1;
                }
                return tokens;
            }

            // Trims both ends and collapses every internal whitespace run to one space, so a
            // qualifier list or array suffix can be spliced back into a rebuilt declaration
            // whatever the original spacing was.
            String NormalizeDeclarationSpacing(const String& text) {
                String out;
                out.reserve(text.size());
                Bool pendingSpace = false;
                for (const char c : text) {
                    if (std::isspace(static_cast<unsigned char>(c))) {
                        pendingSpace = !out.empty();
                        continue;
                    }
                    if (pendingSpace) out += ' ';
                    pendingSpace = false;
                    out += c;
                }
                return out;
            }

            // How an image builtin touches the image it is handed.
            enum class ImageBuiltinAccess { None, Load, Store, Unknown };

            ImageBuiltinAccess ClassifyImageBuiltin(const String& name) {
                if (name == "imageStore") return ImageBuiltinAccess::Store;
                if (name == "imageLoad") return ImageBuiltinAccess::Load;
                // imageAtomic* both reads and writes, but ES only defines the atomics on
                // r32i/r32ui/r32f images - exactly the formats the rule above exempts - so this
                // pass has already skipped any declaration they can legally appear on. Load is
                // enough to keep the classification total without ever being acted upon.
                if (name.compare(0, 11, "imageAtomic") == 0) return ImageBuiltinAccess::Load;
                if (name == "imageSize" || name == "imageSamples") return ImageBuiltinAccess::None;
                // Some other identifier that starts with "image" and is being called: not a
                // shape this pass can reason about, so it poisons the declaration instead of
                // being guessed at.
                return ImageBuiltinAccess::Unknown;
            }

            struct ImageUniformDecl {
                String name;
                String aliasName;   // the repair-tagged name the rewritten declaration takes; empty
                                    // for a declaration this pass leaves alone
                String writeName;   // the writeonly half's name, when split
                String layout;      // raw contents of layout(...)
                String qualifiers;  // memory/precision qualifiers, normalized, no trailing space
                String type;        // image2D, uimage2DArray, ...
                String arraySuffix; // "" or "[7]"
                SizeT declStart = 0;
                SizeT declLength = 0;
                SizeT nameStart = 0;   // the name token alone, for a rename that edits nothing else
                SizeT nameLength = 0;
                SizeT referenceCount = 0; // uses this pass recognized and accounted for
                Bool loaded = false;
                Bool stored = false;
                Bool unknownUse = false;
                Bool split = false;
                // SPIRV-Cross already tagged this one readonly or writeonly, so it needs no
                // qualifier repair - only the rename that keeps two stages from merging it.
                Bool preTaggedReadonly = false;
                Bool preTaggedWriteonly = false;
            };

            // A rebuilt declaration. Keeps SPIRV-Cross's own word order (`uniform readonly
            // highp image2D`) so the image-rebinding regex in Managers.cpp still matches what
            // comes out of here, whichever order the two passes end up running in.
            //
            // `forceCoherent` is for the SPLIT pair only. GLSL guarantees that a write through
            // one image variable is visible to a read through a DIFFERENT one only when both are
            // declared coherent, and the split turns a same-variable read-after-write - which
            // desktop GLSL orders by construction, so the source almost never says `coherent` -
            // into exactly that cross-variable shape. Without it the driver may serve the load
            // from a cache that never saw the store through the writeonly half.
            String BuildImageDeclaration(const ImageUniformDecl& decl, const char* memoryQualifier,
                                         const String& variableName, Bool forceCoherent = false) {
                String out = "layout(" + decl.layout + ") uniform ";
                if (forceCoherent && !ContainsIdentifier(decl.qualifiers, "coherent")) {
                    out += "coherent ";
                }
                out += memoryQualifier;
                out += ' ';
                if (!decl.qualifiers.empty()) {
                    out += decl.qualifiers;
                    out += ' ';
                }
                out += decl.type;
                out += ' ';
                out += variableName;
                out += decl.arraySuffix;
                out += ';';
                return out;
            }

            // A name for a rewritten declaration that no identifier in the shader (and no other
            // alias already minted for this stage) can collide with.
            String MakeImageAliasName(const String& prefix, const String& name, const String& source,
                                      const Vector<String>& taken) {
                String candidate = prefix + name;
                // "__" anywhere in an identifier is reserved (GLSL ES 3.20 3.7), which a name
                // that already starts with '_' would otherwise produce.
                for (SizeT doubled = candidate.find("__"); doubled != String::npos;
                     doubled = candidate.find("__", doubled)) {
                    candidate.erase(doubled, 1);
                }
                auto isTaken = [&](const String& identifier) {
                    if (ContainsIdentifier(source, identifier)) return true;
                    for (const auto& other : taken) {
                        if (other == identifier) return true;
                    }
                    return false;
                };
                while (isTaken(candidate)) candidate += 'X';
                return candidate;
            }

            struct ImageSourceEdit {
                SizeT start;
                SizeT length;
                String text;
            };

            // The offset just past the `;` that terminates the call whose argument list opens at
            // `openParen`, or npos when what follows is not a plain statement. Parentheses alone
            // are counted: every other bracket a GLSL argument list can contain is balanced
            // inside them, and imageStore returns void, so a well-formed call site is always
            // `imageStore(...);` and anything else is a shape this pass declines to edit.
            SizeT FindEndOfCallStatement(const String& code, SizeT openParen) {
                Int depth = 0;
                SizeT scan = openParen;
                for (; scan < code.size(); ++scan) {
                    if (code[scan] == '(') {
                        ++depth;
                    } else if (code[scan] == ')' && --depth == 0) {
                        break;
                    }
                }
                if (scan >= code.size()) return String::npos;
                const SizeT after = code.find_first_not_of(" \t\r\n", scan + 1);
                if (after == String::npos || code[after] != ';') return String::npos;
                return after + 1;
            }
        } // namespace

        namespace {
            // The digits of an array extent or of an element subscript, or -1 for "not a plain
            // decimal literal".
            //
            // One trailing `u`/`U` is PART of the literal rather than grounds for rejection.
            // SPIRV-Cross prints an index in the type SPIR-V gave it, and
            // LegalizeResourceArrayIndexPass mints its per-element constants in the type of the
            // index it replaced (ConstantLikeIndex reads that index's own type_id), so an image
            // array reached through anything unsigned - `for (uint i = 0u; i < 4u; ++i)`, or any
            // expression on gl_LocalInvocationIndex, which is uint by definition - arrives here
            // spelled `g_image[0u]`. Reading that as "not a literal" declined the array and left
            // it on one layout(binding = N), which hands its elements the consecutive units
            // N, N+1, ... - exactly the silently-wrong-units defect the split exists to remove.
            Int ParseNonNegativeIntLiteral(const String& text) {
                if (text.empty()) return -1;
                SizeT digitCount = text.size();
                if (text[digitCount - 1] == 'u' || text[digitCount - 1] == 'U') --digitCount;
                if (digitCount == 0) return -1;
                Int value = 0;
                for (SizeT i = 0; i < digitCount; ++i) {
                    const char c = text[i];
                    if (c < '0' || c > '9') return -1;
                    value = value * 10 + (c - '0');
                    if (value > 4096) return -1; // no image array is anywhere near this
                }
                return value;
            }
        } // namespace

        String RemapImageArrayElementUnits(const String& glslCode, const Vector<ImageArrayUnitPlan>& plans,
                                           Vector<String>* outDeclined) {
#ifdef TRACY_ENABLE
            ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
#endif
            if (outDeclined != nullptr) outDeclined->clear();
            if (plans.empty() || glslCode.find("image") == String::npos) return glslCode;

            // Same declaration shape as the split pass reads, with the array extent captured.
            static const std::regex imageDeclRegex(
                R"(layout\s*\(([^)]*)\)\s*uniform\s+)"
                R"(((?:(?:readonly|writeonly|coherent|volatile|restrict|highp|mediump|lowp)\s+)*))"
                R"(([iu]?image[A-Za-z0-9_]*)\s+([A-Za-z_][A-Za-z0-9_]*)\s*(?:\[\s*([0-9]*)\s*\])?\s*;)");
            static const std::regex bindingValueRegex(R"(binding\s*=\s*\d+)");

            struct StageImageDecl {
                String name;
                String layout;
                String qualifiers;
                String type;
                Int elementCount = 1;
                SizeT declStart = 0;
                SizeT declLength = 0;
            };
            // Every image declaration in the stage; the plans are program-wide and name arrays
            // this stage may not declare at all.
            Vector<StageImageDecl> decls;
            for (std::sregex_iterator it(glslCode.begin(), glslCode.end(), imageDeclRegex), last; it != last; ++it) {
                const std::smatch& match = *it;
                StageImageDecl decl;
                decl.layout = match[1].str();
                decl.qualifiers = NormalizeDeclarationSpacing(match[2].str());
                decl.type = match[3].str();
                decl.name = match[4].str();
                decl.elementCount = match[5].matched ? ParseNonNegativeIntLiteral(match[5].str()) : 1;
                decl.declStart = static_cast<SizeT>(match.position(0));
                decl.declLength = match[0].str().size();
                decls.push_back(Move(decl));
            }

            Vector<ImageSourceEdit> edits;
            Vector<String> takenNames;
            for (const ImageArrayUnitPlan& plan : plans) {
                const auto decline = [&](const char* why) {
                    if (outDeclined != nullptr) outDeclined->push_back(plan.name + ": " + why);
                };
                if (plan.units.size() < 2) continue;

                const StageImageDecl* decl = nullptr;
                for (const auto& candidate : decls) {
                    if (candidate.name == plan.name) {
                        decl = &candidate;
                        break;
                    }
                }
                if (decl == nullptr) {
                    // Absent from this stage entirely is the normal outcome - the reflection is
                    // program-wide and this pass runs per stage. Named but not RECOGNIZED is not:
                    // it means the declaration is spelled in some shape the regex above does not
                    // read, and staying quiet about that is how the wrong units got shipped.
                    if (ContainsIdentifier(glslCode, plan.name)) {
                        decline("the stage names it but declares it in a shape this pass cannot read");
                    }
                    continue;
                }
                if (decl->elementCount < 0 || static_cast<SizeT>(decl->elementCount) != plan.units.size()) {
                    decline("the emitted array extent disagrees with the reflected element count");
                    continue;
                }

                Bool consecutive = true;
                Bool everyElementHasAUnit = true;
                for (SizeT element = 0; element < plan.units.size(); ++element) {
                    const Int unit = plan.units[element];
                    if (unit < 0) {
                        everyElementHasAUnit = false;
                        break;
                    }
                    if (unit != plan.units[0] + static_cast<Int>(element)) consecutive = false;
                }
                if (!everyElementHasAUnit) {
                    decline("an element has no image unit");
                    continue;
                }
                // Already exactly what ESSL would do on its own. The caller filters these out;
                // repeating the test here keeps the pass correct on its own terms.
                if (consecutive) continue;

                // Every use has to be `name[<literal>]`. The literal is what the split turns
                // into a name, and by the time this runs there is always one:
                // LegalizeResourceArrayIndexingForEssl has already folded or lowered every
                // dynamic image-array subscript in the module, because ESSL forbids one
                // outright ("image arrays indexed with non-constant expressions are forbidden
                // in GLSL ES"). A subscript that is still an expression here is therefore a
                // stage that was never going to compile, and guessing which element it meant
                // would only change which unit it addressed wrongly.
                struct ElementUse {
                    SizeT start;   // the first character of the name
                    SizeT length;  // through the closing ']'
                    SizeT element;
                };
                Vector<ElementUse> uses;
                const char* refusal = nullptr;
                for (SizeT pos = glslCode.find(plan.name); pos != String::npos;
                     pos = glslCode.find(plan.name, pos + 1)) {
                    if (pos > 0 && IsImagePassIdentifierChar(glslCode[pos - 1])) continue;
                    const SizeT after = pos + plan.name.size();
                    if (after < glslCode.size() && IsImagePassIdentifierChar(glslCode[after])) continue;
                    if (pos >= decl->declStart && pos < decl->declStart + decl->declLength) {
                        continue; // the declaration's own name
                    }
                    const SizeT open = glslCode.find_first_not_of(" \t\r\n", after);
                    if (open == String::npos || glslCode[open] != '[') {
                        refusal = "it is reached by something other than a subscript, so there is no "
                                  "element index to rewrite";
                        break;
                    }
                    Int depth = 0;
                    SizeT scan = open;
                    for (; scan < glslCode.size(); ++scan) {
                        if (glslCode[scan] == '[') {
                            ++depth;
                        } else if (glslCode[scan] == ']' && --depth == 0) {
                            break;
                        }
                    }
                    if (scan >= glslCode.size() || open + 1 >= scan) {
                        refusal = "it is reached by something other than a subscript, so there is no "
                                  "element index to rewrite";
                        break;
                    }
                    const Int element = ParseNonNegativeIntLiteral(
                        NormalizeDeclarationSpacing(glslCode.substr(open + 1, scan - open - 1)));
                    if (element < 0 || element >= decl->elementCount) {
                        refusal = "its subscript is not a literal element index, so which unit the "
                                  "access reaches cannot be decided here";
                        break;
                    }
                    uses.push_back({pos, scan + 1 - pos, static_cast<SizeT>(element)});
                }
                if (refusal != nullptr) {
                    decline(refusal);
                    continue;
                }

                // One SCALAR declaration per element, each carrying its own binding. ESSL nails
                // an ARRAY's elements to consecutive units and offers no way to move them, so
                // the only spelling that reaches an arbitrary set of units is one declaration
                // per unit - and with every subscript a literal, every use has exactly one of
                // them to be rewritten to.
                //
                // It costs precisely the image uniforms the application declared, which is why
                // there is no budget test here: an array of four elements becomes four scalars
                // however far apart their units are.
                const SizeT elementCount = plan.units.size();
                Vector<String> elementNames;
                String replacement;
                for (SizeT element = 0; element < elementCount; ++element) {
                    const String elementName =
                        MakeImageAliasName(IMAGE_ARRAY_ELEMENT_PREFIX,
                                           plan.name + "_" + std::to_string(element), glslCode, takenNames);
                    takenNames.push_back(elementName);
                    elementNames.push_back(elementName);

                    String layout = decl->layout;
                    const String bindingText = "binding = " + std::to_string(plan.units[element]);
                    if (std::regex_search(layout, bindingValueRegex)) {
                        layout = std::regex_replace(layout, bindingValueRegex, bindingText);
                    } else {
                        layout = bindingText + (layout.empty() ? String() : ", " + layout);
                    }
                    if (element != 0) replacement += '\n';
                    replacement += "layout(" + layout + ") uniform ";
                    if (!decl->qualifiers.empty()) {
                        replacement += decl->qualifiers;
                        replacement += ' ';
                    }
                    replacement += decl->type + " " + elementName + ";";
                }
                edits.push_back({decl->declStart, decl->declLength, Move(replacement)});

                // `name[k]` -> the scalar declared for element k, subscript and all.
                for (const ElementUse& use : uses) {
                    edits.push_back({use.start, use.length, elementNames[use.element]});
                }
            }
            if (edits.empty()) return glslCode;

            // Back to front, so an earlier edit's offsets stay valid. No two edits overlap: each
            // one covers either a whole declaration or a whole `name[k]`, the declaration's own
            // name is skipped when the uses are collected, and one occurrence of a name yields at
            // most one edit.
            std::sort(edits.begin(), edits.end(),
                      [](const ImageSourceEdit& a, const ImageSourceEdit& b) { return a.start > b.start; });
            String result = glslCode;
            for (const ImageSourceEdit& edit : edits) {
                result.replace(edit.start, edit.length, edit.text);
            }
            return result;
        }

        String SplitReadWriteImageUniforms(const String& glslCode, Uint* outSplitCount) {
#ifdef TRACY_ENABLE
            ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
#endif
            // Written before any early return, so the caller never reads a stale count.
            if (outSplitCount != nullptr) *outSplitCount = 0;
            if (glslCode.find("image") == String::npos) {
                return glslCode;
            }

            // layout(...) uniform <memory/precision qualifiers> <image type> <name>[array];
            // The qualifier alternation is order-free even though SPIRV-Cross emits a fixed
            // order (to_qualifiers_glsl: storage, then coherent/restrict/readonly/writeonly,
            // then precision), and the array group is repeated so a hypothetical multi-
            // dimensional image array survives the round trip intact.
            static const std::regex imageDeclRegex(
                R"(layout\s*\(([^)]*)\)\s*uniform\s+)"
                R"(((?:(?:readonly|writeonly|coherent|volatile|restrict|highp|mediump|lowp)\s+)*))"
                R"(([iu]?image[A-Za-z0-9_]*)\s+([A-Za-z_][A-Za-z0-9_]*)\s*((?:\[[^\]]*\]\s*)*);)");

            Vector<ImageUniformDecl> decls;
            for (std::sregex_iterator it(glslCode.begin(), glslCode.end(), imageDeclRegex), last; it != last; ++it) {
                const std::smatch& match = *it;
                const String qualifiers = match[2].str();
                const Bool hasReadonly = ContainsIdentifier(qualifiers, "readonly");
                const Bool hasWriteonly = ContainsIdentifier(qualifiers, "writeonly");
                // Carrying BOTH is a spelling no per-stage access analysis produces (SPIRV-Cross
                // clears one decoration or the other as soon as it sees a load or a store), so it
                // came from the application and is identical in every stage. Nothing to do.
                if (hasReadonly && hasWriteonly) continue;

                Bool hasFormat = false;
                Bool exemptFormat = false;
                for (const String& token : SplitLayoutQualifierList(match[1].str())) {
                    if (!IsImageFormatQualifier(token)) continue;
                    hasFormat = true;
                    exemptFormat = IsMemoryQualifierExemptImageFormat(token);
                }
                // A declaration carrying neither qualifier is illegal ES unless its format is
                // r32f/r32i/r32ui, and no format qualifier at all is a shape SPIRV-Cross refuses
                // to emit for an ES target. Either way there is no repair to make - and no rename
                // to make either, because a declaration with no access qualifier is spelled the
                // same in every stage.
                if (!hasReadonly && !hasWriteonly && (!hasFormat || exemptFormat)) continue;

                ImageUniformDecl decl;
                decl.layout = match[1].str();
                decl.qualifiers = NormalizeDeclarationSpacing(qualifiers);
                decl.type = match[3].str();
                decl.name = match[4].str();
                decl.arraySuffix = NormalizeDeclarationSpacing(match[5].str());
                decl.declStart = static_cast<SizeT>(match.position(0));
                decl.declLength = match[0].str().size();
                decl.nameStart = static_cast<SizeT>(match.position(4));
                decl.nameLength = match[4].str().size();
                decl.preTaggedReadonly = hasReadonly;
                decl.preTaggedWriteonly = hasWriteonly;
                decls.push_back(Move(decl));
            }
            if (decls.empty()) {
                return glslCode;
            }

            auto findDecl = [&decls](const String& name) -> SizeT {
                for (SizeT i = 0; i < decls.size(); ++i) {
                    if (decls[i].name == name) return i;
                }
                return decls.size();
            };

            // Walk every `image*(` call and attribute its first argument to a declaration.
            // EVERY recognized use is recorded, not only the stores: a declaration this pass
            // renames has to take all of its uses with it, and the "every occurrence was one I
            // saw" check below is what makes the recorded set provably the complete set.
            struct ImageUseSite {
                SizeT declIndex;
                SizeT start;
                SizeT length;
                SizeT callOpen; // the '(' of the call this argument belongs to
                Bool stores;    // an imageStore, i.e. the use a split redirects to the write half
            };
            Vector<ImageUseSite> useSites;
            for (SizeT pos = glslCode.find("image"); pos != String::npos; pos = glslCode.find("image", pos + 1)) {
                if (pos > 0 && IsImagePassIdentifierChar(glslCode[pos - 1])) continue; // uimage2D, myimageFoo
                SizeT tokenEnd = pos;
                while (tokenEnd < glslCode.size() && IsImagePassIdentifierChar(glslCode[tokenEnd])) ++tokenEnd;
                const String builtin = glslCode.substr(pos, tokenEnd - pos);

                const SizeT openParen = glslCode.find_first_not_of(" \t\r\n", tokenEnd);
                if (openParen == String::npos || glslCode[openParen] != '(') continue; // a type, not a call

                const SizeT argStart = glslCode.find_first_not_of(" \t\r\n", openParen + 1);
                if (argStart == String::npos) continue;
                if (!std::isalpha(static_cast<unsigned char>(glslCode[argStart])) && glslCode[argStart] != '_') {
                    continue; // an expression, not a bare variable - it names no image of ours
                }
                SizeT argEnd = argStart;
                while (argEnd < glslCode.size() && IsImagePassIdentifierChar(glslCode[argEnd])) ++argEnd;

                const SizeT declIndex = findDecl(glslCode.substr(argStart, argEnd - argStart));
                if (declIndex == decls.size()) continue;
                ImageUniformDecl& decl = decls[declIndex];
                ++decl.referenceCount;

                // The operand has to be the bare variable, optionally subscripted. Anything
                // else (a member access, a call result) is a shape this pass cannot rewrite.
                SizeT after = glslCode.find_first_not_of(" \t\r\n", argEnd);
                if (after != String::npos && glslCode[after] == '[') {
                    Int depth = 0;
                    SizeT scan = after;
                    for (; scan < glslCode.size(); ++scan) {
                        if (glslCode[scan] == '[') ++depth;
                        else if (glslCode[scan] == ']' && --depth == 0) break;
                    }
                    after = scan >= glslCode.size() ? String::npos
                                                    : glslCode.find_first_not_of(" \t\r\n", scan + 1);
                }
                const char nextChar = after == String::npos ? '\0' : glslCode[after];
                if (nextChar != ',' && nextChar != ')') {
                    decl.unknownUse = true;
                    continue;
                }

                switch (ClassifyImageBuiltin(builtin)) {
                case ImageBuiltinAccess::Load:
                    decl.loaded = true;
                    useSites.push_back({declIndex, argStart, argEnd - argStart, openParen, false});
                    break;
                case ImageBuiltinAccess::Store:
                    decl.stored = true;
                    useSites.push_back({declIndex, argStart, argEnd - argStart, openParen, true});
                    break;
                case ImageBuiltinAccess::None:
                    // imageSize/imageSamples touch nothing, but they still NAME the variable, so
                    // a rename has to reach them.
                    useSites.push_back({declIndex, argStart, argEnd - argStart, openParen, false});
                    break;
                default:
                    decl.unknownUse = true;
                    break;
                }
            }

            // Every mention of the name has to be one this pass saw, or the split would leave
            // a store pointing at the readonly half. One occurrence is the declaration itself.
            for (auto& decl : decls) {
                if (CountIdentifierOccurrences(glslCode, decl.name) != decl.referenceCount + 1) {
                    decl.unknownUse = true;
                }
            }

            Vector<ImageSourceEdit> edits;
            Vector<String> takenNames;
            for (auto& decl : decls) {
                if (decl.unknownUse) continue; // leave it exactly as it was; no guessing
                // EVERY declaration this pass rewrites is also RENAMED, under the prefix of the
                // repair it is about to receive - the qualifier below is a decision about ONE
                // STAGE's accesses, and GLSL requires a uniform declared in two stages to be
                // declared IDENTICALLY (GLSL 4.3 4.3.9 / GLSL ES 3.20 4.3.9). A shader that
                // stores to an image in the vertex stage and loads it in the fragment stage gets
                // `writeonly` on one and `readonly` on the other, and on Adreno the linker merges
                // the two same-named declarations and SILENTLY DISCARDS the vertex-stage stores:
                // no GL error, no link log, LINK_STATUS = 1, and the image still holding its
                // initial contents afterwards
                // (KHR-GL4x.shader_image_load_store.advanced-memory-dependentInvocation, and any
                // shader pack that writes an image in one stage to read it in another).
                //
                // Keyed on the REPAIR and not on the stage, which is what makes the rename
                // exactly as wide as the problem. Two stages that use the image the same way
                // reach the same prefix and emit byte-identical declarations, so they keep ONE
                // shared uniform and there is nothing mismatched to merge; two that use it
                // differently reach different prefixes and cannot be merged at all. Tagging by
                // stage instead also broke the merge - but it broke it for the agreeing stages
                // too, turning one image uniform into one PER STAGE that names it, and Adreno
                // allocates image locations per distinct uniform: the five stages of
                // KHR-GL43.shading_language_420pack.binding_images_texture_type_* went from 6
                // image uniforms to 30 and the link failed outright with "Error: Image Image
                // location or component exceeds max allowed." on an Adreno 830, where Mali and
                // Mesa both accept the same text.
                //
                // Nothing downstream reads these names: the two passes that key on the GL uniform
                // name (RebindImageUniformsToFrontendUnits, BakeImageFormatQualifiers) both run
                // BEFORE this one, RemoveLayoutBinding recognises an image declaration by its TYPE
                // token, and CacheResourceLocations skips image uniforms outright because ES image
                // units come only from layout(binding=N). The declarations this pass LEAVES ALONE -
                // already readonly/writeonly in the source, or r32f/r32i/r32ui, which need no
                // qualifier - keep their names, and they are exactly the ones that already match
                // across stages.
                if (decl.preTaggedReadonly || decl.preTaggedWriteonly) {
                    // No repair: SPIRV-Cross already emitted a legal qualifier. But it derived
                    // that qualifier from THIS STAGE's accesses, so a uniform stored in one stage
                    // and loaded in another arrives here `writeonly` in one and `readonly` in the
                    // other under ONE name - precisely the same-name/mismatched-qualifier pair
                    // Adreno merges while silently discarding the writing stage's stores
                    // (advanced-memory-dependentInvocation; a raw-ES probe reproduces it with no
                    // MobileGL in the process, and renaming either half fixes it). Keyed on the
                    // qualifier for the same reason the repair below is: two stages that agree
                    // spell the same alias and stay merged, so no shader gains an image uniform.
                    const char* preTagPrefix =
                        decl.preTaggedReadonly ? IMAGE_READONLY_ALIAS_PREFIX : IMAGE_WRITEONLY_ALIAS_PREFIX;
                    decl.aliasName = MakeImageAliasName(preTagPrefix, decl.name, glslCode, takenNames);
                    takenNames.push_back(decl.aliasName);
                    // The name token alone: the qualifiers are already right, and re-emitting the
                    // whole declaration would only risk changing them.
                    edits.push_back({decl.nameStart, decl.nameLength, decl.aliasName});
                    continue;
                }

                const char* aliasPrefix = decl.loaded && decl.stored ? IMAGE_SPLIT_READ_ALIAS_PREFIX
                                          : decl.stored             ? IMAGE_WRITEONLY_ALIAS_PREFIX
                                                                    : IMAGE_READONLY_ALIAS_PREFIX;
                decl.aliasName = MakeImageAliasName(aliasPrefix, decl.name, glslCode, takenNames);
                takenNames.push_back(decl.aliasName);
                if (decl.loaded && decl.stored) {
                    // Minted from the ALREADY access-tagged name, so the write half of a split
                    // can never collide with the single declaration another stage's repair mints
                    // for the same image.
                    decl.writeName =
                        MakeImageAliasName(IMAGE_WRITE_ALIAS_PREFIX, decl.aliasName, glslCode, takenNames);
                    takenNames.push_back(decl.writeName);
                    decl.split = true;
                    if (outSplitCount != nullptr) ++*outSplitCount;
                    // Both halves carry `coherent`; see BuildImageDeclaration. The
                    // single-declaration cases below stay as they were - nothing aliases them, so
                    // there is no visibility to restore and no reason to pay for the cache
                    // behaviour.
                    edits.push_back({decl.declStart, decl.declLength,
                                     BuildImageDeclaration(decl, "readonly", decl.aliasName,
                                                           /*forceCoherent=*/true) +
                                         "\n" +
                                         BuildImageDeclaration(decl, "writeonly", decl.writeName,
                                                               /*forceCoherent=*/true)});
                } else if (decl.stored) {
                    edits.push_back({decl.declStart, decl.declLength,
                                     BuildImageDeclaration(decl, "writeonly", decl.aliasName)});
                } else {
                    // Loaded only, or only ever handed to imageSize (or unused): readonly is
                    // the qualifier that keeps every one of those legal.
                    edits.push_back({decl.declStart, decl.declLength,
                                     BuildImageDeclaration(decl, "readonly", decl.aliasName)});
                }
            }
            for (const ImageUseSite& site : useSites) {
                const ImageUniformDecl& decl = decls[site.declIndex];
                // Empty exactly when the declaration was poisoned above and left untouched; its
                // uses must keep naming the variable that is still called that.
                if (decl.aliasName.empty()) continue;
                edits.push_back(
                    {site.start, site.length, decl.split && site.stores ? decl.writeName : decl.aliasName});
                if (!decl.split || !site.stores) continue;
                // ...and an explicit barrier behind it. `coherent` on both halves is what makes
                // the store VISIBLE to a load through the other variable, but it says nothing
                // about ORDER within one invocation - and the whole reason a declaration is split
                // is that the shader both stores and loads through it, which on the ES side is now
                // a write to one variable followed by a read of another the compiler has no reason
                // to believe alias. Adreno duly serves the load from before the store
                // (KHR-GL4x.shader_image_load_store.advanced-memory-order's store/load/compare
                // loop reads back the previous iteration's value). memoryBarrierImage() is the
                // GLSL primitive for exactly that ordering, is core GLSL ES 3.10 in every stage,
                // and is not an execution barrier, so it is legal in non-uniform control flow too.
                //
                // Confined to the split pair: a single-declaration repair has nothing aliasing it
                // and must not pay for this, and a shader that never got split never sees it at
                // all.
                const SizeT statementEnd = FindEndOfCallStatement(glslCode, site.callOpen);
                if (statementEnd != String::npos) {
                    edits.push_back({statementEnd, 0, " memoryBarrierImage();"});
                }
            }
            if (edits.empty()) {
                return glslCode;
            }

            // Back to front, so an earlier edit's offsets stay valid.
            std::sort(edits.begin(), edits.end(),
                      [](const ImageSourceEdit& a, const ImageSourceEdit& b) { return a.start > b.start; });
            String result = glslCode;
            for (const ImageSourceEdit& edit : edits) {
                result.replace(edit.start, edit.length, edit.text);
            }
            return result;
        }

        namespace {
            // How a lookup carries its level of detail, and how many arguments it takes
            // before the optional bias.
            struct LodLookupForm {
                const char* name;
                Int requiredArgs; // arguments before the optional bias (implicit form)
                Int explicitLodArg; // index of the explicit LOD argument, -1 for implicit
            };

            // texelFetch* is deliberately absent: an integer fetch names its level directly
            // and takes no LOD bias. textureGather has no bias either. textureGrad* derives
            // the LOD from gradients and offers no argument to fold a bias into, so it is
            // left alone rather than rewritten incorrectly.
            constexpr LodLookupForm LOD_LOOKUP_FORMS[] = {
                {"textureProjLodOffset", 0, 2}, {"textureProjOffset", 4, -1}, {"textureProjLod", 0, 2},
                {"textureLodOffset", 0, 2},     {"textureOffset", 3, -1},     {"textureProj", 2, -1},
                {"textureLod", 0, 2},           {"texture", 2, -1},
            };

            // Sampler types with no mip chain, or whose GLSL lookups have no bias overload
            // at all (the array-shadow forms), so nothing can or should be folded in.
            Bool IsBiasableSamplerType(const String& samplerType) {
                if (samplerType.find("MS") != String::npos) return false;      // multisample
                if (samplerType.find("Buffer") != String::npos) return false;  // texture buffer
                if (samplerType.find("Rect") != String::npos) return false;    // rectangle: no mips
                if (samplerType == "sampler2DArrayShadow") return false;
                if (samplerType == "samplerCubeArrayShadow") return false;
                return true;
            }

            Bool IsIdentifierChar(char c) { return std::isalnum(static_cast<unsigned char>(c)) || c == '_'; }

            // Byte offsets of the top-level argument separators and of the closing paren,
            // starting from the '(' at openParen. Empty when the parentheses do not balance.
            Vector<SizeT> SplitCallArguments(const String& code, SizeT openParen) {
                Vector<SizeT> marks;
                Int depth = 0;
                for (SizeT i = openParen; i < code.size(); ++i) {
                    const char c = code[i];
                    if (c == '(' || c == '[') {
                        ++depth;
                    } else if (c == ']') {
                        --depth;
                    } else if (c == ')') {
                        --depth;
                        if (depth == 0) {
                            marks.push_back(i);
                            return marks;
                        }
                    } else if (c == ',' && depth == 1) {
                        marks.push_back(i);
                    }
                }
                return {};
            }
        } // namespace

        String EmulateTextureLodBias(const String& glslCode, Bool avoidExplicitLodBias) {
#ifdef TRACY_ENABLE
            ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
#endif
            if (glslCode.find("sampler") == String::npos || glslCode.find("texture") == String::npos) {
                return glslCode;
            }

            // Collect the mip-capable sampler uniforms this shader declares.
            static const std::regex samplerDeclRegex(
                R"(uniform\s+(?:(?:highp|mediump|lowp)\s+)?([iu]?sampler[A-Za-z0-9]*)\s+([A-Za-z_][A-Za-z0-9_]*)\s*;)");
            UnorderedMap<String, String> samplerNames; // name -> bias uniform name
            for (std::sregex_iterator it(glslCode.begin(), glslCode.end(), samplerDeclRegex), end; it != end; ++it) {
                const String samplerType = (*it)[1].str();
                if (!IsBiasableSamplerType(samplerType)) continue;
                const String name = (*it)[2].str();
                samplerNames.emplace(name, String(LOD_BIAS_UNIFORM_PREFIX) + name);
            }
            if (samplerNames.empty()) {
                return glslCode;
            }

            // Rewrite the lookups. Right-to-left so earlier offsets stay valid, and only for
            // samplers named directly as the first argument (SPIRV-Cross never produces an
            // expression there for ES output, which has no separate sampler objects).
            String result = glslCode;
            Vector<String> usedSamplers;
            for (SizeT scan = result.size(); scan-- > 0;) {
                if (result[scan] != 't') continue;
                if (scan > 0 && IsIdentifierChar(result[scan - 1])) continue;

                const LodLookupForm* form = nullptr;
                SizeT openParen = 0;
                for (const auto& candidate : LOD_LOOKUP_FORMS) {
                    const SizeT nameLength = std::strlen(candidate.name);
                    if (result.compare(scan, nameLength, candidate.name) != 0) continue;
                    SizeT after = result.find_first_not_of(" \t", scan + nameLength);
                    if (after == String::npos || result[after] != '(') continue;
                    form = &candidate;
                    openParen = after;
                    break;
                }
                if (form == nullptr) continue;

                const Vector<SizeT> marks = SplitCallArguments(result, openParen);
                if (marks.empty()) continue;
                const SizeT argCount = marks.size();
                const SizeT closeParen = marks.back();

                // First argument must be one of our samplers.
                const SizeT firstArgStart = result.find_first_not_of(" \t", openParen + 1);
                SizeT firstArgEnd = marks.front();
                while (firstArgEnd > firstArgStart && (result[firstArgEnd - 1] == ' ' || result[firstArgEnd - 1] == '\t')) {
                    --firstArgEnd;
                }
                if (firstArgStart == String::npos || firstArgEnd <= firstArgStart) continue;
                const String samplerName = result.substr(firstArgStart, firstArgEnd - firstArgStart);
                const auto samplerIt = samplerNames.find(samplerName);
                if (samplerIt == samplerNames.end()) continue;

                const String& biasName = samplerIt->second;
                if (form->explicitLodArg >= 0 && avoidExplicitLodBias) {
                    // The lookup already names its level; leaving it alone keeps a constant
                    // LOD constant. Costs the bias on explicit-LOD lookups only.
                    continue;
                }
                if (form->explicitLodArg >= 0) {
                    // Explicit LOD: the bias adds to it, as Vulkan does for
                    // OpImageSampleExplicitLod and as the CTS reference expects.
                    const SizeT lodIndex = static_cast<SizeT>(form->explicitLodArg);
                    if (argCount <= lodIndex) continue;
                    const SizeT lodStart = marks[lodIndex - 1] + 1;
                    const SizeT lodEnd = marks[lodIndex];
                    result.insert(lodEnd, String(") + ") + biasName + ")");
                    result.insert(lodStart, "((");
                } else {
                    const SizeT required = static_cast<SizeT>(form->requiredArgs);
                    if (argCount == required) {
                        result.insert(closeParen, String(", ") + biasName);
                    } else if (argCount == required + 1) {
                        const SizeT biasStart = marks[argCount - 2] + 1;
                        result.insert(closeParen, String(") + ") + biasName + ")");
                        result.insert(biasStart, "((");
                    } else {
                        continue;
                    }
                }
                usedSamplers.push_back(samplerName);
            }
            if (usedSamplers.empty()) {
                return glslCode;
            }

            // Declare the bias uniforms that were actually referenced, right after the
            // sampler declaration line they belong to.
            for (const auto& samplerName : usedSamplers) {
                const String& biasName = samplerNames[samplerName];
                if (result.find(String("float ") + biasName + ";") != String::npos) continue;
                const std::regex declRegex(
                    R"(uniform\s+(?:(?:highp|mediump|lowp)\s+)?[iu]?sampler[A-Za-z0-9]*\s+)" + samplerName + R"(\s*;)");
                std::smatch match;
                if (!std::regex_search(result, match, declRegex)) continue;
                const SizeT declEnd = static_cast<SizeT>(match.position(0)) + match[0].str().size();
                result.insert(declEnd, String("\nuniform highp float ") + biasName + ";");
            }
            return result;
        }

    } // namespace PrgramImpl

    namespace Utils {
        void CheckGLESError() {
#ifdef TRACY_ENABLE
            ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
#endif
            for (GLenum err = g_GLESFuncs.glGetError(); err != GL_NO_ERROR; err = g_GLESFuncs.glGetError()) {
                MGLOG_D("-> GLES Error: %s", MG_Util::ConvertGLEnumToString(err).c_str());
            }
        }

        GLenum GetBindingQuery(GLenum target, bool isTexture) {
#ifdef TRACY_ENABLE
            ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
#endif
            switch (target) {
            case GL_TEXTURE_BUFFER:
                return isTexture ? GL_TEXTURE_BINDING_BUFFER : GL_TEXTURE_BUFFER_BINDING;

            case GL_ARRAY_BUFFER:
                return GL_ARRAY_BUFFER_BINDING;
            case GL_ATOMIC_COUNTER_BUFFER:
                return GL_ATOMIC_COUNTER_BUFFER_BINDING;
            case GL_COPY_READ_BUFFER:
                return GL_COPY_READ_BUFFER_BINDING;
            case GL_COPY_WRITE_BUFFER:
                return GL_COPY_WRITE_BUFFER_BINDING;
            case GL_DISPATCH_INDIRECT_BUFFER:
                return GL_DISPATCH_INDIRECT_BUFFER_BINDING;
            case GL_DRAW_INDIRECT_BUFFER:
                return GL_DRAW_INDIRECT_BUFFER_BINDING;
            case GL_ELEMENT_ARRAY_BUFFER:
                return GL_ELEMENT_ARRAY_BUFFER_BINDING;
            case GL_PIXEL_PACK_BUFFER:
                return GL_PIXEL_PACK_BUFFER_BINDING;
            case GL_PIXEL_UNPACK_BUFFER:
                return GL_PIXEL_UNPACK_BUFFER_BINDING;
            case GL_QUERY_BUFFER:
                return GL_QUERY_BUFFER_BINDING;
            case GL_SHADER_STORAGE_BUFFER:
                return GL_SHADER_STORAGE_BUFFER_BINDING;
            case GL_TRANSFORM_FEEDBACK_BUFFER:
                return GL_TRANSFORM_FEEDBACK_BUFFER_BINDING;
            case GL_UNIFORM_BUFFER:
                return GL_UNIFORM_BUFFER_BINDING;

            case GL_FRAMEBUFFER:
            case GL_DRAW_FRAMEBUFFER:
                return GL_DRAW_FRAMEBUFFER_BINDING;
            case GL_READ_FRAMEBUFFER:
                return GL_READ_FRAMEBUFFER_BINDING;

            case GL_RENDERBUFFER:
                return GL_RENDERBUFFER_BINDING;

            case GL_VERTEX_ARRAY:
            case GL_VERTEX_ARRAY_BINDING:
                return GL_VERTEX_ARRAY_BINDING;

            case GL_PROGRAM_PIPELINE:
                return GL_PROGRAM_PIPELINE_BINDING;

            case GL_PROGRAM:
                return GL_CURRENT_PROGRAM;

            case GL_SAMPLER:
                return GL_SAMPLER_BINDING;

            case GL_TEXTURE:
                return GL_TEXTURE_BINDING_2D;
            case GL_TEXTURE_1D:
                return GL_TEXTURE_BINDING_1D;
            case GL_TEXTURE_1D_ARRAY:
                return GL_TEXTURE_BINDING_1D_ARRAY;
            case GL_TEXTURE_2D:
                return GL_TEXTURE_BINDING_2D;
            case GL_TEXTURE_2D_ARRAY:
                return GL_TEXTURE_BINDING_2D_ARRAY;
            case GL_TEXTURE_2D_MULTISAMPLE:
                return GL_TEXTURE_BINDING_2D_MULTISAMPLE;
            case GL_TEXTURE_2D_MULTISAMPLE_ARRAY:
                return GL_TEXTURE_BINDING_2D_MULTISAMPLE_ARRAY;
            case GL_TEXTURE_3D:
                return GL_TEXTURE_BINDING_3D;
            case GL_TEXTURE_CUBE_MAP:
                return GL_TEXTURE_BINDING_CUBE_MAP;
            case GL_TEXTURE_CUBE_MAP_ARRAY:
                return GL_TEXTURE_BINDING_CUBE_MAP_ARRAY;
            case GL_TEXTURE_RECTANGLE:
                return GL_TEXTURE_BINDING_RECTANGLE;

            case GL_TRANSFORM_FEEDBACK:
                return GL_TRANSFORM_FEEDBACK_BINDING;

            case GL_SAMPLES_PASSED:
                return GL_SAMPLES_PASSED;
            case GL_PRIMITIVES_GENERATED:
                return GL_PRIMITIVES_GENERATED;

            case GL_DEBUG_OUTPUT:
                return GL_DEBUG_OUTPUT;
            case GL_DEBUG_OUTPUT_SYNCHRONOUS:
                return GL_DEBUG_OUTPUT_SYNCHRONOUS;

            default:
                return 0;
            }
        }
    } // namespace Utils

    // ---- Client-format readback conversion helpers -------------------------------------------------
    // ReadPixels/GetTexImage read a guaranteed wide RGBA(_INTEGER) layout from the ES driver and repack
    // it on the CPU into the client's (format, type) layout. Everything here is pure byte shuffling so
    // unit tests can assert the exact packed words; field positions follow GL 3.3 table 3.6 and mirror
    // the GL CTS packed_pixels oracle (glcPackedPixelsTests.cpp pack_UNSIGNED_* helpers).
    namespace ReadbackImpl {
        using MG_Util::DecodeHalfBitsToFloat;
        using MG_Util::EncodeFloatToHalfBits;

        Bool GetReadbackChannelMapping(GLenum format, ReadbackChannelMapping& outMapping) {
            switch (format) {
            case GL_RED:          outMapping = {{0, 0, 0, 0}, 1, false}; return true;
            case GL_RED_INTEGER:  outMapping = {{0, 0, 0, 0}, 1, true};  return true;
            // Desktop-GL single-channel client formats (GL CTS packed_pixels rgba8_format_green/blue):
            // the destination holds one component sourced from the named channel of the wide RGBA read.
            // GL_ALPHA is mapped here from the raw enum because the state layer folds it into Red for the
            // legacy alpha-texture upload hack.
            case GL_GREEN:         outMapping = {{1, 0, 0, 0}, 1, false}; return true;
            case GL_GREEN_INTEGER: outMapping = {{1, 0, 0, 0}, 1, true};  return true;
            case GL_BLUE:          outMapping = {{2, 0, 0, 0}, 1, false}; return true;
            case GL_BLUE_INTEGER:  outMapping = {{2, 0, 0, 0}, 1, true};  return true;
            case GL_ALPHA:         outMapping = {{3, 0, 0, 0}, 1, false}; return true;
            case GL_ALPHA_INTEGER: outMapping = {{3, 0, 0, 0}, 1, true};  return true;
            case GL_RG:           outMapping = {{0, 1, 0, 0}, 2, false}; return true;
            case GL_RG_INTEGER:   outMapping = {{0, 1, 0, 0}, 2, true};  return true;
            case GL_RGB:          outMapping = {{0, 1, 2, 0}, 3, false}; return true;
            case GL_RGB_INTEGER:  outMapping = {{0, 1, 2, 0}, 3, true};  return true;
            case GL_BGR:          outMapping = {{2, 1, 0, 0}, 3, false}; return true;
            case GL_BGR_INTEGER:  outMapping = {{2, 1, 0, 0}, 3, true};  return true;
            case GL_RGBA:         outMapping = {{0, 1, 2, 3}, 4, false}; return true;
            case GL_RGBA_INTEGER: outMapping = {{0, 1, 2, 3}, 4, true};  return true;
            case GL_BGRA:         outMapping = {{2, 1, 0, 3}, 4, false}; return true;
            case GL_BGRA_INTEGER: outMapping = {{2, 1, 0, 3}, 4, true};  return true;
            default:
                return false;
            }
        }

        Bool GetPackedReadbackLayout(GLenum type, PackedReadbackLayout& out) {
            switch (type) {
            // Non-REV types pack the first format component starting at the most significant bit,
            // *_REV types starting at the least significant bit (GL CTS pack_UNSIGNED_SHORT_5_6_5:
            // R bits 15-11; pack_UNSIGNED_SHORT_1_5_5_5_REV: R bits 4-0, A bit 15).
            case GL_UNSIGNED_BYTE_3_3_2:          out = {3, {3, 3, 2, 0},    {5, 2, 0, 0},    1, false}; return true;
            case GL_UNSIGNED_BYTE_2_3_3_REV:      out = {3, {3, 3, 2, 0},    {0, 3, 6, 0},    1, false}; return true;
            case GL_UNSIGNED_SHORT_5_6_5:         out = {3, {5, 6, 5, 0},    {11, 5, 0, 0},   2, false}; return true;
            case GL_UNSIGNED_SHORT_5_6_5_REV:     out = {3, {5, 6, 5, 0},    {0, 5, 11, 0},   2, false}; return true;
            case GL_UNSIGNED_SHORT_4_4_4_4:       out = {4, {4, 4, 4, 4},    {12, 8, 4, 0},   2, false}; return true;
            case GL_UNSIGNED_SHORT_4_4_4_4_REV:   out = {4, {4, 4, 4, 4},    {0, 4, 8, 12},   2, false}; return true;
            case GL_UNSIGNED_SHORT_5_5_5_1:       out = {4, {5, 5, 5, 1},    {11, 6, 1, 0},   2, false}; return true;
            case GL_UNSIGNED_SHORT_1_5_5_5_REV:   out = {4, {5, 5, 5, 1},    {0, 5, 10, 15},  2, false}; return true;
            case GL_UNSIGNED_INT_8_8_8_8:         out = {4, {8, 8, 8, 8},    {24, 16, 8, 0},  4, false}; return true;
            case GL_UNSIGNED_INT_8_8_8_8_REV:     out = {4, {8, 8, 8, 8},    {0, 8, 16, 24},  4, false}; return true;
            case GL_UNSIGNED_INT_10_10_10_2:      out = {4, {10, 10, 10, 2}, {22, 12, 2, 0},  4, false}; return true;
            case GL_UNSIGNED_INT_2_10_10_10_REV:  out = {4, {10, 10, 10, 2}, {0, 10, 20, 30}, 4, false}; return true;
            // Packed-float RGB types: fields hold unsigned small floats; 5_9_9_9_REV's shared 5-bit
            // exponent (bits 31-27) is emitted by EncodeSharedExponentRGB9E5, not a component field.
            case GL_UNSIGNED_INT_10F_11F_11F_REV: out = {3, {11, 11, 10, 0}, {0, 11, 22, 0},  4, true};  return true;
            case GL_UNSIGNED_INT_5_9_9_9_REV:     out = {3, {9, 9, 9, 0},    {0, 9, 18, 0},   4, true};  return true;
            default:
                return false;
            }
        }

        SizeT GetReadbackComponentSize(GLenum type) {
            PackedReadbackLayout packedLayout{};
            if (GetPackedReadbackLayout(type, packedLayout)) {
                return packedLayout.byteSize;
            }
            switch (type) {
            case GL_UNSIGNED_BYTE:
            case GL_BYTE:
                return 1;
            case GL_UNSIGNED_SHORT:
            case GL_SHORT:
            case GL_HALF_FLOAT:
                return 2;
            case GL_UNSIGNED_INT:
            case GL_INT:
            case GL_FLOAT:
                return 4;
            default:
                return 0;
            }
        }

        SizeT GetReadbackDstPixelSize(const ReadbackChannelMapping& mapping, GLenum type) {
            PackedReadbackLayout packedLayout{};
            if (GetPackedReadbackLayout(type, packedLayout)) {
                if (packedLayout.fieldCount != mapping.channelCount) {
                    return 0; // 3-field packed types pair with 3-component formats only, 4 with 4
                }
                if (mapping.isInteger && packedLayout.isFloatPacked) {
                    return 0; // packed-float RGB types never pair with integer formats
                }
                return packedLayout.byteSize;
            }
            if (mapping.isInteger && (type == GL_FLOAT || type == GL_HALF_FLOAT)) {
                return 0;
            }
            const SizeT componentSize = GetReadbackComponentSize(type);
            return componentSize == 0 ? 0 : static_cast<SizeT>(mapping.channelCount) * componentSize;
        }

        namespace {
            void WritePackedReadbackWord(Uint8* dst, Uint32 word, SizeT byteSize) {
                switch (byteSize) {
                case 1: {
                    const auto out = static_cast<Uint8>(word);
                    Memcpy(dst, &out, sizeof(out));
                    break;
                }
                case 2: {
                    const auto out = static_cast<Uint16>(word);
                    Memcpy(dst, &out, sizeof(out));
                    break;
                }
                default:
                    Memcpy(dst, &word, sizeof(word));
                    break;
                }
            }
        } // namespace

        // Shared encoders live in MG_Util/Math/SmallFloat.h so the upload conversion
        // (PixelStoreProcessor) uses byte-identical packing; kept exported here for unit tests.
        Uint32 EncodeFloatToUnsignedF11(Float value) { return MG_Util::EncodeFloatToUnsignedF11(value); }
        Uint32 EncodeFloatToUnsignedF10(Float value) { return MG_Util::EncodeFloatToUnsignedF10(value); }
        Uint32 EncodeSharedExponentRGB9E5(const Float rgb[3]) { return MG_Util::EncodeSharedExponentRGB9E5(rgb); }

        void ConvertWideReadbackRow(const Uint8* src, Uint8* dst, SizeT width, GLenum wideType,
                                    const ReadbackChannelMapping& mapping, GLenum type) {
            PackedReadbackLayout packedLayout{};
            const Bool isPacked = GetPackedReadbackLayout(type, packedLayout);
            const SizeT dstComponentSize = GetReadbackComponentSize(type);
            const SizeT dstPixelBytes = GetReadbackDstPixelSize(mapping, type);
            const SizeT srcPixelBytes = 4 * GetReadbackComponentSize(wideType);

            for (SizeT col = 0; col < width; ++col) {
                const Uint8* srcPixel = src + col * srcPixelBytes;
                Uint8* dstPixel = dst + col * dstPixelBytes;
                if (mapping.isInteger) {
                    Int64 srcValues[4];
                    for (Int c = 0; c < 4; ++c) {
                        srcValues[c] = wideType == GL_INT
                                           ? static_cast<Int64>(reinterpret_cast<const Int32*>(srcPixel)[c])
                                           : static_cast<Int64>(reinterpret_cast<const Uint32*>(srcPixel)[c]);
                    }
                    if (isPacked) {
                        // Integer sources clamp each component to the unsigned range of its field
                        // (GL 3.3 section 4.3.1 final conversion).
                        Uint32 word = 0;
                        for (Int ch = 0; ch < packedLayout.fieldCount; ++ch) {
                            const Int64 fieldMax = (Int64{1} << packedLayout.width[ch]) - 1;
                            const auto v = static_cast<Uint32>(
                                std::clamp<Int64>(srcValues[mapping.sourceChannel[ch]], 0, fieldMax));
                            word |= v << packedLayout.shift[ch];
                        }
                        WritePackedReadbackWord(dstPixel, word, packedLayout.byteSize);
                    } else {
                        for (Int ch = 0; ch < mapping.channelCount; ++ch) {
                            const Int64 v = srcValues[mapping.sourceChannel[ch]];
                            Uint8* dstComponent = dstPixel + static_cast<SizeT>(ch) * dstComponentSize;
                            switch (type) {
                            case GL_UNSIGNED_BYTE:
                                *dstComponent = static_cast<Uint8>(std::clamp<Int64>(v, 0, 255));
                                break;
                            case GL_BYTE: {
                                const auto out = static_cast<Int8>(std::clamp<Int64>(v, -128, 127));
                                Memcpy(dstComponent, &out, sizeof(out));
                                break;
                            }
                            case GL_UNSIGNED_SHORT: {
                                const auto out = static_cast<Uint16>(std::clamp<Int64>(v, 0, 65535));
                                Memcpy(dstComponent, &out, sizeof(out));
                                break;
                            }
                            case GL_SHORT: {
                                const auto out = static_cast<Int16>(std::clamp<Int64>(v, -32768, 32767));
                                Memcpy(dstComponent, &out, sizeof(out));
                                break;
                            }
                            case GL_UNSIGNED_INT: {
                                const auto out = static_cast<Uint32>(std::clamp<Int64>(v, 0, 4294967295LL));
                                Memcpy(dstComponent, &out, sizeof(out));
                                break;
                            }
                            case GL_INT: {
                                const auto out =
                                    static_cast<Int32>(std::clamp<Int64>(v, -2147483648LL, 2147483647LL));
                                Memcpy(dstComponent, &out, sizeof(out));
                                break;
                            }
                            default:
                                break;
                            }
                        }
                    }
                } else {
                    Float srcValues[4];
                    switch (wideType) {
                    case GL_UNSIGNED_BYTE:
                        for (Int c = 0; c < 4; ++c) {
                            srcValues[c] = static_cast<Float>(srcPixel[c]) / 255.0f;
                        }
                        break;
                    case GL_BYTE:
                        for (Int c = 0; c < 4; ++c) {
                            srcValues[c] = std::max(
                                static_cast<Float>(reinterpret_cast<const Int8*>(srcPixel)[c]) / 127.0f, -1.0f);
                        }
                        break;
                    case GL_UNSIGNED_SHORT:
                        for (Int c = 0; c < 4; ++c) {
                            srcValues[c] =
                                static_cast<Float>(reinterpret_cast<const Uint16*>(srcPixel)[c]) / 65535.0f;
                        }
                        break;
                    case GL_SHORT:
                        for (Int c = 0; c < 4; ++c) {
                            srcValues[c] = std::max(
                                static_cast<Float>(reinterpret_cast<const Int16*>(srcPixel)[c]) / 32767.0f, -1.0f);
                        }
                        break;
                    case GL_HALF_FLOAT:
                        for (Int c = 0; c < 4; ++c) {
                            srcValues[c] = DecodeHalfBitsToFloat(reinterpret_cast<const Uint16*>(srcPixel)[c]);
                        }
                        break;
                    default: // GL_FLOAT
                        for (Int c = 0; c < 4; ++c) {
                            srcValues[c] = reinterpret_cast<const Float*>(srcPixel)[c];
                        }
                        break;
                    }
                    if (isPacked) {
                        Uint32 word = 0;
                        if (packedLayout.isFloatPacked) {
                            const Float fields[3] = {srcValues[mapping.sourceChannel[0]],
                                                     srcValues[mapping.sourceChannel[1]],
                                                     srcValues[mapping.sourceChannel[2]]};
                            word = type == GL_UNSIGNED_INT_5_9_9_9_REV
                                       ? EncodeSharedExponentRGB9E5(fields)
                                       : (EncodeFloatToUnsignedF11(fields[0]) << packedLayout.shift[0]) |
                                             (EncodeFloatToUnsignedF11(fields[1]) << packedLayout.shift[1]) |
                                             (EncodeFloatToUnsignedF10(fields[2]) << packedLayout.shift[2]);
                        } else {
                            // Normalized encode: round(clamp(v, 0, 1) * (2^bits - 1)) into each field.
                            for (Int ch = 0; ch < packedLayout.fieldCount; ++ch) {
                                const auto fieldMax = static_cast<Float>((1u << packedLayout.width[ch]) - 1u);
                                const auto v = static_cast<Uint32>(std::llround(
                                    std::clamp(srcValues[mapping.sourceChannel[ch]], 0.0f, 1.0f) * fieldMax));
                                word |= v << packedLayout.shift[ch];
                            }
                        }
                        WritePackedReadbackWord(dstPixel, word, packedLayout.byteSize);
                    } else {
                        for (Int ch = 0; ch < mapping.channelCount; ++ch) {
                            const Float v = srcValues[mapping.sourceChannel[ch]];
                            Uint8* dstComponent = dstPixel + static_cast<SizeT>(ch) * dstComponentSize;
                            switch (type) {
                            case GL_UNSIGNED_BYTE:
                                *dstComponent =
                                    static_cast<Uint8>(std::llround(std::clamp(v, 0.0f, 1.0f) * 255.0));
                                break;
                            case GL_BYTE: {
                                const auto out =
                                    static_cast<Int8>(std::llround(std::clamp(v, -1.0f, 1.0f) * 127.0));
                                Memcpy(dstComponent, &out, sizeof(out));
                                break;
                            }
                            case GL_UNSIGNED_SHORT: {
                                const auto out =
                                    static_cast<Uint16>(std::llround(std::clamp(v, 0.0f, 1.0f) * 65535.0));
                                Memcpy(dstComponent, &out, sizeof(out));
                                break;
                            }
                            case GL_SHORT: {
                                const auto out =
                                    static_cast<Int16>(std::llround(std::clamp(v, -1.0f, 1.0f) * 32767.0));
                                Memcpy(dstComponent, &out, sizeof(out));
                                break;
                            }
                            case GL_UNSIGNED_INT: {
                                const auto out = static_cast<Uint32>(
                                    std::llround(static_cast<Double>(std::clamp(v, 0.0f, 1.0f)) * 4294967295.0));
                                Memcpy(dstComponent, &out, sizeof(out));
                                break;
                            }
                            case GL_INT: {
                                const auto out = static_cast<Int32>(
                                    std::llround(static_cast<Double>(std::clamp(v, -1.0f, 1.0f)) * 2147483647.0));
                                Memcpy(dstComponent, &out, sizeof(out));
                                break;
                            }
                            case GL_FLOAT:
                                Memcpy(dstComponent, &v, sizeof(v));
                                break;
                            case GL_HALF_FLOAT: {
                                const Uint16 out = EncodeFloatToHalfBits(v);
                                Memcpy(dstComponent, &out, sizeof(out));
                                break;
                            }
                            default:
                                break;
                            }
                        }
                    }
                }
            }
        }

        static SizeT AlignReadbackRow(SizeT rowBytes, Int alignment) {
            const SizeT align = alignment > 0 ? static_cast<SizeT>(alignment) : 1;
            return (rowBytes + align - 1) / align * align;
        }

        // Walks the client-side destination the PACK parameters describe and hands each row to
        // `fillRow(slice, row, dstRow)`, which writes width * dstPixelBytes bytes of finished client
        // texels. Shared by the converting and the raw-word stores so both address the destination -
        // and feed the bound pixel-pack buffer - identically.
        // applyPackImageParams: GL_PACK_IMAGE_HEIGHT / GL_PACK_SKIP_IMAGES apply only to GetTexImage
        // of 3D/array images; ReadPixels and 2D GetTexImage ignore them (GL 3.3 sections 4.3.1, 6.1.4).
        // Per the GL addressing rules, slice k row j lands at
        // SKIP_IMAGES*imageStride + SKIP_ROWS*rowStride + SKIP_PIXELS*pixelBytes
        //   + k*imageStride + j*rowStride, with imageStride = max(IMAGE_HEIGHT, sliceHeight)*rowStride.
        template <typename FillRow>
        static Bool StoreClientRows(SizeT dstPixelBytes, SizeT swapGroupSize, GLsizei width, GLsizei sliceHeight,
                                    GLsizei sliceCount, void* pixels, Bool applyPackImageParams, FillRow&& fillRow) {
            const auto& pixelPackBufferObject =
                MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::PixelPack).GetBoundObject();

            // Destination layout is computed from the client-side PACK parameters; only the actual pixel
            // rows are written so skip regions of the destination stay untouched.
            const auto packParams = MG_State::pGLContext->GetPixelStoreParameters(false);
            const SizeT rowPixels = static_cast<SizeT>(packParams.RowLength > 0 ? packParams.RowLength : width);
            const SizeT dstRowStride = AlignReadbackRow(rowPixels * dstPixelBytes, packParams.Alignment);
            const SizeT imageRows =
                applyPackImageParams && packParams.ImageHeight > 0
                    ? static_cast<SizeT>(packParams.ImageHeight)
                    : static_cast<SizeT>(sliceHeight);
            const SizeT dstImageStride = imageRows * dstRowStride;
            const SizeT skipImages =
                applyPackImageParams ? static_cast<SizeT>(std::max(packParams.SkipImages, 0)) : SizeT{0};
            const SizeT dstSkipOffset = skipImages * dstImageStride +
                                        static_cast<SizeT>(std::max(packParams.SkipRows, 0)) * dstRowStride +
                                        static_cast<SizeT>(std::max(packParams.SkipPixels, 0)) * dstPixelBytes;
            const SizeT dstRowBytes = static_cast<SizeT>(width) * dstPixelBytes;

            const SizeT pboBaseOffset = reinterpret_cast<SizeT>(pixels); // with a PBO, `pixels` is an offset
            if (pixelPackBufferObject) {
                const SizeT requiredSize = pboBaseOffset + dstSkipOffset +
                                        static_cast<SizeT>(sliceCount - 1) * dstImageStride +
                                        static_cast<SizeT>(sliceHeight - 1) * dstRowStride + dstRowBytes;
                if (requiredSize > pixelPackBufferObject->GetSize()) {
                    MGLOG_E_ONCE("Readback conversion: pixel pack buffer is too small");
                    return true;
                }
            }

            Vector<Uint8> convertedRow(dstRowBytes);

            for (GLsizei slice = 0; slice < sliceCount; ++slice) {
                for (GLsizei row = 0; row < sliceHeight; ++row) {
                    fillRow(slice, row, convertedRow.data());

                    if (packParams.SwapBytes && swapGroupSize > 1) {
                        for (SizeT offset = 0; offset + swapGroupSize <= dstRowBytes; offset += swapGroupSize) {
                            std::reverse(convertedRow.data() + offset, convertedRow.data() + offset + swapGroupSize);
                        }
                    }

                    const SizeT dstOffset = dstSkipOffset + static_cast<SizeT>(slice) * dstImageStride +
                                         static_cast<SizeT>(row) * dstRowStride;
                    if (pixelPackBufferObject) {
                        pixelPackBufferObject->WritebackFromBackend({convertedRow.data(), dstRowBytes},
                                                                 pboBaseOffset + dstOffset);
                    } else {
                        Memcpy(static_cast<Uint8*>(pixels) + dstOffset, convertedRow.data(), dstRowBytes);
                    }
                }
            }
            if (pixelPackBufferObject) {
                // WritebackFromBackend bumps change serials with no backend op; re-open
                // the buffer draw-clean memos (once for the whole row loop).
                BufferImpl::BumpBufferMutationEpoch();
            }
            return true;
        }

        // Repacks wide RGBA(_INTEGER) rows into the client's (format, type) layout, honoring the
        // client-side PACK parameters and the bound pixel-pack buffer. `wide` holds
        // `sliceHeight * sliceCount` rows of `width` texels (slice-major, tightly stacked),
        // 4 components x GetReadbackComponentSize(wideType) bytes each.
        Bool StoreWideRowsToClient(const Uint8* wide, GLenum wideType, GLsizei width, GLsizei sliceHeight,
                                   GLsizei sliceCount, const ReadbackChannelMapping& mapping, GLenum type,
                                   void* pixels, Bool applyPackImageParams) {
            const SizeT dstPixelBytes = GetReadbackDstPixelSize(mapping, type);
            if (dstPixelBytes == 0) {
                return false;
            }
            PackedReadbackLayout packedLayout{};
            const Bool isPackedType = GetPackedReadbackLayout(type, packedLayout);
            const SizeT swapGroupSize = isPackedType ? packedLayout.byteSize : GetReadbackComponentSize(type);
            const SizeT srcPixelBytes = 4 * GetReadbackComponentSize(wideType);

            return StoreClientRows(dstPixelBytes, swapGroupSize, width, sliceHeight, sliceCount, pixels,
                                   applyPackImageParams,
                                   [&](GLsizei slice, GLsizei row, Uint8* dstRow) {
                                       const SizeT flatRow = static_cast<SizeT>(slice) *
                                                                 static_cast<SizeT>(sliceHeight) +
                                                             static_cast<SizeT>(row);
                                       const Uint8* srcRow =
                                           wide + flatRow * static_cast<SizeT>(width) * srcPixelBytes;
                                       ConvertWideReadbackRow(srcRow, dstRow, static_cast<SizeT>(width), wideType,
                                                              mapping, type);
                                   });
        }

        Bool StorePackedWordsToClient(const Uint8* srcWords, GLsizei width, GLsizei sliceHeight, GLsizei sliceCount,
                                      GLenum type, void* pixels, Bool applyPackImageParams) {
            PackedReadbackLayout packedLayout{};
            if (!GetPackedReadbackLayout(type, packedLayout) || packedLayout.byteSize != 4) {
                return false;
            }
            const SizeT srcRowBytes = static_cast<SizeT>(width) * 4;

            return StoreClientRows(4, packedLayout.byteSize, width, sliceHeight, sliceCount, pixels,
                                   applyPackImageParams,
                                   [&](GLsizei slice, GLsizei row, Uint8* dstRow) {
                                       const SizeT flatRow = static_cast<SizeT>(slice) *
                                                                 static_cast<SizeT>(sliceHeight) +
                                                             static_cast<SizeT>(row);
                                       Memcpy(dstRow, srcWords + flatRow * srcRowBytes, srcRowBytes);
                                   });
        }
    } // namespace ReadbackImpl
} // namespace MobileGL::MG_Backend::DirectGLES
