mirror of
https://github.com/lucannez64/darling-lol.git
synced 2026-10-26 19:47:42 +00:00
Fix Metal device edge cases; keep reporting the host GPU
From #61, without the default "AMD Radeon Pro 5500M" / 4 GB identity: the host GPU's name and Vulkan working set stay the default for every app, and the existing DARLING_METAL_GPU_NAME and DARLING_METAL_VRAM_MB overrides remain the opt-in. Reporting 4 GB on a 2 GB card invites allocation failures, and under-reports larger cards. Kept from #61: - registryID returns MTLDeviceInfo::kDefaultRegistryID instead of 0; a real Mac never reports 0 and apps key devices by it. - Vertex buffer layout and attribute arrays release the old item after retiring it; the retire step read the item after the release could free it. - newDepthStencilState, newSamplerState and the device name return nil (or the fallback name) when Indium throws, instead of unwinding through Objective-C. - MTLDepthStencilGetIndium is declared only with DARLING_METAL_ENABLED. - The replaceRegion trace probes at most `width` bytes, which stays inside the first row for every pixel format. metal.patch is regenerated in its existing file order, so the diff is these changes only. tests/test_metal_device_info.py compiles the host check against MTLDeviceInfo.h as shipped in the patch and runs in CI. Split from #61. Co-authored-by: DevWebeloper <[email protected]> Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
603b5a168e
commit
3752d9e812
6 files changed
+107
-25
No files matched your search
@@ -17,3 +17,4 @@ jobs:
|
||||
- run: python -m unittest discover -s tests -p 'test_hidden_riot_recovery.py'
|
||||
- run: python -m unittest discover -s tests -p 'test_desktop_run.py'
|
||||
- run: python -m unittest discover -s tests -p 'test_urlsession_runner.py'
|
||||
- run: python -m unittest discover -s tests -p 'test_metal_device_info.py'
|
||||
+1
-1
@@ -23,7 +23,7 @@ c++ -std=c++17 -I build/darling-src/src/external/metal/private-include \
|
||||
build/test-metal-device-info
|
||||
```
|
||||
|
||||
Expected: `METAL_DEVICE_INFO failures=0 pass=1`. It covers how `MTLDevice.name` turns Vulkan names into macOS style (`AMD Radeon RX 6800 XT (RADV NAVI21)` becomes `AMD Radeon RX 6800 XT`, `Intel(R) UHD Graphics 630 (CML GT2)` becomes `Intel UHD Graphics 630`, NVIDIA names stay as they are), the `DARLING_METAL_VRAM_MB` parser, and the working-set sum over Vulkan heaps.
|
||||
Expected: `METAL_DEVICE_INFO failures=0 pass=1`. It covers how `MTLDevice.name` turns Vulkan names into macOS style (`AMD Radeon RX 6800 XT (RADV NAVI21)` becomes `AMD Radeon RX 6800 XT`, `Intel(R) UHD Graphics 630 (CML GT2)` becomes `Intel UHD Graphics 630`, NVIDIA names stay as they are), the `DARLING_METAL_VRAM_MB` parser, the working-set sum over Vulkan heaps, and a non-zero `registryID`. `python3 -m unittest tests.test_metal_device_info` runs the same check against the header in `patches/metal.patch`, so it also works without a prepared source tree; CI runs it.
|
||||
|
||||
The LLVM version check compiles Iridium's actual dynamic loader against small stub libraries. It verifies that a matching major initializes, older/newer majors produce a version mismatch, and a missing library fails initialization. It does not start Darling. The native AIR translator (`python3 tools/build-riot-air-check.py --native`) selects both headers and the shared library from the same `llvm-config`; its build prints those paths so shader results can be attributed to the tested LLVM installation.
|
||||
|
||||
|
||||
@@ -375,6 +375,20 @@ Tested on a GTX 1060 6GB with driver 580.178.04 on NixOS, Hyprland and Xwayland:
|
||||
the client and the tutorial match render on the NVIDIA GPU. Newer drivers were not
|
||||
retested with the option off.
|
||||
|
||||
### Metal device name and memory budget
|
||||
|
||||
`MTLDevice` reports the host GPU: its Vulkan name in macOS style (for example
|
||||
`AMD Radeon RX 6800 XT (RADV NAVI21)` becomes `AMD Radeon RX 6800 XT`) and a working set
|
||||
(`recommendedMaxWorkingSetSize`) from the GPU's Vulkan memory heaps. Apps size their GPU
|
||||
allocations from the working set, so only override it to match the card's real memory.
|
||||
Both can be overridden per launch:
|
||||
|
||||
```sh
|
||||
DARLING_METAL_GPU_NAME='AMD Radeon Pro 5500M' DARLING_METAL_VRAM_MB=4096 ./scripts/run.sh
|
||||
```
|
||||
|
||||
`registryID` is a fixed non-zero value, as on a real Mac.
|
||||
|
||||
### Host-side checks
|
||||
|
||||
These are host settings, not game settings. Run on AC power, and use a performance power profile (`powerprofilesctl set performance`, or `asusctl profile -P Performance` on ASUS laptops). On the tested desktop the game is limited mostly by its own main thread on the CPU, not by the GPU (see [status](STATUS.md#game-fps-2026-10-1314)), so laptop CPU power limits matter as much as the GPU choice.
|
||||
|
||||
+38
-24
@@ -1134,7 +1134,7 @@ index 2a57796..0cbedf2 100644
|
||||
-
|
||||
@end
|
||||
diff --git a/src/Metal/MTLDevice.mm b/src/Metal/MTLDevice.mm
|
||||
index f58184a..a80a625 100644
|
||||
index f58184a..ed918ed 100644
|
||||
--- a/src/Metal/MTLDevice.mm
|
||||
+++ b/src/Metal/MTLDevice.mm
|
||||
@@ -2,14 +2,32 @@
|
||||
@@ -1218,7 +1218,7 @@ index f58184a..a80a625 100644
|
||||
}
|
||||
|
||||
@synthesize device = _device;
|
||||
@@ -116,6 +168,39 @@ void MTLRemoveDeviceObserver(id<NSObject> observer) {
|
||||
@@ -116,6 +168,44 @@ void MTLRemoveDeviceObserver(id<NSObject> observer) {
|
||||
[_threadExitCondition unlock];
|
||||
}
|
||||
|
||||
@@ -1229,7 +1229,12 @@ index f58184a..a80a625 100644
|
||||
+ static const char* const nameOverride = getenv("DARLING_METAL_GPU_NAME");
|
||||
+ static const uint64_t vramOverride = MTLDeviceInfo::parseMegabytes(getenv("DARLING_METAL_VRAM_MB"));
|
||||
+
|
||||
+ std::string vulkanName = _device->name();
|
||||
+ std::string vulkanName;
|
||||
+ try {
|
||||
+ vulkanName = _device ? _device->name() : "";
|
||||
+ } catch (...) {
|
||||
+ vulkanName = "";
|
||||
+ }
|
||||
+ _name = [[NSString alloc] initWithUTF8String: MTLDeviceInfo::resolveName(nameOverride, vulkanName.c_str()).c_str()];
|
||||
+ if (_name == nil) {
|
||||
+ _name = [[NSString alloc] initWithUTF8String: MTLDeviceInfo::kFallbackName];
|
||||
@@ -1258,7 +1263,7 @@ index f58184a..a80a625 100644
|
||||
- (instancetype)initWithDevice: (std::shared_ptr<Indium::Device>)device
|
||||
{
|
||||
self = [super init];
|
||||
@@ -123,6 +208,7 @@ void MTLRemoveDeviceObserver(id<NSObject> observer) {
|
||||
@@ -123,6 +213,7 @@ void MTLRemoveDeviceObserver(id<NSObject> observer) {
|
||||
_device = device;
|
||||
_threadExitCondition = [NSCondition new];
|
||||
_threadIsRunning = NO;
|
||||
@@ -1266,7 +1271,7 @@ index f58184a..a80a625 100644
|
||||
_pollingThread = [[NSThread alloc] initWithTarget: self selector: @selector(pollingLoop) object: nil];
|
||||
[_pollingThread start];
|
||||
}
|
||||
@@ -133,6 +219,7 @@ void MTLRemoveDeviceObserver(id<NSObject> observer) {
|
||||
@@ -133,6 +224,7 @@ void MTLRemoveDeviceObserver(id<NSObject> observer) {
|
||||
{
|
||||
[_pollingThread release];
|
||||
[_threadExitCondition release];
|
||||
@@ -1274,7 +1279,7 @@ index f58184a..a80a625 100644
|
||||
|
||||
[super dealloc];
|
||||
}
|
||||
@@ -153,12 +240,60 @@ void MTLRemoveDeviceObserver(id<NSObject> observer) {
|
||||
@@ -153,12 +245,60 @@ void MTLRemoveDeviceObserver(id<NSObject> observer) {
|
||||
[_threadExitCondition unlock];
|
||||
}
|
||||
|
||||
@@ -1311,7 +1316,7 @@ index f58184a..a80a625 100644
|
||||
+- (BOOL)isRemovable { return NO; }
|
||||
+- (BOOL)isHeadless { return NO; }
|
||||
+- (NSString*)name { return _name; }
|
||||
+- (uint64_t)registryID { return 0; }
|
||||
+- (uint64_t)registryID { return MTLDeviceInfo::kDefaultRegistryID; }
|
||||
+- (BOOL)supportsFamily: (NSUInteger)family { return YES; }
|
||||
+- (BOOL)supportsFeatureSet: (NSUInteger)featureSet { return YES; }
|
||||
+- (BOOL)supportsTextureSampleCount: (NSUInteger)sampleCount { return sampleCount <= 4; }
|
||||
@@ -1336,7 +1341,7 @@ index f58184a..a80a625 100644
|
||||
if (!pso) {
|
||||
if (error) {
|
||||
// TODO: better error and/or match what the official Metal method does
|
||||
@@ -166,6 +301,7 @@ void MTLRemoveDeviceObserver(id<NSObject> observer) {
|
||||
@@ -166,6 +306,7 @@ void MTLRemoveDeviceObserver(id<NSObject> observer) {
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
@@ -1344,7 +1349,7 @@ index f58184a..a80a625 100644
|
||||
return [[MTLComputePipelineStateInternal alloc] initWithState: pso device: self label: descriptor.label];
|
||||
}
|
||||
|
||||
@@ -183,7 +319,13 @@ void MTLRemoveDeviceObserver(id<NSObject> observer) {
|
||||
@@ -183,7 +324,13 @@ void MTLRemoveDeviceObserver(id<NSObject> observer) {
|
||||
reflection: (MTLAutoreleasedComputePipelineReflection*)reflection
|
||||
error: (NSError**)error
|
||||
{
|
||||
@@ -1359,7 +1364,7 @@ index f58184a..a80a625 100644
|
||||
if (!pso) {
|
||||
if (error) {
|
||||
// TODO: better error and/or match what the official Metal method does
|
||||
@@ -191,13 +333,44 @@ void MTLRemoveDeviceObserver(id<NSObject> observer) {
|
||||
@@ -191,13 +338,44 @@ void MTLRemoveDeviceObserver(id<NSObject> observer) {
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
@@ -1405,7 +1410,7 @@ index f58184a..a80a625 100644
|
||||
if (!pso) {
|
||||
if (error) {
|
||||
// TODO: better error and/or match what the official Metal method does
|
||||
@@ -205,7 +378,114 @@ void MTLRemoveDeviceObserver(id<NSObject> observer) {
|
||||
@@ -205,7 +383,118 @@ void MTLRemoveDeviceObserver(id<NSObject> observer) {
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
@@ -1453,7 +1458,9 @@ index f58184a..a80a625 100644
|
||||
+- (id<MTLDepthStencilState>)newDepthStencilStateWithDescriptor:(MTLDepthStencilDescriptor*)descriptor
|
||||
+{
|
||||
+ if (!descriptor) return nil;
|
||||
+ auto state = _device->newDepthStencilState([descriptor asIndiumDescriptor]);
|
||||
+ std::shared_ptr<Indium::DepthStencilState> state;
|
||||
+ try { state = _device->newDepthStencilState([descriptor asIndiumDescriptor]); }
|
||||
+ catch (...) { return nil; }
|
||||
+ if (!state) return nil;
|
||||
+ return [[MTLDepthStencilStateInternal alloc] initWithState:state device:self label:descriptor.label];
|
||||
+}
|
||||
@@ -1475,7 +1482,9 @@ index f58184a..a80a625 100644
|
||||
+ desc.lodMaxClamp = descriptor.lodMaxClamp;
|
||||
+ desc.supportArgumentBuffers = descriptor.supportArgumentBuffers;
|
||||
+ desc.compareFunction = static_cast<Indium::CompareFunction>(descriptor.compareFunction);
|
||||
+ auto sampler = _device->newSamplerState(desc);
|
||||
+ std::shared_ptr<Indium::SamplerState> sampler;
|
||||
+ try { sampler = _device->newSamplerState(desc); }
|
||||
+ catch (...) { return nil; }
|
||||
+ if (!sampler) return nil;
|
||||
+ return [[MTLSamplerStateInternal alloc] initWithSampler:sampler device:self label:descriptor.label];
|
||||
+}
|
||||
@@ -1521,7 +1530,7 @@ index f58184a..a80a625 100644
|
||||
}
|
||||
|
||||
- (id<MTLCommandQueue>)newCommandQueue
|
||||
@@ -275,8 +555,21 @@ void MTLRemoveDeviceObserver(id<NSObject> observer) {
|
||||
@@ -275,8 +564,21 @@ void MTLRemoveDeviceObserver(id<NSObject> observer) {
|
||||
- (id<MTLLibrary>)newLibraryWithData: (dispatch_data_t)data
|
||||
error: (NSError**)error
|
||||
{
|
||||
@@ -2581,7 +2590,7 @@ index 66a9056..f28235c 100644
|
||||
-
|
||||
@end
|
||||
diff --git a/src/Metal/MTLTexture.mm b/src/Metal/MTLTexture.mm
|
||||
index 6c71fb9..822c112 100644
|
||||
index 6c71fb9..186b8c0 100644
|
||||
--- a/src/Metal/MTLTexture.mm
|
||||
+++ b/src/Metal/MTLTexture.mm
|
||||
@@ -1,3 +1,5 @@
|
||||
@@ -2590,7 +2599,7 @@ index 6c71fb9..822c112 100644
|
||||
// SPDX-FileCopyrightText: 2026 Darling Developers
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
@@ -7,11 +9,37 @@
|
||||
@@ -7,11 +9,38 @@
|
||||
#import <Metal/MTLPixelFormatInternal.h>
|
||||
#import <Metal/MTLTypesInternal.h>
|
||||
|
||||
@@ -2606,7 +2615,8 @@ index 6c71fb9..822c112 100644
|
||||
+ if (FILE* log = fopen("/tmp/metal-trace.log", "a")) {
|
||||
+ const unsigned char* b = (const unsigned char*) bytes;
|
||||
+ unsigned long nonZero = 0;
|
||||
+ size_t probe = std::min<size_t>(4096, (size_t) region.size.width * 4);
|
||||
+ // Every pixel format takes at least one byte per pixel, so the first `width` bytes lie inside the first row.
|
||||
+ size_t probe = std::min<size_t>(4096, (size_t) region.size.width);
|
||||
+ for (size_t i = 0; i < probe; ++i) if (b[i]) ++nonZero;
|
||||
+ fprintf(log, "replaceRegion tex=%p %lux%lu fmt=%lu level=%lu region=(%lu,%lu %lux%lu) firstBytesNonZero=%lu/%zu took=%.1fms\n", texture,
|
||||
+ (unsigned long) texture.width, (unsigned long) texture.height, (unsigned long) texture.pixelFormat, (unsigned long) level,
|
||||
@@ -2628,7 +2638,7 @@ index 6c71fb9..822c112 100644
|
||||
@synthesize device = _device;
|
||||
@synthesize resourceOptions = _resourceOptions;
|
||||
@synthesize label = _label;
|
||||
@@ -164,7 +192,9 @@
|
||||
@@ -164,7 +193,9 @@
|
||||
withBytes: (const void*)pixelBytes
|
||||
bytesPerRow: (NSUInteger)bytesPerRow
|
||||
{
|
||||
@@ -2638,7 +2648,7 @@ index 6c71fb9..822c112 100644
|
||||
}
|
||||
|
||||
- (void)replaceRegion: (MTLRegion)region
|
||||
@@ -174,7 +204,9 @@
|
||||
@@ -174,7 +205,9 @@
|
||||
bytesPerRow: (NSUInteger)bytesPerRow
|
||||
bytesPerImage: (NSUInteger)bytesPerImage
|
||||
{
|
||||
@@ -2736,7 +2746,7 @@ index 37cccb3..79b2350 100644
|
||||
-
|
||||
@end
|
||||
diff --git a/src/Metal/MTLVertexDescriptor.mm b/src/Metal/MTLVertexDescriptor.mm
|
||||
index cde968d..aa7fe05 100644
|
||||
index cde968d..0a016c6 100644
|
||||
--- a/src/Metal/MTLVertexDescriptor.mm
|
||||
+++ b/src/Metal/MTLVertexDescriptor.mm
|
||||
@@ -1,24 +1,435 @@
|
||||
@@ -3015,8 +3025,8 @@ index cde968d..aa7fe05 100644
|
||||
+ if (index >= 31) [NSException raise:NSRangeException format:@"Vertex descriptor index %lu exceeds 30", (unsigned long)index];
|
||||
+ MTLVertexBufferLayoutDescriptor* old = _items[index];
|
||||
+ _items[index] = [descriptor retain];
|
||||
+ [old release];
|
||||
+ MTLVertexBufferLayoutArrayRetireItem(self, old);
|
||||
+ [old release];
|
||||
+ const uint32_t bit = 1u << index;
|
||||
+ if (descriptor) _present |= bit; else _present &= ~bit;
|
||||
+ if (descriptor && object_getClass(descriptor) == [MTLVertexBufferLayoutDescriptor class]) _concrete |= bit; else _concrete &= ~bit;
|
||||
@@ -3077,8 +3087,8 @@ index cde968d..aa7fe05 100644
|
||||
+ if (index >= 31) [NSException raise:NSRangeException format:@"Vertex descriptor index %lu exceeds 30", (unsigned long)index];
|
||||
+ MTLVertexAttributeDescriptor* old = _items[index];
|
||||
+ _items[index] = [descriptor retain];
|
||||
+ [old release];
|
||||
+ MTLVertexAttributeArrayRetireItem(self, old);
|
||||
+ [old release];
|
||||
+ const uint32_t bit = 1u << index;
|
||||
+ if (descriptor) _present |= bit; else _present &= ~bit;
|
||||
+ if (descriptor && object_getClass(descriptor) == [MTLVertexAttributeDescriptor class]) _concrete |= bit; else _concrete &= ~bit;
|
||||
@@ -3440,7 +3450,7 @@ diff --git a/private-include/Metal/MTLDepthStencilInternal.h b/private-include/M
|
||||
new file mode 100644
|
||||
--- /dev/null
|
||||
+++ b/private-include/Metal/MTLDepthStencilInternal.h
|
||||
@@ -0,0 +1,25 @@
|
||||
@@ -0,0 +1,27 @@
|
||||
+// SPDX-FileCopyrightText: 2026 Darling Developers
|
||||
+// SPDX-License-Identifier: MPL-2.0
|
||||
+
|
||||
@@ -3462,15 +3472,17 @@ new file mode 100644
|
||||
+- (instancetype)initWithState:(std::shared_ptr<Indium::DepthStencilState>)state device:(id<MTLDevice>)device label:(NSString*)label;
|
||||
+#endif
|
||||
+@end
|
||||
+#if DARLING_METAL_ENABLED
|
||||
+// Direct access for the encoders: the property getter is a message send that returns the shared_ptr by value.
|
||||
+const std::shared_ptr<Indium::DepthStencilState>& MTLDepthStencilGetIndium(id object);
|
||||
+#endif
|
||||
+
|
||||
+#endif
|
||||
diff --git a/private-include/Metal/MTLDeviceInfo.h b/private-include/Metal/MTLDeviceInfo.h
|
||||
new file mode 100644
|
||||
--- /dev/null
|
||||
+++ b/private-include/Metal/MTLDeviceInfo.h
|
||||
@@ -0,0 +1,148 @@
|
||||
@@ -0,0 +1,150 @@
|
||||
+// SPDX-FileCopyrightText: 2026 Darling Developers
|
||||
+// SPDX-License-Identifier: MPL-2.0
|
||||
+
|
||||
@@ -3489,6 +3501,8 @@ new file mode 100644
|
||||
+
|
||||
+// Used when neither the cleaned nor the raw Vulkan name has any characters.
|
||||
+static constexpr const char* kFallbackName = "Darling Indium Device";
|
||||
+// A real Mac never reports registry ID 0, and apps use it as a key for the device.
|
||||
+static constexpr uint64_t kDefaultRegistryID = 0x100000439ULL;
|
||||
+
|
||||
+static inline bool isSpace(char c) {
|
||||
+ return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\f' || c == '\v';
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Compile tools/test-metal-device-info.cpp against MTLDeviceInfo.h as shipped in patches/metal.patch."""
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
HEADER = 'private-include/Metal/MTLDeviceInfo.h'
|
||||
|
||||
|
||||
def header_from_patch():
|
||||
"""MTLDeviceInfo.h is a new file in the patch, so its text is the section's added lines."""
|
||||
patch = (ROOT / 'patches/metal.patch').read_text()
|
||||
section = re.search(r'(?ms)^diff --git a/%s b/%s\n.*?^@@ [^\n]*\n(.*?)(?=^diff --git |\Z)' % (HEADER, HEADER), patch)
|
||||
if not section:
|
||||
raise AssertionError(HEADER + ' is not a new file in patches/metal.patch')
|
||||
lines = section.group(1).splitlines(keepends=True)
|
||||
if not all(line.startswith('+') for line in lines):
|
||||
raise AssertionError(HEADER + ' section has context or removed lines')
|
||||
return ''.join(line[1:] for line in lines)
|
||||
|
||||
|
||||
class MetalDeviceInfoTests(unittest.TestCase):
|
||||
def test_host_check_passes(self):
|
||||
compiler = shutil.which('c++')
|
||||
if not compiler:
|
||||
self.skipTest('no C++ compiler')
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
include = Path(folder) / 'include/Metal'
|
||||
include.mkdir(parents=True)
|
||||
(include / 'MTLDeviceInfo.h').write_text(header_from_patch())
|
||||
binary = Path(folder) / 'test-metal-device-info'
|
||||
build = subprocess.run([compiler, '-std=c++17', '-Wall', '-Werror', '-I', str(include.parent),
|
||||
str(ROOT / 'tools/test-metal-device-info.cpp'), '-o', str(binary)],
|
||||
capture_output=True, text=True)
|
||||
self.assertEqual(build.returncode, 0, build.stderr)
|
||||
result = subprocess.run([str(binary)], capture_output=True, text=True, timeout=30)
|
||||
self.assertEqual(result.returncode, 0, result.stdout)
|
||||
self.assertIn('METAL_DEVICE_INFO failures=0 pass=1', result.stdout)
|
||||
self.assertIn('PASS registry ID is non-zero', result.stdout)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -49,6 +49,13 @@ int main() {
|
||||
expectName("", MTLDeviceInfo::kFallbackName);
|
||||
expectName(nullptr, MTLDeviceInfo::kFallbackName);
|
||||
|
||||
// -[MTLDevice registryID]: never 0, like a real Mac.
|
||||
{
|
||||
bool ok = MTLDeviceInfo::kDefaultRegistryID != 0;
|
||||
failures += !ok;
|
||||
std::printf("%s registry ID is non-zero\n", ok ? "PASS" : "FAIL");
|
||||
}
|
||||
|
||||
// DARLING_METAL_GPU_NAME override.
|
||||
{
|
||||
bool ok = MTLDeviceInfo::resolveName("My GPU", "AMD Radeon RX 6800 XT (RADV NAVI21)") == "My GPU"
|
||||
|
||||
Reference in new issue
Block a user