Skip to content

tvOS EDR / HDR output from a CAMetalLayer (fork spike)

Research and spike notes, 2026-10-08. Landed in the normal fork build: tvos-render-1080p was fast-forwarded to efa0097 (80336df spike, 376976c AVKit crash fix, efa0097 tightened support rule), and the installed tvos.zip release lib was repackaged. Verified on the Apple TV 4K (BUILD 285/286, with the 376976c lib): with Match Content on, the TV auto-switches to HDR, enabled=true, and Chris confirmed by eye that highlights go brighter than UI white. The efa0097 support rule below had not been on a device yet when it landed.

Why HDR output never turned on (BUILD 283, Apple TV 4K set to “4K HDR”)

Section titled “Why HDR output never turned on (BUILD 283, Apple TV 4K set to “4K HDR”)”

Device readout: capable=true on=true supported=false requested=false max_nits=100.

  1. Detection. DisplayServerTVOS::_screen_hdr_is_supported() returned UIScreen.potentialEDRHeadroom > 1.0. tvOS reports 1.0 even while the box outputs HDR, so window_is_hdr_output_supported() was false. render/hdr_output.gd only requests HDR on a supported screen, which explains requested=false. The game side was behaving correctly.
  2. Layer opt-in. The Metal surface (drivers/metal/rendering_context_driver_metal.cpp, both SurfaceLayer and SurfaceOffscreen) opts into EDR with CALayer.wantsExtendedDynamicRangeContent. The tvOS 27 SDK marks it API_UNAVAILABLE(tvos) in both CALayer.h and CAMetalLayer.h, and it is deprecated on iOS/macOS 26. Because metal-cpp calls it through objc_msgSend, it compiles; at runtime the SDR branch has always sent it (with NO) and the app hasn’t crashed, so the selector exists but probably does nothing.
  3. Headroom. max_nits = potentialEDRHeadroom (1.0) x 100 gives the reported 100.

What the tvOS 27 SDK offers (AppleTVOS27.0.sdk headers)

Section titled “What the tvOS 27 SDK offers (AppleTVOS27.0.sdk headers)”
API tvOS Notes
CALayer.preferredDynamicRange (CADynamicRangeAutomatic/Standard/ConstrainedHigh/High) 26.0+ Replaces wantsExtendedDynamicRangeContent. The default is Standard. EDR activates only when the layer’s contents carry headroom tagging > 1.0. Any value other than Standard stops tone mapping to SDR.
CALayer.contentsHeadroom 26.0+ “CAMetalLayers can use this value to define how much headroom is needed by their MTLDrawables.” 0 means untagged.
CALayer.toneMapMode 18.0+ Default Automatic. Left alone.
CAMetalLayer.EDRMetadata, CAEDRMetadata unavailable So HDR10/HLG metadata on the layer is not an option on tvOS.
UIScreen.potentialEDRHeadroom / currentEDRHeadroom 16.0+ Reports 1.0 on an HDR-mode ATV (observed). It may rise once tagged EDR content is on screen. Forum reports say iOS only raises current after >1.0 pixels are presented. This is unconfirmed on tvOS.
AVPlayer.eligibleForHDRPlayback + AVPlayerEligibleForHDRPlaybackDidChangeNotification 13.4+ “YES if an HDR display is available and the device is capable of playing HDR.” This is a capability signal: it says nothing about the current output mode. It replaced the now-deprecated availableHDRModes.
UITraitCollection.imageDynamicRange 17.0+ How HDR images render in this trait environment (2 = high). It may track the current output mode. Logged by the spike to find out.
UIWindow.avDisplayManager.preferredDisplayCriteria (AVDisplayCriteria initWithRefreshRate:formatDescription:) 11.2 / 17.0+ Asks the TV to switch display mode. The system honours it only when the user has Match Content > Match Dynamic Range turned on. displayCriteriaMatchingEnabled reports that setting.

metal-cpp in the fork already wraps setPreferredDynamicRange, setContentsHeadroom and the CA::DynamicRange* constants (thirdparty/metal-cpp/QuartzCore/CALayer.hpp), so no new bindings were needed.

Apple’s public documentation for contentsHeadroom and for preferredDynamicRange on a CAMetalLayer is still a bare symbol page. No Apple sample, WWDC session or forum post was found that shows EDR from a Metal layer on tvOS. WWDC21 (10161) says Mac, iPad and Apple TV driving a typical HDR10 display get “roughly 5x” EDR headroom. Apple’s HDR image guide gives 4.926 (1000/203) as the default content headroom for untagged HDR images.

What the spike changes (tvOS only; the iOS/macOS paths are unchanged)

