mirror of
https://github.com/lucannez64/darling-lol.git
synced 2026-10-26 19:47:42 +00:00
setup failures reported in #137, #138 and #143 happened only after the long fetch/build or at Riot startup. scripts/preflight.py now runs from scripts/build.sh (before configure.py, so an old interpreter gets this message and not a traceback) and from scripts/install-runtime.sh, and checks the host once: - python 3.10+, cmake 3.20+ (the bundled indium CMakeLists requires it), git-lfs 2.13+, kernel 5.14+ and LLVM 16+ exit nonzero with one actionable line each. RCLOL_SKIP_PREFLIGHT=1 bypasses the gate. - llvm-objdump and llvm-nm are required, and unresolved names are explained. - Vulkan 1.3 and timeline semaphores are required when vulkaninfo is installed; they only warn when it is not, because a missing tool does not mean a missing driver. - the X display is probed with xdpyinfo in the environment Darling inherits. It only warns: a build does not need a display. - a prepared build/darling-src is scanned for small .dylib/.a/.so/.o files that still contain Git LFS pointer text, which links but crashes the guest. - --json prints the same report for the diagnostic tools. The installers no longer call hashlib.file_digest (Python 3.11+), so Ubuntu 22.04's Python 3.10 works instead of failing after the overlays are built (#137). A streaming tools/file_digest.py replaces it. LLVM command line tools are resolved by tools/llvm_tools.py: an unversioned name on PATH first, then the highest llvm-<tool>-<N>, then llvm-config --bindir. Debian/Ubuntu install llvm-objdump and llvm-nm only under /usr/lib/llvm-<N>/bin, which broke the runtime validation (#138). Six tools now use it. The LLVM 16 floor also stops the LLVM 15 runtime abort from #139 before anything is built. Two test helpers used APIs added after Python 3.10; they are shimmed so the same suite runs on 3.10, 3.11 and 3.14. Adds tests/test_preflight.py (24 tests, fake host tools only).
238 lines
9.3 KiB
Python
238 lines
9.3 KiB
Python
#!/usr/bin/env python3
|
|
"""Audit which runtime library file the guest actually loads for every dylib a
|
|
binary uses, and estimate how it was compiled.
|
|
|
|
The guest resolves a dependent dylib by leaf name through DYLD_LIBRARY_PATH
|
|
before the install path (dyld3/PathOverrides.cpp:forEachPathVariant and the
|
|
legacy dyld2.cpp:loadPhase1 both do this), so a file in RuntimeLibraries
|
|
shadows both the per-user prefix copy and the installed SDK copy in
|
|
/usr/local/libexec/darling. Frameworks resolve the same way through
|
|
DYLD_FRAMEWORK_PATH.
|
|
|
|
The optimization estimate is the share of instructions that read or write a
|
|
stack slot, counted only in functions of 20 instructions or more. -O0 spills
|
|
every local; register-allocated -O2 code mostly does not. Calibrate with
|
|
--calibrate against the libmalloc -O0/-O2 objects in
|
|
build/perf-research/memory/o-compare.
|
|
"""
|
|
import argparse
|
|
import collections
|
|
import json
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
import llvm_tools
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
GAME = (Path.home() / ".darling/Applications/League of Legends.app/Contents/LoL/Game"
|
|
/ "LeagueofLegends.app/Contents/MacOS/LeagueofLegends")
|
|
# Overlay artifacts the tools/ builders produce and install; used only to name
|
|
# the builder behind an overlay file.
|
|
BUILDERS = {
|
|
"libsystem_kernel.dylib": "build-riot-kqueue-runtime.py",
|
|
"libsystem_malloc.dylib": "build-riot-malloc-runtime.py",
|
|
"libsystem_pthread.dylib": "build-riot-pthread-runtime.py",
|
|
"libsystem_platform.dylib": "build-riot-platform-strings.py",
|
|
"libobjc.A.dylib": "build-riot-objc-runtime.py",
|
|
"libc++abi.dylib": "build-riot-cxxabi-runtime.py",
|
|
"libc++.1.dylib": "build-riot-cxx-optimized.py",
|
|
"libindium.dylib": "build-riot-indium-runtime.py",
|
|
"libiridium.dylib": "build-riot-indium-runtime.py",
|
|
"libz.1.dylib": "-",
|
|
}
|
|
MIN_FUNCTION_INSTRUCTIONS = 20
|
|
|
|
|
|
def tool(*arguments):
|
|
"""Run an LLVM tool, resolving versioned names from PATH or llvm-config."""
|
|
return subprocess.run((llvm_tools.require(arguments[0]), *arguments[1:]),
|
|
capture_output=True, text=True, errors="replace").stdout
|
|
|
|
|
|
def load_commands(path, requested):
|
|
"""Direct dependencies of one Mach-O file, in load-command order."""
|
|
text = tool("llvm-objdump", "--macho", "--dylibs-used", str(path))
|
|
dependencies = []
|
|
for line in text.splitlines():
|
|
match = re.match(r"^\t(\S+) \(compatibility version", line)
|
|
if match:
|
|
dependencies.append(match.group(1))
|
|
return dependencies
|
|
|
|
|
|
def rpaths_of(path):
|
|
text = tool("llvm-objdump", "--macho", "--private-headers", str(path))
|
|
return re.findall(r"^\s+path (\S+) \(offset \d+\)$", text, re.M)
|
|
|
|
|
|
def framework_partial_path(path):
|
|
index = path.find(".framework/")
|
|
if index < 0:
|
|
return None
|
|
return path[path.rfind("/", 0, index) + 1:]
|
|
|
|
|
|
def resolve(install_path, origin, rpaths, config):
|
|
"""Return the file the guest would load for an install path, or None."""
|
|
overlay = config["prefix"].rstrip("/") + config["guest_home"] + "/RuntimeLibraries"
|
|
frameworks = config["prefix"].rstrip("/") + config["guest_home"] + "/RuntimeFrameworks"
|
|
sdk = config["sdk"].rstrip("/")
|
|
candidates = []
|
|
partial = framework_partial_path(install_path)
|
|
if partial is not None:
|
|
candidates.append(frameworks + "/" + partial)
|
|
else:
|
|
candidates.append(overlay + "/" + install_path.rsplit("/", 1)[-1])
|
|
if install_path.startswith("@rpath/"):
|
|
for rpath in list(rpaths) + [None]:
|
|
base = str(Path(origin).parent) if rpath in ("@loader_path", None) else rpath
|
|
if rpath == "@executable_path":
|
|
base = str(Path(config["game"]).parent)
|
|
if base is not None:
|
|
candidates.append(base.rstrip("/") + "/" + install_path[len("@rpath/"):])
|
|
elif install_path.startswith("@loader_path/"):
|
|
candidates.append(str(Path(origin).parent) + "/" + install_path[len("@loader_path/"):])
|
|
elif install_path.startswith("@executable_path/"):
|
|
candidates.append(str(Path(config["game"]).parent) + "/" + install_path[len("@executable_path/"):])
|
|
else:
|
|
candidates.append(install_path)
|
|
for candidate in candidates:
|
|
for root in ("", config["prefix"].rstrip("/"), sdk):
|
|
path = Path(root + candidate) if root or candidate.startswith("/") else None
|
|
if path is not None and path.is_file():
|
|
return path.resolve()
|
|
return None
|
|
|
|
|
|
def instructions(path):
|
|
"""Function-level instruction lists from a Mach-O file."""
|
|
text = tool("llvm-objdump", "--macho", "--disassemble", "--no-show-raw-insn", str(path))
|
|
functions = collections.OrderedDict()
|
|
current = None
|
|
for line in text.splitlines():
|
|
name = re.match(r"^([_a-zA-Z][\w.$]*):$", line.strip())
|
|
if name and not line.startswith(" "):
|
|
current = name.group(1)
|
|
functions[current] = []
|
|
elif current is not None:
|
|
instruction = re.match(r"^\s+[0-9a-f]+:\t(.*)$", line)
|
|
if instruction:
|
|
functions[current].append(instruction.group(1))
|
|
return functions
|
|
|
|
|
|
def optimization_estimate(path):
|
|
"""(instructions, stack references, function count) over functions >= 20
|
|
instructions."""
|
|
total = stack = functions = 0
|
|
try:
|
|
for body in instructions(path).values():
|
|
if len(body) < MIN_FUNCTION_INSTRUCTIONS:
|
|
continue
|
|
functions += 1
|
|
total += len(body)
|
|
stack += sum(1 for i in body if "(%rbp)" in i or "(%rsp)" in i)
|
|
except Exception as error: # a fat or malformed file is reported, not fatal
|
|
return 0, 0, 0, str(error)
|
|
return total, stack, functions, None
|
|
|
|
|
|
def imports(path):
|
|
"""Symbols the file imports, per providing dylib."""
|
|
counts = collections.Counter()
|
|
for option in ("--bind", "--lazy-bind", "--weak-bind"):
|
|
for line in tool("llvm-objdump", "--macho", option, str(path)).splitlines():
|
|
match = re.match(r"^\S+\s+\S+\s+0x[0-9a-f]+\s+\S+\s+\d+\s+(\S+)\s+(\S+)$", line)
|
|
if match:
|
|
counts[match.group(1)] += 1
|
|
return counts
|
|
|
|
|
|
def walk(config):
|
|
"""Resolve the transitive closure starting at the game executable."""
|
|
queue = [Path(config["game"]).resolve()]
|
|
seen = {}
|
|
order = []
|
|
while queue:
|
|
path = queue.pop(0)
|
|
if path in seen:
|
|
continue
|
|
origin = str(path)
|
|
seen[path] = rpaths_of(path)
|
|
order.append(path)
|
|
for dependency in load_commands(path, None):
|
|
if dependency.startswith("/usr/lib/libSystem.B.dylib"):
|
|
continue
|
|
resolved = resolve(dependency, origin, seen[path], config)
|
|
if resolved is None:
|
|
print("warning: cannot resolve " + dependency + " (from " + origin + ")",
|
|
file=sys.stderr)
|
|
continue
|
|
if resolved not in seen:
|
|
queue.append(resolved)
|
|
return order
|
|
|
|
|
|
def report(config):
|
|
order = walk(config)
|
|
print(f"{'library':42s} {'insns':>8s} {'stack%':>7s} {'funcs':>6s} {'-O':>3s} effective path")
|
|
names = {}
|
|
for path in order:
|
|
total, stack, functions, error = optimization_estimate(path)
|
|
if error:
|
|
print(f"{path.name:42s} error: {error}")
|
|
continue
|
|
share = 100.0 * stack / max(total, 1)
|
|
estimate = "-O0" if share > 20 else ("-O1+" if share > 8 else "-O2")
|
|
print(f"{path.name:42s} {total:8d} {share:6.1f}% {functions:6d} {estimate:>3s} {path}")
|
|
names[path] = estimate
|
|
print()
|
|
for path in order:
|
|
if path.name not in BUILDERS or BUILDERS[path.name] == "-":
|
|
continue
|
|
print(f"builder for {path.name}: tools/{BUILDERS[path.name]}")
|
|
return order, names
|
|
|
|
|
|
def calibrate():
|
|
folder = ROOT / "build/perf-research/memory/o-compare"
|
|
if not folder.is_dir():
|
|
sys.exit("no calibration objects in " + str(folder))
|
|
for base in sorted(set(p.name.rsplit(".", 2)[0] for p in folder.glob("*.O?.o"))):
|
|
row = [base]
|
|
for level in ("O0", "O2"):
|
|
path = folder / f"{base}.{level}.o"
|
|
total, stack, functions, _ = optimization_estimate(path)
|
|
row.append(f"{level} {total:6d} insns {100.0*stack/max(total,1):5.1f}% stack")
|
|
print("calibration " + row[0] + ": " + "; ".join(row[1:]))
|
|
|
|
|
|
def parse_arguments(argv=None):
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--binary", type=Path, default=None,
|
|
help="binary to audit (default: the installed League game)")
|
|
parser.add_argument("--config", type=Path, default=ROOT / "build/config.json")
|
|
parser.add_argument("--calibrate", action="store_true",
|
|
help="print the -O0/-O2 metric on known same-source objects")
|
|
arguments = parser.parse_args(argv)
|
|
return parser, arguments
|
|
|
|
|
|
def main():
|
|
parser, arguments = parse_arguments()
|
|
if arguments.calibrate:
|
|
calibrate()
|
|
return
|
|
config = json.loads(arguments.config.read_text())
|
|
config["game"] = str(arguments.binary or GAME)
|
|
if not Path(config["game"]).is_file():
|
|
parser.exit(1, "The binary to audit does not exist: " + config["game"] + "\n")
|
|
report(config)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|