[GPU] Async shader compilation for D3D12 and Vulkan
Adds async_shader_compilation cvar (default true) that forces new pipelines to be created async from the main thread, skips draws entirely on D3D12 and on vulkan replaces pixel shader with placeholder until the real one is ready. Causes some visual artifacts on first load but greatly reduces load times and stutter.
This commit is contained in:
@@ -11,6 +11,7 @@
|
||||
|
||||
#include <cmath>
|
||||
#include <cstring>
|
||||
#include <thread>
|
||||
|
||||
#include "third_party/dxbc/DXBCChecksum.h"
|
||||
#include "third_party/fmt/include/fmt/format.h"
|
||||
@@ -31,6 +32,7 @@
|
||||
#include "xenia/gpu/dxbc.h"
|
||||
#include "xenia/gpu/dxbc_shader_translator.h"
|
||||
#include "xenia/gpu/gpu_flags.h"
|
||||
#include "xenia/gpu/pipeline_util.h"
|
||||
#include "xenia/gpu/registers.h"
|
||||
#include "xenia/gpu/xenos.h"
|
||||
#include "xenia/ui/d3d12/d3d12_util.h"
|
||||
@@ -195,7 +197,11 @@ void PipelineCache::Shutdown() {
|
||||
// Destroy all pipelines.
|
||||
current_pipeline_ = nullptr;
|
||||
for (auto it : pipelines_) {
|
||||
it.second->state->Release();
|
||||
ID3D12PipelineState* state =
|
||||
it.second->state.load(std::memory_order_acquire);
|
||||
if (state) {
|
||||
state->Release();
|
||||
}
|
||||
delete it.second;
|
||||
}
|
||||
pipelines_.clear();
|
||||
@@ -669,9 +675,19 @@ void PipelineCache::InitializeShaderStorage(
|
||||
&pipeline_description, sizeof(pipeline_description));
|
||||
|
||||
Pipeline* new_pipeline = new Pipeline;
|
||||
new_pipeline->state = nullptr;
|
||||
std::memcpy(&new_pipeline->description, &pipeline_runtime_description,
|
||||
sizeof(pipeline_runtime_description));
|
||||
// Calculate priority based on whether shader writes to visible RTs.
|
||||
if (pixel_shader) {
|
||||
uint32_t bound_rts =
|
||||
(pipeline_description.render_targets[0].used ? 1 : 0) |
|
||||
(pipeline_description.render_targets[1].used ? 2 : 0) |
|
||||
(pipeline_description.render_targets[2].used ? 4 : 0) |
|
||||
(pipeline_description.render_targets[3].used ? 8 : 0);
|
||||
new_pipeline->priority = pipeline_util::CalculatePipelinePriority(
|
||||
bound_rts, pixel_shader->writes_color_targets(),
|
||||
pixel_shader->writes_depth());
|
||||
}
|
||||
pipelines_.emplace(pipeline_stored_description.description_hash,
|
||||
new_pipeline);
|
||||
COUNT_profile_set("gpu/pipeline_cache/pipelines", pipelines_.size());
|
||||
@@ -679,11 +695,13 @@ void PipelineCache::InitializeShaderStorage(
|
||||
// Submit the pipeline for creation to any available thread.
|
||||
{
|
||||
std::lock_guard<xe_mutex> lock(creation_request_lock_);
|
||||
creation_queue_.push_back(new_pipeline);
|
||||
creation_queue_.push(new_pipeline);
|
||||
}
|
||||
creation_request_cond_.notify_one();
|
||||
} else {
|
||||
new_pipeline->state = CreateD3D12Pipeline(pipeline_runtime_description);
|
||||
new_pipeline->state.store(
|
||||
CreateD3D12Pipeline(pipeline_runtime_description),
|
||||
std::memory_order_release);
|
||||
}
|
||||
++pipelines_created;
|
||||
}
|
||||
@@ -806,24 +824,10 @@ void PipelineCache::EndSubmission() {
|
||||
pipeline_storage_file_flush_needed_ = false;
|
||||
}
|
||||
if (!creation_threads_.empty()) {
|
||||
CreateQueuedPipelinesOnProcessorThread();
|
||||
// Await creation of all queued pipelines.
|
||||
bool await_creation_completion_event;
|
||||
{
|
||||
std::lock_guard<xe_mutex> lock(creation_request_lock_);
|
||||
// Assuming the creation queue is already empty (because the processor
|
||||
// thread also worked on creating the leftover pipelines), so only check
|
||||
// if there are threads with pipelines currently being created.
|
||||
await_creation_completion_event = creation_threads_busy_ != 0;
|
||||
if (await_creation_completion_event) {
|
||||
creation_completion_event_->Reset();
|
||||
creation_completion_set_event_ = true;
|
||||
}
|
||||
}
|
||||
if (await_creation_completion_event) {
|
||||
creation_request_cond_.notify_one();
|
||||
xe::threading::Wait(creation_completion_event_.get(), false);
|
||||
}
|
||||
// Don't wait for pipeline creation - let background threads work
|
||||
// asynchronously. Draws will be skipped until pipelines are ready.
|
||||
// This avoids frame-time spikes from blocking on pipeline creation.
|
||||
creation_request_cond_.notify_one();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1010,10 +1014,22 @@ bool PipelineCache::ConfigurePipeline(
|
||||
register_file_.Get<reg::SQ_PROGRAM_CNTL>().vs_export_mode !=
|
||||
xenos::VertexShaderExportMode::kPosition2VectorsEdgeKill);
|
||||
assert_false(register_file_.Get<reg::SQ_PROGRAM_CNTL>().gen_index_vtx);
|
||||
if (!vertex_shader->is_translated()) {
|
||||
if (!vertex_shader->shader().is_ucode_analyzed()) {
|
||||
vertex_shader->shader().AnalyzeUcode(ucode_disasm_buffer_);
|
||||
}
|
||||
|
||||
// Check if we should use async pipeline creation.
|
||||
// When enabled, defer shader translation and pipeline creation to background.
|
||||
// Only use async when there's a pixel shader - VS-only pipelines are fast
|
||||
// to compile and don't benefit from async (vertex shaders are small).
|
||||
bool use_async = cvars::async_shader_compilation &&
|
||||
!creation_threads_.empty() && pixel_shader != nullptr;
|
||||
|
||||
// Ensure VS ucode is analyzed (needed for description hash).
|
||||
if (!vertex_shader->shader().is_ucode_analyzed()) {
|
||||
vertex_shader->shader().AnalyzeUcode(ucode_disasm_buffer_);
|
||||
}
|
||||
|
||||
// For async mode, defer VS translation to background thread.
|
||||
// For sync mode, translate VS now on main thread.
|
||||
if (!vertex_shader->is_translated() && !use_async) {
|
||||
if (!TranslateAnalyzedShader(*shader_translator_, *vertex_shader,
|
||||
dxbc_converter_, dxc_utils_, dxc_compiler_)) {
|
||||
XELOGE("Failed to translate the vertex shader!");
|
||||
@@ -1031,11 +1047,15 @@ bool PipelineCache::ConfigurePipeline(
|
||||
storage_write_request_cond_.notify_all();
|
||||
}
|
||||
}
|
||||
if (!vertex_shader->is_valid()) {
|
||||
// Translation attempted previously, but not valid.
|
||||
if (!use_async && !vertex_shader->is_valid()) {
|
||||
// Translation attempted previously, but not valid (sync mode only).
|
||||
return false;
|
||||
}
|
||||
if (pixel_shader != nullptr) {
|
||||
|
||||
if (pixel_shader != nullptr && !use_async) {
|
||||
// Sync mode - must translate PS now on main thread.
|
||||
// No mutex needed - main thread translator is not shared with background
|
||||
// threads (they have their own translators).
|
||||
if (!pixel_shader->is_translated()) {
|
||||
if (!pixel_shader->shader().is_ucode_analyzed()) {
|
||||
pixel_shader->shader().AnalyzeUcode(ucode_disasm_buffer_);
|
||||
@@ -1070,7 +1090,8 @@ bool PipelineCache::ConfigurePipeline(
|
||||
vertex_shader, pixel_shader, primitive_processing_result,
|
||||
normalized_depth_control, normalized_color_mask,
|
||||
bound_depth_and_color_render_target_bits,
|
||||
bound_depth_and_color_render_target_formats, runtime_description)) {
|
||||
bound_depth_and_color_render_target_formats, runtime_description,
|
||||
use_async)) {
|
||||
return false;
|
||||
}
|
||||
PipelineDescription& description = runtime_description.description;
|
||||
@@ -1078,7 +1099,7 @@ bool PipelineCache::ConfigurePipeline(
|
||||
if (current_pipeline_ != nullptr &&
|
||||
current_pipeline_->description.description == description) {
|
||||
*pipeline_handle_out = current_pipeline_;
|
||||
*root_signature_out = runtime_description.root_signature;
|
||||
*root_signature_out = current_pipeline_->description.root_signature;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1096,21 +1117,32 @@ bool PipelineCache::ConfigurePipeline(
|
||||
}
|
||||
|
||||
Pipeline* new_pipeline = new Pipeline;
|
||||
new_pipeline->state = nullptr;
|
||||
std::memcpy(&new_pipeline->description, &runtime_description,
|
||||
sizeof(runtime_description));
|
||||
pipelines_.emplace(hash, new_pipeline);
|
||||
COUNT_profile_set("gpu/pipeline_cache/pipelines", pipelines_.size());
|
||||
|
||||
if (!creation_threads_.empty()) {
|
||||
// Submit the pipeline for creation to any available thread.
|
||||
if (use_async) {
|
||||
// Queue for background thread.
|
||||
new_pipeline->pending_vertex_shader = vertex_shader;
|
||||
new_pipeline->pending_pixel_shader = pixel_shader;
|
||||
// Calculate priority based on whether shader writes to visible RTs.
|
||||
if (pixel_shader) {
|
||||
uint32_t bound_rts = pipeline_util::GetBoundRTMaskFromNormalizedColorMask(
|
||||
normalized_color_mask);
|
||||
new_pipeline->priority = pipeline_util::CalculatePipelinePriority(
|
||||
bound_rts, pixel_shader->shader().writes_color_targets(),
|
||||
pixel_shader->shader().writes_depth());
|
||||
}
|
||||
{
|
||||
std::lock_guard<xe_mutex> lock(creation_request_lock_);
|
||||
creation_queue_.push_back(new_pipeline);
|
||||
creation_queue_.push(new_pipeline);
|
||||
}
|
||||
creation_request_cond_.notify_one();
|
||||
} else {
|
||||
new_pipeline->state = CreateD3D12Pipeline(runtime_description);
|
||||
// Sync mode or no creation threads: create synchronously.
|
||||
new_pipeline->state.store(CreateD3D12Pipeline(runtime_description),
|
||||
std::memory_order_release);
|
||||
}
|
||||
|
||||
if (pipeline_storage_file_) {
|
||||
@@ -1339,10 +1371,14 @@ bool PipelineCache::GetCurrentStateDescription(
|
||||
uint32_t normalized_color_mask,
|
||||
uint32_t bound_depth_and_color_render_target_bits,
|
||||
const uint32_t* bound_depth_and_color_render_target_formats,
|
||||
PipelineRuntimeDescription& runtime_description_out) {
|
||||
PipelineRuntimeDescription& runtime_description_out, bool for_placeholder) {
|
||||
// Translated shaders needed at least for the root signature.
|
||||
assert_true(vertex_shader->is_translated() && vertex_shader->is_valid());
|
||||
assert_true(!pixel_shader ||
|
||||
// Exception: for_placeholder mode (async pipeline creation) allows
|
||||
// untranslated shaders - root signature uses VS bindings only initially,
|
||||
// updated after background translation.
|
||||
assert_true(for_placeholder ||
|
||||
(vertex_shader->is_translated() && vertex_shader->is_valid()));
|
||||
assert_true(!pixel_shader || for_placeholder ||
|
||||
(pixel_shader->is_translated() && pixel_shader->is_valid()));
|
||||
|
||||
PipelineDescription& description_out = runtime_description_out.description;
|
||||
@@ -1376,10 +1412,13 @@ bool PipelineCache::GetCurrentStateDescription(
|
||||
RenderTargetCache::Path::kPixelShaderInterlock;
|
||||
|
||||
// Root signature.
|
||||
// For placeholder mode, pass nullptr for pixel_shader since placeholder PS
|
||||
// has no texture/sampler bindings - root signature only needs VS bindings.
|
||||
runtime_description_out.root_signature = command_processor_.GetRootSignature(
|
||||
static_cast<const DxbcShader*>(&vertex_shader->shader()),
|
||||
pixel_shader ? static_cast<const DxbcShader*>(&pixel_shader->shader())
|
||||
: nullptr,
|
||||
(pixel_shader && !for_placeholder)
|
||||
? static_cast<const DxbcShader*>(&pixel_shader->shader())
|
||||
: nullptr,
|
||||
tessellated);
|
||||
if (runtime_description_out.root_signature == nullptr) {
|
||||
return false;
|
||||
@@ -2831,6 +2870,72 @@ const std::vector<uint32_t>& PipelineCache::GetGeometryShader(
|
||||
return geometry_shaders_.emplace(key, std::move(shader)).first->second;
|
||||
}
|
||||
|
||||
void PipelineCache::EnsurePipelineShadersTranslated(
|
||||
Pipeline* pipeline, DxbcShaderTranslator& translator,
|
||||
StringBuffer& ucode_disasm_buffer, IDxbcConverter* dxbc_converter,
|
||||
IDxcUtils* dxc_utils, IDxcCompiler* dxc_compiler, bool use_try_claim,
|
||||
bool handle_non_placeholder) {
|
||||
D3D12Shader::D3D12Translation* pending_vs = pipeline->pending_vertex_shader;
|
||||
D3D12Shader::D3D12Translation* pending_ps = pipeline->pending_pixel_shader;
|
||||
|
||||
// Helper lambda to translate a shader, optionally using TryClaimTranslation.
|
||||
auto translate_shader = [&](D3D12Shader::D3D12Translation* translation,
|
||||
const char* shader_type) {
|
||||
if (!translation->is_translated()) {
|
||||
bool should_translate = true;
|
||||
if (use_try_claim) {
|
||||
should_translate = translation->TryClaimTranslation();
|
||||
if (!should_translate) {
|
||||
// Another thread is translating - wait for it.
|
||||
while (!translation->is_translated()) {
|
||||
std::this_thread::yield();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (should_translate) {
|
||||
DxbcShader& shader = static_cast<DxbcShader&>(translation->shader());
|
||||
if (!shader.is_ucode_analyzed()) {
|
||||
shader.AnalyzeUcode(ucode_disasm_buffer);
|
||||
}
|
||||
if (!TranslateAnalyzedShader(translator, *translation, dxbc_converter,
|
||||
dxc_utils, dxc_compiler)) {
|
||||
XELOGE("Failed to translate {} shader {:016X}", shader_type,
|
||||
shader.ucode_data_hash());
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Translate pending VS if present.
|
||||
if (pending_vs != nullptr) {
|
||||
translate_shader(pending_vs, "vertex");
|
||||
pipeline->pending_vertex_shader = nullptr;
|
||||
}
|
||||
|
||||
// Translate pending PS if present and update root signature.
|
||||
if (pending_ps != nullptr) {
|
||||
translate_shader(pending_ps, "pixel");
|
||||
// Update root signature now that PS is translated.
|
||||
if (pending_ps->is_valid()) {
|
||||
PipelineRuntimeDescription& desc = pipeline->description;
|
||||
bool tessellated = Shader::IsHostVertexShaderTypeDomain(
|
||||
DxbcShaderTranslator::Modification(desc.vertex_shader->modification())
|
||||
.vertex.host_vertex_shader_type);
|
||||
desc.root_signature = command_processor_.GetRootSignature(
|
||||
static_cast<const DxbcShader*>(&desc.vertex_shader->shader()),
|
||||
static_cast<const DxbcShader*>(&pending_ps->shader()), tessellated);
|
||||
}
|
||||
pipeline->pending_pixel_shader = nullptr;
|
||||
} else if (handle_non_placeholder && pending_vs == nullptr) {
|
||||
// Non-placeholder mode: translate desc.pixel_shader if needed (for
|
||||
// pipelines loaded from cache).
|
||||
D3D12Shader::D3D12Translation* ps = pipeline->description.pixel_shader;
|
||||
if (ps != nullptr) {
|
||||
translate_shader(ps, "pixel");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ID3D12PipelineState* PipelineCache::CreateD3D12Pipeline(
|
||||
const PipelineRuntimeDescription& runtime_description) {
|
||||
const PipelineDescription& description = runtime_description.description;
|
||||
@@ -3238,16 +3343,23 @@ ID3D12PipelineState* PipelineCache::CreateD3D12Pipeline(
|
||||
// Create the D3D12 pipeline state object.
|
||||
ID3D12Device* device = command_processor_.GetD3D12Provider().GetDevice();
|
||||
ID3D12PipelineState* state;
|
||||
if (FAILED(device->CreateGraphicsPipelineState(&state_desc,
|
||||
IID_PPV_ARGS(&state)))) {
|
||||
HRESULT hr =
|
||||
device->CreateGraphicsPipelineState(&state_desc, IID_PPV_ARGS(&state));
|
||||
if (FAILED(hr)) {
|
||||
if (runtime_description.pixel_shader != nullptr) {
|
||||
XELOGE("Failed to create graphics pipeline with VS {:016X}, PS {:016X}",
|
||||
runtime_description.vertex_shader->shader().ucode_data_hash(),
|
||||
runtime_description.pixel_shader->shader().ucode_data_hash());
|
||||
XELOGE(
|
||||
"Failed to create graphics pipeline with VS {:016X}, PS {:016X}: "
|
||||
"HRESULT 0x{:08X}",
|
||||
runtime_description.vertex_shader->shader().ucode_data_hash(),
|
||||
runtime_description.pixel_shader->shader().ucode_data_hash(), hr);
|
||||
} else {
|
||||
XELOGE("Failed to create graphics pipeline with VS {:016X}",
|
||||
runtime_description.vertex_shader->shader().ucode_data_hash());
|
||||
XELOGE(
|
||||
"Failed to create graphics pipeline with VS {:016X}: HRESULT "
|
||||
"0x{:08X}",
|
||||
runtime_description.vertex_shader->shader().ucode_data_hash(), hr);
|
||||
}
|
||||
// Log D3D12 debug messages for all pipeline failures.
|
||||
command_processor_.GetD3D12Provider().LogD3D12DebugMessages();
|
||||
return nullptr;
|
||||
}
|
||||
std::wstring name;
|
||||
@@ -3346,6 +3458,33 @@ void PipelineCache::StorageWriteThread() {
|
||||
}
|
||||
|
||||
void PipelineCache::CreationThread(size_t thread_index) {
|
||||
// Create thread-local translator to avoid contention with main thread.
|
||||
// This mirrors what the shader storage loading threads do.
|
||||
const ui::d3d12::D3D12Provider& provider =
|
||||
command_processor_.GetD3D12Provider();
|
||||
bool edram_rov_used = render_target_cache_.GetPath() ==
|
||||
RenderTargetCache::Path::kPixelShaderInterlock;
|
||||
StringBuffer ucode_disasm_buffer;
|
||||
DxbcShaderTranslator translator(
|
||||
provider.GetAdapterVendorID(), bindless_resources_used_, edram_rov_used,
|
||||
!(edram_rov_used ||
|
||||
render_target_cache_.gamma_render_target_as_unorm16()),
|
||||
render_target_cache_.msaa_2x_supported(),
|
||||
render_target_cache_.draw_resolution_scale_x(),
|
||||
render_target_cache_.draw_resolution_scale_y(),
|
||||
provider.GetGraphicsAnalysis() != nullptr);
|
||||
// Create thread-local DXIL conversion objects if needed.
|
||||
IDxbcConverter* dxbc_converter = nullptr;
|
||||
IDxcUtils* dxc_utils = nullptr;
|
||||
IDxcCompiler* dxc_compiler = nullptr;
|
||||
if (cvars::d3d12_dxbc_disasm_dxilconv && dxbc_converter_ && dxc_utils_ &&
|
||||
dxc_compiler_) {
|
||||
provider.DxbcConverterCreateInstance(CLSID_DxbcConverter,
|
||||
IID_PPV_ARGS(&dxbc_converter));
|
||||
provider.DxcCreateInstance(CLSID_DxcUtils, IID_PPV_ARGS(&dxc_utils));
|
||||
provider.DxcCreateInstance(CLSID_DxcCompiler, IID_PPV_ARGS(&dxc_compiler));
|
||||
}
|
||||
|
||||
while (true) {
|
||||
Pipeline* pipeline_to_create = nullptr;
|
||||
|
||||
@@ -3361,6 +3500,10 @@ void PipelineCache::CreationThread(size_t thread_index) {
|
||||
creation_completion_event_->Set();
|
||||
}
|
||||
if (thread_index >= creation_threads_shutdown_from_) {
|
||||
// Cleanup thread-local resources.
|
||||
if (dxc_compiler) dxc_compiler->Release();
|
||||
if (dxc_utils) dxc_utils->Release();
|
||||
if (dxbc_converter) dxbc_converter->Release();
|
||||
return;
|
||||
}
|
||||
creation_request_cond_.wait(lock);
|
||||
@@ -3370,15 +3513,38 @@ void PipelineCache::CreationThread(size_t thread_index) {
|
||||
// until the pipeline is created - other threads must be able to dequeue
|
||||
// requests, but can't set the completion event until the pipelines are
|
||||
// fully created (rather than just started creating).
|
||||
pipeline_to_create = creation_queue_.front();
|
||||
creation_queue_.pop_front();
|
||||
pipeline_to_create = creation_queue_.top();
|
||||
creation_queue_.pop();
|
||||
++creation_threads_busy_;
|
||||
}
|
||||
|
||||
// Translate pending shaders and update root signature.
|
||||
EnsurePipelineShadersTranslated(pipeline_to_create, translator,
|
||||
ucode_disasm_buffer, dxbc_converter,
|
||||
dxc_utils, dxc_compiler,
|
||||
/*use_try_claim=*/true,
|
||||
/*handle_non_placeholder=*/true);
|
||||
|
||||
// Create the D3D12 pipeline state object.
|
||||
pipeline_to_create->state =
|
||||
ID3D12PipelineState* new_state =
|
||||
CreateD3D12Pipeline(pipeline_to_create->description);
|
||||
|
||||
// Store the pipeline. If creation failed, state stays nullptr and draws
|
||||
// will be skipped.
|
||||
if (new_state != nullptr) {
|
||||
pipeline_to_create->state.store(new_state, std::memory_order_release);
|
||||
} else {
|
||||
XELOGE("Pipeline creation failed (VS {:016X}, PS {:016X})",
|
||||
pipeline_to_create->description.vertex_shader
|
||||
? pipeline_to_create->description.vertex_shader->shader()
|
||||
.ucode_data_hash()
|
||||
: 0,
|
||||
pipeline_to_create->description.pixel_shader
|
||||
? pipeline_to_create->description.pixel_shader->shader()
|
||||
.ucode_data_hash()
|
||||
: 0);
|
||||
}
|
||||
|
||||
// Pipeline created - the thread is not busy anymore, safe to set the
|
||||
// completion event if needed (at the next iteration, or in some other
|
||||
// thread).
|
||||
@@ -3398,11 +3564,26 @@ void PipelineCache::CreateQueuedPipelinesOnProcessorThread() {
|
||||
if (creation_queue_.empty()) {
|
||||
break;
|
||||
}
|
||||
pipeline_to_create = creation_queue_.front();
|
||||
creation_queue_.pop_front();
|
||||
pipeline_to_create = creation_queue_.top();
|
||||
creation_queue_.pop();
|
||||
}
|
||||
pipeline_to_create->state =
|
||||
|
||||
// Translate pending shaders and update root signature.
|
||||
EnsurePipelineShadersTranslated(pipeline_to_create, *shader_translator_,
|
||||
ucode_disasm_buffer_, dxbc_converter_,
|
||||
dxc_utils_, dxc_compiler_,
|
||||
/*use_try_claim=*/true,
|
||||
/*handle_non_placeholder=*/true);
|
||||
|
||||
ID3D12PipelineState* new_state =
|
||||
CreateD3D12Pipeline(pipeline_to_create->description);
|
||||
|
||||
// Store the pipeline. If creation failed, state stays nullptr.
|
||||
if (new_state != nullptr) {
|
||||
pipeline_to_create->state.store(new_state, std::memory_order_release);
|
||||
} else {
|
||||
XELOGW("ProcessorThread: Pipeline creation failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user