From 43f112d09ac0f20c0b19bd189297d3d5cad16bd3 Mon Sep 17 00:00:00 2001 From: Ryan Murray <81541646+RyanTheTide@users.noreply.github.com> Date: Thu, 5 Nov 2026 23:49:13 +1100 Subject: [PATCH] drm/apple: Cap desktop 26.x panel EDR at the panel's potential headroom On the J616s in GNOME's BT.2100 mode, stepping the brightness down through the low end made the panel dip darker and then come back brighter on every key press, and kept flickering for seconds after the last one. dcp_edr_prepare() gave the panel headroom peak / SDR white with no limit. At the lowest GNOME level (6 nits) that is a headroom of 266: every swap asked for the panel peak and scaled the picture by 1/266. Reaching it took about 120 one-step swaps, and a still desktop sends about one swap a second, so the ramp crawled on for seconds and each OSD redraw of a later key press ran another stretch of it. Meanwhile the backlight fade lowers SDR white faster than one step a swap raises the headroom, so the requested panel level went down and up again within one key press: traced on the J616s for one 35 to 6 nit step, it went 1599, 1527, 1495, 1566, 1599, 1559, ... 891, and back up to 1599 over the next 4.5 s. The firmware eases the panel level over several frames but applies the EDR scale at once, so each reversal shows as a dip and a recovery. macOS limits the headroom to the panel's potential headroom, which the ADT carries as EDRPotentialHeadroom in the /backlight node. Read it with the panel's other ADT properties and cap the headroom there. Below the SDR white at which the peak needs more than that, the headroom stays at the cap, and a brightness change moves only the panel level. Above it, a swap that lowers SDR white while the headroom is still below its target now raises the headroom just enough to hold the panel level, so the requested level never moves against a fade. The macOS ramp for EDR content starting, one step per swap, is unchanged and continues once SDR white settles. Without the ADT property, the headroom is not capped, as before. The KUnit cases model the swaps of a backlight fade and check that the panel level never moves against it, with and without a cap, that swaps at a steady level change nothing, and the start ramp. The host selftest's ADT fixture gains a /backlight node. Assisted-by: LLM Signed-off-by: Ryan Murray <81541646+RyanTheTide@users.noreply.github.com> --- drivers/gpu/drm/apple/dcp-edr.h | 66 +++++++++ drivers/gpu/drm/apple/dcp-internal.h | 9 +- drivers/gpu/drm/apple/dcp-panel-adt.c | 5 +- drivers/gpu/drm/apple/dcp-panel-adt.h | 2 + drivers/gpu/drm/apple/dcp.c | 5 + drivers/gpu/drm/apple/dcp_backlight_test.c | 132 ++++++++++++++++++ drivers/gpu/drm/apple/iomfb_template.c | 27 ++-- .../drivers/gpu/apple-dcp/desktop.py | 7 +- 8 files changed, 236 insertions(+), 17 deletions(-) create mode 100644 drivers/gpu/drm/apple/dcp-edr.h diff --git a/drivers/gpu/drm/apple/dcp-edr.h b/drivers/gpu/drm/apple/dcp-edr.h new file mode 100644 index 0000000000000..462f3076bee2d --- /dev/null +++ b/drivers/gpu/drm/apple/dcp-edr.h @@ -0,0 +1,66 @@ +/* SPDX-License-Identifier: GPL-2.0-only OR MIT */ +/* Copyright 2026 Ryan Murray */ + +#ifndef __APPLE_DCP_EDR_H__ +#define __APPLE_DCP_EDR_H__ + +#include +#include +#include +#include + +/* + * EDR headroom on a desktop 26.x panel, x1000: 1000 is SDR white at the + * panel level. A swap asks for brightness SDR white x H and scales the + * picture by 1 / H (see dcp_edr_prepare()). + */ +#define DCP_EDR_UNITY 1000U +#define DCP_EDR_STEP_UP 1047U /* x1000 per swap */ + +/* + * The panel's potential headroom, the ADT backlight node's + * EDRPotentialHeadroom (16.16), as a ceiling x1000. macOS never gives the + * panel more. 0: no ceiling, for a value that is missing or not above 1.0. + */ +static inline u32 dcp_edr_ceiling(u32 fixed) +{ + u64 hm = ((u64)fixed * DCP_EDR_UNITY) >> 16; + + return hm > DCP_EDR_UNITY && hm <= U32_MAX ? hm : 0; +} + +/* The headroom the panel has above @sdr nits: peak / sdr, up to @ceiling. */ +static inline u32 dcp_edr_target(u32 peak, u32 sdr, u32 ceiling) +{ + u32 target = DCP_EDR_UNITY; + + if (sdr && peak > sdr) + target = div_u64((u64)peak * DCP_EDR_UNITY, sdr); + if (ceiling) + target = clamp(target, DCP_EDR_UNITY, ceiling); + return target; +} + +/* + * The headroom for the next swap: up one step per swap, down at once. A + * swap that lowers SDR white from @prev_sdr to @sdr while the headroom is + * below its target raises the headroom just enough to hold the panel level, + * as far as the target allows, and no further: the firmware eases the panel + * level over several frames but applies the scale at once, so a level that + * moved against the fade would show as a dip and a recovery. Held, only the + * scale moves and SDR content dims in the frame that asks for it. A ramp + * cut short continues once SDR white settles. + */ +static inline u32 dcp_edr_next(u32 cur, u32 target, u32 prev_sdr, u32 sdr) +{ + if (target < cur) + return target; + if (target == cur) + return cur; + if (sdr && prev_sdr > sdr) + return min_t(u64, div_u64((u64)cur * prev_sdr + sdr / 2, sdr), target); + return min_t(u64, div_u64((u64)cur * DCP_EDR_STEP_UP, DCP_EDR_UNITY) + 1, + target); +} + +#endif /* __APPLE_DCP_EDR_H__ */ diff --git a/drivers/gpu/drm/apple/dcp-internal.h b/drivers/gpu/drm/apple/dcp-internal.h index f27b6cfefb882..1064f68849d44 100644 --- a/drivers/gpu/drm/apple/dcp-internal.h +++ b/drivers/gpu/drm/apple/dcp-internal.h @@ -203,6 +203,8 @@ struct dcp_panel { bool has_mini_led; /// peak panel luminance in nits, for EDR (desktop 26.x; 0 if unknown) u32 peak_nits; + /// EDR headroom ceiling x1000 from the ADT (desktop 26.x; 0 if none) + u32 edr_ceiling; }; enum dcp_iomfb_method_profile { @@ -594,11 +596,12 @@ struct apple_dcp { bool output_edr; bool edr_sent; /* - * EDR headroom the panel was last given, x1000 (0: not in use), and - * a power off since, after which the ramp starts again from 1.0 (see - * dcp_edr_prepare()). + * EDR headroom the panel was last given, x1000 (0: not in use), the + * SDR white in nits it was given for, and a power off since, after + * which the ramp starts again from 1.0 (see dcp_edr_prepare()). */ u32 edr_hm; + u32 edr_sdr; bool edr_restart; struct dcp_set_digital_out_mode_req mode; diff --git a/drivers/gpu/drm/apple/dcp-panel-adt.c b/drivers/gpu/drm/apple/dcp-panel-adt.c index 5887091aa57cf..521ff6cd79a3a 100644 --- a/drivers/gpu/drm/apple/dcp-panel-adt.c +++ b/drivers/gpu/drm/apple/dcp-panel-adt.c @@ -76,7 +76,7 @@ static int dcp_adt_node(struct dcp_adt_cursor *c, struct dcp_panel_adt *panel, size_t props_start, props_end; u32 props, children; char node_name[33] = {}; - bool product, selected; + bool product, selected, backlight; if (IS_ERR(header)) return PTR_ERR(header); @@ -106,6 +106,7 @@ static int dcp_adt_node(struct dcp_adt_cursor *c, struct dcp_panel_adt *panel, product = depth == 1 && !strcmp(node_name, "product"); selected = (depth == 1 && !strcmp(node_name, "panel")) || (product_parent && !strcmp(node_name, "panel")); + backlight = depth == 1 && !strcmp(node_name, "backlight"); /* /product carries raw-panel-serial-number on machines without /panel. */ c->pos = props_start; for (u32 i = 0; i < props; i++) { @@ -115,6 +116,8 @@ static int dcp_adt_node(struct dcp_adt_cursor *c, struct dcp_panel_adt *panel, if (selected || (product && !strcmp(name, "raw-panel-serial-number"))) dcp_adt_panel_property(panel, name, prop + 36, size); + else if (backlight && !strcmp(name, "EDRPotentialHeadroom") && size == 4) + panel->edr_potential_headroom = get_unaligned_le32(prop + 36); c->pos += 36 + ALIGN((size_t)size, 4); } /* A valid ADT may have neither panel node; retain the OF defaults. */ diff --git a/drivers/gpu/drm/apple/dcp-panel-adt.h b/drivers/gpu/drm/apple/dcp-panel-adt.h index 2d724adb7d0e1..32b585660974a 100644 --- a/drivers/gpu/drm/apple/dcp-panel-adt.h +++ b/drivers/gpu/drm/apple/dcp-panel-adt.h @@ -8,6 +8,8 @@ struct dcp_panel_adt { struct dcp_display_metadata identity; u32 width_mm, height_mm; + /* /backlight EDRPotentialHeadroom, 16.16; 0 if absent */ + u32 edr_potential_headroom; bool found; }; diff --git a/drivers/gpu/drm/apple/dcp.c b/drivers/gpu/drm/apple/dcp.c index 85caa87494ae7..c5d05af2f976f 100644 --- a/drivers/gpu/drm/apple/dcp.c +++ b/drivers/gpu/drm/apple/dcp.c @@ -51,6 +51,7 @@ #include "afk.h" #include "av.h" #include "dcp.h" +#include "dcp-edr.h" #include "dcp-panel-adt.h" #include #include @@ -878,9 +879,13 @@ static void dcp_panel_adt_identity(struct apple_dcp *dcp) dcp->panel.width_mm = panel.width_mm; dcp->panel.height_mm = panel.height_mm; dcp->desktop->panel_adt_identity = panel.identity; + dcp->panel.edr_ceiling = dcp_edr_ceiling(panel.edr_potential_headroom); dev_info(dcp->dev, "panel identity fallback: ADT %s, panel node %s, dimensions %ux%u mm\n", ret ? "unavailable" : "parsed", panel.found ? "present" : "absent", panel.width_mm, panel.height_mm); + if (dcp->panel.edr_ceiling) + dev_info(dcp->dev, "panel EDR headroom up to %u.%03u\n", + dcp->panel.edr_ceiling / 1000, dcp->panel.edr_ceiling % 1000); } /* Internal panel identity has no detachable Type-C service or EDID transport. */ diff --git a/drivers/gpu/drm/apple/dcp_backlight_test.c b/drivers/gpu/drm/apple/dcp_backlight_test.c index c8a4d31bde2af..2a40415fb635e 100644 --- a/drivers/gpu/drm/apple/dcp_backlight_test.c +++ b/drivers/gpu/drm/apple/dcp_backlight_test.c @@ -5,6 +5,7 @@ #include #include "dcp_backlight.h" +#include "dcp-edr.h" static void backlight_millinits_takeover_test(struct kunit *test) { @@ -633,6 +634,132 @@ static void backlight_reports_keep_target_test(struct kunit *test) KUNIT_EXPECT_FALSE(test, state.dirty); } + +/* Synthetic panel: a 1200 nit peak and a potential headroom of 8.0. */ +#define EDR_TEST_PEAK 1200U +#define EDR_TEST_CEILING 0x80000U + +static void edr_ceiling_test(struct kunit *test) +{ + KUNIT_EXPECT_EQ(test, dcp_edr_ceiling(0), 0U); + KUNIT_EXPECT_EQ(test, dcp_edr_ceiling(0x8000), 0U); + KUNIT_EXPECT_EQ(test, dcp_edr_ceiling(0x10000), 0U); + KUNIT_EXPECT_EQ(test, dcp_edr_ceiling(0x18000), 1500U); + KUNIT_EXPECT_EQ(test, dcp_edr_ceiling(EDR_TEST_CEILING), 8000U); + KUNIT_EXPECT_EQ(test, dcp_edr_ceiling(U32_MAX), 65535999U); +} + +static void edr_target_test(struct kunit *test) +{ + u32 ceiling = dcp_edr_ceiling(EDR_TEST_CEILING); + + /* Bright SDR white: the peak sets the headroom. */ + KUNIT_EXPECT_EQ(test, dcp_edr_target(EDR_TEST_PEAK, 400, ceiling), 3000U); + KUNIT_EXPECT_EQ(test, dcp_edr_target(EDR_TEST_PEAK, 150, ceiling), 8000U); + /* Dim SDR white: the ceiling does, never peak / sdr. */ + KUNIT_EXPECT_EQ(test, dcp_edr_target(EDR_TEST_PEAK, 6, ceiling), 8000U); + KUNIT_EXPECT_EQ(test, dcp_edr_target(EDR_TEST_PEAK, 1, ceiling), 8000U); + KUNIT_EXPECT_EQ(test, dcp_edr_target(EDR_TEST_PEAK, 6, 0), 200000U); + /* No headroom above the peak, and none to give with no SDR white. */ + KUNIT_EXPECT_EQ(test, dcp_edr_target(EDR_TEST_PEAK, 1200, ceiling), 1000U); + KUNIT_EXPECT_EQ(test, dcp_edr_target(EDR_TEST_PEAK, 1500, ceiling), 1000U); + KUNIT_EXPECT_EQ(test, dcp_edr_target(EDR_TEST_PEAK, 0, ceiling), 1000U); + KUNIT_EXPECT_EQ(test, dcp_edr_target(10000, 1, 0), 10000000U); +} + +/* + * Present a fade of SDR white from @from to @to in @steps swaps, as the + * backlight fade does, then @extra swaps at the new level, and return the + * number of swaps whose panel level moved against the fade. + */ +static unsigned int edr_fade(struct kunit *test, u32 *hm, u32 ceiling, + u32 from, u32 to, unsigned int steps, + unsigned int extra, u32 *last_bri) +{ + u32 prev = from, bri = (u64)from * *hm / DCP_EDR_UNITY; + unsigned int reversals = 0; + + for (unsigned int i = 1; i <= steps + extra; i++) { + u32 sdr = i >= steps ? to : + (from > to ? from - (from - to) * i / steps : + from + (to - from) * i / steps); + u32 target = dcp_edr_target(EDR_TEST_PEAK, sdr, ceiling); + u32 next = dcp_edr_next(*hm, target, prev, sdr); + u32 level = (u64)sdr * next / DCP_EDR_UNITY; + + KUNIT_EXPECT_LE(test, next, max(target, *hm)); + /* Allow the rounding of the headroom and of the level. */ + u32 slack = max(1U, bri / 200); + + if ((from > to && level > bri + slack) || + (from < to && level + slack < bri)) + reversals++; + *hm = next; + prev = sdr; + bri = level; + } + *last_bri = bri; + return reversals; +} + +static void edr_low_end_steady_test(struct kunit *test) +{ + u32 ceiling = dcp_edr_ceiling(EDR_TEST_CEILING); + u32 hm = dcp_edr_target(EDR_TEST_PEAK, 35, ceiling), bri; + + /* At the ceiling below 150 nits: a fade only moves the panel level. */ + KUNIT_EXPECT_EQ(test, edr_fade(test, &hm, ceiling, 35, 6, 15, 0, &bri), 0U); + KUNIT_EXPECT_EQ(test, hm, 8000U); + KUNIT_EXPECT_EQ(test, bri, 48U); + /* Swaps at the floor, e.g. for an OSD, change nothing. */ + for (unsigned int i = 0; i < 200; i++) + KUNIT_EXPECT_EQ(test, dcp_edr_next(hm, dcp_edr_target(EDR_TEST_PEAK, 6, ceiling), + 6, 6), hm); +} + +static void edr_fade_monotonic_test(struct kunit *test) +{ + u32 ceilings[] = { dcp_edr_ceiling(EDR_TEST_CEILING), 0 }; + + for (unsigned int c = 0; c < ARRAY_SIZE(ceilings); c++) { + u32 ceiling = ceilings[c]; + u32 hm = dcp_edr_target(EDR_TEST_PEAK, 400, ceiling), bri; + + /* Down: the panel level holds, then follows SDR white down. */ + KUNIT_EXPECT_EQ(test, edr_fade(test, &hm, ceiling, 400, 6, 15, 300, &bri), 0U); + KUNIT_EXPECT_EQ(test, hm, dcp_edr_target(EDR_TEST_PEAK, 6, ceiling)); + KUNIT_EXPECT_EQ(test, bri, (u32)((u64)6 * hm / DCP_EDR_UNITY)); + /* Up: the headroom drops at once and the level only rises. */ + KUNIT_EXPECT_EQ(test, edr_fade(test, &hm, ceiling, 6, 400, 15, 10, &bri), 0U); + KUNIT_EXPECT_EQ(test, hm, 3000U); + KUNIT_EXPECT_EQ(test, bri, EDR_TEST_PEAK); + /* One step at a time between near levels. */ + KUNIT_EXPECT_EQ(test, edr_fade(test, &hm, ceiling, 400, 300, 15, 50, &bri), 0U); + KUNIT_EXPECT_EQ(test, hm, 4000U); + } +} + +static void edr_start_ramp_test(struct kunit *test) +{ + u32 ceiling = dcp_edr_ceiling(EDR_TEST_CEILING); + u32 target = dcp_edr_target(EDR_TEST_PEAK, 6, ceiling); + u32 hm = DCP_EDR_UNITY; + unsigned int swaps = 0; + + /* EDR starting at a steady level rises one step per swap. */ + while (hm < target) { + u32 next = dcp_edr_next(hm, target, 6, 6); + + KUNIT_ASSERT_GT(test, next, hm); + KUNIT_EXPECT_LE(test, next, hm * DCP_EDR_STEP_UP / DCP_EDR_UNITY + 1); + hm = next; + KUNIT_ASSERT_LT(test, ++swaps, 100U); + } + KUNIT_EXPECT_EQ(test, hm, target); + /* Ending EDR returns to 1.0 in one swap. */ + KUNIT_EXPECT_EQ(test, dcp_edr_next(hm, DCP_EDR_UNITY, 6, 6), DCP_EDR_UNITY); +} + static struct kunit_case backlight_cases[] = { KUNIT_CASE(backlight_reports_keep_target_test), KUNIT_CASE(backlight_millinits_takeover_test), @@ -654,6 +781,11 @@ static struct kunit_case backlight_cases[] = { KUNIT_CASE(backlight_fade_test), KUNIT_CASE(backlight_fade_restart_test), KUNIT_CASE(backlight_fade_blank_test), + KUNIT_CASE(edr_ceiling_test), + KUNIT_CASE(edr_target_test), + KUNIT_CASE(edr_low_end_steady_test), + KUNIT_CASE(edr_fade_monotonic_test), + KUNIT_CASE(edr_start_ramp_test), {} }; diff --git a/drivers/gpu/drm/apple/iomfb_template.c b/drivers/gpu/drm/apple/iomfb_template.c index 2d7268f84e290..eadd480e76690 100644 --- a/drivers/gpu/drm/apple/iomfb_template.c +++ b/drivers/gpu/drm/apple/iomfb_template.c @@ -29,6 +29,7 @@ #include "dcp.h" #include "dcp-internal.h" +#include "dcp-edr.h" #include "iomfb.h" #include "iomfb_internal.h" #include "parser.h" @@ -160,10 +161,12 @@ static u64 dcp_binary64_ratio(u64 num, u64 den) * PQ 100 lands at SDR white and highlights reach the panel peak. H rises * in steps linear in stops, one per swap, about 1 s when EDR starts, with * a brightness limit of 1.3 x the brightness in the middle of the ramp. + * H never exceeds the panel's potential headroom from the ADT, as on + * macOS: below the SDR white at which the peak needs more, H stays at that + * ceiling and the panel level follows SDR white. * When EDR ends, headroom used drops to 1.0 and H returns to 1.0. * Unchanged values are left out: the firmware keeps them. */ -#define DCP_EDR_STEP_UP 1047 /* x1000 per swap */ #define dcp_swap_set_ratio(swap, field, flag, num, den) \ do { \ @@ -184,7 +187,8 @@ static void dcp_edr_prepare(struct apple_dcp *dcp, struct DCP_FW_NAME(dcp_swap) { u32 peak = READ_ONCE(dcp->panel.peak_nits); u32 sdr = carries ? nits : dcp_backlight_desktop_nits(dcp); - u32 cur = dcp->edr_hm ?: 1000, target = 1000, next, bri; + u32 cur = dcp->edr_hm ?: DCP_EDR_UNITY, target = DCP_EDR_UNITY, next, bri; + u32 prev_sdr = dcp->edr_sdr; bool restart = dcp->edr_restart; dcp->edr_restart = false; @@ -192,10 +196,13 @@ static void dcp_edr_prepare(struct apple_dcp *dcp, struct DCP_FW_NAME(dcp_swap) return; /* no EDR, and none to wind down */ if (!sdr) return; - if (dcp->output_edr && peak > sdr) - target = peak * 1000 / sdr; - if (restart && dcp->output_edr) - cur = 1000; + if (dcp->output_edr) + target = dcp_edr_target(peak, sdr, dcp->panel.edr_ceiling); + if (restart && dcp->output_edr) { + cur = DCP_EDR_UNITY; + prev_sdr = sdr; + } + dcp->edr_sdr = sdr; if (cur == target && !carries && !restart && dcp->edr_sent == dcp->output_edr) return; @@ -205,13 +212,9 @@ static void dcp_edr_prepare(struct apple_dcp *dcp, struct DCP_FW_NAME(dcp_swap) * no further swap, and a ramp stuck partway leaves SDR content dimmed * by the scale (headroom used is already 1.0). */ - next = cur; - if (target > cur) - next = min(cur * DCP_EDR_STEP_UP / 1000 + 1, target); - else if (target < cur) - next = target; + next = dcp_edr_next(cur, target, prev_sdr, sdr); - bri = (u64)sdr * next / 1000; + bri = (u64)sdr * next / DCP_EDR_UNITY; dcp_swap_set_brightness(swap, bri); dcp_swap_set_ratio(swap, edr_scale, 2, 1000, next); if (dcp->output_edr) { diff --git a/tools/testing/selftests/drivers/gpu/apple-dcp/desktop.py b/tools/testing/selftests/drivers/gpu/apple-dcp/desktop.py index 860578ee16f1a..7eeef292efaf0 100644 --- a/tools/testing/selftests/drivers/gpu/apple-dcp/desktop.py +++ b/tools/testing/selftests/drivers/gpu/apple-dcp/desktop.py @@ -765,8 +765,12 @@ def node(props, children=()): return bytes(out)+b''.join(children) panel=node({'product-id':42,'vendor-id':0x5274,'width-mm':345,'height-mm':223, 'serial-number':b'SYNTHETIC-PANEL','name':b'panel\0'}) + # A synthetic potential headroom of 6.5; a nested node of that name is no + # panel backlight and must not override it. + backlight=node({'name':b'backlight\0','EDRPotentialHeadroom':0x68000}, + [node({'name':b'backlight\0','EDRPotentialHeadroom':0x10000})]) data=node({'name':b'device-tree\0'},[node({'name':b'product\0'},[panel]), - node({'name':b'chosen\0','serial-number':b'PRIVATE-MACHINE'})]) + node({'name':b'chosen\0','serial-number':b'PRIVATE-MACHINE'}),backlight]) base=root/'drivers/gpu/drm/apple' source=(base/'dcp-panel-adt.c').read_text() source='\n'.join(l for l in source.splitlines() if not l.startswith('#include')) @@ -798,6 +802,7 @@ def node(props, children=()): assert(panel.found && panel.width_mm==345 && panel.height_mm==223); assert(!strcmp(panel.identity.manufacturer,"TST") && panel.identity.product==42); assert(panel.identity.serial && panel.identity.serial==dcp_adt_serial((const u8 *)"SYNTHETIC-PANEL",15)); + assert(panel.edr_potential_headroom==0x68000); for(unsigned n=0;n