Posted on ::

在 Linux(特别是 NixOS / Wayland)环境下使用 REAPER + yabridge + Wine 运行 Windows VST3 音频插件,是目前 Linux 音乐制作(DAW)生态中最强大的解决方案,但其底层堪称现代操作系统图形学与系统调用的“兼容性修罗场”。

商业音频插件由于各自采用了截然不同的 GUI 渲染后端(DirectComposition、Direct2D 1.3、DirectWrite、Direct3D 9Ex/11、OpenGL、JUCE 8 向量光栅化),再加上双显卡(Intel 核显 + NVIDIA 独显)与 Xwayland 嵌套窗口通信,极容易出现卡死、黑屏或排版异常。

本文深入复盘近期在 NixOS 上配置主流专业插件工具链时遇到的核心矛盾、底层渲染栈全景,以及如何将第三方补丁版 Wine 封装为 NixOS 原生声明式 Derivation,并彻底解决三大典型疑难杂症。


一、 技术基石:Linux 专业音频的 Wine 选型与渲染栈全景

1. Wine 版本迷局:从 nixpkgs 到 giang17/wine 与 COPR

在 Linux 下运行现代音频插件,第一道门槛就是 Wine 版本的选型:

  • nixpkgs 官方 Wine (wineWow64Packages.yabridge / wine-staging 9.x): 官方仓库中的 Wine 适合通用 Windows 软件和较老版本的 VST 插件。但面对近一两年的现代商业插件(如 iZotope Ozone 12、FabFilter 2024+、基于 JUCE 8 开发的新一代合成器),官方 Wine 往往会直接崩溃或无法创建窗口。核心瓶颈在于:官方 Wine 对 Windows 高级窗口图层合成与 Direct2D/DirectWrite 扩展支持滞后。
  • giang17/wine 补丁树: 由 Linux 音频社区核心开发者 Giang Nguyen 维护的定制 Wine 分支。它是现代音频插件在 Linux 上存活的关键基石,专门针对音频宿主修复了极其硬核的底层子系统:
    • DirectComposition (dcomp.dll):现代 Windows 界面用于多图层硬件级流式合成的核心组件。原生 Wine 长期仅有空桩(Stubs),导致 Ozone 12 等插件在启动初始化渲染目标时直接致命崩溃。giang17 在 Wine 原生 dxgi.dll 与 wined3d.dll 中植入了私有接口,完整打通了 CreateSwapChainForComposition。
    • Direct2D 1.3 (d2d1.dll) 与 DirectWrite (dwrite.dll):修复了 Direct2D 设备上下文创建、字体集合索引以及与 HarfBuzz 文本塑形引擎的协同,为 JUCE 8 提供了坚实支撑。
    • winex11 与 DXGI 窗口生命周期协同:针对 VST 插件作为宿主子窗口(Child Window)时的焦点切换、重绘事件、Surface 销毁进行了防死锁优化。
  • COPR patrickl/wine-staging-dev (Fedora RPMs): 由于 Wine 拥有庞大的 32 位与 64 位多架构(WoW64)交叉编译链,用户若从源码全量编译带 Staging + giang17 补丁的 Wine,单次需耗费数小时及数十吉字节磁盘。Fedora 打包者 PatrickL 在 Fedora COPR 上提供了自动化集成构建仓库,直接将 giang17/wine 的补丁树合入最新 Wine-Staging 主干(目前跟进至 Wine 11.21-100)。

2. DXVK 的迷思:为什么 giang17/wine 的最佳实践是“彻底移除 DXVK”?

在排查初期,社区经常有人尝试通过 winetricks dxvk 引入 DXVK 来“提升 Direct3D 性能”,甚至把某些插件的卡死归咎于 DXVK 配置不当。但深入底层架构后会发现:在现代音频插件场景下,安装 DXVK 是一个典型的反模式(Antipattern)!

① DXVK 会直接破坏 DirectComposition 合成管线

