Files
darling-lol/scripts/preflight.py
T
MintMachineHQ 6a00849048 fix(preflight): require Vulkan 1.3 and timeline semaphores on the same device
vulkan_devices() flattened every apiVersion and check_vulkan() accepted any
timelineSemaphore = true, so a host with an old fast device and a new device
without timeline semaphores could pass without a usable device. Each GPUn
block is now one record, and one device must report both.

The full dump supplies the features and --summary the versions, matched by
GPU index. A dump without feature sections still gets the version check, and a
summary that lists no device is a required failure.

Adds three tests: a second device satisfies both, requirements are not
combined across devices, and no reported device.
2026-10-25 14:17:28 -06:00

307 lines
13 KiB
Python

#!/usr/bin/env python3
"""Check host versions the build and the installed runtime need, before the expensive steps.
Required checks exit 1 with one actionable line each; optional checks only warn.
Set RCLOL_SKIP_PREFLIGHT=1 in the environment to bypass the gate. Nothing here
touches the prefix, the guest or the network.
"""
import argparse
import json
import os
import platform
import re
import shutil
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / 'tools'))
import llvm_tools
MIN_PYTHON = (3, 10)
MIN_LLVM = 16 # LLVMGetVersion, checked by Iridium at startup, appeared in LLVM 16.
TESTED_LLVM = 22 # The patch tracks LLVM 22/23.
MIN_CMAKE = (3, 20) # The bundled indium CMakeLists requires 3.20.
MIN_LFS = (2, 13) # Debian 11 ships 2.13, the oldest release the setup is tested with.
MIN_KERNEL = (5, 14) # MADV_POPULATE_WRITE.
MIN_VULKAN = (1, 3) # Indium rejects older devices and needs timeline semaphores.
SOURCE = Path(os.environ.get('RCLOL_PREFLIGHT_SOURCE') or ROOT / 'build/darling-src')
POINTER = b'version https://git-lfs.github.com/spec/v1'
LIBRARY_SUFFIXES = ('.dylib', '.a', '.so', '.o')
def version(text):
"""(major, minor) from the first x.y in text, or None."""
match = re.search(r'(\d+)\.(\d+)', text or '')
return (int(match.group(1)), int(match.group(2))) if match else None
def dotted(value):
return '.'.join(str(part) for part in value) if value else 'unknown'
def output(command, timeout=30):
"""stdout of a command, or None when it is missing, fails or times out."""
try:
result = subprocess.run(command, capture_output=True, text=True, errors='replace',
timeout=timeout)
except (OSError, subprocess.SubprocessError):
return None
return result.stdout if result.returncode == 0 else None
def check_python(report):
found = platform.python_version()
report('python', sys.version_info[:2] >= MIN_PYTHON, found,
f'Python {dotted(MIN_PYTHON)} or newer is required; older releases lack standard '
'library calls the helpers use. Ubuntu 22.04 ships 3.10, which is enough.')
def check_cmake(report):
text = output(['cmake', '--version'])
found = version(text.splitlines()[0]) if text else None
report('cmake', found is not None and found >= MIN_CMAKE, dotted(found),
f'CMake {dotted(MIN_CMAKE)} or newer is required by the configured build.')
def check_git_lfs(report):
text = output(['git', 'lfs', 'version'])
found = version(text)
report('git-lfs', found is not None and found >= MIN_LFS, dotted(found),
f'Git LFS {dotted(MIN_LFS)} or newer is required: the pinned submodules ship '
'libraries as LFS objects and pointer text crashes the guest.')
def lfs_pointers(source):
"""Small library-like files under source that are still Git LFS pointer text."""
found = []
for folder, directories, files in os.walk(source):
directories[:] = [name for name in directories if name not in ('.git', '__pycache__')]
if Path(folder) == source:
directories[:] = [name for name in directories if name != 'build']
for name in files:
if not name.endswith(LIBRARY_SUFFIXES):
continue
path = Path(folder) / name
# A pointer is ~130 bytes. Skip everything else before opening it, so a
# prepared checkout adds about a second instead of opening every file.
try:
if path.is_symlink() or path.stat().st_size > 1024:
continue
with path.open('rb') as handle:
if handle.read(len(POINTER)) == POINTER:
found.append(path)
except OSError:
continue
return found
def check_lfs_pointers(report):
if not SOURCE.is_dir():
# The first build runs this before prepare.py fetches the pinned sources.
report('lfs-pointer', True, 'no prepared checkout yet')
return
pointers = lfs_pointers(SOURCE)
shown = ', '.join(str(path.relative_to(SOURCE)) for path in pointers[:3])
if len(pointers) > 3:
shown += f' (+{len(pointers) - 3} more)'
report('lfs-pointer', not pointers,
f'{len(pointers)} library file(s) still contain pointer text: {shown}'
if pointers else 'no pointer text in the prepared libraries',
'Git LFS did not fetch these objects. Install git-lfs and re-run '
'./scripts/build.sh (or git lfs pull in that checkout); a pointer file links but '
'crashes the guest at run time.')
def check_kernel(report):
release = os.environ.get('RCLOL_PREFLIGHT_KERNEL_RELEASE') or platform.release()
found = version(release)
report('kernel', found is not None and found >= MIN_KERNEL, release,
f'Linux {dotted(MIN_KERNEL)} or newer is required for MADV_POPULATE_WRITE, which '
"Darwin's allocator uses when it reuses pages.")
def check_llvm(report):
configuration = llvm_tools.find('llvm-config')
text = output([configuration, '--version']) if configuration else None
found = version(text.splitlines()[0]) if text else None
major = found[0] if found else None
report('llvm', major is not None and major >= MIN_LLVM,
f'{dotted(found)} ({configuration or "llvm-config not found"})',
f'LLVM {MIN_LLVM} or newer is required: Iridium calls LLVMGetVersion at startup and '
'LLVM 15 does not export it. LLVM 22 or 23 is what the patches are tested with. '
'Install the development package (for example llvm-dev or llvm23-dev).')
if major is not None and MIN_LLVM <= major < TESTED_LLVM:
report('llvm-tested', False, dotted(found),
f'LLVM {major} builds, but the patch tracks LLVM {TESTED_LLVM}/23 and older '
'majors are untested; use 22 or 23 to be safe.', required=False)
def check_llvm_tools(report):
tools = {name: llvm_tools.find(name) for name in ('llvm-objdump', 'llvm-nm')}
missing = [name for name, path in tools.items() if not path]
present = ', '.join(f'{name}={path}' for name, path in tools.items() if path)
report('llvm-tools', not missing, present or 'none found',
f'Install the LLVM tools package (for example llvm-dev or llvm23-tools): {", ".join(missing)} '
'not found as an unversioned name, as llvm-<tool>-<N> or in `llvm-config --bindir`. '
'The runtime install validates Darwin binaries with them.')
def gpu_blocks(text):
"""{index: [lines]} for each `GPUn:` block in vulkaninfo output."""
blocks = {}
current = None
for line in (text or '').splitlines():
header = re.match(r'GPU(\d+):\s*$', line)
if header:
current = blocks.setdefault(int(header.group(1)), [])
continue
if current is not None:
current.append(line)
return blocks
def first_match(pattern, lines):
for line in lines:
found = re.search(pattern, line)
if found:
return found.group(1).strip()
return None
def vulkan_devices():
"""One record per Vulkan device, or None when vulkaninfo does not run.
A device is kept as a whole record so a host with an old fast GPU and a new
GPU without timeline semaphores cannot pass by combining the two. The full
dump carries the features; `--summary` carries the versions that every
version of the tool prints.
"""
summary = output(['vulkaninfo', '--summary'])
if summary is None:
return None
features = gpu_blocks(output(['vulkaninfo'], timeout=60))
devices = []
for index, lines in sorted(gpu_blocks(summary).items()):
found = features.get(index, [])
values = [value == 'true'
for value in re.findall(r'timelineSemaphore\s*=\s*(true|false)',
'\n'.join(found))]
devices.append({'name': first_match(r'deviceName\s*=\s*(.+)', lines),
'api': version(first_match(r'apiVersion\s*=\s*(\d+\.\d+)', lines)),
'timeline': any(values) if values else None})
return devices
def describe_device(device):
text = f'{device["name"] or "unnamed device"} {dotted(device["api"])}'
if device['timeline'] is not None:
text += ', timelineSemaphore ' + ('true' if device['timeline'] else 'false')
return text
def check_vulkan(report):
devices = vulkan_devices()
if devices is None and shutil.which('vulkaninfo'):
report('vulkan', False, 'vulkaninfo did not finish',
'vulkaninfo is installed but exited with an error or timed out. Run '
'`vulkaninfo --summary` for the loader or ICD error, then fix or reinstall the '
'driver: the runtime needs Vulkan 1.3 and timeline semaphores.')
return
if devices is None:
report('vulkan', False, 'vulkaninfo not available',
'Install vulkan-tools to check the device. The runtime needs Vulkan 1.3 and '
'timeline semaphores; without a suitable device the game cannot render.',
required=False)
return
if not devices:
report('vulkan', False, 'no devices reported',
'`vulkaninfo --summary` listed no Vulkan device. Check the loader and the ICD '
'files, or reinstall the driver: the game cannot render without a device.')
return
# A dump that carries no feature section still gets the version check, as before.
checked = any(device['timeline'] is not None for device in devices)
usable = [device for device in devices
if device['api'] is not None and device['api'] >= MIN_VULKAN
and (device['timeline'] or not checked)]
report('vulkan', bool(usable), '; '.join(describe_device(device) for device in devices),
f'Vulkan {dotted(MIN_VULKAN)} or newer and timeline semaphores on the same device are '
'required: Indium rejects older devices and uses the semaphores. Update Mesa or the '
'vendor driver, or check `vulkaninfo`.')
def check_display(report):
display = os.environ.get('DISPLAY')
if not display:
report('x11', False, 'DISPLAY unset',
'Without X11/Xwayland the runtime boots but the game cannot present; expected on '
'a headless host that is only used to build.', required=False)
return
if not shutil.which('xdpyinfo'):
report('x11', False, f'DISPLAY={display}, xdpyinfo not installed',
'Install x11-utils to check the display before a launch.', required=False)
return
if output(['xdpyinfo'], timeout=15) is None:
report('x11', False, f'DISPLAY={display}',
f'Cannot open {display}. On servers, SSH and sudo sessions export XAUTHORITY '
'with a cookie the user running Darling can read; otherwise iokitd runs without '
'X11/EGL and the game cannot render. Builds still work without it.',
required=False)
return
vendor = ''
for probe in (['glxinfo', '-B'], ['eglinfo']):
text = output(probe, timeout=20)
if text:
found = re.search(r'(?:OpenGL vendor string|EGL_VENDOR):\s*(.+)', text)
if found:
vendor = f', {probe[0]} vendor {found.group(1).strip()}'
break
report('x11', True, f'DISPLAY={display} (xdpyinfo){vendor}')
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--json', action='store_true', help='machine-readable output')
args = parser.parse_args()
checks = []
def report(name, ok, detail, hint=None, required=True):
checks.append({'check': name, 'ok': bool(ok), 'required': required,
'detail': detail, 'hint': None if ok else hint})
for check in (check_python, check_cmake, check_git_lfs, check_lfs_pointers, check_kernel,
check_llvm, check_llvm_tools, check_vulkan, check_display):
check(report)
failed = [check for check in checks if check['required'] and not check['ok']]
warned = [check for check in checks if not check['required'] and not check['ok']]
passed = [check for check in checks if check['ok']]
if args.json:
print(json.dumps({'ok': not failed, 'checks': checks}, indent=2))
else:
print('preflight: host checks the build and runtime need')
for check in checks:
state = 'ok ' if check['ok'] else ('FAIL' if check['required'] else 'warn')
print(f' {state} {check["check"]:<13} {check["detail"]}')
if check['hint'] and not check['ok']:
print(f' {check["hint"]}')
summary = f'preflight: {len(passed)} checks passed'
if warned:
summary += f', {len(warned)} warning' + ('s' if len(warned) != 1 else '')
if failed:
summary += (f', {len(failed)} required check'
+ ('s' if len(failed) != 1 else '') + ' failed')
print(summary)
if failed:
if not args.json:
print('preflight: fix the required checks above, or set RCLOL_SKIP_PREFLIGHT=1 to bypass',
file=sys.stderr)
raise SystemExit(1)
if __name__ == '__main__':
main()