Section titled “What the spike changes (tvOS only; the iOS/macOS paths are unchanged)”
  • Detection (platform/tvos/display_server_tvos.mm): HDR is supported when potentialEDRHeadroom > 1 or AVPlayer.eligibleForHDRPlayback. The eligibility value is cached and refreshed on its change notification, which also re-runs _update_hdr_output.
  • Headroom: the real screen value is used when it is above 1.0. Otherwise, when the display is eligible, the code assumes 1000/203 ≈ 4.93, giving max_nits ≈ 493 and an output_max_linear_value of about 4.93. The null-screen fallbacks now return 1.0 instead of 0, which used to divide by zero in the reference-luminance calculation.
  • Layer (drivers/metal/rendering_context_driver_metal.cpp): on tvOS HDR, the layer gets preferredDynamicRange = High and contentsHeadroom = max/reference luminance (≈ 4.93), with the same RGBA16Float + kCGColorSpaceExtendedLinearSRGB swapchain iOS uses. For SDR it gets Standard + 0. The tvOS build no longer sends setWantsExtendedDynamicRangeContent:.
  • Mode switch (optional, included): window_request_hdr_output sets preferredDisplayCriteria to an HEVC 2160p BT.2020/PQ format description at the screen’s refresh rate while HDR is enabled, and to nil when it is off. If the ATV is already in a “4K HDR” format, this does nothing. With Match Dynamic Range on and the ATV in SDR, the toggle triggers a real TV mode switch, which blanks the screen for a few seconds.
  • Diagnostics: one print_line at first use and on every request: tvOS HDR [<why>]: eligibleForHDRPlayback=… potentialEDRHeadroom=… currentEDRHeadroom=… imageDynamicRange=… displayGamut=… criteriaMatching=… modeSwitching=….

Launch crash in BUILD 284 (fixed in fork 376976c)

Section titled “Launch crash in BUILD 284 (fixed in fork 376976c)”

BUILD 284 (spike 80336df) crashed on every launch with -[UIWindow avDisplayManager]: unrecognized selector. avDisplayManager is an AVKit category on UIWindow. Calling it creates no AVKit symbol reference, and ld only links a framework listed in an object’s LC_LINKER_OPTION (clang module autolinking) when that framework resolves an undefined symbol. So AVKit was silently dropped, which this repro confirmed: a category-only reference links without AVKit even though AVKit is in the object’s autolink list. The diagnostic log queried it on the first HDR-support poll.

The fix adds a hard [AVDisplayManager class] reference, which forces _OBJC_CLASS_$_AVDisplayManager to resolve against AVKit. Every AVKit/AVFoundation selector the spike added is guarded with respondsToSelector:, and AVKit is only touched once HDR output is actually requested. A test link of the rebuilt object with -dead_strip_dylibs now lists AVKit, AVFoundation and CoreMedia in otool -L. The Xcode shell needs no extra flag. General rule: an ObjC category from a framework the binary doesn’t otherwise reference will not autolink, so add a class reference.

supported = display_hdr && (output_hdr || can_switch). eligibleForHDRPlayback alone was capability-only and overstated on a fixed 4K-SDR setup.

  • display_hdr: AVPlayer.eligibleForHDRPlayback, or potentialEDRHeadroom > 1.
  • output_hdr: EDR headroom > 1, or the screen’s traitCollection.imageDynamicRange == High, or the engine’s HDR output is already on. That last one is hysteresis, so supported stays true across a mode switch the game itself triggered.
  • can_switch: AVDisplayManager.displayCriteriaMatchingEnabled (Match Content is on, so the request’s preferredDisplayCriteria can switch the TV). This is the device-verified path.
  • The rule is re-evaluated on AVDisplayManagerModeSwitchSettingsChanged / ModeSwitchEnd and on eligibility changes.
  • Unverified: a fixed “4K HDR” format with Match Content off. That case depends on imageDynamicRange or headroom reporting HDR. If tvOS reports neither, the rule under-reports (the game stays SDR) rather than overstating.

Open questions (only the device can answer)

Section titled “Open questions (only the device can answer)”
  1. Does eligibleForHDRPlayback return YES on the ATV in “4K HDR”? It probably also returns YES in 4K SDR with an HDR TV attached. If so, supported means “capable”, not “currently HDR”. That is harmless, because the compositor tone-maps tagged content down, but the readout would mislead. If imageDynamicRange turns out to track the output mode, gate on it instead.
  2. Does the tvOS compositor honour preferredDynamicRange + contentsHeadroom on a CAMetalLayer? The headers say yes. Nothing public shows it working on tvOS. If it doesn’t, the game looks the same as SDR (values above 1.0 clip): no crash, just no gain.
  3. Does currentEDRHeadroom rise above 1.0 once tagged content is presented? If it does, the real figure replaces the 4.93 assumption on the next frame poll.
  4. Extended linear sRGB vs BT.2020 PQ: the compositor has to map extended-sRGB values beyond the P3/709 gamut. That is fine for neon brightness, though Rec.2020 saturation outside sRGB isn’t reachable this way.

Read the game’s console hdr fields: supported should become true. After the toggle, requested and enabled should be true and max_nits about 493 (or potential x 100 if the screen reports a real headroom). Then check by eye whether highlights above 1.0 go brighter than UI white. The tvOS HDR [...] log lines (Xcode console / devicectl ... --console on a USB/ paired launch) show the raw signals for question 1.