giang17/wine 之所以能让各种新插件跑起来,核心依赖于 Wine 内部的 dxgi.dll 和 wined3d.dll 与 dcomp.dll 的紧密协作。

  • 如果在 Prefix 中安装了 DXVK,DXVK 的 dxgi.dll 会接管系统的 DXGI 接口。
  • 开源 DXVK 并不具备与 Wine 原生 dcomp 进程内私有通信的能力,在面对 CreateSwapChainForComposition 时通常直接返回 E_NOTIMPL(未实现)。
  • 后果:所有依赖 DirectComposition 绘制窗口的现代插件(如 JUCE 8 DComp 模式、iZotope Ozone 12 等)在被 DXVK 接管 DXGI 后会直接白屏、黑屏或无响应。giang17 上游文档明确警告:绝不要在 Prefix 中使用 DXVK,保持 Wine 原生 Builtin!

② 误导人的“DXVK 1x1 Buffer 卡死”

在测试 Ozone 12 时,我们一度在日志中看到了 DXVK 的经典报错:

[Ozone 12] [Wine STDERR] info:  D3D9DeviceEx::ResetSwapChain:
[Ozone 12] [Wine STDERR] info:    Buffer size: 1x1, Image count: 5

这让人误以为是 DXVK 引起了卡死。然而经过彻底清除 DXVK、将 d3d9.dll 全面换回 Wine 11.21 原生 Builtin 后测试发现:Ozone 12 依然在移除时假死!

这揭示了真正的问题:Ozone 12 在关闭窗口时将 SwapChain 缩放至 1x1 属于正常析构行为;卡死的真凶并非 DXVK,而是 NVIDIA Linux 专有驱动在 Xwayland 下销毁 GL/EGL 窗口上下文时的驱动死锁。既然根本原因在显卡驱动析构层,那么 Prefix 完全无需引入 DXVK,直接使用 Wine 原生 WineD3D + 显卡分流 才是最纯净、最稳定的官方标准方案。

③ 警惕盲目引入过时的 Native 覆盖(如 gdiplus)

早期(如 Wine 9.x 时代)网络教程常推荐执行 winetricks gdiplus 来解决某些插件界面的渐变色或文字渲染问题。但在最新的 giang17/wine(Wine 11+)中,Wine 内置的 GDI+ 实现早已非常完备。相反,如果强行安装 Windows 7 时代的 Native gdiplus.dll,会导致其无法枚举通过 AddFontResourceEx 动态注入的私有字体(例如 Serum 界面上的 Unison 声音数、旋钮数值读数等会因此直接消失)。

黄金准则:除标准微软 C++ 运行库(vcrun2022,音频插件标准 ABI 必需)外,所有图形和渲染库(d3d*、dxgi、dcomp、d2d1、dwrite、gdiplus)务必保持 100% Wine 原生 Builtin!


3. 主流专业插件图形渲染栈对照表

Windows 音频插件界面的底层绘制远比普通桌面软件异构:

插件代表核心渲染技术栈依赖的 Windows / Wine 库最佳运行后端典型踩坑点
iZotope Ozone 12DirectComposition + Direct3D 9Ex / 11dcomp.dll, d3d9.dll, dxgi.dll (全部 Builtin)Intel Iris (WineD3D)卸载时 NVIDIA 驱动在 Surface 销毁时死锁
FabFilter Pro-Q 3 / Pro-L 2自研图形引擎(Direct3D 11 / OpenGL 双后端)d3d11.dll, opengl32.dllIntel Iris (Mesa)60fps 频谱重绘,窗口关闭时 NVIDIA 驱动争用挂死
XLN Audio Addictive Drums 2自研向量/位图光栅化(OpenGL 渲染管线)opengl32.dll, gdi32.dllIntel Iris (Mesa)卸载时触发 NVIDIA 上下文析构挂起
Xfer Serum原生 OpenGL 直接渲染上下文opengl32.dllNVIDIA 原生驱动强依赖原生 GPU 扩展,切入非原生驱动层时易黑屏
Ample Sound 吉他系列原生 OpenGL(六线谱与琴弦拟真动画)opengl32.dll, gdi32.dllNVIDIA 原生驱动依赖原生 GLX/EGL 上下文,Mesa 兼容层下着色器全黑
Modartt Pianoteq 9JUCE 8 框架(Direct2D 1.3 + DirectWrite + HarfBuzz)d2d1.dll, dwrite.dll, gdi32.dllNVIDIA 原生驱动依赖注册表枚举 DirectWrite 字体集合;字体键值丢失导致文字隐形

