DDC 命令成功但屏幕不动:用 gamma 表重做亮度映射

外接屏亮度这件事,标准答案是 DDC/CI。MonitorControl、Lunar、BetterDisplay 都是这个路子:通过 IOAVService 给显示器发一条 set luminance,让固件自己去改背光。干净、不损失画质、不经过合成器,我一开始也走这条。

然后撞上了最难受的一种失败。用 m1ddc 写 15,读回来还是 15:

bash 复制代码
$ m1ddc display 2 set luminance 15
Writing 15
$ m1ddc display 2 get luminance
15          # 寄存器里真的是 15,屏幕还是满亮

不是没发出去,不是权限,不是线序,也不是 IOAVService 没打开。显示器收下命令、ACK、把值写进寄存器,然后背光一动不动。README 里这段复现记录后面只跟了一句注释:# ...the panel is still at full brightness。

这个失败形态决定了后面所有选择:故障点在显示器固件里,不在主机侧。所以换一个 DDC 工具毫无意义------Lunar 和 BetterDisplay 请求的是同一个固件动作,结果不会不一样。想救这块屏,只能换一层。

二、换层:不改背光,改它收到的码值

DDC 失败之后还剩一条路:软件调光。屏幕最终发出的光,是显卡送的码值过一遍显示器 gamma 响应之后的结果,所以只要把整张 gamma 表乘一个系数 f,输出光就变成 f^gamma 倍。CGSetDisplayTransferByTable() 干的就是这件事,按显示器生效,不需要 sudo、不需要辅助功能权限、不需要签名。

关键不在于能调暗,而在于可以按尼特调 。nitsync 的整个模型只有三行(来自 src/nitsyncd.c 的头注释):

text 复制代码
mac_nits   = linearBrightness * mac_nits_at_full * gain
panel_nits = panel_nits_at_full * f ^ gamma
f          = clamp( (mac_nits / panel_nits) ^ (1 / gamma), 0, 1 )

这里有一步容易被忽略的反解:我要的不是"外接屏输出多少比例的光",而是"外接屏输出和内置屏一样多的尼特",所以是拿目标尼特除以面板峰值尼特,再开 gamma 次方根,得到的是码值缩放系数 ,不是亮度百分比。写进 gamma 表的是这个 f。这等价于把显示器固件本该做的那一步在合成器里重做一遍,顺便做得更准。

三、采样层:一次 dlsym,两条降级路径

内置屏的真实亮度只能从私有框架里拿。DisplayServices.framework 没有公开头文件,所以用 dlopen + dlsym 运行时取符号,取不到就退到滑块值再近似成线性:

c 复制代码
void *ds = dlopen("/System/Library/PrivateFrameworks/DisplayServices.framework/DisplayServices",
                  RTLD_NOW);
if (!ds) {
    fprintf(stderr, "nitsyncd: cannot open DisplayServices\n");
    return 1;
}
GetLinearFn get_linear = (GetLinearFn)dlsym(ds, "DisplayServicesGetLinearBrightness");
GetBrightnessFn get_brightness = (GetBrightnessFn)dlsym(ds, "DisplayServicesGetBrightness");
if (!get_brightness && !get_linear) {
    fprintf(stderr, "nitsyncd: brightness symbols missing\n");
    return 1;
}

采样循环里两个符号的优先级是硬编码的:

符号 返回值语义 代码里的处理 拿不到时
DisplayServicesGetLinearBrightness 已经线性化,0..1,正比于光输出 直接用 落入下一档
DisplayServicesGetBrightness 滑块位置,非线性 powf(value, 2.2f) 近似线性 本 tick 跳过
两个都没有 --- 启动即失败,退出码 1 进程不进入主循环

powf(value, 2.2f) 是近似,不是标定。两条路径出来的 f 会有肉眼可见的差异,所以启动日志里专门打了一行 linear=yes/no,方便先确认自己走的是哪一档:

text 复制代码
nitsyncd 1.0.0: mac_nits=625 panel_nits=300 gamma=2.20 gain=1.00 deadband=0.020 tau=0.060 linear=yes

四、控制层:三个变量就是一个状态机

整段亮度逻辑没有结构体,状态靠 main() 里三个 double:target(算出来的目标)、applied(当前真实写进表里的值)、have_target(是否已经有过一次有效采样)。加上 last_time 算 dt、ticks 做心跳,就是全部。

c 复制代码
double mac_nits = (double)value * g_mac_nits * g_gain;
double ratio = mac_nits / g_panel_nits;
double raw = pow(ratio, 1.0 / g_gamma);
if (raw > 1.0) raw = 1.0;

if (!have_target) {
    target = applied = raw;      /* 第一帧直接落位,不做渐变 */
    apply_factor(applied);
    have_target = 1;
} else if (fabs(raw - target) >= g_deadband) {
    target = raw;                /* 认定是人为改动,不是传感器噪声 */
}

