[UPDATE: This is actually a Microsoft bug in Windows. I owe Razer, and their firmware / EC team, a huge apology. 100% my fault.]
Upgrading my laptop is like a second Christmas - I always benefit from the extra horsepower, and the general year-over-year advancements in things like displays, WiFi, and PCIe / NVMe are always nice to have. A little over a week ago I upgraded to the latest Razer Blade 15 featuring an Alder Lake CPU and a 3080 Ti, and the performance / thermals have been nothing short of phenomenal.
Then I noticed something - whenever the display wakes, it’s always at 100% brightness. I usually use the laptop with the display at 20%, so when I walk away for a while and the display sleeps I come back and touch the trackpad to wake it up only to be faced by an eye-searing full-brightness display. I would then decrease the brightness using the keyboard keys or the Action Center slider, but I noticed that the machine seems to believe it’s still at 20% - using brightness down lowers it from 100% to 15% and brightness up takes it from 100% to 25%. So the system believed the brightness hasn’t changed, despite it being glaringly obvious that it did, in the most literal sense. Reinstalling drivers and updating Razer Synapse software didn’t fix the issue, and it was so annoying that I decided to try and root-cause it and maybe come up with a workaround. It seemed to be an Embedded Controller (EC) or firmware / BIOS bug, but still, my thought was that a workaround might still be possible.
My first attempt at a workaround was writing an app that queries the screen brightness, then sets it again on wakeup. This turned out to be even more involved than I’d thought: SetMonitorBrightness() fails to change the brightness on the built-in display connected over Embedded DisplayPort (eDP), with
0xC0262582, with a description of
An error occurred while transmitting data to the device on the I2C bus. Some reading later, I learn that eDP displays do not support this interface, and instead I should use WMI.
Interfacing with WMI through C++ was standard COM fare, but even though my code was able to adjust to arbitrary brightness levels, after waking from sleep restoring the previous brightness would have no effect, but setting any other brightness would work. I also tried the much more straightforward approach of using the
IOCTL_VIDEO_SET_DISPLAY_BRIGHTNESS ioctl via
DeviceIoControl(), but while that would report success it still had the same issue of not being able to restore the previous brightness. More digging was needed.
Using Process Monitor I noticed registry accesses to
HKLM\SYSTEM\CurrentControlSet\Control\Power\User\PowerSchemes\381b4222-f694-41f0-9685-ff5bb260df2e\..., and I recognized that GUID as the one identifying the “Balanced” power plan. Using RegEdit, I found some registry keys under the above path at
\7516b95f-f776-4464-8c53-06167f40cc99\aded5e82-b909-4619-9949-f5d71dac0bcb specifying AC and DC brightness values that seem to map to the values in the DISPLAY_BRIGHTNESS struct used by the ioctl calls. Manually editing them had no effect on brightness, but changing them to another value made the ioctl / WMI calls finally have effect, so I realized what was happening: the
IOCTL_VIDEO_SET_DISPLAY_BRIGHTNESS was checking the registry first, and if the values there matched the requested values it would drop the ioctl - so if the registry key had a brightness value of
20, and the ioctl requests a new brightness of
20, it is sliently dropped. The funny thing is that the
IOCTL_VIDEO_QUERY_DISPLAY_BRIGHTNESS ioctl would still report the actual brightness level, so my system would wake up from sleep with the registry state out-of-sync with the display state, and the display at full brightness, and querying the display brightness via
IOCTL_VIDEO_QUERY_DISPLAY_BRIGHTNESS would indeed report a brightness level of
100, but calls to
IOCTL_VIDEO_SET_DISPLAY_BRIGHTNESS would drop the ioctl since the registry, incorrectly, believed the brightness to still be
So I modified my workaround app to set the brightness to zero, then back to the level it was at before sleep - so the first ioctl overwrites the registry keys in question while the second ioctl no longer gets dropped due to the registry state being out of sync. With this, finally, I saw it work after a wake for the first time. But it would only work when I walk away from my laptop for an extended amount of time, not if I wake it right after the display sleeps. I could have stopped there, since the latter case would only affect me in the uncommon situation of the machine powering the display off while I’m reading a document for an extended period of time, but I was curious. And I found out another EC bug.
When the display powers off and the machine starts going into a lower power state, I’d notice the display powering off then two or three seconds later the keyboard lights go out. If I wake up the machine after the keyboard lights are out, everything works fine, but in the window between the display powering off and the keyboard lights powering off, my workaround doesn’t work and the screen remains in the full-bright state with an out-of-sync registry. Some head-scratching and
OutputDebugString()s later, I found out the reason. I register my app for power state changes using RegisterPowerSettingNotification(), but it seems like the EC aborts the transition to a lower power state if the user wakes the machine before the transition is complete (or at least before the keyboard lights are powered off). This is a good thing: if the user interrupts a power state transition right after the display sleeps but before the rest of the components have had time to power down, it makes more sense to early exit than to power everything down and then back up again. The problem is that the EC never notifies the OS of the display state transitions, so the power notification never fires and my workaround app does not receive a
WM_POWERBROADCAST message. I’m still not sure how to fix this case without polling, as polling in general is A Bad Thing and on a mobile system doubly so.
It’s a small paper cut in an otherwise excellent experience, but I don’t believe users should have to go through this. It’s a system bug and the system is realtively new so I hope something’s coming down the road, but so far I have not been able to get actionable help from Razer support or find out any way to contact their engineering. If you know such a way, please reach out to me.
And if you’re affected by this issue (congrats on your new 2022 Razer Blade!), the code for the fixer app can be found here. It is messy and with sometimes non-meaningful commit messages, but that’s the way it was developed so there’s that. Licensed under the GPLv3.