二、 NixOS 声明式工程化:将预编译 Wine 封装为原生 Derivation

在非 NixOS 系统上,用户或许可以直接把 RPM 解压到 ~/.local 或 /opt。但在 NixOS 独特的文件系统布局下,如何既不费时编译庞大的 Wine 源码,又能以最优雅、声明式的方式管理这套定制 Wine 呢?

1. 为什么不能直接使用 autoPatchelfHook?

在 NixOS 打包常规预编译程序时,通常使用 patchelf 修改 ELF 解释器和 RPATH。但 Wine 是绝对不能轻易 patchelf 的:

  • 极端特殊的虚拟内存布局:Wine 自身的加载器(wine / wineserver)在启动时需要为 Windows PE 进程强行预留 0x00000000 到 0x7fffffff 的低内存地址空间。
  • WoW64 混合链接机制:现代 Wine 采用了 PE-ELF 双生机制,32 位 Windows 代码与 64 位 Unix 后端库在内存中进行 thunking 跳转。修改 ELF 头的动态段和重定位表,极易打破内存段对齐,造成启动即 Segmentation fault。

2. 正确解法:rpmextract + pkgs.buildFHSEnv

Nixpkgs 提供的 buildFHSEnv 利用了 Linux 内核的 Mount Namespace(Bubblewrap)。它在沙盒内构建出标准的 /usr/bin、/usr/lib64 等 FHS 层级,将 Nix Store 中的标准库只读映射进去。

[!TIP] 关于运行性能的说明: buildFHSEnv 仅在进程启动的动态链接阶段提供路径解析。当动态库通过 mmap 载入内存后,所有 CPU 指令(AVX2/SSE 音频 DSP 运算)和 GPU 渲染调用均以 100% 裸机原生速度 在硬件上执行,带来 0% 的 CPU / DSP 额外开销,音频低延迟周期完全不受影响。

3. 实现 pkgs.wine-dcomp

我们在 NixOS 配置仓库中创建 pkgs/wine-dcomp/default.nix,直接从 Fedora COPR 抓取已包含 giang17 DirectComposition 补丁的最新构建:

{ lib, stdenv, fetchurl, rpmextract, buildFHSEnv, writeShellScript
, alsa-lib, dbus, fontconfig, freetype, glib, gnutls
, libGL, libGLU, libxkbcommon, pipewire, pulseaudio, vulkan-loader
, libx11, libxcomposite, libxcursor, libxext, libxfixes, libxi
, libxinerama, libxrandr, libxrender, libxxf86vm, libxcb, zlib }:

let
  version = "11.21-100.fc44";
  baseUrl = "https://packages.redhat.com/api/pulp-content/public-copr/patrickl/wine-staging-dev/fedora-44-x86_64/Packages/w";

  # 1. 声明式拉取 Fedora COPR 预编译 RPM 并解包
  wine-unwrapped = stdenv.mkDerivation {
    pname = "wine-staging-copr-unwrapped";
    inherit version;

    srcs = [
      (fetchurl { url = "${baseUrl}/wine-${version}.x86_64.rpm"; hash = "sha256-Qg/fm49NqpwqYYorXBLfqBm36r5x8b0O89GLBWXzFLM="; })
      (fetchurl { url = "${baseUrl}/wine-core-${version}.x86_64.rpm"; hash = "sha256-KYLW4f2NVNEg7Z/utOGfERCmqm2OLtSPRZeSN2mCKyY="; })
      (fetchurl { url = "${baseUrl}/wine-common-${version}.noarch.rpm"; hash = "sha256-3rj6DrTVeCGoRZGSu0ZBiicScHj3XzIMa6xGb9eYSss="; })
      (fetchurl { url = "${baseUrl}/wine-filesystem-${version}.noarch.rpm"; hash = "sha256-GPSCx8el4cLVmoAO9Yh8zBRaRw8Rtrg6qq/5G1YZpqI="; })
      (fetchurl { url = "${baseUrl}/wine-alsa-${version}.x86_64.rpm"; hash = "sha256-RrmLVrf7LFa37DMMY7sJu7lmizLbT6iM2Ls2eGjj4fc="; })
      (fetchurl { url = "${baseUrl}/wine-pulseaudio-${version}.x86_64.rpm"; hash = "sha256-h7S2pYtZKmFar/SPilaVEy4fQoOOMTih1T5Ipve9+4A="; })
      (fetchurl { url = "${baseUrl}/wine-cms-${version}.x86_64.rpm"; hash = "sha256-+0aKK7P/B4F4MaHeG3hA+/aKSYjfxzfHPt4n+sntktI="; })
      (fetchurl { url = "${baseUrl}/wine-tahoma-winefonts-${version}.noarch.rpm"; hash = "sha256-5xQLEQPJ/K0t1x1/XPyf/BORw6y3pxXM+Pk1ISKRKxo="; })
      (fetchurl { url = "${baseUrl}/wine-winefonts-${version}.noarch.rpm"; hash = "sha256-P5d5DdXj55MWUKjKnbH082xZzGMIPmqVbV521o+Zmmw="; })
    ];

    nativeBuildInputs = [ rpmextract ];
    sourceRoot = ".";
    unpackPhase = ''
      mkdir -p unpacked && cd unpacked
      for src in $srcs; do rpmextract "$src"; done
    '';
    installPhase = ''
      mkdir -p $out
      if [ -d usr ]; then cp -a usr/* $out/; fi
    '';
    dontFixup = true;
  };
in
# 2. 通过 buildFHSEnv 组装多架构运行环境与内置智能分流
buildFHSEnv {
  name = "wine-dcomp";
  targetPkgs = pkgs: [
    wine-unwrapped
    alsa-lib dbus fontconfig freetype glib gnutls libGL libGLU
    libxkbcommon pipewire pulseaudio vulkan-loader zlib
    libx11 libxcomposite libxcursor libxext libxfixes libxi
    libxinerama libxrandr libxrender libxxf86vm libxcb
  ];
  multiPkgs = pkgs: [
    # WoW64 必需的 32 位运行库
    pkgs.alsa-lib pkgs.freetype pkgs.libGL pkgs.vulkan-loader
  ];

  runScript = writeShellScript "wine-dcomp-wrapper" ''
    unset WAYLAND_DISPLAY
    export DISPLAY="''${DISPLAY:-:0}"

    target_bin="/usr/bin/wine"
    if [[ "$1" =~ ^(wineserver|wine64|wine|winecfg|regedit|regsvr32|wineboot|notepad|msiexec)$ ]]; then
        target_bin="/usr/bin/$1"
        shift
    fi

    # 对 wineserver 直通运行
    if [[ "$target_bin" == */wineserver ]]; then
        exec "$target_bin" "$@"
    fi

    # 智能分流:遇到在 NVIDIA 上下文析构时会死锁的插件,自动注入 Intel Iris Mesa 驱动隔离
    is_mesa_target=0
    for arg in "$@"; do
        arg_lower="''${arg,,}"
        if [[ "$arg_lower" =~ (fabfilter|addictive|xln|ozone|izotope) ]]; then
            is_mesa_target=1
            break
        fi
    done

    extra_env=()
    if (( is_mesa_target )); then
        extra_env+=(
            "__EGL_VENDOR_LIBRARY_FILENAMES=/run/opengl-driver/share/glvnd/egl_vendor.d/50_mesa.json"
            "__GLX_VENDOR_LIBRARY_NAME=mesa"
            "MESA_LOADER_DRIVER_OVERRIDE=iris"
            "DRI_PRIME=0"
        )
    fi

    YABRIDGE_DIR="''${HOME}/.local/share/yabridge"
    export PATH="$YABRIDGE_DIR:/usr/bin:$PATH"
    export WINESERVER="/usr/bin/wineserver"
    export WINELOADER="/usr/bin/wine"
    export WINEDLLPATH="$YABRIDGE_DIR''${WINEDLLPATH:+:$WINEDLLPATH}"

    # 重写 yabridge-host 参数路径
    args=()
    for arg in "$@"; do
        if [[ "$arg" == */yabridge-host.exe.so ]]; then
            args+=("$YABRIDGE_DIR/yabridge-host.exe.so")
        elif [[ "$arg" == */yabridge-host.exe ]]; then
            args+=("$YABRIDGE_DIR/yabridge-host.exe")
        elif [[ "$arg" == */yabridge-host-32.exe.so ]]; then
            args+=("$YABRIDGE_DIR/yabridge-host-32.exe.so")
        elif [[ "$arg" == */yabridge-host-32.exe ]]; then
            args+=("$YABRIDGE_DIR/yabridge-host-32.exe")
        else
            args+=("$arg")
        fi
    done

    exec env "''${extra_env[@]}" "$target_bin" "''${args[@]}"
  '';

  extraInstallCommands = ''
    for tool in wine wineserver wine64 winecfg regedit regsvr32 wineboot; do
      cat <<EOF > "$out/bin/$tool"
#!/bin/sh
exec "$out/bin/wine-dcomp" "$tool" "\$@"
EOF
      chmod +x "$out/bin/$tool"
    done
  '';
}

4. 系统模块接入:声明式掌控全局

在 NixOS 的音频模块(如 modules/services/audio.nix)中,直接以最优雅的方式接入:

  environment.systemPackages = with pkgs; [
    yabridge
    yabridgectl
    wine-dcomp      # 替换官方仓库的默认 Wine,提供 wine、wineserver 等全套命令行入口
    winetricks
  ];

得益于 extraInstallCommands 自动生成了 wine、wineserver、wine64、winecfg 等全套标准包装,它们会直接被链接至 /run/current-system/sw/bin,完全处于系统标准 $PATH 中。yabridge 会自动从 $PATH 探测并调用它,无需在外部配置任何脆弱的 WINELOADER、WINESERVER 环境变量或自定义包装脚本,系统环境保持绝对纯净。

带来的巨大收益:

  1. 升级极度轻松:以后上游发布 Wine 11.22 时,只需在 pkgs/wine-dcomp/default.nix 里改动版本号并更新 hash,执行 nixos-rebuild switch 即可全自动完成整个系统的升级。
  2. 彻底消灭临时脚本与裸文件:所有二进制和多架构库完全收归 Nix store,不再依赖任何脆弱的本地外部命令。

三、 三大实战疑难杂症排查与救砖


问题一:Ozone 12 / FabFilter 移除插件时宿主卡死

1. 故障现象

在 REAPER 中加载 iZotope Ozone 12、FabFilter Pro 系列或 XLN Audio Addictive Drums 2 时音频与界面均可正常运行。然而一旦关闭插件窗口、在 FX 列表中移除插件,或者删除所在轨道,REAPER 会瞬间陷入死锁无响应,只能强制强杀进程。

2. 根因剖析:N卡特有的上下文析构死锁

无论走 DXVK 还是纯原生 WineD3D,当这些插件关闭时,都会缩放释放自身的 SwapChain 并注销窗口。在 Linux 下,NVIDIA 专有驱动在 Xwayland 嵌套子窗口销毁并释放 EGL/GLX 上下文时,会与 Wine 的 Windows 消息泵线程发生不可调和的互斥锁争用(Deadlock)。