if (fabs(target - applied) > EPSILON) {
    double alpha = 1.0 - exp(-dt / g_tau);
    if (alpha > MAX_STEP) alpha = MAX_STEP;
    applied += (target - applied) * alpha;
    if (applied < 0.0) applied = 0.0;
    if (applied > 1.0) applied = 1.0;
    apply_factor(applied);
}

三个常量定义了这里的控制行为,取值都在文件头部:

  • TICK = 0.05:采样周期 50ms,名义 20Hz。
  • EPSILON = 0.002:target 与 applied 差不到这个数就跳过这次写表,避免每 tick 都去动合成器。
  • MAX_STEP = 0.25:单 tick 最大步进,防止 dt 异常时一帧跳到底。
  • g_deadband = 0.02 / g_tau = 0.06:可从配置覆盖,deadband 卡在 f 空间,tau 是秒。

dt 还有一行钳位:if (dt > 0.5) dt = 0.5;。机器睡眠醒来时 CLOCK_MONOTONIC 会给出一个巨大的间隔,不钳的话 alpha 直接饱和到 1,屏幕从黑到全亮闪一下。

五、输出层:读改写,别把 Night Shift 洗掉

apply_factor(f) 的骨架是"读现有表 → 等比缩放 → 写回",而不是直接生成一张线性表:

c 复制代码
CGGammaValue cur_r[256], cur_g[256], cur_b[256];
uint32_t samples = 256;
if (CGGetDisplayTransferByTable(ids[i], 256, cur_r, cur_g, cur_b, &samples) != kCGErrorSuccess)
    continue;
if (samples < 2 || samples > 256) continue;

double cur_max = cur_r[samples - 1];
if (cur_max < 0.02) continue;    /* 没东西可缩,跳过 */

double k = f / cur_max;
for (uint32_t j = 0; j < samples; j++) {
    double r = cur_r[j] * k, g = cur_g[j] * k, b = cur_b[j] * k;
    out_r[j] = (CGGammaValue)(r > 1.0 ? 1.0 : r);
    out_g[j] = (CGGammaValue)(g > 1.0 ? 1.0 : g);
    out_b[j] = (CGGammaValue)(b > 1.0 ? 1.0 : b);
}
CGSetDisplayTransferByTable(ids[i], samples, out_r, out_g, out_b);

为什么必须读改写:Night Shift 和 True Tone 改的也是这张表。直接写一张干净的表,暖色调会被一起抹掉,晚上用起来像换了个屏幕。等比缩放保住了颜色形状,只改整体峰值。

几个防御性分支值得单独说。samples 是输入输出参数,调用方给 256、系统可能返回别的值,所以 samples < 2 || samples > 256 都要挡;cur_max < 0.02 意味着这张表几乎全黑,再乘系数只会得到更黑的东西,直接跳过;最后每个通道单独夹到 1.0,因为 f 只在函数入口夹过一次,乘上 1/cur_max 之后仍可能溢出。目标显示器靠 CGGetOnlineDisplayList(16, ...) 拿,然后 if (ids[i] == main_id) continue; 跳过内置屏------内置屏的亮度归 macOS 管,动了就是跟系统抢。

六、踩到的坑

坑 1:把百分比当亮度。 第一版是"内置屏 50%,外接屏就乘 0.5"。结果是外接屏暗到没法看。原因在你写进表里的是码值,屏幕输出的是 码值^2.2,0.5 只发出约 22% 的光。仓库 docs/article-zh.md 里记了实测的三个点:滑块 1.00 → linear 0.80,滑块 0.50 → linear 0.267,滑块 0.30 → linear 0.038。差着一个 2.2 次方,这就是第一版偏暗的全部原因。改法是把整条链路换成尼特:625 这个默认 mac_nits 也是这么来的------MacBook Air SDR 满亮度 500 nits 时 linear = 0.8,所以 linear = 1.0 对应 625。

坑 2:外接屏会自己"呼吸"。 尼特对齐之后,什么都不做,外接屏每隔两三秒自己跳一下。抓 40 秒数据是 0.669 → 0.648 → 0.593 → 0.534 → 0.554 → 0.594 → 0.643,不是噪声,是震荡:自动亮度开着,环境光传感器一直在漂;更糟的是外接屏的光会照回传感器,于是外接屏一暗、房间变暗、内置屏调亮、外接屏跟着亮,闭环。这里我一开始选错了------把死区放在滑块空间。正确的位置是 f 空间,因为那才是我们实际写进表、也是眼睛实际看到的量。deadband 0.02 之后,作者的记录是 30 秒内 gamma 表一次都没变。

