Commit eeb14308 by Shahbaz Youssefi Committed by Commit Bot

Vulkan: Support xfb capture of I/O block fields

In the emulation path, it's ensured that the generated code references the I/O block field correctly (using the instance name if provided, and without it otherwise). In the extension path, the info map is augmented with an array of xfb decorations for its fields. Then when `OpDecorate %IOBlockId Block` is encountered, the transform feedback decorations on the fields are inserted: OpMemberDecorate %IOBlockId MemberN XfbBuffer buffer OpMemberDecorate %IOBlockId MemberN XfbStride stride OpMemberDecorate %IOBlockId MemberN Offset offset Future work includes removing the duplicate varying added for gl_PointSize and use this mechanism to decorate gl_PerVertex directly. Bug: angleproject:3606 Change-Id: I6fed0b1ee7245fe695337043b40b281fb01a1fb0 Reviewed-on: https://chromium-review.googlesource.com/c/angle/angle/+/2599953Reviewed-by: 's avatarJamie Madill <jmadill@chromium.org> Reviewed-by: 's avatarTim Van Patten <timvp@google.com> Commit-Queue: Shahbaz Youssefi <syoussefi@chromium.org>
parent 8065aa82
......@@ -75,8 +75,14 @@ struct TransformFeedbackVarying : public sh::ShaderVariable
*thisVar = field;
interpolation = parent.interpolation;
isInvariant = parent.isInvariant;
name = parent.name + "." + name;
mappedName = parent.mappedName + "." + mappedName;
ASSERT(parent.isShaderIOBlock || !parent.name.empty());
if (!parent.name.empty())
{
name = parent.name + "." + name;
mappedName = parent.mappedName + "." + mappedName;
}
structName = parent.structName;
mappedStructName = parent.mappedStructName;
}
std::string nameWithArrayIndex() const
......
......@@ -522,18 +522,35 @@ void VaryingPacking::packUserVaryingTF(const ProgramVaryingRef &ref, size_t subs
void VaryingPacking::packUserVaryingFieldTF(const ProgramVaryingRef &ref,
const sh::ShaderVariable &field,
GLuint fieldIndex)
GLuint fieldIndex,
GLuint secondaryFieldIndex)
{
const sh::ShaderVariable *input = ref.frontShader;
VaryingInShaderRef frontVarying(ref.frontShaderStage, &field);
const sh::ShaderVariable *frontField = &field;
if (secondaryFieldIndex != GL_INVALID_INDEX)
{
frontField = &frontField->fields[secondaryFieldIndex];
}
VaryingInShaderRef frontVarying(ref.frontShaderStage, frontField);
VaryingInShaderRef backVarying(ref.backShaderStage, nullptr);
frontVarying.parentStructName = input->name;
frontVarying.parentStructMappedName = input->mappedName;
if (frontField->isShaderIOBlock)
{
frontVarying.parentStructName = input->structName;
frontVarying.parentStructMappedName = input->mappedStructName;
}
else
{
ASSERT(!frontField->isStruct() && !frontField->isArray());
frontVarying.parentStructName = input->name;
frontVarying.parentStructMappedName = input->mappedName;
}
mPackedVaryings.emplace_back(std::move(frontVarying), std::move(backVarying),
input->interpolation, GL_INVALID_INDEX, fieldIndex, 0);
input->interpolation, GL_INVALID_INDEX, fieldIndex,
secondaryFieldIndex == GL_INVALID_INDEX ? 0 : secondaryFieldIndex);
}
bool VaryingPacking::collectAndPackUserVaryings(gl::InfoLog &infoLog,
......@@ -620,6 +637,10 @@ bool VaryingPacking::collectAndPackUserVaryings(gl::InfoLog &infoLog,
if (input)
{
uniqueFullNames[ref.frontShaderStage].insert(input->name);
if (input->isShaderIOBlock)
{
uniqueFullNames[ref.frontShaderStage].insert(input->structName);
}
}
if (output)
{
......@@ -655,9 +676,11 @@ bool VaryingPacking::collectAndPackUserVaryings(gl::InfoLog &infoLog,
{
subscript = subscripts.back();
}
// Already packed for fragment shader.
// Already packed as active varying.
if (uniqueFullNames[ref.frontShaderStage].count(tfVarying) > 0 ||
uniqueFullNames[ref.frontShaderStage].count(baseName) > 0)
uniqueFullNames[ref.frontShaderStage].count(baseName) > 0 ||
(input->isShaderIOBlock &&
uniqueFullNames[ref.frontShaderStage].count(input->structName) > 0))
{
continue;
}
......@@ -667,9 +690,38 @@ bool VaryingPacking::collectAndPackUserVaryings(gl::InfoLog &infoLog,
const sh::ShaderVariable *field = input->findField(tfVarying, &fieldIndex);
if (field != nullptr)
{
ASSERT(!field->isStruct() && (!field->isArray() || input->isShaderIOBlock));
ASSERT(input->isShaderIOBlock || (!field->isStruct() && !field->isArray()));
packUserVaryingFieldTF(ref, *field, fieldIndex);
// If it's an I/O block whose member is being captured, pack every member of the
// block. Currently, we pack either all or none of an I/O block.
if (input->isShaderIOBlock)
{
for (fieldIndex = 0; fieldIndex < input->fields.size(); ++fieldIndex)
{
if (input->fields[fieldIndex].isStruct())
{
for (GLuint nestedIndex = 0;
nestedIndex < input->fields[fieldIndex].fields.size();
nestedIndex++)
{
packUserVaryingFieldTF(ref, input->fields[fieldIndex],
fieldIndex, nestedIndex);
}
}
else
{
packUserVaryingFieldTF(ref, input->fields[fieldIndex], fieldIndex,
GL_INVALID_INDEX);
}
}
uniqueFullNames[ref.frontShaderStage].insert(input->structName);
}
else
{
packUserVaryingFieldTF(ref, *field, fieldIndex, GL_INVALID_INDEX);
}
uniqueFullNames[ref.frontShaderStage].insert(tfVarying);
}
uniqueFullNames[ref.frontShaderStage].insert(input->name);
......
......@@ -263,7 +263,8 @@ class VaryingPacking final : angle::NonCopyable
void packUserVaryingTF(const ProgramVaryingRef &ref, size_t subscript);
void packUserVaryingFieldTF(const ProgramVaryingRef &ref,
const sh::ShaderVariable &field,
GLuint fieldIndex);
GLuint fieldIndex,
GLuint secondaryFieldIndex);
void clearRegisterMap();
......
......@@ -277,19 +277,30 @@ void AddVaryingLocationInfo(ShaderInterfaceVariableInfoMap &infoMap,
// Modify an existing out variable and add transform feedback information.
ShaderInterfaceVariableInfo *SetXfbInfo(ShaderInterfaceVariableInfoMap *infoMap,
const std::string &varName,
int fieldIndex,
uint32_t xfbBuffer,
uint32_t xfbOffset,
uint32_t xfbStride)
{
ShaderInterfaceVariableInfo *info = GetShaderInterfaceVariable(infoMap, varName);
ShaderInterfaceVariableInfo *info = GetShaderInterfaceVariable(infoMap, varName);
ShaderInterfaceVariableXfbInfo *xfb = &info->xfb;
ASSERT(info->xfbBuffer == ShaderInterfaceVariableInfo::kInvalid);
ASSERT(info->xfbOffset == ShaderInterfaceVariableInfo::kInvalid);
ASSERT(info->xfbStride == ShaderInterfaceVariableInfo::kInvalid);
if (fieldIndex >= 0)
{
if (info->fieldXfb.size() <= static_cast<size_t>(fieldIndex))
{
info->fieldXfb.resize(fieldIndex + 1);
}
xfb = &info->fieldXfb[fieldIndex];
}
ASSERT(xfb->buffer == ShaderInterfaceVariableXfbInfo::kInvalid);
ASSERT(xfb->offset == ShaderInterfaceVariableXfbInfo::kInvalid);
ASSERT(xfb->stride == ShaderInterfaceVariableXfbInfo::kInvalid);
info->xfbBuffer = xfbBuffer;
info->xfbOffset = xfbOffset;
info->xfbStride = xfbStride;
xfb->buffer = xfbBuffer;
xfb->offset = xfbOffset;
xfb->stride = xfbStride;
return info;
}
......@@ -435,13 +446,16 @@ void GenerateTransformFeedbackEmulationOutputs(const GlslangSourceOptions &optio
*vertexShader = SubstituteTransformFeedbackMarkers(*vertexShader, xfbDecl, xfbOut);
}
bool IsFirstRegisterOfVarying(const gl::PackedVaryingRegister &varyingReg)
bool IsFirstRegisterOfVarying(const gl::PackedVaryingRegister &varyingReg, bool allowFields)
{
const gl::PackedVarying &varying = *varyingReg.packedVarying;
// In Vulkan GLSL, struct fields are not allowed to have location assignments. The varying of a
// struct type is thus given a location equal to the one assigned to its first field.
if (varying.isStructField() && (varying.fieldIndex > 0 || varying.secondaryFieldIndex > 0))
// struct type is thus given a location equal to the one assigned to its first field. With I/O
// blocks, transform feedback can capture an arbitrary field. In that case, we need to look at
// every field, not just the first one.
if (!allowFields && varying.isStructField() &&
(varying.fieldIndex > 0 || varying.secondaryFieldIndex > 0))
{
return false;
}
......@@ -597,7 +611,7 @@ void AssignVaryingLocations(const GlslangSourceOptions &options,
for (const gl::PackedVaryingRegister &varyingReg :
programExecutable.getResources().varyingPacking.getRegisterList())
{
if (!IsFirstRegisterOfVarying(varyingReg))
if (!IsFirstRegisterOfVarying(varyingReg, false))
{
continue;
}
......@@ -725,42 +739,59 @@ void AssignTransformFeedbackExtensionQualifiers(const gl::ProgramExecutable &pro
}
const gl::TransformFeedbackVarying &tfVarying = tfVaryings[varyingIndex];
const std::string &tfVaryingName = tfVarying.mappedName;
if (tfVarying.isBuiltIn())
{
uint32_t xfbVaryingLocation = currentBuiltinLocation++;
std::string xfbVaryingName = kXfbBuiltInPrefix + tfVaryingName;
std::string xfbVaryingName = kXfbBuiltInPrefix + tfVarying.mappedName;
ASSERT(xfbVaryingLocation < locationsUsedForXfbExtension);
AddLocationInfo(variableInfoMapOut, xfbVaryingName, xfbVaryingLocation,
ShaderInterfaceVariableInfo::kInvalid, shaderType, 0, 0);
SetXfbInfo(variableInfoMapOut, xfbVaryingName, bufferIndex, currentOffset,
SetXfbInfo(variableInfoMapOut, xfbVaryingName, -1, bufferIndex, currentOffset,
currentStride);
}
else if (!tfVarying.isArray() || tfVarying.arrayIndex == GL_INVALID_INDEX)
{
// Note: capturing individual array elements using the Vulkan transform feedback
// extension is not supported, and it unlikely to be ever supported (on the contrary, it
// extension is not supported, and is unlikely to be ever supported (on the contrary, it
// may be removed from the GLES spec). http://anglebug.com/4140
// ANGLE should support capturing the whole array.
// Find the varying with this name. If a struct is captured, we would be iterating over
// its fields, and the name of the varying is found through parentStructMappedName. Not
// only that, but also we should only do this for the first field of the struct.
// its fields, and the name of the varying is found through parentStructMappedName.
// This should only be done for the first field of the struct. For I/O blocks on the
// other hand, we need to decorate the exact member that is captured (as whole-block
// capture is not supported).
const gl::PackedVarying *originalVarying = nullptr;
for (const gl::PackedVaryingRegister &varyingReg :
programExecutable.getResources().varyingPacking.getRegisterList())
{
if (!IsFirstRegisterOfVarying(varyingReg))
if (!IsFirstRegisterOfVarying(varyingReg, tfVarying.isShaderIOBlock))
{
continue;
}
const gl::PackedVarying *varying = varyingReg.packedVarying;
if (varying->frontVarying.varying->name == tfVarying.name)
if (tfVarying.isShaderIOBlock)
{
if (varying->frontVarying.parentStructName == tfVarying.structName)
{
size_t pos = tfVarying.name.find_first_of(".");
std::string fieldName = pos == std::string::npos
? tfVarying.name
: tfVarying.name.substr(pos + 1);
if (fieldName == varying->frontVarying.varying->name.c_str())
{
originalVarying = varying;
break;
}
}
}
else if (varying->frontVarying.varying->name == tfVarying.name)
{
originalVarying = varying;
break;
......@@ -774,9 +805,11 @@ void AssignTransformFeedbackExtensionQualifiers(const gl::ProgramExecutable &pro
? originalVarying->frontVarying.parentStructMappedName
: originalVarying->frontVarying.varying->mappedName;
const int fieldIndex = tfVarying.isShaderIOBlock ? originalVarying->fieldIndex : -1;
// Set xfb info for this varying. AssignVaryingLocations should have already added
// location information for these varyings.
SetXfbInfo(variableInfoMapOut, mappedName, bufferIndex, currentOffset,
SetXfbInfo(variableInfoMapOut, mappedName, fieldIndex, bufferIndex, currentOffset,
currentStride);
}
}
......@@ -1107,6 +1140,7 @@ class SpirvTransformerBase : angle::NonCopyable
const angle::FixedVector<uint32_t, 4> &constituents);
void writeCompositeExtract(uint32_t id, uint32_t typeId, uint32_t compositeId, uint32_t field);
void writeLoad(uint32_t id, uint32_t typeId, uint32_t tempVarId);
void writeMemberDecorate(uint32_t typeId, uint32_t member, uint32_t decoration, uint32_t value);
void writeStore(uint32_t pointerId, uint32_t objectId);
void writeTypePointer(uint32_t id, uint32_t storageClass, uint32_t typeId);
void writeVariable(uint32_t id, uint32_t typeId, uint32_t storageClass);
......@@ -1281,6 +1315,29 @@ void SpirvTransformerBase::writeLoad(uint32_t pointerId, uint32_t typeId, uint32
copyInstruction(load.data(), kOpLoadInstructionLength);
}
void SpirvTransformerBase::writeMemberDecorate(uint32_t typeId,
uint32_t member,
uint32_t decoration,
uint32_t value)
{
// SPIR-V 1.0 Section 3.32 Instructions, OpMemberDecorate
constexpr size_t kTypeIdIndex = 1;
constexpr size_t kMemberIndex = 2;
constexpr size_t kDecorationIndex = 3;
constexpr size_t kValueIndex = 4;
constexpr size_t kOpMemberDecorateInstructionLength = 5;
std::array<uint32_t, kOpMemberDecorateInstructionLength> memberDecorate = {};
SetSpirvInstructionOp(memberDecorate.data(), spv::OpMemberDecorate);
SetSpirvInstructionLength(memberDecorate.data(), kOpMemberDecorateInstructionLength);
memberDecorate[kTypeIdIndex] = typeId;
memberDecorate[kMemberIndex] = member;
memberDecorate[kDecorationIndex] = decoration;
memberDecorate[kValueIndex] = value;
copyInstruction(memberDecorate.data(), kOpMemberDecorateInstructionLength);
}
void SpirvTransformerBase::writeStore(uint32_t pointerId, uint32_t objectId)
{
// SPIR-V 1.0 Section 3.32 Instructions, OpStore
......@@ -1736,6 +1793,22 @@ void SpirvTransformer::visitDecorate(const uint32_t *instruction)
if (decoration == spv::DecorationBlock)
{
mIsIOBlockById[id] = true;
// For I/O blocks, associate the type with the info, which is used to decorate its members
// with transform feedback if any.
const char *name = mNamesById[id];
ASSERT(name != nullptr);
// TODO: decorate gl_PerVertex members for transform feedback similarly to I/O blocks
// http://anglebug.com/3606
if (strcmp(name, "gl_PerVertex") != 0)
{
auto infoIter = mVariableInfoMap.find(name);
ASSERT(infoIter != mVariableInfoMap.end());
const ShaderInterfaceVariableInfo *info = &infoIter->second;
mVariableInfoById[id] = info;
}
}
}
......@@ -1950,7 +2023,7 @@ void SpirvTransformer::visitVariable(const uint32_t *instruction)
// Note if the variable is captured by transform feedback. In that case, the TransformFeedback
// capability needs to be added.
if (mOptions.shaderType != gl::ShaderType::Fragment &&
info->xfbBuffer != ShaderInterfaceVariableInfo::kInvalid &&
(info->xfb.buffer != ShaderInterfaceVariableInfo::kInvalid || !info->fieldXfb.empty()) &&
info->activeStages[mOptions.shaderType])
{
mHasTransformFeedbackOutput = true;
......@@ -1983,6 +2056,50 @@ bool SpirvTransformer::transformDecorate(const uint32_t *instruction, size_t wor
return true;
}
// If this is the Block decoration of a shader I/O block, add the transform feedback decorations
// to its members right away.
constexpr size_t kXfbDecorationCount = 3;
constexpr uint32_t kXfbDecorations[kXfbDecorationCount] = {
spv::DecorationXfbBuffer,
spv::DecorationXfbStride,
spv::DecorationOffset,
};
if (mOptions.shaderType != gl::ShaderType::Fragment && decoration == spv::DecorationBlock &&
!info->fieldXfb.empty())
{
for (uint32_t fieldIndex = 0; fieldIndex < info->fieldXfb.size(); ++fieldIndex)
{
const ShaderInterfaceVariableXfbInfo &xfb = info->fieldXfb[fieldIndex];
if (xfb.buffer == ShaderInterfaceVariableXfbInfo::kInvalid)
{
continue;
}
ASSERT(xfb.stride != ShaderInterfaceVariableXfbInfo::kInvalid);
ASSERT(xfb.offset != ShaderInterfaceVariableXfbInfo::kInvalid);
const uint32_t xfbDecorationValues[kXfbDecorationCount] = {
xfb.buffer,
xfb.stride,
xfb.offset,
};
// Generate the following three instructions:
//
// OpMemberDecorate %id fieldIndex XfbBuffer xfb.buffer
// OpMemberDecorate %id fieldIndex XfbStride xfb.stride
// OpMemberDecorate %id fieldIndex Offset xfb.offset
for (size_t i = 0; i < kXfbDecorationCount; ++i)
{
writeMemberDecorate(id, fieldIndex, kXfbDecorations[i], xfbDecorationValues[i]);
}
}
return false;
}
uint32_t newDecorationValue = ShaderInterfaceVariableInfo::kInvalid;
switch (decoration)
......@@ -2056,21 +2173,15 @@ bool SpirvTransformer::transformDecorate(const uint32_t *instruction, size_t wor
// Add Xfb decorations, if any.
if (mOptions.shaderType != gl::ShaderType::Fragment &&
info->xfbBuffer != ShaderInterfaceVariableInfo::kInvalid)
info->xfb.buffer != ShaderInterfaceVariableXfbInfo::kInvalid)
{
ASSERT(info->xfbStride != ShaderInterfaceVariableInfo::kInvalid);
ASSERT(info->xfbOffset != ShaderInterfaceVariableInfo::kInvalid);
ASSERT(info->xfb.stride != ShaderInterfaceVariableXfbInfo::kInvalid);
ASSERT(info->xfb.offset != ShaderInterfaceVariableXfbInfo::kInvalid);
constexpr size_t kXfbDecorationCount = 3;
constexpr uint32_t xfbDecorations[kXfbDecorationCount] = {
spv::DecorationXfbBuffer,
spv::DecorationXfbStride,
spv::DecorationOffset,
};
const uint32_t xfbDecorationValues[kXfbDecorationCount] = {
info->xfbBuffer,
info->xfbStride,
info->xfbOffset,
info->xfb.buffer,
info->xfb.stride,
info->xfb.offset,
};
// Copy the location decoration declaration three times, and modify them to contain the
......@@ -2083,7 +2194,7 @@ bool SpirvTransformer::transformDecorate(const uint32_t *instruction, size_t wor
// Change the id to replacement variable
(*mSpirvBlobOut)[xfbInstructionOffset + kIdIndex] = mFixedVaryingId[id];
}
(*mSpirvBlobOut)[xfbInstructionOffset + kDecorationIndex] = xfbDecorations[i];
(*mSpirvBlobOut)[xfbInstructionOffset + kDecorationIndex] = kXfbDecorations[i];
(*mSpirvBlobOut)[xfbInstructionOffset + kDecorationValueIndex] = xfbDecorationValues[i];
}
}
......
......@@ -64,6 +64,15 @@ using SpirvBlob = std::vector<uint32_t>;
using GlslangErrorCallback = std::function<angle::Result(GlslangError)>;
struct ShaderInterfaceVariableXfbInfo
{
static constexpr uint32_t kInvalid = std::numeric_limits<uint32_t>::max();
uint32_t buffer = kInvalid;
uint32_t offset = kInvalid;
uint32_t stride = kInvalid;
};
// Information for each shader interface variable. Not all fields are relevant to each shader
// interface variable. For example opaque uniforms require a set and binding index, while vertex
// attributes require a location.
......@@ -85,9 +94,8 @@ struct ShaderInterfaceVariableInfo
// The stages this shader interface variable is active.
gl::ShaderBitSet activeStages;
// Used for transform feedback extension to decorate vertex shader output.
uint32_t xfbBuffer = kInvalid;
uint32_t xfbOffset = kInvalid;
uint32_t xfbStride = kInvalid;
ShaderInterfaceVariableXfbInfo xfb;
std::vector<ShaderInterfaceVariableXfbInfo> fieldXfb;
// Indicates that the precision needs to be modified in the generated SPIR-V
// to support only transferring medium precision data when there's a precision
// mismatch between the shaders. For example, either the VS casts highp->mediump
......
......@@ -229,10 +229,17 @@ std::unique_ptr<rx::LinkEvent> ProgramExecutableVk::load(gl::BinaryInputStream *
info->location = stream->readInt<uint32_t>();
info->component = stream->readInt<uint32_t>();
// PackedEnumBitSet uses uint8_t
info->activeStages = gl::ShaderBitSet(stream->readInt<uint8_t>());
info->xfbBuffer = stream->readInt<uint32_t>();
info->xfbOffset = stream->readInt<uint32_t>();
info->xfbStride = stream->readInt<uint32_t>();
info->activeStages = gl::ShaderBitSet(stream->readInt<uint8_t>());
info->xfb.buffer = stream->readInt<uint32_t>();
info->xfb.offset = stream->readInt<uint32_t>();
info->xfb.stride = stream->readInt<uint32_t>();
info->fieldXfb.resize(stream->readInt<size_t>());
for (ShaderInterfaceVariableXfbInfo &xfb : info->fieldXfb)
{
xfb.buffer = stream->readInt<uint32_t>();
xfb.offset = stream->readInt<uint32_t>();
xfb.stride = stream->readInt<uint32_t>();
}
info->useRelaxedPrecision = stream->readBool();
info->varyingIsInput = stream->readBool();
info->varyingIsOutput = stream->readBool();
......@@ -258,9 +265,16 @@ void ProgramExecutableVk::save(gl::BinaryOutputStream *stream)
stream->writeInt(it.second.component);
// PackedEnumBitSet uses uint8_t
stream->writeInt(it.second.activeStages.bits());
stream->writeInt(it.second.xfbBuffer);
stream->writeInt(it.second.xfbOffset);
stream->writeInt(it.second.xfbStride);
stream->writeInt(it.second.xfb.buffer);
stream->writeInt(it.second.xfb.offset);
stream->writeInt(it.second.xfb.stride);
stream->writeInt(it.second.fieldXfb.size());
for (const ShaderInterfaceVariableXfbInfo &xfb : it.second.fieldXfb)
{
stream->writeInt(xfb.buffer);
stream->writeInt(xfb.offset);
stream->writeInt(xfb.stride);
}
stream->writeBool(it.second.useRelaxedPrecision);
stream->writeBool(it.second.varyingIsInput);
stream->writeBool(it.second.varyingIsOutput);
......
......@@ -2809,9 +2809,6 @@ TEST_P(TransformFeedbackTestES31, IOBlocksInterleaved)
// http://anglebug.com/5488
ANGLE_SKIP_TEST_IF(IsQualcomm() && IsOpenGLES());
// Not supported in Vulkan yet. http://anglebug.com/3606
ANGLE_SKIP_TEST_IF(IsVulkan());
constexpr char kVS[] = R"(#version 310 es
#extension GL_EXT_shader_io_blocks : require
......@@ -2920,9 +2917,6 @@ TEST_P(TransformFeedbackTestES31, IOBlocksSeparate)
// http://anglebug.com/5488
ANGLE_SKIP_TEST_IF(IsQualcomm() && IsOpenGLES());
// Not supported in Vulkan yet. http://anglebug.com/3606
ANGLE_SKIP_TEST_IF(IsVulkan());
constexpr char kVS[] = R"(#version 310 es
#extension GL_EXT_shader_io_blocks : require
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment