diff --git a/compat/edp-backlight.h b/compat/edp-backlight.h index 89a6aec..5ec37f1 100644 --- a/compat/edp-backlight.h +++ b/compat/edp-backlight.h @@ -233,6 +233,27 @@ static inline uint32_t edp_bl_nits_to_slider(uint64_t nits_q16, uint64_t lo_q16, if (nits_q16 >= hi_q16) return EDP_BL_SLIDER_MAX; return (uint32_t)(((nits_q16 - lo_q16) * EDP_BL_SLIDER_MAX + (hi_q16 - lo_q16) / 2) / (hi_q16 - lo_q16)); } +/* A swap record's brightness (an IEEE 754 binary64 in nits, as IOMobileFramebufferSwapSetBrightness stores it) as + * 16.16 nits, rounded to nearest and capped at cap_q16, in integer arithmetic only: the bits come straight from a + * client's record. 0 with *q16 set; -1 for a NaN, an infinity or a negative value, which name no brightness. Zero and + * subnormals are 0 nits (the darkest request). */ +static inline int edp_bl_double_to_q16(uint64_t bits, uint64_t cap_q16, uint64_t *q16) +{ + /* A normal value is mantissa * 2^(exponent - 1075), so value * 2^16 = mantissa * 2^(exponent - 1059). */ + uint32_t exponent = (uint32_t)(bits >> 52) & 0x7ffu; + uint64_t mantissa = (bits & ((1ull << 52) - 1)) | 1ull << 52, v; + if (exponent == 0x7ffu || (bits >> 63 && bits << 1)) return -1; + if (exponent == 0) + v = 0; + else if (exponent >= 1059) /* 2^36 nits or more; a mantissa below 2^53 shifted by up to 10 stays below 2^63 */ + v = exponent - 1059 > 10 ? UINT64_MAX : mantissa << (exponent - 1059); + else if (1059 - exponent > 63) + v = 0; + else + v = (mantissa + (1ull << (1059 - exponent - 1))) >> (1059 - exponent); + *q16 = v > cap_q16 ? cap_q16 : v; + return 0; +} /* ---- Allow-lists: every MMIO access and every DPCD transaction the monitor makes passes one of these ----------- */ static inline int edp_bl_mmio_read_ok(uint64_t pa) diff --git a/docs/RUNNING.md b/docs/RUNNING.md index c614994..a926069 100644 --- a/docs/RUNNING.md +++ b/docs/RUNNING.md @@ -202,10 +202,11 @@ DPCD `0x721`-`0x723`, clamping the level to between 2% of full scale and the panel's maximum, and stopping for the boot on any failure with the brightness left as it was (`compat/edp-backlight.h`, event `EV_EDP_BACKLIGHT`). The guest gets the AuxKC pinned as `INPUT_AUXKC_DESKTOP_BACKLIGHT`, whose AdrenoX2 has the -`AdrenoBacklight` service, and the boot-arg `adreno_backlight=1`. CoreBrightness -hands its nits to WindowServer, whose BrightnessControl writes them to the -service as `brightness-nits` because the service carries -`force-brightness-control` (the path and its evidence are in the kext's +`AdrenoBacklight` service, and the boot-arg `adreno_backlight=1`. The service +lets WindowServer bind the panel's framebuffer to a brightness control; +CoreBrightness's nits then arrive in the swaps WindowServer submits (as on a +DCP Mac), and AdrenoDisplay hands each new value to the service, which logs +`SET from swap` (the path and its evidence are in the kext's `src/AdrenoBacklight.h`). The service maps nits to a level proportionally, the top level being `adreno_backlight_max_nits` (default 500) and the darkest request `adreno_backlight_min_nits` (default 5, raised to the 2% floor's ~10 diff --git a/gpu/adreno-kext/kext b/gpu/adreno-kext/kext index 9cc1e92..e1cf072 160000 --- a/gpu/adreno-kext/kext +++ b/gpu/adreno-kext/kext @@ -1 +1 @@ -Subproject commit 9cc1e92ba9ea440c8f422697179f2cde4099b9eb +Subproject commit e1cf0729c63488204cc56df9414ebf22948b9d20 diff --git a/tools/check-iomfb-vtable.py b/tools/check-iomfb-vtable.py index feea293..6e16588 100755 --- a/tools/check-iomfb-vtable.py +++ b/tools/check-iomfb-vtable.py @@ -24,7 +24,7 @@ Writes /iomfb-vtable-check.json (the full per-slot table) and exits 1 on an Usage: tools/check-iomfb-vtable.py [--out DIR] [--kc PATH] [--kdk PATH] [--no-display] [--display-define NAME[=V] ...] --display-define (alias --define) compiles AdrenoDisplay.cpp, and only it, with that define too: a profile's AdrenoDisplay set, or an opt-in override such as ADRENO_DISPLAY_TRACE_MODES=1 or ADRENO_BACKLIGHT=1 (whose -set_brightness_gated override must then pass the same slot check); without it the default build is checked. +set_brightness_gated and swap_submit overrides must then pass the same slot check); without it the default build is checked. """ import argparse import hashlib diff --git a/tools/test-edp-backlight.c b/tools/test-edp-backlight.c index 45930ab..8e165b1 100644 --- a/tools/test-edp-backlight.c +++ b/tools/test-edp-backlight.c @@ -242,6 +242,54 @@ static void test_curve(void) CHECK(edp_bl_nits_to_level(edp_bl_slider_to_nits(65536, lo, Q(500)), maxn, floor, max) == max); } +/* A swap record's brightness double as 16.16 nits: bit-exact against the host's own double arithmetic, refusing what + * names no brightness, capped, and never trusting the bits to be a sensible number. */ +static uint64_t bits_of(double d) { uint64_t b; memcpy(&b, &d, sizeof b); return b; } +static void test_swap_double(void) +{ + const uint64_t cap = (uint64_t)EDP_BL_NITS_LIMIT_MAX << 16; + uint64_t q = 12345; + static const double exact[] = {0.0, 1.0, 5.0, 10.015, 140.0, 174.89, 384.615, 499.999, 500.0, 0.5, 1.0 / 65536, + 1.5 / 65536, 0.49 / 65536, 1e-300, 999.99998}; + for (unsigned i = 0; i < sizeof exact / sizeof exact[0]; i++) { + double diff; + CHECK(edp_bl_double_to_q16(bits_of(exact[i]), cap, &q) == 0); + diff = (double)q - exact[i] * 65536.0; + CHECK(diff <= 0.5 && diff >= -0.5); + } + CHECK(edp_bl_double_to_q16(bits_of(140.0), cap, &q) == 0 && q == 140ull << 16); + CHECK(edp_bl_double_to_q16(bits_of(-0.0), cap, &q) == 0 && q == 0); + CHECK(edp_bl_double_to_q16(bits_of(4.9e-324), cap, &q) == 0 && q == 0); /* subnormal */ + CHECK(edp_bl_double_to_q16(bits_of(1000.5), cap, &q) == 0 && q == cap); + CHECK(edp_bl_double_to_q16(bits_of(1e300), cap, &q) == 0 && q == cap); + CHECK(edp_bl_double_to_q16(bits_of(68719476736.0), cap, &q) == 0 && q == cap); /* 2^36: the shifted branch */ + CHECK(edp_bl_double_to_q16(bits_of(1e300), UINT64_MAX, &q) == 0 && q == UINT64_MAX); + CHECK(edp_bl_double_to_q16(bits_of(68719476736.0), UINT64_MAX, &q) == 0 && q == 1ull << 52); + q = 777; + CHECK(edp_bl_double_to_q16(0x7ff8000000000000ull, cap, &q) == -1 && q == 777); /* the NaN CoreAnimation logs */ + CHECK(edp_bl_double_to_q16(0x7ff0000000000001ull, cap, &q) == -1); /* signalling NaN */ + CHECK(edp_bl_double_to_q16(0x7ff0000000000000ull, cap, &q) == -1); /* +inf */ + CHECK(edp_bl_double_to_q16(0xfff0000000000000ull, cap, &q) == -1); /* -inf */ + CHECK(edp_bl_double_to_q16(bits_of(-1.0), cap, &q) == -1 && edp_bl_double_to_q16(bits_of(-1e-300), cap, &q) == -1); + /* Every exponent and a spread of mantissas: monotonic in the value, never above the cap, rounding within half a + * step of the host's arithmetic. */ + uint64_t seed = 0x9e3779b97f4a7c15ull, prev = 0; + for (uint32_t e = 0; e < 0x7ff; e++) { + for (unsigned k = 0; k < 8; k++) { + seed = seed * 6364136223846793005ull + 1442695040888963407ull; + uint64_t frac = k == 0 ? 0 : k == 1 ? (1ull << 52) - 1 : seed >> 12; + uint64_t b = (uint64_t)e << 52 | frac; + double d; + memcpy(&d, &b, sizeof d); + CHECK(edp_bl_double_to_q16(b, cap, &q) == 0 && q <= cap); + double want = d * 65536.0; + if (want < (double)cap) CHECK((double)q >= want - 0.5 && (double)q <= want + 0.5); + else CHECK(q == cap); + if (k == 0) { CHECK(q >= prev); prev = q; } + } + } +} + static void test_helpers(void) { uint32_t bits = 0; @@ -272,6 +320,7 @@ static void test_helpers(void) CHECK(edp_bl_encode(0x5A3, 1, b) == 2 && b[0] == 0x05 && b[1] == 0xA3 && edp_bl_decode(b, 1) == 0x5A3); CHECK(edp_bl_encode(0xC8, 0, b) == 1 && b[0] == 0xC8 && edp_bl_decode(b, 0) == 0xC8); test_curve(); + test_swap_double(); uint8_t caps[4] = {0x03, 0x07, 0x06, 0x10}; uint64_t x1 = edp_bl_reply_x1(EDP_BL_STATE_MODE, EDP_BL_DETAIL_BITS, caps, 0x12); CHECK(edp_bl_reply_abi(x1) == EDP_BL_ABI && edp_bl_reply_state(x1) == EDP_BL_STATE_MODE && diff --git a/tools/test-edp-backlight.py b/tools/test-edp-backlight.py index 6edaf3c..9b9de3e 100644 --- a/tools/test-edp-backlight.py +++ b/tools/test-edp-backlight.py @@ -13,12 +13,14 @@ safety property or bends the curve in a copy of the engine; the same program mus Wiring: probes/xnuboot.c defaults SNAP_EDP_BACKLIGHT to 0 and every monitor use sits under it; compat/edp-backlight.c is linked only through probes/run.py OPTIONAL; the call number and event number are unique; the kext's backlight code sits -under ADRENO_BACKLIGHT and publishes what BrightnessControl needs to write to it (force-brightness-control), mapping -every request through the one curve; installed-fresh appends the define last, adds the boot-args, and refuses until the +under ADRENO_BACKLIGHT, publishes what BrightnessControl reads to bind the panel's IOMFB (not +force-brightness-control), takes the brightness from the swap record and maps every request through the one curve; +installed-fresh appends the define last, adds the boot-args, and refuses until the AuxKC is pinned. -UserspaceContract (a 26A428 host with ipsw only, else SKIP): the dyld cache's BrightnessControl and IOMobileFramebuffer -still carry the names that path relies on (gpu/adreno-kext/kext/src/AdrenoBacklight.h). +UserspaceContract (a 26A428 host with ipsw only, else SKIP): the dyld cache's BrightnessControl, IOMobileFramebuffer and +QuartzCore still carry the names that path relies on, and IOMobileFramebufferSwapSetBrightness still writes the swap +record where the kext reads it (gpu/adreno-kext/kext/src/AdrenoBacklight.h). --build: xnuboot with each --qualification's flags, define absent, =0 and =1: absent == =0 == the qualification's monitor_sha256; =1 differs and fits the code budget (probes/run.py OUT_AT - CODE_AT), its headroom printed. @@ -58,6 +60,13 @@ MUTATIONS = ( 'if (nits_q16 >= full) return max - 1;'), ('minimum below the floor', 'compat/edp-backlight.h', 'return configured > at_floor ? configured : at_floor;', 'return configured + 0 * at_floor;'), + ('swap NaN accepted', 'compat/edp-backlight.h', 'if (exponent == 0x7ffu || (bits >> 63 && bits << 1)) return -1;', + 'if (bits >> 63 && bits << 1) return -1;'), + ('swap negative accepted', 'compat/edp-backlight.h', + 'if (exponent == 0x7ffu || (bits >> 63 && bits << 1)) return -1;', 'if (exponent == 0x7ffu) return -1;'), + ('swap nits truncated', 'compat/edp-backlight.h', + 'v = (mantissa + (1ull << (1059 - exponent - 1))) >> (1059 - exponent);', 'v = mantissa >> (1059 - exponent);'), + ('swap cap ignored', 'compat/edp-backlight.h', '*q16 = v > cap_q16 ? cap_q16 : v;', '*q16 = v + 0 * cap_q16;'), ('no clamp', 'compat/edp-backlight.c', 'uint32_t level = edp_bl_clamp(request, s->floor, s->max);', 'uint32_t level = (uint32_t)request;'), ('mask bits dropped', 'compat/edp-backlight.c', ' << 1 | (status & EDP_INTR_MASK_BITS));', ' << 1);'), @@ -175,19 +184,33 @@ class Wiring(unittest.TestCase): self.assertEqual(bad, []) def test_kext_brightness_path(self): - # The service carries what WindowServer's BrightnessControl needs to write brightness-nits to it - # (gpu/adreno-kext/kext/src/AdrenoBacklight.h, steps 2 and 3), and every request goes through the curve. - src = (ROOT / 'gpu/adreno-kext/kext/src/AdrenoDisplay/backlight.cpp').read_text() - force = re.search(r'kForceBrightnessControl\[(\d+)\] = \{([^}]*)\}', src) - self.assertIsNotNone(force, 'force-brightness-control bytes not found') - self.assertNotEqual(int(force.group(2).split(',')[0], 0), 0, 'its first byte must be nonzero') - for needle in ('setProperty("force-brightness-control", (void *)kForceBrightnessControl,', - 'setProperty("backlight-control", kOSBooleanTrue)', 'd->getObject("brightness-nits")', - 'putDict(params, "BrightnessMilliNits", nits)', 'setProperty("IODisplayParameters", params)', + # The service carries what WindowServer's BrightnessControl reads to bind the panel's IOMFB (and nothing that + # makes it write to the service: the sandbox refuses that), AdrenoDisplay takes the brightness from the swap + # record, and every request goes through the one curve (gpu/adreno-kext/kext/src/AdrenoBacklight.h). + kext = ROOT / 'gpu/adreno-kext/kext/src' + src = (kext / 'AdrenoDisplay/backlight.cpp').read_text() + for needle in ('setProperty("backlight-control", kOSBooleanTrue)', 'putDict(params, "BrightnessMilliNits", nits)', + 'setProperty("IODisplayParameters", params)', 'edp_bl_nits_to_level(nits_q16 < fMinQ16 ? fMinQ16 : nits_q16, fMaxNits, fFloor, fMax)', - 'fMinQ16 = edp_bl_min_nits_q16(fMinNits, fMaxNits, fFloor, fMax);'): + 'fMinQ16 = edp_bl_min_nits_q16(fMinNits, fMaxNits, fFloor, fMax);', + 'uint64_t AdrenoDisplay::swap_submit(IOMFBSwapRec *rec, IOUserClient *client, unsigned int *swapID)', + 'if (!edp_bl_double_to_q16(bits, (uint64_t)EDP_BL_NITS_LIMIT_MAX << 16, &q16))', + 'return IOMobileFramebufferLegacy::swap_submit(rec, client, swapID);', + 'fBacklight->requestNits(v & ~kSwapNitsPending, AdrenoBacklight::kSourceSwap);'): self.assertIn(needle, src) + self.assertNotIn('force-brightness-control', src) self.assertEqual(src.count('edp_bl_nits_to_level('), 1, 'one mapping for every source') + # The record's brightness lies where IOMobileFramebufferSwapSetBrightness writes it (UserspaceContract checks + # the same constants against the dyld cache). + consts = dict(re.findall(r'static constexpr size_t (kSwap\w+) = (0x[0-9a-f]+);', src)) + self.assertEqual({k: int(v, 16) for k, v in consts.items()}, + {'kSwapRecordSize': 0x6e0, 'kSwapBrightnessPresent': 0x354, 'kSwapBrightnessNits': 0x35e}) + header = (kext / 'AdrenoDisplay.h').read_text() + self.assertIn('virtual uint64_t swap_submit(IOMFBSwapRec *rec, IOUserClient *client, unsigned int *swapID) ' + 'APPLE_KEXT_OVERRIDE;', header) + bad = scope_violations(kext / 'AdrenoDisplay/presentation.cpp', r'backlight', 'ADRENO_BACKLIGHT') + self.assertEqual(bad, []) + self.assertIn('backlightSwapGated();', (kext / 'AdrenoDisplay/presentation.cpp').read_text()) def test_cycle(self): from cycle_lib import installed_fresh as f @@ -231,14 +254,21 @@ class Wiring(unittest.TestCase): DSC = Path('/System/Volumes/Preboot/Cryptexes/OS/System/Library/dyld/dyld_shared_cache_arm64e') IPSW = shutil.which('ipsw') or '/opt/homebrew/bin/ipsw' -# The names the guest's 26A428 userspace must still use for AdrenoBacklight.h's path, per image: BrightnessControl's -# match key, the keys it reads and writes, and the BrightnessControl entry points IOMobileFramebuffer resolves. +# What the guest's 26A428 userspace must still do for AdrenoBacklight.h's path. Strings per image: BrightnessControl's +# match key and the keys it reads; the BrightnessControl entry points IOMobileFramebuffer resolves to bind an IOMFB. USERSPACE_NAMES = { - 'BrightnessControl': ('IOPropertyMatch', 'backlight-control', 'force-brightness-control', 'brightness-nits', - 'IODisplayParameters', 'BrightnessMilliNits', 'LmaxProduct'), - 'IOMobileFramebuffer': ('BCBrtControlNewMonitorForAllDisplaysWithHandler', 'BCBrtControlGetCapabilities', - 'BCBrtControlSetNits'), + 'BrightnessControl': ('IOPropertyMatch', 'backlight-control', 'IODisplayParameters', 'BrightnessMilliNits', + 'LmaxProduct'), + 'IOMobileFramebuffer': ('BCBrtControlNewMonitorForAllDisplaysWithHandler', 'BCBrtControlGetCapabilities'), } +# Symbols per image: the swap brightness call and CoreAnimation's per-swap sender. +USERSPACE_SYMBOLS = { + 'IOMobileFramebuffer': ('_IOMobileFramebufferSwapSetBrightness',), + 'QuartzCore': ('__ZN2CA12WindowServer12IOMFBDisplay15swap_brightnessEv',), +} +# The framework keeps the swap record at this offset of its framebuffer object (kern_SwapBegin clears it there and +# kern_SwapEnd submits it from there as external method 5). +RECORD_BASE = 0x18 def host_build(): @@ -263,6 +293,32 @@ class UserspaceContract(unittest.TestCase): missing = [(image, n) for image, ns in USERSPACE_NAMES.items() for n in ns if (n, image) not in found] self.assertEqual(missing, []) + def test_symbols_present(self): + for image, symbols in USERSPACE_SYMBOLS.items(): + for symbol in symbols: + out = subprocess.run([IPSW, 'dsc', 'symaddr', str(DSC), symbol, '--image', image, '--no-color'], + capture_output=True, text=True, timeout=600) + self.assertRegex(out.stdout, r'0x[0-9a-f]+:.*\b%s\s+%s\b' % (re.escape(symbol), image), + f'{image}: {symbol}') + + def disass(self, symbol): + out = subprocess.run([IPSW, 'dsc', 'disass', str(DSC), '--symbol', symbol, '--symbol-image', + 'IOMobileFramebuffer', '--no-color'], capture_output=True, text=True, timeout=600) + self.assertEqual(out.returncode, 0, out.stderr[-1000:]) + return out.stdout + + def test_record_offsets(self): + # The kext's offsets, rebased onto the framework's object: the setter must address both, and the submit must + # hand the whole record (that size, from that base) to external method 5. + src = (ROOT / 'gpu/adreno-kext/kext/src/AdrenoDisplay/backlight.cpp').read_text() + consts = {k: int(v, 16) for k, v in re.findall(r'static constexpr size_t (kSwap\w+) = (0x[0-9a-f]+);', src)} + setter = self.disass('_kern_SwapSetBrightness') + for field in ('kSwapBrightnessPresent', 'kSwapBrightnessNits'): + self.assertRegex(setter, r'#%#x\b' % (RECORD_BASE + consts[field]), field) + submit = self.disass('_kern_SwapEnd') + for value in (RECORD_BASE, consts['kSwapRecordSize'], 5): + self.assertRegex(submit, r'#%#x\b' % value) + @unittest.skipUnless(ARGS.build, 'pass --build to compile the monitor (minutes)') class Build(unittest.TestCase): @@ -302,14 +358,16 @@ class Kext(unittest.TestCase): c = subprocess.run([sys.executable, str(ROOT / 'tools/macho-sections.py'), str(ref), str(out / 'default/AdrenoX2.kext/Contents/MacOS/AdrenoX2')], capture_output=True, text=True) self.assertRegex(c.stdout, r'(\d+) of \1 sections identical', c.stdout) - default = (out / 'default/AdrenoX2.kext/Contents/MacOS/AdrenoX2').read_bytes() - self.assertNotIn(b'force-brightness-control', default) + default = subprocess.run(['nm', '-m', str(out / 'default/AdrenoX2.kext/Contents/MacOS/AdrenoX2')], + capture_output=True, text=True).stdout + self.assertNotIn('__ZN13AdrenoDisplay11swap_submitEP12IOMFBSwapRecP12IOUserClientPj', default) for profile in profiles: binary = out / profile / 'AdrenoX2.kext/Contents/MacOS/AdrenoX2' nm = subprocess.run(['nm', '-m', str(binary)], capture_output=True, text=True).stdout self.assertIn('__ZN13AdrenoDisplay20set_brightness_gatedEi', nm, profile) + self.assertIn('__ZN13AdrenoDisplay11swap_submitEP12IOMFBSwapRecP12IOUserClientPj', nm, profile) self.assertIn('__ZN15AdrenoBacklight10gMetaClassE', nm, profile) - self.assertIn(b'force-brightness-control\0', binary.read_bytes(), profile) + self.assertNotIn(b'force-brightness-control', binary.read_bytes(), profile) if __name__ == '__main__':