diff --git a/tools/test-adreno-settings-ci-encoding.py b/tools/test-adreno-settings-ci-encoding.py new file mode 100755 index 0000000..c541f41 --- /dev/null +++ b/tools/test-adreno-settings-ci-encoding.py @@ -0,0 +1,288 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +"""The CoreImage compute shapes of the System Settings pane icons, decoded from the plug-in's packages (no GPU, no A16). + +ADRENO_SETTINGS_ICONS (gpu/adreno-kext/plugin/AdrenoMetal/AdrenoDrawCommands/Compute.m): iconservicesagent's CoreImage +kernels sample RGBA8Unorm_sRGB (icns device and display images), RG8Unorm / RG16Float (grey+alpha document icons) and +composite several inputs in one kernel (folder, documents, app icons with effects). The harness (gpu/adreno-kext/plugin/harness/settings-ci-encoding.m) runs one +CoreImage kernel per sampled format and a two-input srcOver, twice: as the deployed kext answers (no +AdrenoIBComputeSamplers) and standing in for a kext that publishes AdrenoIBComputeSamplers = 8. Checks: + - every single-input kernel is encoded, nothing omitted; its package's CS T# for the source texture has the format's + FMT6, swap, own channel mapping and sRGB decode (Mesa fd6_format_table; A8 as the fragment sampler encodes it), + linear tiling, the texture's size and a 64-byte-multiple pitch; the single shape keeps one sampler unit, a T# table of + T#0 (null) + T#1 and SP_CS_CONFIG NTEX 1 / NSAMP 1 / NUAV 2; + - the two-input kernel: without the property it is omitted, naming AdrenoIBComputeSamplers, and no package carries a + CS sampler load of more than one unit; with it, it is encoded with a 3-entry T# table (null, the two inputs, each + relocated READ to its own texture with its own format), a 2-unit sampler table of clamping samplers, SP_CS_TSIZE 3 + and SP_CS_CONFIG NTEX 3 / NSAMP 2; + - a replaceRegion/getBytes round trip of each format returns the written bytes on the plug-in device and on this + Mac's GPU (the CPU layout of the textures CoreImage uploads icons into); + - every package replays through the production validator, except the two-input package, which that validator must + refuse (the reason for the gate) and, with --cs-samplers-validator, this tree's validator built with + ADRENO_IB_CS_SAMPLERS and opted in (a kext booted with adreno_cs_samplers=1) must admit. +--expect-before: the unchanged plug-in must fail at least one of these. + + python3 tools/test-adreno-settings-ci-encoding.py --bundle BUNDLE --output NEW-DIR [--expect-before] + [--cs-samplers-validator] +""" +import argparse +import importlib.util +import json +import os +from pathlib import Path +import struct +import subprocess +import sys + +ROOT = Path(__file__).resolve().parents[1] +HARNESS = ROOT / 'gpu/adreno-kext/plugin/harness/settings-ci-encoding.m' + + +def load(name, path): + spec = importlib.util.spec_from_file_location(name, ROOT / path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +T = load('targets', 'tools/test-adreno-targets.py') +P, hv = T.P, T.header_value +SB6_CS_TEX, SB6_CS_SHADER = hv('A8XX_E_SB6_CS_TEX'), hv('A8XX_E_SB6_CS_SHADER') +ST6_SHADER, ST6_CONSTANTS, ST6_UAV = hv('A8XX_E_ST6_SHADER'), hv('A8XX_E_ST6_CONSTANTS'), hv('A8XX_E_ST6_UAV') +CP_LOAD_STATE6_FRAG = P.R.CP_LOAD_STATE6_FRAG +REG_TSIZE, REG_CONFIG = hv('A8XX_REG_SP_CS_TSIZE'), hv('A8XX_REG_SP_CS_CONFIG') +# Metal format -> (TEX format, swap, the format's own channel mapping, sRGB decode, bytes per pixel) +EXPECT = {71: ('FMT6_8_8_8_8_UNORM', 'WZYX', 'XYZW', 1, 4), 81: ('FMT6_8_8_8_8_UNORM', 'WXYZ', 'XYZW', 1, 4), + 80: ('FMT6_8_8_8_8_UNORM', 'WXYZ', 'XYZW', 0, 4), 30: ('FMT6_8_8_UNORM', 'WZYX', 'XY01', 0, 2), + 65: ('FMT6_16_16_FLOAT', 'WZYX', 'XY01', 0, 4), 10: ('FMT6_8_UNORM', 'WZYX', 'X001', 0, 1), + 1: ('FMT6_8_UNORM', 'WZYX', '000X', 0, 1), 25: ('FMT6_16_FLOAT', 'WZYX', 'X001', 0, 2), + 20: ('FMT6_16_UNORM', 'WZYX', 'X001', 0, 2), 60: ('FMT6_16_16_UNORM', 'WZYX', 'XY01', 0, 4), + 110: ('FMT6_16_16_16_16_UNORM', 'WZYX', 'XYZW', 0, 8), 94: ('FMT6_10_10_10_2_UNORM', 'WXYZ', 'XYZW', 0, 4), + 90: ('FMT6_10_10_10_2_UNORM', 'WZYX', 'XYZW', 0, 4)} +SWZ = {'X': 'X', 'Y': 'Y', 'Z': 'Z', 'W': 'W', '0': 'ZERO', '1': 'ONE'} +GATE = '(AdrenoIBComputeSamplers)' +checks, failures = 0, [] + + +def check(ok, what): + global checks + checks += 1 + if not ok: + failures.append(what) + return ok + + +def u32(pkg, at): + return struct.unpack_from('> 14) & 3, (w0 >> 18) & 0xf, w0 >> 22 + if block not in (SB6_CS_TEX, SB6_CS_SHADER): + continue + src = by_lo.get(4 * (start + 1)) + loads[(block, typ)] = (units, src['offset'] if src and src['kind'] == 2 else None) + values = {off: words[at] for at, off in regs} + return loads, by_lo, values.get(REG_TSIZE), values.get(REG_CONFIG) + + +def name_of(table, value): + try: + return T.enum(table, value) + except KeyError: + return f'{table}#{value}' + + +def tdesc(pkg, at): + d = [u32(pkg, at + 4 * k) for k in range(16)] + f = lambda w, desc, name: T.dfield(d[w], desc, name) + return dict(fmt=name_of('a6xx_format', f(3, 'A8XX_TEX_MEMOBJ_3', 'FMT')), + swap=name_of('a3xx_color_swap', f(3, 'A8XX_TEX_MEMOBJ_3', 'SWAP')), + swiz=[f(3, 'A8XX_TEX_MEMOBJ_3', f'SWIZ_{c}') for c in 'XYZW'], + srgb=f(4, 'A8XX_TEX_MEMOBJ_4', 'SRGB'), tile=f(4, 'A8XX_TEX_MEMOBJ_4', 'TILE_MODE'), + width=f(2, 'A8XX_TEX_MEMOBJ_2', 'WIDTH'), height=f(2, 'A8XX_TEX_MEMOBJ_2', 'HEIGHT'), + pitch=f(6, 'A8XX_TEX_MEMOBJ_6', 'TEX_LINE_OFFSET') // 8, words=d) + + +def packages(out, case): + pk = [] + for c in case['commits']: + f = out / c['dir'] / 'commands.bin' + if f.is_file(): + pk += [(c['dir'], P.parse_package(cmd)) for k, cmd in P.kernel_commands(f.read_bytes()) if k == 0x1002] + return pk + + +def check_texture(name, pkg, by_lo, table, slot, source): + exp = EXPECT.get(source['format']) + if not check(exp is not None, f'{name}: no expectation for format {source["format"]}'): + return + at = table + 64 * slot + rel = by_lo.get(at) + check(rel is not None and rel['kind'] == 1 and rel['resource_id'] == source['resource'] and rel['access'] == 1, + f'{name}: T#{slot} is not relocated READ to the source texture {source["resource"]}: {rel}') + d = tdesc(pkg, at) + want_swiz = [T.SWIZ[SWZ[c]] for c in exp[2]] + check((d['fmt'], d['swap'], d['swiz'], d['srgb'], d['tile']) == (exp[0], exp[1], want_swiz, exp[3], 0), + f'{name}: T#{slot} {d["fmt"]} {d["swap"]} swiz {d["swiz"]} srgb {d["srgb"]} tile {d["tile"]}; ' + f'expected {exp[0]} {exp[1]} {want_swiz} srgb {exp[3]} linear') + check((d['width'], d['height']) == (source['width'], source['height']) and d['pitch'] % 64 == 0 and + d['pitch'] >= source['width'] * exp[4], f'{name}: T#{slot} size {d["width"]}x{d["height"]} pitch {d["pitch"]}') + + +def verify(out, report, gated, replays, proposal): + multi_refused_by_main = [] + for case in report['cases']: + name = case['name'] + multi = name == 'composite' + omissions = [o for c in case['commits'] for o in ((c.get('work') or {}).get('omissions') or [])] + # A strict build raises where a lenient one omits: the gated two-input kernel is then an exception naming it. + strict_gate = multi and gated and case.get('status') == 'exception' and GATE in (case.get('exception') or '') + check(case.get('status') == 'committed' or strict_gate, f'{name}: {case.get("status")} {case.get("exception")}') + if multi and gated: + check(strict_gate or (len(omissions) == 1 and GATE in (omissions[0].get('details') or {}).get('reason', '')), + f'{name}: without AdrenoIBComputeSamplers the two-input kernel must be omitted naming it: ' + f'{[(o.get("details") or {}).get("reason") for o in omissions]}') + else: + check(not omissions, f'{name}: omitted: {[(o.get("details") or {}).get("reason") for o in omissions][:2]}') + if not multi: + rt = case.get('roundtrip') or {} + check(rt.get('adreno') and rt.get('native'), f'{name}: replaceRegion/getBytes round trip {rt}') + pks = packages(out, case) + sampler_units = [] + for d, pkg in pks: + loads, by_lo, tsize, config = cs_state(pkg) + if (SB6_CS_TEX, ST6_SHADER) in loads: + sampler_units.append(loads[(SB6_CS_TEX, ST6_SHADER)][0]) + if (SB6_CS_TEX, ST6_CONSTANTS) not in loads: + continue + units, table = loads[(SB6_CS_TEX, ST6_CONSTANTS)] + samplers = loads.get((SB6_CS_TEX, ST6_SHADER), (0, None))[0] + ntex = (config >> hv('A8XX_REG_SP_CS_CONFIG__NTEX__SHIFT')) & 0xff if config is not None else None + nsamp = (config >> hv('A8XX_REG_SP_CS_CONFIG__NSAMP__SHIFT')) & 0x1f if config is not None else None + nuav = (config >> hv('A8XX_REG_SP_CS_CONFIG__NUAV__SHIFT')) & 0x7f if config is not None else None + check(table is not None, f'{name}: CS T# load without a package table') + if table is None: + continue + if not multi: + check((units, samplers, tsize, ntex, nsamp, nuav) == (2, 1, 2, 1, 1, 2), + f'{name}: single shape T# {units} samplers {samplers} TSIZE {tsize} NTEX {ntex} NSAMP {nsamp} NUAV {nuav}') + check(not any(tdesc(pkg, table)['words']) and by_lo.get(table) is None, f'{name}: T#0 is not null') + check_texture(name, pkg, by_lo, table, 1, case['sources'][0]) + else: + check((units, samplers, tsize, ntex, nsamp, nuav) == (3, 2, 3, 3, 2, 2), + f'{name}: two-input shape T# {units} samplers {samplers} TSIZE {tsize} NTEX {ntex} NSAMP {nsamp} NUAV {nuav}') + check(not any(tdesc(pkg, table)['words']) and by_lo.get(table) is None, f'{name}: T#0 is not null') + kernel = case['kernels'][0]['textures'] if case.get('kernels') else {} + by_res = {s['resource']: s for s in case['sources']} + for slot in (1, 2): + bound = kernel.get(str(slot)) or {} + check(bound.get('resource') in by_res, f'{name}: texture({slot}) is not one of the sources: {bound}') + if bound.get('resource') in by_res: + check_texture(name, pkg, by_lo, table, slot, by_res[bound['resource']]) + st = loads[(SB6_CS_TEX, ST6_SHADER)][1] + for k in range(2): + w = [u32(pkg, st + 16 * k + 4 * i) for i in range(4)] + wraps = [T.dfield(w[0], 'A8XX_TEX_SAMP_0', f'WRAP_{a}') for a in 'STR'] + check(any(w) and all(name_of('a6xx_tex_clamp', x) in ('A6XX_TEX_CLAMP_TO_EDGE', 'A6XX_TEX_CLAMP_TO_BORDER') + for x in wraps), f'{name}: sampler {k} words {w} wraps {wraps}') + for label, exe in replays: + rd = out / f'replay-{label}' / d + rd.mkdir(parents=True, exist_ok=True) + (rd / 'commands.bin').write_bytes((out / d / 'commands.bin').read_bytes()) + try: + T_ds.replay(exe, rd, f'{label}:{d}', window=0x3a00000) + admitted = '' + except Exception as error: + admitted = str(error) or 'refused' + if multi and samplers > 1 and label == 'main': + multi_refused_by_main.append(bool(admitted)) + check(bool(admitted), f'{d}: the production validator admitted a {samplers}-unit CS sampler load') + elif multi and samplers > 1 and not proposal: + continue + else: + check(not admitted, f'{d}: {label} kext validator: {admitted[:300]}') + if gated or not multi: + check(all(n <= 1 for n in sampler_units), f'{name}: a CS sampler load of {sampler_units} units without the property') + if multi and not gated: + check(any(n == 2 for n in sampler_units), f'{name}: no 2-unit CS sampler load: {sampler_units}') + return multi_refused_by_main + + +T_ds = None + + +def main(): + global T_ds + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument('--bundle', type=Path, required=True) + ap.add_argument('--output', type=Path, required=True) + ap.add_argument('--expect-before', action='store_true') + ap.add_argument('--cs-samplers-validator', action='store_true', + help='also replay through this tree\'s validator built with ADRENO_IB_CS_SAMPLERS and opted in') + args = ap.parse_args() + out = args.output.resolve() + out.mkdir(parents=True, exist_ok=False) + exe = out / 'settings-ci-encoding' + subprocess.run(['xcrun', 'clang', '-arch', 'arm64e', '-fobjc-arc', '-O1', '-Wall', '-Werror', str(HARNESS), '-o', str(exe), + '-framework', 'Foundation', '-framework', 'Metal', '-framework', 'CoreImage', '-framework', 'CoreGraphics', + '-framework', 'IOKit'], check=True) + subprocess.run(['codesign', '-f', '-s', '-', str(exe)], check=True, capture_output=True) + T_ds = load('depth_stencil', 'tools/test-adreno-depth-stencil.py') + replays = [] + if not args.expect_before: + replays.append(('main', T_ds.build_replay(out, guest_size=0x4000000, arena=0x3c00000, fixtures=160, capacity=320, + name='kernel-replay-64m', arena_pages=129))) + if args.cs_samplers_validator: + st = load('settings_icons', 'tools/test-adreno-settings-icons.py') + replays.append(('cs-samplers', st.build_replay_at(T_ds, ROOT, out, 'kernel-replay-cs-samplers', + st.CS_SAMPLERS_DEFINES))) + results = {} + for label, units in (('deployed-kext', None), ('cs-samplers-8', 8)): + cap = out / label + env = dict(os.environ) + env.pop('SETTINGS_ICONS_CS_SAMPLERS', None) + if units: + env['SETTINGS_ICONS_CS_SAMPLERS'] = str(units) + r = subprocess.run([str(exe), str(args.bundle.resolve()), str(cap)], capture_output=True, text=True, env=env, timeout=600) + (out / f'{label}.log').write_text(r.stdout + r.stderr) + if not check(r.returncode == 0 and (cap / 'report.json').is_file(), f'{label}: harness exit {r.returncode}'): + continue + report = json.loads((cap / 'report.json').read_text()) + use = [x for x in replays if units or x[0] != 'cs-samplers'] + refused = verify(cap, report, gated=units is None, replays=use, proposal=args.cs_samplers_validator) + results[label] = dict(cases=len(report['cases']), multi_refused_by_main=refused) + if units and not args.expect_before: + check(refused and all(refused), f'{label}: the production validator must refuse the two-input package (gate)') + result = dict(checks=checks, failures=failures, runs=results, expect_before=args.expect_before, + validators=[label for label, _ in replays]) + (out / 'result.json').write_text(json.dumps(result, indent=1)) + if args.expect_before: + if failures: + print(f'PASS: the unchanged plug-in fails {len(failures)} of {checks} encoding checks (first: {failures[0][:200]}); ' + f'{out / "result.json"}') + return 0 + print(f'FAIL: the unchanged plug-in passed every encoding check; {out / "result.json"}') + return 1 + if failures: + for f in failures[:40]: + print(f'FAIL {f[:400]}') + print(f'FAIL: {len(failures)} of {checks} encoding checks; {out / "result.json"}') + return 1 + runs = ', '.join(f'{k}: {v["cases"]} cases' for k, v in results.items()) + print(f'PASS: {checks} encoding checks ({runs}; validators {[label for label, _ in replays]}); {out / "result.json"}') + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/tools/test-adreno-settings-icons.py b/tools/test-adreno-settings-icons.py new file mode 100755 index 0000000..8e92196 --- /dev/null +++ b/tools/test-adreno-settings-icons.py @@ -0,0 +1,368 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +"""System Settings pane icons through the plug-in device, against this Mac's GPU (no GPU submission, no A16). + +ADRENO_SETTINGS_ICONS (gpu/adreno-kext/plugin/AdrenoMetal/AdrenoDrawCommands/Compute.m): the pane rows' icons +(AsyncIconImage) come from iconservicesagent's ISGenerationRequest -generateImage on the system default Metal device, are redrawn by the client on the CPU and drawn +by WindowServer's Core Animation. The harness (gpu/adreno-kext/plugin/harness/settings-icons.m) runs those stages for +every Settings extension icon, the device/display/storage/document type icons the panes show, the system apps' icons +(Login Items, Notifications, Privacy lists), named images from the Settings extensions' asset catalogs, MonitorPanel's +display previews and SF Symbols, twice: --host-caps (this Mac's GPU answering like the plug-in device: Apple's +reference, with pixels) and --adreno (the plug-in device as the system default; commits intercepted). Per case: + - reference: this Mac's GPU produces a non-transparent image and draws it (else the case is invalid, and counted); + - the plug-in run produces an image of the same pixel size; + - every generate/display commit encodes work, omits nothing and fails no encoding; no exception, no pipeline error; + - CoreImage dispatches as many kernels on the plug-in device as on this Mac's GPU (same work); + - every commit's kernel commands replay through the production kext validator (and, with --validator-tree, through + that tree's validator sources too). +--expect-before: the unchanged plug-in must fail at least one of these. +Multi-input CoreImage kernels need more than one CS sampler unit, which the validator admits only in a kext built with +ADRENO_IB_CS_SAMPLERS and booted with adreno_cs_samplers=1; an ADRENO_SETTINGS_ICONS plug-in encodes them only when the +kext publishes AdrenoIBComputeSamplers. --cs-samplers N stands in for such a kext (replay those packages with +--cs-samplers-validator, this tree's validator built and opted in that way, and --skip-main-validator); +--allow-kext-gated reports the kernels refused for want of the property without failing on them. --jobs runs that many +validator replays at once (each commit's replay is independent); --cases keeps the cases whose name matches a regex. + + python3 tools/test-adreno-settings-icons.py --bundle BUNDLE --output NEW-DIR [--expect-before] [--quick] + [--cases REGEX] [--validator-tree TREE]... [--cs-samplers-validator] [--skip-main-validator] + [--cs-samplers N] [--allow-kext-gated] [--reference HOST-CAPS-RUN-DIR] [--jobs N] +""" +import argparse +from collections import Counter +from concurrent.futures import ThreadPoolExecutor +import importlib.util +import json +import os +from pathlib import Path +import plistlib +import re +import subprocess +import sys + +ROOT = Path(__file__).resolve().parents[1] +HARNESS = ROOT / 'gpu/adreno-kext/plugin/harness' +EXT = Path('/System/Library/ExtensionKit/Extensions') +# Type icons the panes show (About/Storage/Displays/Bluetooth/Network/Time Machine/Privacy rows, documents, folders). +TYPES = ['public.folder', 'public.plain-text', 'public.volume', 'public.display', 'public.disk-image', 'public.item', + 'public.image', 'public.movie', 'public.audio', 'com.apple.application-bundle', 'com.apple.network-neighborhood', + 'com.apple.storage-external', 'com.apple.generic-time-machine-disk', 'com.apple.macbookpro-16-space-gray', + 'com.apple.macbookair-13-2022-midnight', 'com.apple.imac-2021-blue', 'com.apple.macmini', 'com.apple.studio-display', + 'com.apple.pro-display-xdr', 'com.apple.cinema-display', 'com.apple.magic-mouse', 'com.apple.airpods-pro', + 'com.apple.iphone', 'com.apple.ipad', 'com.apple.application-icon.icloud', 'com.apple.application-icon.icloud-storage', + 'com.apple.graphic-icon.bluetooth', 'com.apple.graphic-icon.display', 'com.apple.graphic-icon.usb', + 'com.apple.graphic-icon.local-network', 'com.apple.graphic-icon.external-drive', 'com.apple.graphic-icon.privacy', + 'com.apple.graphic-icon.trackpad-and-mouse', 'com.apple.graphic-icon.icloud-backup'] +LARGE_TYPES = ['com.apple.macbookpro-16-space-gray', 'com.apple.studio-display', 'public.display', 'com.apple.storage-external'] +SYMBOLS = [('wifi', None), ('network', None), ('gear', None), ('externaldrive.fill', None), ('lock.fill', None), + ('person.crop.circle.fill', 'palette'), ('battery.100percent', 'multicolor'), ('bell.badge.fill', 'multicolor'), + ('sun.max.fill', 'multicolor'), ('speaker.wave.3.fill', None), ('display', None), ('keyboard', None), + ('hand.raised.fill', None), ('cloud.fill', 'multicolor')] +MONITORPANEL = Path('/System/Library/PrivateFrameworks/MonitorPanel.framework/Versions/A/Resources') + + +def settings_extensions(): + out = [] + for info in sorted(EXT.glob('*.appex/Contents/Info.plist')): + try: + d = plistlib.loads(info.read_bytes()) + except Exception: + continue + if (d.get('EXAppExtensionAttributes') or {}).get('EXExtensionPointIdentifier') == 'com.apple.Settings.extension.ui': + out.append(info.parents[1]) + return out + + +def asset_cases(exts, per_kind): + """Named images of the Settings extensions' own asset catalogs, a few per (catalog, encoding).""" + cases = [] + for ext in exts: + car = ext / 'Contents/Resources/Assets.car' + if not car.is_file(): + continue + r = subprocess.run(['assetutil', '--info', str(car)], capture_output=True, text=True) + try: + rows = json.loads(r.stdout)[1:] + except Exception: + continue + picked = {} + for row in rows: + if row.get('AssetType') not in ('Image', 'PackedImage', 'Icon Image', 'MultiSized Image', 'Vector'): + continue + if '/' in (row.get('Name') or '') or (row.get('Name') or '').startswith('ZZZZPackedAsset'): + continue # a layer of an icon stack or a packed-asset atlas, not a named image + enc = row.get('Encoding') or row.get('AssetType') + names = picked.setdefault(enc, []) + if row.get('Name') and row['Name'] not in names and len(names) < per_kind: + names.append(row['Name']) + for enc, names in sorted(picked.items()): + for n in names: + cases.append(dict(name=f'asset-{ext.stem}-{enc}-{n}', kind='asset', path=str(ext), asset=n, size=32, scale=2)) + return cases + + +def make_cases(quick, only=None): + exts = settings_extensions() + cases = [dict(name=f'ext-{e.stem}', kind='bundle', path=str(e), size=20, scale=2) for e in exts] + cases += [dict(name=f'type-{t}', kind='type', type=t, size=20, scale=2) for t in TYPES] + cases += [dict(name=f'type64-{t}', kind='type', type=t, size=64, scale=2) for t in LARGE_TYPES] + apps = sorted(Path('/System/Applications').glob('*.app')) + sorted(Path('/System/Applications/Utilities').glob('*.app')) + apps += [Path('/Applications/Safari.app'), Path('/System/Library/CoreServices/Finder.app')] + cases += [dict(name=f'app-{a.stem}', kind='bundle', path=str(a), size=20, scale=2) for a in apps if a.exists()] + cases += asset_cases(exts, 1 if quick else 2) + cases += [dict(name=f'file-{p.stem}', kind='file', path=str(p), size=64, scale=2) for p in sorted(MONITORPANEL.glob('*.tiff'))] + for name, mode in SYMBOLS: + c = dict(name=f'symbol-{name}' + (f'-{mode}' if mode else ''), kind='symbol', symbol=name, size=20, scale=2) + if mode: + c[mode] = True + cases.append(c) + if quick: + keep = re.compile(r'ext-(General|Power|Headphone|TimeMachine|Displays)|type-public\.(folder|plain-text|display)$|' + r'type-com\.apple\.macbookpro|type64-com\.apple\.studio|app-(Safari|Calculator|Terminal)|asset-|file-|symbol-(wifi|battery)') + cases = [c for c in cases if keep.search(c['name'])] + if only: + cases = [c for c in cases if re.search(only, c['name'])] + names = [c['name'] for c in cases] + assert len(names) == len(set(names)), 'duplicate case names' + return cases + + +def load_ds(): + spec = importlib.util.spec_from_file_location('depth_stencil', ROOT / 'tools/test-adreno-depth-stencil.py') + ds = importlib.util.module_from_spec(spec) + spec.loader.exec_module(ds) + return ds + + +# The validator of a kext built with ADRENO_IB_CS_SAMPLERS and booted with adreno_cs_samplers=1 (8 CS sampler units). +CS_SAMPLERS_DEFINES = ['-DADRENO_IB_CS_SAMPLERS=1', '-DIB_REPLAY_CS_SAMPLER_UNITS=8'] + + +def build_replay_at(ds, root, out, name, defines=()): + """ds.build_replay with the validator sources of a tree (this one or another) and extra defines.""" + sources = ['tools/test-adreno-ib-command.c', 'compat/adreno-ib-command.c', 'compat/adreno-ib.c', 'compat/adreno-fill-command.c', + 'compat/adreno-fill.c', 'compat/adreno-submit.c', 'compat/adreno-resource.c', 'compat/adreno-resource-list.c', + 'compat/adreno.c', 'compat/irq-forward.c', 'shadow/stage2.c', 'shadow/shadow.c', 'shadow/mmu.c'] + exe = out / name + flags = ['clang', '-std=gnu11', '-Icompat', '-Ishadow', '-DGUEST_SIZE=0x4000000', '-DIB_ARENA_OFFSET=0x3c00000', + '-DIB_FIXTURES=160', '-DIB_REPLAY_CAPACITY=320', '-DIB_ARENA_PAGES=129u', '-O1', '-g', '-Wall', '-Wextra', '-Werror', + *defines] + r = subprocess.run(flags + sources + ['-o', str(exe)], cwd=root, capture_output=True, text=True, timeout=300) + (out / f'{name}-build.log').write_text(r.stdout + r.stderr) + if r.returncode: + raise SystemExit(f'FAIL: {name} build failed (see {out / (name + "-build.log")})') + return exe + + +def run_harness(exe, lib, mode, bundle, out, cases_file, cs_samplers=None): + env = dict(os.environ, OS_ACTIVITY_DT_MODE='1') + env.pop('SETTINGS_ICONS_CS_SAMPLERS', None) + if mode == '--adreno': + env['DYLD_INSERT_LIBRARIES'] = str(lib) + if cs_samplers: + env['SETTINGS_ICONS_CS_SAMPLERS'] = str(cs_samplers) + r = subprocess.run([str(exe), mode, str(bundle), str(out), str(cases_file)], capture_output=True, text=True, env=env, timeout=3600) + log = r.stdout + r.stderr + return r.returncode, log + + +def attribute(log): + """os_log lines per case (the harness prints '@@case NAME STAGE' before each stage).""" + per, current = {}, None + for line in log.splitlines(): + if line.startswith('@@case '): + current = line.split()[1] + continue + if current: + per.setdefault(current, []).append(line) + return per + + +def compute_counts(commits): + enc = om = 0 + for c in commits: + for e in ((c.get('work') or {}).get('encoders') or {}).values(): + if e.get('kind') == 'compute': + enc += int(e.get('encoded') or 0) + om += int(e.get('omitted') or 0) + return enc, om + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument('--bundle', type=Path, required=True) + ap.add_argument('--output', type=Path, required=True) + ap.add_argument('--expect-before', action='store_true') + ap.add_argument('--quick', action='store_true', help='a representative subset (about 40 cases)') + ap.add_argument('--cases', help='only the cases whose name matches this regular expression') + ap.add_argument('--reference', type=Path, help='reuse a --host-caps run (its report.json) instead of running one') + ap.add_argument('--validator-tree', type=Path, action='append', default=[], help='also replay through this tree\'s validator') + ap.add_argument('--cs-samplers-validator', action='store_true', + help='also replay through this tree\'s validator built with ADRENO_IB_CS_SAMPLERS and opted in') + ap.add_argument('--skip-main-validator', action='store_true', help='do not replay through this tree\'s production validator') + ap.add_argument('--jobs', type=int, default=min(8, os.cpu_count() or 1), help='validator replays run at once') + ap.add_argument('--cs-samplers', type=int, help='stand in for a kext publishing AdrenoIBComputeSamplers = N') + ap.add_argument('--allow-kext-gated', action='store_true', + help='kernels refused only for want of AdrenoIBComputeSamplers are reported, not failed') + args = ap.parse_args() + out = args.output.resolve() + out.mkdir(parents=True, exist_ok=False) + build = subprocess.run(['sw_vers', '-buildVersion'], capture_output=True, text=True).stdout.strip() + if build != '26A428': + print(f'FAIL: host build {build}, not 26A428') + return 1 + lib, exe = out / 'default-device-interpose.dylib', out / 'settings-icons' + subprocess.run(['xcrun', 'clang', '-dynamiclib', '-arch', 'arm64e', str(HARNESS / 'default-device-interpose.m'), '-o', str(lib), + '-framework', 'Foundation', '-framework', 'Metal'], check=True) + subprocess.run(['xcrun', 'clang', '-arch', 'arm64e', '-fobjc-arc', '-O1', '-Wall', '-Werror', str(HARNESS / 'settings-icons.m'), + '-o', str(exe), '-framework', 'AppKit', '-framework', 'QuartzCore', '-framework', 'Metal', '-framework', 'IOSurface', + '-framework', 'IOKit', '-framework', 'CoreGraphics'], check=True) + for f in (lib, exe): + subprocess.run(['codesign', '-f', '-s', '-', str(f)], check=True, capture_output=True) + cases = make_cases(args.quick, args.cases) + cases_file = out / 'cases.json' + cases_file.write_text(json.dumps(cases, indent=1)) + bundle = args.bundle.resolve() + if args.reference: + ref = json.loads((args.reference / 'report.json').read_text()) + ref_log = (args.reference.parent / 'host-caps.log').read_text() if (args.reference.parent / 'host-caps.log').is_file() else '' + else: + rc, ref_log = run_harness(exe, lib, '--host-caps', bundle, out / 'host-caps', cases_file) + (out / 'host-caps.log').write_text(ref_log) + if rc or not (out / 'host-caps/report.json').is_file(): + print(f'FAIL: host-caps harness exit {rc}; {out / "host-caps.log"}') + return 1 + ref = json.loads((out / 'host-caps/report.json').read_text()) + rc, log = run_harness(exe, lib, '--adreno', bundle, out / 'adreno', cases_file, args.cs_samplers) + (out / 'adreno.log').write_text(log) + failures, checks, invalid = [], 0, [] + + def check(ok, what): + nonlocal checks + checks += 1 + if not ok: + failures.append(what) + + check(rc == 0 and (out / 'adreno/report.json').is_file(), f'adreno harness exit {rc}') + if failures: + print(f'FAIL: {failures[0]}; {out / "adreno.log"}') + return 1 + got = json.loads((out / 'adreno/report.json').read_text()) + refs = {c['name']: c for c in ref['cases']} + lines = attribute(log) + ds = load_ds() + replays = [] + if not args.expect_before and not args.skip_main_validator: + replays.append(('main', ds.build_replay(out, guest_size=0x4000000, arena=0x3c00000, fixtures=160, capacity=320, + name='kernel-replay-64m', arena_pages=129))) + if not args.expect_before: + if args.cs_samplers_validator: + replays.append(('cs-samplers', build_replay_at(ds, ROOT, out, 'kernel-replay-cs-samplers', CS_SAMPLERS_DEFINES))) + for n, tree in enumerate(args.validator_tree): + replays.append((f'tree{n}', build_replay_at(ds, tree.resolve(), out, f'kernel-replay-tree{n}'))) + gated, pending = {}, [] + summary, omitted_reasons = {}, {} + for c in got['cases']: + name = c['name'] + r = refs.get(name) or {} + ref_img = r.get('client') or {} + ref_ok = bool(r.get('client_coverage')) and all(f.get('changed_pixels', 0) > 0 for f in (r.get('display') or {}).get('frames', [{}])) + if not ref_ok: + invalid.append(name) + continue + img = c.get('client') or {} + check(bool(img) and img.get('width') == ref_img.get('width') and img.get('height') == ref_img.get('height'), + f'{name}: image {img.get("width")}x{img.get("height")}, reference {ref_img.get("width")}x{ref_img.get("height")}') + check(not c.get('exception'), f'{name}: exception {c.get("exception")}') + errors = [l for l in lines.get(name, []) if 'pipeline error: ' in l] + check(not errors, f'{name}: pipeline errors {errors[:2]}') + stages = c.get('stages') or {} + row = {} + for stage in ('generate', 'display'): + commits = (stages.get(stage) or {}).get('commits', []) + ref_census = ((r.get('stages') or {}).get(stage) or {}).get('census') or {} + got_census = (stages.get(stage) or {}).get('census') or {} + omissions = [o for x in commits for o in ((x.get('work') or {}).get('omissions') or [])] + if args.allow_kext_gated: + kept = [o for o in omissions if '(AdrenoIBComputeSamplers)' not in ((o.get('details') or {}).get('reason') or '')] + if len(kept) != len(omissions): + gated[name] = sorted({((o.get('details') or {}).get('pipeline') or {}).get('entry', '?') for o in omissions + if o not in kept}) + omissions = kept + for o in omissions: + d = o.get('details') or {} + k = (d.get('reason') or '?')[:140] + omitted_reasons.setdefault(k, set()).add(name) + check(not omissions, f'{name}/{stage}: {len(omissions)} omitted: ' + '; '.join(sorted({(o.get("details") or {}).get("reason", "?")[:160] for o in omissions})[:2])) + check(not any((x.get('work') or {}).get('encoding_failed') for x in commits), f'{name}/{stage}: an encoding failed') + if stage == 'generate': + # Same work: CoreImage dispatches the same kernels, as often, on the plug-in device as on this Mac's GPU + # answering like it. (Other compute work, e.g. RenderBox's, may differ with the device and is not compared.) + # Without a GPU a flattened icon stack is transparent and IconServices retries ("Central pixel is still + # transparent even with retry"; os_log drops some of these lines under load), adding Lanczos passes: + # extra kernels are allowed with that line or for an icon-stack flatten (RenderBox draws on this Mac). + want = Counter(k for k in ref_census.get('kernels', []) if k.startswith('ci_')) + have = Counter(k for k in got_census.get('kernels', []) if k.startswith('ci_')) + retried = any('Central pixel is still transparent' in l for l in lines.get(name, [])) or ref_census.get('draws', 0) > 0 + check(not (want - have), f'{name}/generate: CoreImage kernels the plug-in did not run: {sorted((want - have).elements())[:3]}') + check(not (have - want) or retried, f'{name}/generate: extra CoreImage kernels without a retry: ' + f'{sorted((have - want).elements())[:3]}') + enc, om = compute_counts(commits) + check(enc + om == got_census.get('dispatches', 0), f'{name}/generate: {enc}+{om} compute dispatches in the work ' + f'reports, {got_census.get("dispatches")} counted') + if stage == 'display': + check(bool(commits), f'{name}/display: no commits') + for label, rexe in replays: + for x in commits: + d = out / 'adreno' / x['dir'] + if not (d / 'commands.bin').is_file(): + continue + # A commit without an IB package (an empty command buffer: one 0x1000 header) has nothing to admit. + if not any(kind == 0x1002 for kind, _ in ds.P.kernel_commands((d / 'commands.bin').read_bytes())): + continue + if label != 'main': + d2 = out / f'replay-{label}' / x['dir'] + d2.mkdir(parents=True, exist_ok=True) + (d2 / 'commands.bin').write_bytes((d / 'commands.bin').read_bytes()) + d = d2 + pending.append((label, rexe, d, x['dir'])) + row[stage] = dict(commits=len(commits), omitted=len(omissions)) + summary[name] = row + + def replay_one(task): + label, rexe, d, rel = task + try: + ds.replay(rexe, d, f'{label}:{rel}', window=0x3a00000) + return '' + except Exception as error: # the replay module's own failure class + return str(error) or 'refused' + + # Each replay reads only its own commit's capture and writes only its own replay directory. + with ThreadPoolExecutor(max_workers=max(1, args.jobs)) as pool: + for (label, _rexe, _d, rel), admitted in zip(pending, pool.map(replay_one, pending)): + check(not admitted, f'{rel}: {label} kext validator: {admitted[:300]}') + check(len(invalid) <= max(3, len(got['cases']) // 10), f'{len(invalid)} cases without a reference image: {invalid[:8]}') + result = dict(checks=checks, failures=failures, invalid=invalid, cases=len(got['cases']), summary=summary, kext_gated=gated, + cs_samplers=args.cs_samplers, validators=[label for label, _ in replays], + omitted_reasons={k: sorted(v) for k, v in omitted_reasons.items()}, expect_before=args.expect_before) + (out / 'result.json').write_text(json.dumps(result, indent=1)) + if args.expect_before: + if failures: + print(f'PASS: the unchanged plug-in fails {len(failures)} of {checks} settings-icon checks ' + f'({len(omitted_reasons)} omission reasons; first: {failures[0][:200]}); {out / "result.json"}') + return 0 + print(f'FAIL: the unchanged plug-in passed every settings-icon check; {out / "result.json"}') + return 1 + if failures: + for f in failures[:60]: + print(f'FAIL {f[:400]}') + print(f'FAIL: {len(failures)} of {checks} settings-icon checks ({len(got["cases"])} cases, {len(invalid)} invalid); ' + f'{out / "result.json"}') + return 1 + extra = f'; {len(gated)} cases with kernels awaiting AdrenoIBComputeSamplers: {sorted(gated)}' if gated else '' + print(f'PASS: {checks} settings-icon checks, {len(got["cases"])} cases ({len(invalid)} without a reference image), ' + f'validators {[label for label, _ in replays]}{extra}; {out / "result.json"}') + return 0 + + +if __name__ == '__main__': + sys.exit(main())