坑 3:后台进程被降频到 ~7Hz。 死区解决了抖动,又带来"按完键要三四秒才跟上"。原因有两个,第二个才致命:一是当时限制每周期最多走 2%、周期 0.1 秒,全量程要 5 秒;二是 LaunchAgent 默认以 ProcessType = Background 跑,被 App Nap 当成后台批处理降频到 ~7Hz,TICK = 0.05 根本兑现不了。安装脚本里那行 <string>Interactive</string> 就是治这个的,改成 Interactive 后实测稳定在 20Hz。再把固定步长换成时间相关的指数跟随(alpha = 1 - exp(-dt/tau),tau = 0.06,MAX_STEP 封顶 0.25),主要变化在 0.5 秒内完成,观感从"追上去"变成"快速淡出"。

坑 4:表会被别人擦掉。 唤醒、改分辨率、切色彩配置都会重置 gamma 表,而守护进程完全不知情,applied 还停在旧值上,于是外接屏回到满亮并且再也不跟着动。修法是心跳:每 100 个 tick(约 5 秒)读一次外接屏当前表的最大值,跟 applied 差超过 0.01 就重写。

c 复制代码
if (have_target && ++ticks >= 100) {
    ticks = 0;
    double cur = external_ramp_max();      /* 拿不到返回 -1.0 */
    if (cur >= 0.0 && fabs(cur - applied) > 0.01) apply_factor(applied);
}

external_ramp_max() 的 -1.0 是个哨兵值,区分"没有外接屏"和"外接屏表是 0"。用 0 当哨兵的话,一块全黑的屏会被误判成需要重写。

七、边界与代价

配置文件 ~/Library/Application Support/NitSync/config 里只有六个数,源码对它们各自有钳位,防止手改出离谱的值:

key 默认 含义 load_config 里的钳位
mac_nits 625 内置屏 linear=1.0 时的尼特 无
panel_nits 300 外接屏峰值尼特(查规格书) < 1.0 拉到 1.0
gamma 2.2 面板响应曲线 < 1.0 拉到 1.0
gain 1.0 全局微调,1.0 = 相信数字 无
deadband 0.02 小于此值的漂移忽略 < 0.0 拉到 0.0
tau 0.06 跟随时间常数,秒 < 0.01 拉到 0.01

再往上是硬边界,参数怎么调都绕不过:

  • 只能往下调。 面板 300 nits、内置屏 500 nits,内置屏过约 78% 之后外接屏已经顶满,f 被钳在 1.0,只能看着内置屏继续变亮。这是面板物理上限,不是配置问题。
  • 黑位会被压。 码值缩放压下去的是整条曲线,低亮度下黑场更接近"发灰",对比度下降。
  • HDR/EDR 不过这张表。 只有 SDR 合成层走 gamma 表,HDR 视频不受影响,也就不参与"同尼特"。
  • 私有符号没有兼容承诺。 Apple Silicon + 内置屏是前提,README 说在 macOS 26/27 上测过;dlsym 失败时代码只是打一行 brightness symbols missing 然后退出 1,LaunchAgent 的 KeepAlive 会让它不停重启------真要长期用,这行日志得盯着。
  • 心跳只看第一块外接屏。 external_ramp_max() 拿到第一块非主屏就返回,多外接屏时其余几块的重置检测不到。
  • 退出会还原。 main() 结束前调一次 apply_factor(1.0);卸载脚本也提醒了一句,如果颜色看着不对就重新登录。

八、小结

DDC 失败不是"再换个工具"能解决的,它是固件层的不作为,唯一出路是把亮度控制从显示器搬回合成器。搬回去之后要做的不是"调个百分比",而是重新建一套以尼特为单位的映射:线性亮度采样 → 目标尼特 → 反解码值系数 → 死区 + 指数跟随 → 读改写 gamma 表,外加一个 5 秒心跳防重置。整套东西是一个 src/nitsyncd.c,安装脚本只做几件事:clang 编译、复制默认配置、装 LaunchAgent。

代码在 Cooperiano/nitsync,MIT。

相关推荐
师夷长技以自愈38 分钟前
手搓 AI Agent 第 7 篇:菜做砸了别硬炒——错误分类 + 双账本熔断
人工智能·python
用户956256254291 小时前
15 分钟社媒简报 Agent:我只用了搜索和模型两步
python·api
Dumbness1 小时前
关键词回复里写了 {订单号},群机器人整条消息都炸了
python·微信
调接口的阿光1 小时前
调用对话接口时输出被截断怎么办:先看 finish_reason,再决定是续写一次、缩小范围,还是换成更短的格式(Python)
人工智能·python
Cooper251 小时前
单文件 Flask 跑通微信服务器校验:先跑通再谈工程化
python·flask
Cooper251 小时前
三源热榜去重加 AI 摘要:URL 唯一键踩出的三个坑
前端·python
用户4019925568611 小时前
多源异构 B2B 公开需求信号去重与企业实体证据链存储设计(64-bit SimHash 分桶 + Jaccard)
python
goehou1 小时前
两台 Mac 拼成推理池,加机器不加内存
mac·集群·ollama·本地推理
aifannihao2 小时前
GitHub Actions 锁文件实测:6 个 uses 只锁住 2 个
python·ci/cd·标准库·供应链安全·github actions