3. 终极解法:按需动态路由至 Intel Mesa (Iris)

Intel 开源 Mesa 驱动在上下文创建与销毁逻辑上严格遵循标准规范,完全不存在此死锁问题。

正如我们在 wine-dcomp 中实现的代码逻辑:

  • 启动器自动检测命令行参数,凡是匹配到 (fabfilter|addictive|xln|ozone|izotope) 的插件,自动注入:
    __EGL_VENDOR_LIBRARY_FILENAMES=/run/opengl-driver/share/glvnd/egl_vendor.d/50_mesa.json
    __GLX_VENDOR_LIBRARY_NAME=mesa
    MESA_LOADER_DRIVER_OVERRIDE=iris
    DRI_PRIME=0
    
  • 插件界面自动走核显的 Intel Iris Mesa 驱动进行 WineD3D / OpenGL 渲染。
  • 删除插件时瞬间完成上下文释放,彻底从物理根源上消除了卡死现象!

问题二:Serum 与 Ample Guitar 界面全黑

1. 故障现象

既然 Intel Mesa 能解决卡死,能否“一刀切”将所有插件全局环境变量全部强行切到 Mesa? 实验证明不可行:一旦全局强制 Mesa,Xfer Serum 与 Ample Guitar 系列 的界面会瞬间全黑——声音发声完全正常,但界面控件和高光六线谱无一显示。

2. 根因剖析

Serum 和 Ample Sound 深度依赖标准的 NVIDIA 原生 OpenGL 上下文扩展(FBO 离屏渲染与私有着色器管线)。在 Mesa 兼容层或者非原生 Direct3D 代理下,其自定义着色器管线无法正确完成图层混合,输出全是全黑像素。

3. 解决方案:精细化动态白名单

这也是为什么 wine-dcomp 必须采用参数动态识别分流:

  • Ozone / FabFilter / Addictive Drums:动态注入 Mesa 变量,走核显避开析构 Bug;
  • Serum / Ample Guitar / SWAM 等:不注入任何变量,直接享受独显 NVIDIA 原生 GPU 加速与 Wine 原生 D3D11 渲染。

按需分流,兼收并蓄。


问题三:Modartt Pianoteq 9 界面文字彻底“失踪”

这是整个排查过程中最隐蔽、最精彩的一个案例。

1. 故障现象

Modartt 最新的旗舰物理建模钢琴插件 Pianoteq 9 (v9.1.2) 加载后:

  • 琴键、木纹面板、旋钮高光、踏板等全部渲染极度精致流畅;
  • 弹奏有声,物理建模引擎工作完美;
  • 但界面上所有文字(预设名、按钮文本、参数数值、下拉菜单)完全消失,界面全是一片空白!
+-------------------------------------------------------+
|  [   ]   [   ]           [       ]             [   ]  |  <-- 按钮和边框完好,文字全空
|                                                       |
|  | | | | | | | | | | | | | | | | | | | | | | | | | |  |  <-- 琴键渲染完好
+-------------------------------------------------------+

2. 排除假象与死代码逆向

① 误区一:COM/OLE 缺失?

Wine 日志中反复报出: err:ole:com_get_class_object class {aa509086-5ca9-4c25-8f95-589d3c07b48a} not registered 查询 GUID 发现它只是 Windows.UI.ViewManagement.UIViewSettings(Win10 平板模式感知接口),与字体渲染毫不相干。

② 误区二:环境变量 MDRT_DIRECT2D=0?

网络传言设置 MDRT_DIRECT2D=0 可让 Pianoteq 回退到软件光栅化。我们使用 IDA / GDB 反编译 Pianoteq 9.vst3plugin 定位到该变量的逻辑:

1811aed1e: cmp dword ptr [0x181453f48], -1
1811aed25: jne 0x1811aed50
1811aed27: lea rcx, [rip + 0x2af40]  ; "MDRT_DIRECT2D"
1811aed2e: call getenv

