diff --git a/.github/workflows/source-check.yml b/.github/workflows/source-check.yml index 1d02ffc..893aafd 100644 --- a/.github/workflows/source-check.yml +++ b/.github/workflows/source-check.yml @@ -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' diff --git a/docs/TESTING.md b/docs/TESTING.md index 8a544f9..78b7f54 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -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. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index e9494f3..a498826 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -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. diff --git a/patches/metal.patch b/patches/metal.patch index adeb448..a5292d3 100644 --- a/patches/metal.patch +++ b/patches/metal.patch @@ -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 observer) { +@@ -116,6 +168,44 @@ void MTLRemoveDeviceObserver(id 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)device { self = [super init]; -@@ -123,6 +208,7 @@ void MTLRemoveDeviceObserver(id observer) { +@@ -123,6 +213,7 @@ void MTLRemoveDeviceObserver(id 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 observer) { +@@ -133,6 +224,7 @@ void MTLRemoveDeviceObserver(id observer) { { [_pollingThread release]; [_threadExitCondition release]; @@ -1274,7 +1279,7 @@ index f58184a..a80a625 100644 [super dealloc]; } -@@ -153,12 +240,60 @@ void MTLRemoveDeviceObserver(id observer) { +@@ -153,12 +245,60 @@ void MTLRemoveDeviceObserver(id 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 observer) { +@@ -166,6 +306,7 @@ void MTLRemoveDeviceObserver(id 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 observer) { +@@ -183,7 +324,13 @@ void MTLRemoveDeviceObserver(id 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 observer) { +@@ -191,13 +338,44 @@ void MTLRemoveDeviceObserver(id 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 observer) { +@@ -205,7 +383,118 @@ void MTLRemoveDeviceObserver(id observer) { } return nil; } @@ -1453,7 +1458,9 @@ index f58184a..a80a625 100644 +- (id)newDepthStencilStateWithDescriptor:(MTLDepthStencilDescriptor*)descriptor +{ + if (!descriptor) return nil; -+ auto state = _device->newDepthStencilState([descriptor asIndiumDescriptor]); ++ std::shared_ptr 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(descriptor.compareFunction); -+ auto sampler = _device->newSamplerState(desc); ++ std::shared_ptr 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)newCommandQueue -@@ -275,8 +555,21 @@ void MTLRemoveDeviceObserver(id observer) { +@@ -275,8 +564,21 @@ void MTLRemoveDeviceObserver(id observer) { - (id)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 #import @@ -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(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(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)state device:(id)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& 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'; diff --git a/tests/test_metal_device_info.py b/tests/test_metal_device_info.py new file mode 100644 index 0000000..4eac0f9 --- /dev/null +++ b/tests/test_metal_device_info.py @@ -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() diff --git a/tools/test-metal-device-info.cpp b/tools/test-metal-device-info.cpp index 926dd75..dd64710 100644 --- a/tools/test-metal-device-info.cpp +++ b/tools/test-metal-device-info.cpp @@ -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"