在 GDB 中检查内存变量 [0x181453f48],其硬编码值为 3519138,永远不等于 -1!这意味着在 9.1.2 正式 Release 版本中,读取 MDRT_DIRECT2D 的逻辑已经被官方编译器完全短路成死代码。

③ 误区三:需要单独建立一个 Prefix 隔离?

排查初期曾怀疑是否是与其他插件共用 Prefix 产生了污染,尝试单独建了 pianoteq prefix。但随后证实:这完全没有必要!只要补齐核心字体,所有插件统统可以在单一主 Prefix 下和平共处。

3. 顺藤摸瓜:JUCE 8 的 DirectWrite 字体加载管线

使用 strings 分析二进制,定位到了关键类:AVWindowsDirectWriteTypeface。Pianoteq 9 基于 JUCE 8 框架开发,查阅 JUCE 8 的 juce_DirectWriteTypeface_windows.cpp,其寻找字体的真实调用链浮出水面:

// 1. JUCE 默认无衬线字体为 "Verdana",备用为 "Tahoma"
struct DefaultFontNames {
    const String defaultSans     { "Verdana" },
                 defaultFallback { "Tahoma" };
};

// 2. 尝试从 DirectWrite 字体集合查找家族
const auto family = factories->getFonts().getFamilyByName (name.toWideCharPointer());
if (family == nullptr)
    return getLastResortTypeface (f); // 找不到则进入终极回退机制!

当 DirectWrite 找不到 Verdana 时,会退入 getLastResortTypeface:

static Ptr getLastResortTypeface (const Font& font) {
    // a. 通过 GDI 创建字体
    auto* hfont = CreateFontIndirectW (&lf);
    SelectObject (dc, hfont);

    // b. 通过 GDI Interop 获取 DirectWrite 的 FontFace
    interop->CreateFontFaceFromHdc (dc, dwFontFace.resetAndGetPointerAddress());

    // c. 致命一步:JUCE 要求从现有的字体集合中根据 FontFace 逆向找回 IDWriteFont
    const auto dwFont = factories->getFonts().findFontForFace (dwFontFace);
    if (dwFont == nullptr)
        return {}; // 逆向匹配失败,直接返回空指针!

    return fromFont (dwFont, ...);
}

而在 AggregateFontCollection::findFontForFace 中:

ComSmartPtr<IDWriteFont> AggregateFontCollection::findFontForFace (IDWriteFontFace* face) {
    for (const auto& collection : collections) {
        ComSmartPtr<IDWriteFont> result;
        if (SUCCEEDED (collection->GetFontFromFontFace (face, result.resetAndGetPointerAddress())))
            return result;
    }
    return {};
}

4. 破案:注册表中的字体失踪案

Wine 的 DirectWrite 是如何构建系统字体集合(collection)的? 查看 Wine 源码 dlls/dwrite/main.c 中的 factory_create_system_fontset:

// Wine 遍历 Windows 注册表 Fonts 项构建 DirectWrite 索引
RegOpenKeyExW(HKEY_LOCAL_MACHINE, L"Software\\Microsoft\\Windows NT\\CurrentVersion\\Fonts", ...);

检查 Wine Prefix 的注册表 system.reg:

[Software\\Microsoft\\Windows NT\\CurrentVersion\\Fonts]
"Arial Italic (TrueType)"="ariali.ttf"
"Tahoma (TrueType)"="tahoma.ttf"
"Verdana Bold (TrueType)"="verdanab.ttf"
# ... 唯独漏掉了 "Verdana (TrueType)"="verdana.ttf"!

真相大白:

  1. 尽管 C:\windows\Fonts\ 下物理存在 verdana.ttf,但注册表中漏掉了它最关键的 Regular(标准常规体)键值。
  2. Wine 的 DirectWrite 启动时扫描注册表,在集合中根本没有收录 Verdana 常规体。
  3. JUCE 查询 getFamilyByName("Verdana") 失败,降级调用 getLastResortTypeface。
  4. GDI 成功在磁盘找到了 verdana.ttf 并创建了 FontFace,但紧接着 JUCE 在字体集合中调用 GetFontFromFontFace 逆向寻源。由于字体集合最初就没索引 Verdana,逆向寻源再次失败返回 NULL。
  5. JUCE 彻底放弃,拿到空指针 Typeface。所有字形渲染出的轮廓点数均为 0,整个界面的文字瞬间“蒸发”!

5. 终极修复:完整字体注册表补全

编写一个完整的字体补全注册表文件 add_fonts.reg:

Windows Registry Editor Version 5.00

[HKEY_LOCAL_MACHINE\Software\Microsoft\Windows NT\CurrentVersion\Fonts]
"Arial (TrueType)"="arial.ttf"
"Arial Bold (TrueType)"="arialbd.ttf"
"Arial Bold Italic (TrueType)"="arialbi.ttf"
"Arial Italic (TrueType)"="ariali.ttf"
"Arial Black (TrueType)"="ariblk.ttf"
"Comic Sans MS (TrueType)"="comic.ttf"
"Courier New (TrueType)"="cour.ttf"
"Georgia (TrueType)"="georgia.ttf"
"Segoe UI (TrueType)"="segoeui.ttf"
"Segoe UI Bold (TrueType)"="segoeuib.ttf"
"Tahoma (TrueType)"="tahoma.ttf"
"Times New Roman (TrueType)"="times.ttf"
"Trebuchet MS (TrueType)"="trebuc.ttf"
"Verdana (TrueType)"="verdana.ttf"
"Verdana Bold (TrueType)"="verdanab.ttf"
"Verdana Italic (TrueType)"="verdanai.ttf"
"Verdana Bold Italic (TrueType)"="verdanaz.ttf"
"Webdings (TrueType)"="webdings.ttf"

将其导入 Wine Prefix:

WINEPREFIX=~/.local/share/wineprefixes/audio wine regedit add_fonts.reg

重启 REAPER 并重新加载 Pianoteq 9,DirectWrite 成功索引 Verdana,所有预设名称、操作界面文字瞬间完美复活!


四、 核心经验与总结

  1. 全面拥抱 giang17/wine,拒绝 DXVK 与过时 Native 污染:现代专业插件依赖 DirectComposition 与高版本 GDI+/Direct2D,安装 DXVK 会打断 Wine 原生 DirectComposition 合成管线,而盲目安装 Win7 native gdiplus 则会破坏私有字体索引。保持 Prefix 内所有图形与系统渲染库(dxgi、d3d*、dcomp、d2d1、dwrite、gdiplus)100% Wine 原生 Builtin 是最稳健的基石。
  2. GPU 动态白名单路由是双显卡环境的解药:不要试图设置全局环境变量。针对 NVIDIA 驱动特有的上下文析构 Bug,将特定插件(Ozone / FabFilter / Addictive Drums)按需路由到核显 Intel Mesa,而让重度依赖 OpenGL 原生扩展的插件(Serum / Ample Sound)直通独显。
  3. 警惕“民间流传”的配置变量:很多历史变量(如 MDRT_DIRECT2D)可能早已成为死代码。遇到顽疾时,静态分析反编译结合 GDB 内存断点能省下大量盲目试错的时间。
  4. Wine 下 DirectWrite 与 GDI 的双重索引逻辑:GDI 依赖字体目录下的物理文件,而 DirectWrite 严格依赖 Windows 注册表中的 Fonts 键。在自建或精简 Prefix 时,不仅要放入 .ttf 文件,更务必确保注册表条目完整。
  5. 善用 NixOS 的 buildFHSEnv 声明式封装:面对具有特殊内存映射(WoW64)与复杂多架构依赖的第三方二进制,buildFHSEnv 既能保障零性能损耗的裸机执行能力,又能将其优雅纳入 Nix Flake 体系,实现纯净且惬意的日常版本迭代。
Table of Contents