中文:把老核显用起来

我的 Windows 主机用的是 30HX,装了魔改驱动,可以渲染游戏,但这张卡没有可用的视频编码器。机器里还有一颗 E3-1275 v3,它的 Haswell 核显支持老一代 Intel Quick Sync。我就想让 30HX 负责跑游戏,核显负责把画面编码给手机。

折腾下来,这个组合跑通了。后面又加了一个和手机尺寸一致的虚拟显示器,手机终于从三四十帧变成了稳定接近 60 帧。这个过程是和 AI 一起排查、改依赖、编译和实机测试的;我把版本、补丁和验收条件留在这里,希望下一位遇到同样问题的人,能把文章交给 AI,少走一些弯路。

实际改动很小:Sunshine 和 FFmpeg 的原生源码都没有改。编码支持来自 oneVPL dispatcher 的兼容补丁;手机分辨率使用虚拟显示器和 Sunshine 已有的配置。

1. 这台机器,和已经验证的范围

项目实测环境
系统Windows 11 Pro,22631.2861
CPU / 核显Xeon E3-1275 v3 / Intel HD Graphics P4600/P4700
Intel PCI ID8086:041A
Intel 驱动20.19.15.5171
另一张显卡30HX,原有魔改驱动在系统中报告为 GTX 1660 SUPER
Media SDK runtimelibmfxhw64.dll,7.16.10.20,API 1.20
实体显示器Dell U2412M,1920×1200,约 60 Hz
手机屏幕竖屏 720×1612,横屏 1612×720
最终串流H.264、SDR、1612×720、60 FPS

CPU 带核显,还得确认主板和 BIOS 实际启用了它,Windows 能枚举到 Intel 显示适配器。显卡排列顺序也不能猜:双显卡机器上的 DXGI adapter 0 未必是 Intel,要读取真实的 Vendor ID、Device ID 和 LUID(Windows 用来标识适配器的本地唯一标识)。

这次没有升级 Intel 或 NVIDIA 驱动,没有覆盖系统 Intel 驱动 DLL。新增的虚拟显示器是独立的间接显示设备,仍会改变显示拓扑,所以安装前需要记录和保留回滚方法。Windows 的这类设备使用 Indirect Display Driver 模型。

截至本文记录时,实机验证覆盖这台 Windows 11 / Intel 041A 机器。Windows 10、其他 Haswell 型号、现代 Intel GPU 的回归都还没做。oneVPL 的公开支持表最早列到 Broadwell,Haswell 更早;本文记录的是旧 Media SDK 的兼容性实践,不代表上游已经承诺支持这个组合。

2. QSV 找不到,问题出在 DeviceId=0

一开始 Sunshine 找不到可用的 QSV H.264 实现。但直接调用旧 Media SDK,硬件 D3D11 session 可以创建成功。继续查,发现 MFXVideoCORE_QueryPlatform 返回了 DeviceId=0。

这就和 FFmpeg 的设备筛选撞上了:FFmpeg 根据选中的 D3D11 设备,要求 QSV 实现同时匹配真实 Device ID 和 LUID。旧 runtime 的设备描述缺少 Device ID,一个实际上可工作的编码器就被筛掉了。相关逻辑可以对照固定版本的 FFmpeg QSV hwcontext和 oneVPL legacy dispatcher。

我们做了同版本的前后对照:

检查原 dispatcher修复后
直接创建 Media SDK 硬件 D3D11 session成功,DeviceId 为 0runtime 不变
oneVPL 不筛 Device ID / 仅筛 LUID可以枚举可以枚举
同时筛真实 Device ID 041A 和 LUIDMFX_ERR_NOT_FOUND (-9)成功
同一套 FFmpeg 静态库,D3D11→QSV 派生失败,-9成功,可分配 QSV 帧并编码
故意给错误 Device ID被拒绝仍被拒绝
Sunshine 编码器检测回退到软件编码找到 h264_qsv [quicksync]

这里能确认的是设备身份信息缺失造成了筛选失败。修补的方式是:只有旧路径的 Device ID 仍为 0 时,查询同一个 session adapter 的信息;必须是 Intel、Device ID 在有效的非零 16 位范围内、LUID 完全一致,才补回 Device ID。 已有非零值保持原样,查询失败或返回 warning 时也不补。

这几个限制很关键。直接写死 041A、随便查第一张显卡、删掉 FFmpeg 的设备筛选,都可能把 session 绑到错误的 GPU。

3. 完整补丁:可以直接保存再应用

下面两份补丁对应上面的固定 oneVPL commit。保存代码块内部的原始内容为 patch-a.patch 和 patch-b.patch,使用 LF 换行;代码围栏不属于补丁内容。先 git apply --check,再实际应用。已经打过补丁的工作目录不要重复执行应用步骤。

Patch A:设备身份恢复与九个测试

功能代码是新增头文件和调用接入,共 38 行;含测试及构建接入的完整补丁为四个文件、94 行新增。它只进入 Windows 的 legacy MSDK 路径。

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
diff --git a/libvpl/src/mfx_dispatcher_legacy_device_id.h b/libvpl/src/mfx_dispatcher_legacy_device_id.h
new file mode 100644
index 0000000..63a713f
--- /dev/null
+++ b/libvpl/src/mfx_dispatcher_legacy_device_id.h
@@ -0,0 +1,24 @@
+/* Copyright (C) Intel Corporation
+ * SPDX-License-Identifier: MIT
+ */
+#pragma once
+#include "vpl/mfxdefs.h"
+
+/**
+ * Recover a missing legacy runtime DeviceId from its session adapter.
+ * The query must preserve adapter identity; failures leave the description unchanged.
+ */
+template <typename Query>
+mfxU16 RecoverLegacyDeviceID(mfxU16 current, mfxU32 adapterID, mfxU64 sessionLuid, Query query) {
+    if (current != 0)
+        return current;
+
+    mfxU32 vendorID = 0, deviceID = 0;
+    mfxU64 luid      = 0;
+    mfxStatus status = query(adapterID, &vendorID, &deviceID, &luid);
+    if (status == MFX_ERR_NONE && vendorID == 0x8086 && deviceID > 0 && deviceID <= 0xFFFF &&
+        luid == sessionLuid)
+        return static_cast<mfxU16>(deviceID);
+
+    return current;
+}
diff --git a/libvpl/src/mfx_dispatcher_vpl_msdk.cpp b/libvpl/src/mfx_dispatcher_vpl_msdk.cpp
index 770014b..9584000 100644
--- a/libvpl/src/mfx_dispatcher_vpl_msdk.cpp
+++ b/libvpl/src/mfx_dispatcher_vpl_msdk.cpp
@@ -8,6 +8,7 @@
 #include "src/mfx_dispatcher_vpl.h"
 
 #if defined(_WIN32) || defined(_WIN64)
+    #include "src/mfx_dispatcher_legacy_device_id.h"
     #include "src/mfx_dispatcher_vpl_win.h"
 #endif
 
@@ -404,6 +405,19 @@ mfxStatus LoaderCtxMSDK::QueryMSDKCaps(STRING_TYPE libNameFull,
     if (m_deviceID == 0)
         m_deviceID = m_loaderDeviceID;
 
+#if defined(_WIN32) || defined(_WIN64)
+    // Some legacy runtimes and the Windows loader both return DeviceId == 0.
+    // Query the session adapter, never a default GPU, and retain its LUID binding.
+    m_deviceID = RecoverLegacyDeviceID(
+        m_deviceID,
+        adapterID,
+        m_luid,
+        [](mfxU32 index, mfxU32 *vendorID, mfxU32 *deviceID, mfxU64 *luid) {
+            mfxIMPL implTest = MFX_IMPL_VIA_D3D11;
+            return MFX::SelectImplementationType(index, &implTest, vendorID, deviceID, luid);
+        });
+#endif
+
     // store DeviceID as "DevID" (hex) / "AdapterIdx" (dec) to match GPU RT
     Dev->Version.Version = MFX_DEVICEDESCRIPTION_VERSION;
     snprintf(Dev->DeviceID, sizeof(Dev->DeviceID), "%x/%d", m_deviceID, m_id.VendorImplID);
diff --git a/libvpl/test/unit/CMakeLists.txt b/libvpl/test/unit/CMakeLists.txt
index f192036..e2331c2 100644
--- a/libvpl/test/unit/CMakeLists.txt
+++ b/libvpl/test/unit/CMakeLists.txt
@@ -7,6 +7,7 @@ cmake_minimum_required(VERSION 3.13.0)
 
 set(TARGET vpl-tests)
 set(test_sources
+    src/legacy_device_id.cpp
     src/session-test.cpp
     src/legacycpp-session-test-1x.cpp
     src/legacycpp-session-test-2x.cpp
diff --git a/libvpl/test/unit/src/legacy_device_id.cpp b/libvpl/test/unit/src/legacy_device_id.cpp
new file mode 100644
index 0000000..b369296
--- /dev/null
+++ b/libvpl/test/unit/src/legacy_device_id.cpp
@@ -0,0 +1,55 @@
+/* Copyright (C) Intel Corporation
+ * SPDX-License-Identifier: MIT
+ */
+#include "../../../src/mfx_dispatcher_legacy_device_id.h"
+#include "gtest/gtest.h"
+
+namespace {
+mfxU16 Recover(mfxStatus status, mfxU32 vendor, mfxU32 device, mfxU64 luid) {
+    return RecoverLegacyDeviceID(0,
+                                 2,
+                                 0x12345678,
+                                 [=](mfxU32 index, mfxU32 *v, mfxU32 *d, mfxU64 *l) {
+                                     EXPECT_EQ(index, 2u);
+                                     *v = vendor;
+                                     *d = device;
+                                     *l = luid;
+                                     return status;
+                                 });
+}
+} // namespace
+TEST(LegacyDeviceID, RecoversZeroFromSessionAdapterAtNonzeroIndex) {
+    EXPECT_EQ(Recover(MFX_ERR_NONE, 0x8086, 0x041A, 0x12345678), 0x041A);
+}
+TEST(LegacyDeviceID, PreservesKnownIDWithoutQuery) {
+    EXPECT_EQ(RecoverLegacyDeviceID(
+                  0x9A49,
+                  1,
+                  123,
+                  [](mfxU32, mfxU32 *, mfxU32 *, mfxU64 *) {
+                      ADD_FAILURE() << "Existing runtime or loader DeviceId must not be queried";
+                      return MFX_ERR_UNKNOWN;
+                  }),
+              0x9A49);
+}
+TEST(LegacyDeviceID, QueryFailureKeepsZero) {
+    EXPECT_EQ(Recover(MFX_ERR_NOT_FOUND, 0x8086, 0x041A, 0x12345678), 0);
+}
+TEST(LegacyDeviceID, QueryWarningKeepsZero) {
+    EXPECT_EQ(Recover(MFX_WRN_PARTIAL_ACCELERATION, 0x8086, 0x041A, 0x12345678), 0);
+}
+TEST(LegacyDeviceID, NonIntelKeepsZero) {
+    EXPECT_EQ(Recover(MFX_ERR_NONE, 0x10DE, 0x041A, 0x12345678), 0);
+}
+TEST(LegacyDeviceID, MissingDeviceIDKeepsZero) {
+    EXPECT_EQ(Recover(MFX_ERR_NONE, 0x8086, 0, 0x12345678), 0);
+}
+TEST(LegacyDeviceID, OutOfRangeDeviceIDKeepsZero) {
+    EXPECT_EQ(Recover(MFX_ERR_NONE, 0x8086, 0x10000, 0x12345678), 0);
+}
+TEST(LegacyDeviceID, DifferentIntelAdapterLUIDKeepsZero) {
+    EXPECT_EQ(Recover(MFX_ERR_NONE, 0x8086, 0x041A, 0x12345679), 0);
+}
+TEST(LegacyDeviceID, AcceptsMaximum16BitDeviceID) {
+    EXPECT_EQ(Recover(MFX_ERR_NONE, 0x8086, 0xFFFF, 0x12345678), 0xFFFF);
+}

九个测试覆盖:非零 adapter index、已有 ID 不查询、查询失败、查询 warning、非 Intel、零 ID、超出 16 位范围、LUID 不一致,以及 0xFFFF 边界值。

Patch B:这套 UCRT64 工具链的编译兼容修正

这一份处理旧 MSVC 宏在 MinGW 下误展开、shlwapi 库名,以及 Windows 诊断程序的 version 链接库。它和 Device ID 的根因分开保存;换工具链时,应先确认是否仍然需要。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
diff --git a/libvpl/src/windows/mfx_dispatcher_defs.h b/libvpl/src/windows/mfx_dispatcher_defs.h
index 38fd221..a23f1ce 100644
--- a/libvpl/src/windows/mfx_dispatcher_defs.h
+++ b/libvpl/src/windows/mfx_dispatcher_defs.h
@@ -17,7 +17,7 @@
 #define MAX_PLUGIN_PATH 4096
 #define MAX_PLUGIN_NAME 4096
 
-#if _MSC_VER < 1400
+#if defined(_MSC_VER) && _MSC_VER < 1400
     #define wcscpy_s(to, to_size, from) \
         (void)(to_size);                \
         wcscpy(to, from)
diff --git a/libvpl/test/diagnostic/vpl-timing/CMakeLists.txt b/libvpl/test/diagnostic/vpl-timing/CMakeLists.txt
index a9c17c1..b1adfdb 100644
--- a/libvpl/test/diagnostic/vpl-timing/CMakeLists.txt
+++ b/libvpl/test/diagnostic/vpl-timing/CMakeLists.txt
@@ -7,6 +7,8 @@ cmake_minimum_required(VERSION 3.13.0)
 
 if(MSVC)
   add_definitions(-D_CRT_SECURE_NO_WARNINGS)
+endif()
+if(WIN32)
   set(LIBS version)
 endif()
 
diff --git a/libvpl/test/unit/CMakeLists.txt b/libvpl/test/unit/CMakeLists.txt
index f192036..fb8a53b 100644
--- a/libvpl/test/unit/CMakeLists.txt
+++ b/libvpl/test/unit/CMakeLists.txt
@@ -37,7 +37,7 @@ target_include_directories(
                     ${CMAKE_CURRENT_SOURCE_DIR}/../runtimes/stub)
 
 if(WIN32)
-  target_link_libraries(${TARGET} PUBLIC shlwapi.lib)
+  target_link_libraries(${TARGET} PUBLIC shlwapi)
 endif()
 
 include(GoogleTest)

4. 固定版本,重新链接 Sunshine

这次测试用的版本如下。复现时先用同一基线,之后再考虑迁移到新版;新版可能已经改变了路径或修复了问题,不能盲套旧补丁。

ComponentCommit / version
Sunshinedfa9e884978398f23d2f63dd987b9d554fa4d68e
build-depsa1fe2841cbc0d8c4501a1006d2d1cb88f219cc8c
FFmpegbf1b838f2ab88b4f8fd83443325c782ea0e0f7fa
oneVPL / libvpl674d015bcb294bc39fa276e99a652ea045423e82
build-deps releasev2026.910.121303
ToolchainMSYS2 UCRT64, GCC 16.2.0, CMake, Ninja
Node.jsNative Windows Node 22.23.3

在独立目录准备 Sunshine 和 libvpl 的 Git 工作副本,分别 checkout 表中的 commit。初始化 Sunshine 子模块,并核对 third-party/build-deps 和其中 FFmpeg 的 revision。先阅读各仓库的 AGENTS.md,安装 Sunshine 官方 Windows 构建文档要求的 MSYS2 UCRT64 依赖。Web UI 使用原生 Windows Node;这次 MSYS2 Node 的 Rolldown 原生绑定没有正常工作。

示例目录约定为 C:\Downloads\sunshine-haswell,其中有 Sunshine、libvpl、两份 patch 和 deps。源码来自 LizardByte/Sunshine及 intel/libvpl。

FFmpeg 没有修改或重编译,使用固定 build-deps release 的 Windows-AMD64-ffmpeg.tar.gz。下载校验后解压到 deps,得到 deps/ffmpeg/include 和 deps/ffmpeg/lib。归档 SHA256:

1
ee79a7a295e8fbd24305a3f4ec94a06f550e0b52f49cb1c4535f649e2dd8b234

下面是在依赖已安装、源码已固定后使用的 Bash 构建步骤。必须在 MSYS2 UCRT64 环境执行。若从 PowerShell 启动保存好的 build-haswell.sh,入口是:

1
C:\msys64\msys2_shell.cmd -defterm -here -no-start -ucrt64 -c 'bash /c/Downloads/sunshine-haswell/build-haswell.sh'
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
set -euo pipefail
export LANG=C
cd /c/Downloads/sunshine-haswell

# patch-a.patch and patch-b.patch contain the two complete diffs in this article.
git -C libvpl apply --check ../patch-b.patch
git -C libvpl apply ../patch-b.patch
git -C libvpl apply --check ../patch-a.patch
git -C libvpl apply ../patch-a.patch

cmake -S libvpl -B libvpl/cmake-build-patched -G Ninja \
  -DCMAKE_BUILD_TYPE=Release -DBUILD_SHARED_LIBS=OFF \
  -DBUILD_TESTS=ON -DBUILD_EXAMPLES=OFF -DINSTALL_EXAMPLES=OFF \
  -DCMAKE_INSTALL_PREFIX=C:/Downloads/sunshine-haswell/vpl-patched
cmake --build libvpl/cmake-build-patched -j 3
cmake --install libvpl/cmake-build-patched

export PATH=/c/Downloads/sunshine-haswell/libvpl/cmake-build-patched:$PATH
export ONEVPL_SEARCH_PATH=C:/Downloads/sunshine-haswell/libvpl/cmake-build-patched
./libvpl/cmake-build-patched/vpl-tests.exe --gtest_filter='LegacyDeviceID.*'
./libvpl/cmake-build-patched/vpl-tests.exe -disp:stub
unset ONEVPL_SEARCH_PATH

# Put native Windows Node ahead of MSYS2 Node in PATH before building the Web UI.
cmake -S Sunshine -B Sunshine/cmake-build-haswell -G Ninja \
  -DCMAKE_BUILD_TYPE=Release -DBUILD_DOCS=OFF -DBUILD_TESTS=ON \
  -DSUNSHINE_ENABLE_TRAY=OFF \
  -DFFMPEG_PREPARED_BINARIES=C:/Downloads/sunshine-haswell/deps/ffmpeg \
  '-DFFMPEG_PLATFORM_LIBRARIES=mfplat;ole32;strmiids;mfuuid;C:/Downloads/sunshine-haswell/vpl-patched/lib/libvpl.a' \
  -DCMAKE_POLICY_VERSION_MINIMUM=3.5
cmake --build Sunshine/cmake-build-haswell --target sunshine test_sunshine -j 3
cmake --build Sunshine/cmake-build-haswell --target web-ui -j 2

# Inspect the actual link command and imported DLLs.
ninja -C Sunshine/cmake-build-haswell -t commands sunshine > sunshine-build-commands.txt
objdump -p Sunshine/cmake-build-haswell/sunshine.exe > sunshine-imports.txt

真正容易漏的是 FFMPEG_PLATFORM_LIBRARIES。 编译安装了新 libvpl,还要让 Sunshine 的最终链接命令引用 vpl-patched/lib/libvpl.a,否则可能仍用了旧 dispatcher。检查 sunshine-build-commands.txt 的最终链接行,并检查导入表:这套构建使用静态 dispatcher,不应依赖 libvpl.dll;底层 Intel Media SDK runtime 仍然从系统加载。

如果构建机器访问 GitHub 很慢,可以提前准备 nv-codec-headers 的 n11.0.10.3、n12.0.16.2、n13.0.19.1 源码,再分别用 CPM_nv_codec_headers_11_SOURCE、CPM_nv_codec_headers_12_SOURCE、CPM_nv_codec_headers_13_SOURCE 指向本地目录。这些是编译用头文件,不会安装 NVIDIA 驱动。

构建出的 exe 还需要运行资产:按该 commit 的安装规则整理 assets、Web UI、应用配置模板及实际依赖的运行库,放到单独的便携目录;只复制一个 exe 不足以得到完整测试版。禁用 tray 是这次的构建选择,Web UI 保留,不是 Haswell 修复的必要条件。

本次新增测试 9/9 通过;原有 stub 测试 717 通过、309 跳过、0 失败,另有原有 disabled 用例;Sunshine 相关测试 70/70 通过。stub 用的 ONEVPL_SEARCH_PATH 必须在实机测试前清掉,别把模拟 runtime 当成实际硬件。

5. 先验证编码,再看游戏帧率

建议让 AI 做一个很小的诊断程序,保留原版和修复版两个 dispatcher,其他条件一致。按这一顺序验收:

  1. 枚举实际 Intel adapter,记录 Vendor ID、Device ID、LUID;直接创建旧 Media SDK 硬件 D3D11 session,读取 API 版本和 MFXVideoCORE_QueryPlatform。
  2. 分别测试不带设备身份过滤、只带 LUID、同时带正确 Device ID/LUID、带错误 Device ID。确认补丁只修复缺失身份,错误筛选仍失败。
  3. 使用同一个启用 D3D11 multithread protection 的设备,通过同一套 FFmpeg 库派生 QSV,分配帧、编码 H.264,再解码核对尺寸和帧数。
  4. 验证 Sunshine 日志出现 Found H.264 encoder: h264_qsv [quicksync],串流使用的设备也确实是 Intel。只看任务管理器总 GPU 占用不足以证明编码设备。
  5. 最后再跑 Moonlight 动态画面,记录输入帧率、发送帧率、重复帧、客户端解码耗时和帧间隔。

系统 C:\Program Files\Intel\Media SDK\libmfxhw64.dll 的 SHA256 在测试前后均为下面这个值,说明修复没有替换它。其他机器的原始 hash 可以不同,要比较的是自己机器的前后变化。

1
77EFA168D1E632B7B41B786EEE412662AC65D168F2899B8468CD0D5835CD8814

我们完成了四档十秒动态 NV12 输入测试:720p30、720p60、1080p30、1080p60,输出均可解码且帧数一致。720p60 的上传及编码 API 调用平均约 7.21 ms;1080p60 约 12.51 ms,但有 26.77 ms 的峰值,600 帧墙钟约 10.21 秒。这只能证明短测可工作,不能据此承诺游戏里持续低延迟 1080p60,也不是完整的端到端延迟测量。

Haswell 上 low_power=1 初始化失败后,Sunshine 原有的 low_power=0 回退可以工作,这次没有为它新增参数补丁。日志中的第一次初始化失败,需要和后续回退结果一起看。裸 H.264 的 ffprobe 推断帧率也曾出现翻倍,验收应以实际提交/解码帧数、墙钟和串流节奏为准。

6. 手机尺寸:独立虚拟显示器更合适

我的手机是 720×1612,横着玩游戏就是 1612×720。只改 Moonlight 输出尺寸,可以改变编码后的画面;如果捕获源仍是实体显示器的 1920×1200,游戏的显示模式和捕获成本不会自动一起变小。我希望游戏本身就跑在一个匹配手机比例的显示器上。

实体屏不接受所需的自定义显示模式,所以这次选择了一个独立虚拟屏。使用 Virtual Display Driver 25.7.23 的 Driver Only 包,没有安装虚拟音频驱动。包名里虽然有 x86,这次使用的 INF 包含 NTamd64,实机是 x64。ZIP SHA256:

1
e24210692b442b39af763536330ce78b423f19342b7a7792c26de3944e418b3a

按项目的 手动安装说明,先把驱动和配置放在 C:\VirtualDisplayDriver,再用设备管理器的“添加过时硬件 / 从磁盘安装”创建间接显示设备。仅添加 INF 到驱动仓库,不一定会创建这种 root 设备。自动化时只用可信来源的工具,并记录这次新增的设备实例 ID、实际发布的 oemXX.inf、安装前的显示拓扑。

这台机器最初遇到发布者未受信任的错误。核对 CAT/DLL 的 Authenticode 签名有效,签名者为 SignPath Foundation 后,才处理这个发布者的信任并更新已有设备节点。没有关闭 Secure Boot、没有启用测试签名,也没有不停地重新创建节点。若你的机器也需要处理信任,应先核对签名并记录原有证书状态,便于只回退本次新增的信任。

以下是 25.7.23 包对应的配置格式,保存为 C:\VirtualDisplayDriver\vdd_settings.xml。只创建一个虚拟屏;把 GPU 名称改成自己机器上实际的 Intel 名称。新版驱动的 XML 格式可能不同,不要混用。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
<?xml version='1.0' encoding='utf-8'?>
<vdd_settings>
    <monitors>
        <count>1</count>
    </monitors>
    <gpu>
        <friendlyname>Intel(R) HD Graphics P4600/P4700</friendlyname>
    </gpu>
    <global>
        <g_refresh_rate>60</g_refresh_rate>
    </global>
    <resolutions>
        <resolution>
            <width>1612</width>
            <height>720</height>
            <refresh_rate>30</refresh_rate>
        </resolution>
        <resolution>
            <width>720</width>
            <height>1612</height>
            <refresh_rate>30</refresh_rate>
        </resolution>
        <resolution>
            <width>1280</width>
            <height>720</height>
            <refresh_rate>30</refresh_rate>
        </resolution>
    </resolutions>
    <options>
        <CustomEdid>false</CustomEdid>
        <PreventSpoof>false</PreventSpoof>
        <EdidCeaOverride>false</EdidCeaOverride>
        <HardwareCursor>true</HardwareCursor>
        <SDR10bit>false</SDR10bit>
        <HDRPlus>false</HDRPlus>
        <logging>false</logging>
        <debuglogging>false</debuglogging>
    </options>
</vdd_settings>

这个版本的 global 增加 60 Hz,各分辨率条目的 30 Hz 加上它,实际可枚举到 30/60 Hz。安装或按版本提供的方法重新加载后,要在 Windows 中确认 1612×720、60 Hz 真正生效,不能只看 XML 写了什么。保留实体屏,先用扩展模式验证。

Sunshine 使用已有设置即可。下面是这次稳定工作时的关键项,hevc_mode=1、av1_mode=1 在这个版本中表示不向客户端提供对应编码能力:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
encoder = quicksync
hevc_mode = 1
av1_mode = 1
qsv_preset = veryfast
qsv_coder = cavlc
adapter_name = Intel(R) HD Graphics P4600/P4700
capture = wgc
dd_configuration_option = ensure_primary
dd_resolution_option = auto
dd_refresh_rate_option = auto
dd_config_revert_on_disconnect = enabled
dd_hdr_option = disabled
min_log_level = info

另外必须设置 output_name:从 Sunshine 启动日志的显示器列表中找出该虚拟屏的 device_id,写成 output_name = {实际的 UUID}。这里的中文占位文字需要替换;不要复制别人的 UUID,也不要把 DISPLAY3 之类的编号当成稳定标识。选项含义可查 Sunshine 配置文档,具体以固定 commit 为准。

这次使用 WGC 捕获,便携版运行在已登录、未锁屏的本地控制台。Sunshine 功能表区分了 WGC 的便携版与服务模式支持,本文不把此结果外推到 Windows 服务。SSH 负责管理,Sunshine 应由交互式桌面启动;测试时避免 RDP 改变桌面和显示路径,同时只运行一个 Sunshine 实例。

Moonlight 设置 H.264、SDR、60 FPS,先从 8 Mbps 开始,分辨率选自定义 1612×720。启用“优化游戏设置”(Optimize game settings),让本版本 Sunshine 接收到允许调整显示模式的客户端标志。连接时让虚拟屏成为主屏,游戏使用这个屏幕并把游戏分辨率设为相同尺寸。ensure_primary 会保留实体屏;dd_config_revert_on_disconnect 配置为断开后恢复,也应实测恢复是否符合预期。

安装后我一度看到了三个显示目标。核对安装前后的拓扑才发现,其中一个未命名目标原本就存在,和 Dell 共用桌面源;断开这个旧目标后,留下实体屏和一个虚拟屏。这不是每个人都需要的步骤。 不能看到“屏幕 3”就删除显示设备,更不能拿本机的 target ID 去改另一台机器。

7. 为什么虚拟屏之后突然接近 60 帧?

前面遇到过手机只有二三十帧,Unisoc H.264 解码器显示约 58 ms 解码时间。换 iPad 后仍有三四十帧,关游戏垂直同步也没完全解决;改 WGC 后有所提升,最后独立虚拟屏启用,手机才明显稳定了。

手机解码器可能是其中一个瓶颈,但这些对照说明,不能把所有问题都归给手机。CPU/GPU 总占用低,也可能是捕获、同步、复制或合成路径在等待。

最终会话的服务端日志与我的手机体验一致:

指标会话末尾连续 120 秒
发送帧数7,199
发送 FPS59.9858
去除重复画面的 FPS59.9608
平均帧间隔16.6706 ms
P95 / P99 帧间隔18 / 18 ms
最大帧间隔47 ms
发送帧序号缺口0

整段约 11 分钟会话的平均发送 FPS 是 54.36。所以这里只能说末尾两分钟接近稳定 60 帧,不能把整段都写成全程 60 帧。服务端发送序号连续,也不等于证明客户端没有丢包、解码掉帧或音频问题。

复算方法是临时设 min_log_level = verbose,统计 Sent Frame seq [...] 日志。按 frame index 去重,避免把同一帧的多个 FEC block 算成多帧;带 Dupe 的重复画面单独计数。对一段连续动态画面的 N 个独立帧,用 (N-1)/(最后时间-最早时间) 计算节奏,并统计相邻帧时间差。测试结束改回 info。

有一个很直观的变化:捕获源从 1920×1200 = 2,304,000 像素,降到 1612×720 = 1,160,640,减少了约 49.6%。原来捕获较大的桌面,再适配客户端输出;现在捕获源和串流尺寸匹配,可以减少这部分缩放需求,RGB→NV12 的颜色转换仍然需要。

但虚拟屏同时带来了独立的 60 Hz 输出路径,还断开了原有未命名目标。这几个变化没有做逐项控制实验,所以“捕获源更小、显示路径更合适”是目前的合理解释,不能把提升全部归给某一个因素,也没有证明跨 GPU 复制完全消失。

实体主屏仍然可以保持 1920×1200,显示自己的桌面。游戏放在虚拟屏上,游戏的输出尺寸随之改变,内部渲染分辨率还取决于游戏设置;这不等于整个 Windows 的总像素量减少。关键是被 Sunshine 捕获和编码的那一路变小了。

8. 可以直接交给 AI 的复现任务

下面这段配合全文使用。它要求先检查本机证据,适合有终端、源码和 Windows 访问能力的 AI 编程工具;单纯问答模型无法代替实机验收。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
请按这篇文章复现 Windows 上的 Haswell QSV H.264 Sunshine 串流。
目标:独显渲染游戏,Intel 核显编码,手机横屏使用其原生尺寸。

先询问/读取本机系统版本、CPU、所有 GPU 的真实 PCI ID 和 LUID、驱动、
旧 Media SDK runtime 版本、显示拓扑、手机尺寸,并阅读各仓库 AGENTS.md。
不要假设 Intel 是 adapter 0。不要升级/替换现有 Intel 或 NVIDIA 驱动,
不要覆盖任何系统驱动 DLL,不要启用测试签名或关闭 Secure Boot。

建立单独测试目录,保存原 Sunshine、配置、显示拓扑和 DLL hash。
先判断本机是否需要补丁:直接 MSDK 硬件 D3D11 session 是否成功?
QueryPlatform 是否返回 DeviceId=0?oneVPL 仅 LUID 能成功而加入真实
Device ID 后是否为 -9?如果不是这个根因,停止套补丁,继续查实际原因。

使用文中固定版本,分别构建 baseline 和 patched dispatcher。
baseline 如有必要只应用 Patch B;patched 应用 Patch B 和 Patch A。
保留正确/错误 Device ID、LUID 一致/不一致的对照;已有非零 ID 不覆盖。
先运行九个新增测试与原有 stub 测试,清除 stub 搜索环境后做实机探针。
使用相同 FFmpeg 静态库做 D3D11→QSV→H.264→解码对照。
重新链接 Sunshine,证明最终命令引用 patched libvpl.a,而非系统旧库。
完成 Web UI、运行资产和便携目录,只运行一个实例,确认日志为 h264_qsv。

编码支持成立后,再安装本文固定版本的一个 VDD 显示设备。
先核验下载 hash、签名、INF 架构,记录新增设备/INF/信任,保留实体屏。
按本机 Intel 名称、手机横屏宽高配置 XML;确认实际显示模式为 60 Hz。
使用真实虚拟屏 device_id 设置 output_name,WGC 在交互桌面运行。
Moonlight 使用 H.264、SDR、原生尺寸、60 FPS、8 Mbps 起步,并启用
Optimize game settings。把游戏放到虚拟屏,核对渲染 GPU 与编码 GPU。
不要盲删多余屏幕;先证明它来自哪里,再给出可回滚的具体操作。

记录至少两分钟动态游戏的发送 FPS、重复帧、帧间隔、客户端体验和声音。
逐档测试 720p30/60、1080p30/60;长期测试未完成就明确写未完成。
再验证断开/重连、主屏恢复、分辨率切换、休眠唤醒。
不要把短时编码通过、静止桌面或低 GPU 占用当成稳定 60 FPS 的证据。
凡是涉及已有驱动变化、实体屏失去输出或其他功能取舍,先给出具体方案
和回滚步骤,让我确认。最后交付补丁、版本清单、构建命令、脱敏日志、回滚脚本。

9. 回滚和还没验证的事

编码补丁只进入新便携版:停止它,恢复原 Sunshine 及原配置,就能回退编码尝试。虚拟屏可以先禁用本次新建的设备,再恢复记录下来的主屏和显示布局。要彻底卸载,核对自己的设备实例和 oemXX.inf,只移除本次新增的 VDD;证书信任也只移除本次新增且之前不存在的项。保留实体屏和远程管理通道,回滚不需要更换 Intel/NVIDIA 驱动。

MPO、进程优先级、电源等也曾在排查中试过,但没有证据表明它们是这套方案的必要条件,因此不把这些实验写成通用设置。新增 VDD 不会替换 30HX 的驱动,显示拓扑变化仍需要在自己的机器上验收。

目前只验证了 H.264、8-bit、4:2:0、SDR。没有验证 HEVC、AV1 或 HDR;720×1612 竖屏仅完成短时编码和解码,端到端竖屏游戏尚未验收;四档各十分钟的完整 soak、休眠唤醒和全部显示恢复场景也没有完成。之前出现过的音视频间歇卡顿,没有单独完成最终音频验收,不能说已全部解决。

现在这台机器确实能把 30HX 渲染的游戏,经 Haswell 核显编码,送到手机上接近 60 帧。把这条实际跑通的路径留下来,比只留一句“我这里可以”更有用。

English: 30HX rendering, Haswell QSV encoding, and a phone-sized virtual display

My Windows gaming machine uses a 30HX with a modified driver. It can render games, but it has no usable video encoder in this setup. The machine also has a Xeon E3-1275 v3 with a Haswell integrated GPU, so I wanted the NVIDIA card to render the game and Intel Quick Sync to encode the stream for my phone.

That combination now works. Adding a virtual display matching the phone’s aspect ratio also brought the stream from roughly 30–40 FPS to close to 60 FPS. I worked through this with an AI coding assistant; the versions, complete patches, and verification gates below are intended to make the result reproducible rather than merely describe a successful installation.

Neither Sunshine’s native source nor FFmpeg’s source was modified. The encoding fix is in the oneVPL dispatcher, which was rebuilt and statically linked into Sunshine. Phone-sized output uses an independent virtual display and existing Sunshine settings.

1. Tested hardware and scope

ItemTested configuration
OSWindows 11 Pro, build 22631.2861
CPU / iGPUXeon E3-1275 v3 / Intel HD Graphics P4600/P4700
Intel PCI identity8086:041A
Intel driver20.19.15.5171
Discrete GPU30HX; the existing modified driver reports GTX 1660 SUPER
Legacy Media SDK runtimelibmfxhw64.dll, version 7.16.10.20, API 1.20
Physical monitorDell U2412M, 1920×1200, approximately 60 Hz
Phone720×1612 portrait; 1612×720 landscape
Working streamH.264, SDR, 1612×720, 60 FPS

Verify that the motherboard and BIOS actually expose the Intel GPU. Having a CPU with Quick Sync is insufficient if Windows cannot enumerate it. Read the real Vendor ID, Device ID, and adapter LUID; never assume Intel is DXGI adapter 0 on a multi-GPU system.

The existing Intel and NVIDIA drivers were kept unchanged. The virtual display adds a separate Indirect Display Driver device, which changes display topology and therefore needs its own rollback record.

This is a legacy-runtime compatibility case tested on one Windows 11 / Intel 041A machine. Windows 10, other Haswell devices, and modern Intel GPUs have not been tested. The oneVPL public support table starts at Broadwell, which is newer than Haswell; this article does not imply upstream support for this configuration.

2. The reproducible failure: a working runtime reports DeviceId=0

Direct initialization of a legacy Media SDK hardware D3D11 session succeeded, but MFXVideoCORE_QueryPlatform returned DeviceId=0. FFmpeg selects a QSV implementation matching the selected D3D11 device’s Device ID and LUID. With the ID missing from the legacy implementation description, the strict filter rejected an otherwise working encoder.

The pinned FFmpeg QSV device code and oneVPL legacy dispatcher provide the relevant context. On identical versions, the tests showed:

TestBaseline dispatcherPatched dispatcher
Direct legacy hardware D3D11 sessionWorks; reported DeviceId is 0Same system runtime
Enumeration without Device ID / with LUID aloneWorksWorks
Correct Device ID 041A plus LUIDMFX_ERR_NOT_FOUND (-9)Works
FFmpeg D3D11→QSV derived device, same static FFmpeg librariesFails with -9Works; frames allocate and H.264 encodes
Deliberately incorrect Device IDRejectedStill rejected
Sunshine encoder detectionSoftware fallbackh264_qsv [quicksync] found

The fix only recovers a still-missing Device ID from the same session adapter. The query must succeed without warnings, report Intel vendor 0x8086, return a nonzero 16-bit Device ID, and match the session LUID exactly. A known ID is never overwritten. No Device ID is hardcoded and FFmpeg’s identity filtering remains intact.

3. Complete patches

Save the contents of these two code blocks as patch-a.patch and patch-b.patch, using LF line endings. They target the pinned libvpl commit in the version table. Run git apply --check before applying, and do not apply them twice to the same working tree.

Patch A: device identity recovery and nine tests

The functional header and call-site additions total 38 lines. Including tests and their CMake registration, this patch adds 94 lines across four files and only changes the Windows legacy MSDK path.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
diff --git a/libvpl/src/mfx_dispatcher_legacy_device_id.h b/libvpl/src/mfx_dispatcher_legacy_device_id.h
new file mode 100644
index 0000000..63a713f
--- /dev/null
+++ b/libvpl/src/mfx_dispatcher_legacy_device_id.h
@@ -0,0 +1,24 @@
+/* Copyright (C) Intel Corporation
+ * SPDX-License-Identifier: MIT
+ */
+#pragma once
+#include "vpl/mfxdefs.h"
+
+/**
+ * Recover a missing legacy runtime DeviceId from its session adapter.
+ * The query must preserve adapter identity; failures leave the description unchanged.
+ */
+template <typename Query>
+mfxU16 RecoverLegacyDeviceID(mfxU16 current, mfxU32 adapterID, mfxU64 sessionLuid, Query query) {
+    if (current != 0)
+        return current;
+
+    mfxU32 vendorID = 0, deviceID = 0;
+    mfxU64 luid      = 0;
+    mfxStatus status = query(adapterID, &vendorID, &deviceID, &luid);
+    if (status == MFX_ERR_NONE && vendorID == 0x8086 && deviceID > 0 && deviceID <= 0xFFFF &&
+        luid == sessionLuid)
+        return static_cast<mfxU16>(deviceID);
+
+    return current;
+}
diff --git a/libvpl/src/mfx_dispatcher_vpl_msdk.cpp b/libvpl/src/mfx_dispatcher_vpl_msdk.cpp
index 770014b..9584000 100644
--- a/libvpl/src/mfx_dispatcher_vpl_msdk.cpp
+++ b/libvpl/src/mfx_dispatcher_vpl_msdk.cpp
@@ -8,6 +8,7 @@
 #include "src/mfx_dispatcher_vpl.h"
 
 #if defined(_WIN32) || defined(_WIN64)
+    #include "src/mfx_dispatcher_legacy_device_id.h"
     #include "src/mfx_dispatcher_vpl_win.h"
 #endif
 
@@ -404,6 +405,19 @@ mfxStatus LoaderCtxMSDK::QueryMSDKCaps(STRING_TYPE libNameFull,
     if (m_deviceID == 0)
         m_deviceID = m_loaderDeviceID;
 
+#if defined(_WIN32) || defined(_WIN64)
+    // Some legacy runtimes and the Windows loader both return DeviceId == 0.
+    // Query the session adapter, never a default GPU, and retain its LUID binding.
+    m_deviceID = RecoverLegacyDeviceID(
+        m_deviceID,
+        adapterID,
+        m_luid,
+        [](mfxU32 index, mfxU32 *vendorID, mfxU32 *deviceID, mfxU64 *luid) {
+            mfxIMPL implTest = MFX_IMPL_VIA_D3D11;
+            return MFX::SelectImplementationType(index, &implTest, vendorID, deviceID, luid);
+        });
+#endif
+
     // store DeviceID as "DevID" (hex) / "AdapterIdx" (dec) to match GPU RT
     Dev->Version.Version = MFX_DEVICEDESCRIPTION_VERSION;
     snprintf(Dev->DeviceID, sizeof(Dev->DeviceID), "%x/%d", m_deviceID, m_id.VendorImplID);
diff --git a/libvpl/test/unit/CMakeLists.txt b/libvpl/test/unit/CMakeLists.txt
index f192036..e2331c2 100644
--- a/libvpl/test/unit/CMakeLists.txt
+++ b/libvpl/test/unit/CMakeLists.txt
@@ -7,6 +7,7 @@ cmake_minimum_required(VERSION 3.13.0)
 
 set(TARGET vpl-tests)
 set(test_sources
+    src/legacy_device_id.cpp
     src/session-test.cpp
     src/legacycpp-session-test-1x.cpp
     src/legacycpp-session-test-2x.cpp
diff --git a/libvpl/test/unit/src/legacy_device_id.cpp b/libvpl/test/unit/src/legacy_device_id.cpp
new file mode 100644
index 0000000..b369296
--- /dev/null
+++ b/libvpl/test/unit/src/legacy_device_id.cpp
@@ -0,0 +1,55 @@
+/* Copyright (C) Intel Corporation
+ * SPDX-License-Identifier: MIT
+ */
+#include "../../../src/mfx_dispatcher_legacy_device_id.h"
+#include "gtest/gtest.h"
+
+namespace {
+mfxU16 Recover(mfxStatus status, mfxU32 vendor, mfxU32 device, mfxU64 luid) {
+    return RecoverLegacyDeviceID(0,
+                                 2,
+                                 0x12345678,
+                                 [=](mfxU32 index, mfxU32 *v, mfxU32 *d, mfxU64 *l) {
+                                     EXPECT_EQ(index, 2u);
+                                     *v = vendor;
+                                     *d = device;
+                                     *l = luid;
+                                     return status;
+                                 });
+}
+} // namespace
+TEST(LegacyDeviceID, RecoversZeroFromSessionAdapterAtNonzeroIndex) {
+    EXPECT_EQ(Recover(MFX_ERR_NONE, 0x8086, 0x041A, 0x12345678), 0x041A);
+}
+TEST(LegacyDeviceID, PreservesKnownIDWithoutQuery) {
+    EXPECT_EQ(RecoverLegacyDeviceID(
+                  0x9A49,
+                  1,
+                  123,
+                  [](mfxU32, mfxU32 *, mfxU32 *, mfxU64 *) {
+                      ADD_FAILURE() << "Existing runtime or loader DeviceId must not be queried";
+                      return MFX_ERR_UNKNOWN;
+                  }),
+              0x9A49);
+}
+TEST(LegacyDeviceID, QueryFailureKeepsZero) {
+    EXPECT_EQ(Recover(MFX_ERR_NOT_FOUND, 0x8086, 0x041A, 0x12345678), 0);
+}
+TEST(LegacyDeviceID, QueryWarningKeepsZero) {
+    EXPECT_EQ(Recover(MFX_WRN_PARTIAL_ACCELERATION, 0x8086, 0x041A, 0x12345678), 0);
+}
+TEST(LegacyDeviceID, NonIntelKeepsZero) {
+    EXPECT_EQ(Recover(MFX_ERR_NONE, 0x10DE, 0x041A, 0x12345678), 0);
+}
+TEST(LegacyDeviceID, MissingDeviceIDKeepsZero) {
+    EXPECT_EQ(Recover(MFX_ERR_NONE, 0x8086, 0, 0x12345678), 0);
+}
+TEST(LegacyDeviceID, OutOfRangeDeviceIDKeepsZero) {
+    EXPECT_EQ(Recover(MFX_ERR_NONE, 0x8086, 0x10000, 0x12345678), 0);
+}
+TEST(LegacyDeviceID, DifferentIntelAdapterLUIDKeepsZero) {
+    EXPECT_EQ(Recover(MFX_ERR_NONE, 0x8086, 0x041A, 0x12345679), 0);
+}
+TEST(LegacyDeviceID, AcceptsMaximum16BitDeviceID) {
+    EXPECT_EQ(Recover(MFX_ERR_NONE, 0x8086, 0xFFFF, 0x12345678), 0xFFFF);
+}

The tests cover a nonzero adapter index, preservation of known IDs without querying, query errors and warnings, non-Intel vendors, zero and out-of-range IDs, mismatched LUIDs, and the maximum 16-bit ID.

Patch B: UCRT64 build portability

This separately fixes an old MSVC-only macro being activated under MinGW, the shlwapi library name, and linking the Windows diagnostic utility with version. Check whether these changes are needed if you use a different toolchain.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
diff --git a/libvpl/src/windows/mfx_dispatcher_defs.h b/libvpl/src/windows/mfx_dispatcher_defs.h
index 38fd221..a23f1ce 100644
--- a/libvpl/src/windows/mfx_dispatcher_defs.h
+++ b/libvpl/src/windows/mfx_dispatcher_defs.h
@@ -17,7 +17,7 @@
 #define MAX_PLUGIN_PATH 4096
 #define MAX_PLUGIN_NAME 4096
 
-#if _MSC_VER < 1400
+#if defined(_MSC_VER) && _MSC_VER < 1400
     #define wcscpy_s(to, to_size, from) \
         (void)(to_size);                \
         wcscpy(to, from)
diff --git a/libvpl/test/diagnostic/vpl-timing/CMakeLists.txt b/libvpl/test/diagnostic/vpl-timing/CMakeLists.txt
index a9c17c1..b1adfdb 100644
--- a/libvpl/test/diagnostic/vpl-timing/CMakeLists.txt
+++ b/libvpl/test/diagnostic/vpl-timing/CMakeLists.txt
@@ -7,6 +7,8 @@ cmake_minimum_required(VERSION 3.13.0)
 
 if(MSVC)
   add_definitions(-D_CRT_SECURE_NO_WARNINGS)
+endif()
+if(WIN32)
   set(LIBS version)
 endif()
 
diff --git a/libvpl/test/unit/CMakeLists.txt b/libvpl/test/unit/CMakeLists.txt
index f192036..fb8a53b 100644
--- a/libvpl/test/unit/CMakeLists.txt
+++ b/libvpl/test/unit/CMakeLists.txt
@@ -37,7 +37,7 @@ target_include_directories(
                     ${CMAKE_CURRENT_SOURCE_DIR}/../runtimes/stub)
 
 if(WIN32)
-  target_link_libraries(${TARGET} PUBLIC shlwapi.lib)
+  target_link_libraries(${TARGET} PUBLIC shlwapi)
 endif()
 
 include(GoogleTest)

4. Pinned versions and static linking

ComponentCommit / version
Sunshinedfa9e884978398f23d2f63dd987b9d554fa4d68e
build-depsa1fe2841cbc0d8c4501a1006d2d1cb88f219cc8c
FFmpegbf1b838f2ab88b4f8fd83443325c782ea0e0f7fa
oneVPL / libvpl674d015bcb294bc39fa276e99a652ea045423e82
build-deps releasev2026.910.121303
ToolchainMSYS2 UCRT64, GCC 16.2.0, CMake, Ninja
Node.jsNative Windows Node 22.23.3

Prepare separate Git working copies of Sunshine and libvpl, checking out the listed commits. Initialize Sunshine’s submodules and verify the listed build-deps and FFmpeg revisions. Read each repository’s AGENTS.md and install the prerequisites in the official Windows build instructions. Use native Windows Node for the Web UI; the MSYS2 Node/Rolldown native binding did not work in this build.

The example root is C:\Downloads\sunshine-haswell, containing Sunshine, libvpl, the two patches, and deps. FFmpeg was neither patched nor rebuilt. Download Windows-AMD64-ffmpeg.tar.gz from the pinned build-deps release, verify its SHA256 below, and extract it into deps to obtain deps/ffmpeg/include and deps/ffmpeg/lib.

1
ee79a7a295e8fbd24305a3f4ec94a06f550e0b52f49cb1c4535f649e2dd8b234

Run the following Bash steps in MSYS2 UCRT64, after installing prerequisites and pinning the sources. A saved build-haswell.sh can be launched from PowerShell as follows:

1
C:\msys64\msys2_shell.cmd -defterm -here -no-start -ucrt64 -c 'bash /c/Downloads/sunshine-haswell/build-haswell.sh'
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
set -euo pipefail
export LANG=C
cd /c/Downloads/sunshine-haswell

# patch-a.patch and patch-b.patch contain the two complete diffs in this article.
git -C libvpl apply --check ../patch-b.patch
git -C libvpl apply ../patch-b.patch
git -C libvpl apply --check ../patch-a.patch
git -C libvpl apply ../patch-a.patch

cmake -S libvpl -B libvpl/cmake-build-patched -G Ninja \
  -DCMAKE_BUILD_TYPE=Release -DBUILD_SHARED_LIBS=OFF \
  -DBUILD_TESTS=ON -DBUILD_EXAMPLES=OFF -DINSTALL_EXAMPLES=OFF \
  -DCMAKE_INSTALL_PREFIX=C:/Downloads/sunshine-haswell/vpl-patched
cmake --build libvpl/cmake-build-patched -j 3
cmake --install libvpl/cmake-build-patched

export PATH=/c/Downloads/sunshine-haswell/libvpl/cmake-build-patched:$PATH
export ONEVPL_SEARCH_PATH=C:/Downloads/sunshine-haswell/libvpl/cmake-build-patched
./libvpl/cmake-build-patched/vpl-tests.exe --gtest_filter='LegacyDeviceID.*'
./libvpl/cmake-build-patched/vpl-tests.exe -disp:stub
unset ONEVPL_SEARCH_PATH

# Put native Windows Node ahead of MSYS2 Node in PATH before building the Web UI.
cmake -S Sunshine -B Sunshine/cmake-build-haswell -G Ninja \
  -DCMAKE_BUILD_TYPE=Release -DBUILD_DOCS=OFF -DBUILD_TESTS=ON \
  -DSUNSHINE_ENABLE_TRAY=OFF \
  -DFFMPEG_PREPARED_BINARIES=C:/Downloads/sunshine-haswell/deps/ffmpeg \
  '-DFFMPEG_PLATFORM_LIBRARIES=mfplat;ole32;strmiids;mfuuid;C:/Downloads/sunshine-haswell/vpl-patched/lib/libvpl.a' \
  -DCMAKE_POLICY_VERSION_MINIMUM=3.5
cmake --build Sunshine/cmake-build-haswell --target sunshine test_sunshine -j 3
cmake --build Sunshine/cmake-build-haswell --target web-ui -j 2

# Inspect the actual link command and imported DLLs.
ninja -C Sunshine/cmake-build-haswell -t commands sunshine > sunshine-build-commands.txt
objdump -p Sunshine/cmake-build-haswell/sunshine.exe > sunshine-imports.txt

The important setting is FFMPEG_PLATFORM_LIBRARIES: rebuilding libvpl is insufficient unless Sunshine’s final link command actually references vpl-patched/lib/libvpl.a. Inspect the generated command list and import table. This build uses a static dispatcher and should not import libvpl.dll; the underlying Intel Media SDK runtime is still loaded from the system.

For machines with unreliable GitHub access, pre-download nv-codec-headers tags n11.0.10.3, n12.0.16.2, and n13.0.19.1 and set the corresponding CPM_nv_codec_headers_11_SOURCE, _12_SOURCE, and _13_SOURCE options to their local directories. These are build headers, not NVIDIA driver installations.

Create a separate portable runtime directory using this commit’s install rules, including assets, the built Web UI, application configuration templates, and required runtime libraries. A lone executable is not a complete deployment. Disabling the tray was a build choice; the Web UI remained available.

The nine new tests passed. Existing stub tests reported 717 passed, 309 skipped, and zero failures, with additional pre-existing disabled tests. Seventy relevant Sunshine tests passed. Clear the stub search environment before hardware testing so that an emulated runtime is not mistaken for the installed Intel runtime.

5. Hardware verification gates

Keep baseline and patched dispatchers and use the same dependencies for both. Ask the AI to create a small diagnostic probe and verify these stages before game testing:

  1. Enumerate the real Intel adapter and record its Vendor ID, Device ID, and LUID. Initialize a legacy hardware D3D11 session and query its API/platform information.
  2. Compare no identity filter, LUID-only, correct Device ID plus LUID, and deliberately incorrect Device ID. Recovery must not weaken rejection of the wrong device.
  3. Using the same D3D11 device with multithread protection enabled, derive a QSV device through the same FFmpeg libraries, allocate frames, encode H.264, and decode the output to verify dimensions and frame count.
  4. Confirm Found H.264 encoder: h264_qsv [quicksync] in Sunshine and verify the Intel device used during streaming. Overall GPU utilization alone is insufficient evidence.
  5. Measure dynamic Moonlight streaming separately, including sent frames, duplicates, frame intervals, and client decoding behavior.

The SHA256 of C:\Program Files\Intel\Media SDK\libmfxhw64.dll remained unchanged before and after testing:

1
77EFA168D1E632B7B41B786EEE412662AC65D168F2899B8468CD0D5835CD8814

Another machine may have a different original hash; compare its own before/after values. Do not overwrite the system runtime.

Ten-second dynamic NV12 tests at 720p30, 720p60, 1080p30, and 1080p60 all produced decodable H.264 with matching frame counts. Average upload and encode-API time was approximately 7.21 ms at 720p60 and 12.51 ms at 1080p60. However, the latter had a 26.77 ms peak and roughly 10.21 seconds of wall time for 600 frames. This does not establish sustained low-latency 1080p60 gameplay or measure end-to-end latency.

Haswell rejected initialization with low_power=1; Sunshine’s existing retry with low_power=0 worked without an additional parameter patch. Read the retry result before treating the first error as final failure. Raw H.264 frame-rate inference also reported double rates in one probe, so validate submitted/decoded counts and elapsed time rather than relying on that inferred number.

6. A phone-sized virtual display

For my phone, the correct landscape size is 1612×720, reversing the portrait dimensions. Changing the client’s stream size alone does not necessarily change the game’s display mode or the size of the captured desktop. The physical monitor rejected the desired custom mode, so I added one independent virtual display.

The tested package is Virtual Display Driver 25.7.23, using its Driver Only ZIP. No virtual audio driver was installed. Despite x86 in the filename, this package’s INF includes NTamd64 and was used on x64 Windows. ZIP SHA256:

1
e24210692b442b39af763536330ce78b423f19342b7a7792c26de3944e418b3a

Follow the project’s manual installation guide: prepare the driver and configuration in C:\VirtualDisplayDriver, then create the device through Device Manager’s Add Legacy Hardware / Have Disk flow. Staging an INF alone may not create the root-enumerated device. Record the new device instance, actual published oemXX.inf, and original display topology.

This machine initially rejected an untrusted publisher. The CAT/DLL Authenticode signatures were checked as valid, with SignPath Foundation as the signer, before trusting that publisher and updating the already-created node. Secure Boot was not disabled and test signing was not enabled. If publisher trust needs changing, verify the signer and record whether the certificate was already trusted; do not repeatedly create duplicate device nodes.

The following XML matches the 25.7.23 package’s format. Save it as C:\VirtualDisplayDriver\vdd_settings.xml and replace the GPU friendly name with your actual Intel adapter name. Newer packages may use a different schema.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
<?xml version='1.0' encoding='utf-8'?>
<vdd_settings>
    <monitors>
        <count>1</count>
    </monitors>
    <gpu>
        <friendlyname>Intel(R) HD Graphics P4600/P4700</friendlyname>
    </gpu>
    <global>
        <g_refresh_rate>60</g_refresh_rate>
    </global>
    <resolutions>
        <resolution>
            <width>1612</width>
            <height>720</height>
            <refresh_rate>30</refresh_rate>
        </resolution>
        <resolution>
            <width>720</width>
            <height>1612</height>
            <refresh_rate>30</refresh_rate>
        </resolution>
        <resolution>
            <width>1280</width>
            <height>720</height>
            <refresh_rate>30</refresh_rate>
        </resolution>
    </resolutions>
    <options>
        <CustomEdid>false</CustomEdid>
        <PreventSpoof>false</PreventSpoof>
        <EdidCeaOverride>false</EdidCeaOverride>
        <HardwareCursor>true</HardwareCursor>
        <SDR10bit>false</SDR10bit>
        <HDRPlus>false</HDRPlus>
        <logging>false</logging>
        <debuglogging>false</debuglogging>
    </options>
</vdd_settings>

One monitor is created. For this version, the global 60 Hz setting supplements each mode’s 30 Hz entry, producing actual 30/60 Hz choices. After installation or a version-appropriate reload, verify that Windows really applies 1612×720 at 60 Hz. Keep the physical monitor and initially test an extended desktop.

The working Sunshine settings were:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
encoder = quicksync
hevc_mode = 1
av1_mode = 1
qsv_preset = veryfast
qsv_coder = cavlc
adapter_name = Intel(R) HD Graphics P4600/P4700
capture = wgc
dd_configuration_option = ensure_primary
dd_resolution_option = auto
dd_refresh_rate_option = auto
dd_config_revert_on_disconnect = enabled
dd_hdr_option = disabled
min_log_level = info

Also set output_name to the virtual display’s actual device_id from Sunshine’s startup log, using output_name = {actual UUID} with a real value replacing the placeholder. Do not reuse another computer’s UUID or rely on DISPLAY3 as a stable identifier. On this pinned version, hevc_mode=1 and av1_mode=1 disable advertising those codec capabilities. Refer to the configuration documentation and the pinned source for option semantics.

This setup uses WGC capture with portable Sunshine in a logged-in, unlocked local-console desktop. The Sunshine feature matrix distinguishes WGC support between portable and service operation. Manage the machine through SSH, but launch Sunshine in the interactive desktop. Avoid RDP changing the display/session during measurements and run only one Sunshine instance.

Configure Moonlight for H.264, SDR, custom 1612×720, 60 FPS, initially 8 Mbps, and enable Optimize game settings so this Sunshine version receives the client flag permitting display-mode adjustments. Make the virtual display primary during streaming and run the game on it at the intended resolution. ensure_primary preserves the physical output; verify that the configured disconnect restoration actually restores the expected layout.

An extra unnamed output was already present before VDD installation on this machine. Disconnecting that specific pre-existing target left the physical monitor and one virtual display. This is not a universal cleanup step: inspect and back up the topology before removing anything, and never copy a different machine’s display target ID.

7. What changed when the stream reached 60 FPS?

Initially the phone showed roughly 20–30 FPS and about 58 ms decoding time on its Unisoc H.264 decoder. An iPad also stayed around 30–40 FPS. Disabling game VSync did not solve it; WGC improved performance somewhat, and the virtual display brought the final improvement.

The phone decoder may have contributed, but the comparisons show a host-side problem was also plausible. Low total CPU/GPU utilization does not rule out stalls in capture, synchronization, copying, or composition.

The last continuous two-minute window of the final session showed:

MetricLast 120 seconds
Frames sent7,199
Send rate59.9858 FPS
Rate excluding duplicate pictures59.9608 FPS
Mean frame interval16.6706 ms
P95 / P99 frame interval18 / 18 ms
Maximum interval47 ms
Gaps in sent frame indices0

The approximately eleven-minute session as a whole averaged 54.36 sent FPS. The near-60 result applies to the final two-minute window, not every moment of the session. Continuous server-side indices do not prove absence of network loss, client decoding drops, or audio problems.

To reproduce the measurement, temporarily set min_log_level = verbose and parse Sent Frame seq [...]. Deduplicate frame indices because one frame can produce multiple FEC-block log entries. Count Dupe pictures separately. For N unique frames, calculate cadence as (N-1)/(last timestamp-first timestamp) and inspect adjacent timestamp differences; return logging to info afterward.

The captured source shrank from 1920×1200 = 2,304,000 pixels to 1612×720 = 1,160,640, approximately 49.6% fewer pixels. Matching source and stream size reduces the need for spatial scaling, though RGB→NV12 conversion remains necessary.

The independent 60 Hz output and removal of the old unnamed target changed at the same time. Their individual contributions were not isolated. A smaller capture source and a more suitable display path are plausible explanations, not a proven single cause, and this does not demonstrate that all cross-GPU copying disappeared.

The physical display can remain at 1920×1200 with its own desktop. The game outputs to the virtual display; its internal rendering resolution still depends on game settings. Windows’ total desktop pixel count need not decrease. What became smaller was the output Sunshine captures and encodes.

8. A replication prompt for an AI coding agent

Provide this prompt together with the entire article to an agent with terminal, source-code, and Windows access:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
Reproduce this article's Haswell QSV H.264 Sunshine setup:
discrete GPU renders games, Intel iGPU encodes, phone uses its native landscape size.

Read repository AGENTS.md files. Inspect OS, CPU, actual GPU PCI identities and
LUIDs, drivers, legacy Media SDK runtime, display topology, and phone dimensions.
Never assume Intel is adapter 0. Do not upgrade existing Intel/NVIDIA drivers,
overwrite system driver DLLs, enable test signing, or disable Secure Boot.
Create an isolated test directory and save original Sunshine/configuration,
display topology, and runtime DLL hashes.

First verify this exact root cause: direct legacy hardware D3D11 initialization
works, QueryPlatform reports DeviceId=0, and adding the correct Device ID to a
working LUID-only oneVPL selection fails with -9. If not, investigate the actual
cause instead of blindly applying the patch.
Use the pinned versions. Build baseline with Patch B only if needed, and patched
with Patch B plus Patch A. Preserve rejection of wrong IDs/LUIDs and known IDs.
Run the nine new tests and existing stub tests; clear stub environment variables.
Compare hardware probes and D3D11→QSV→H.264→decode using identical FFmpeg libraries.
Relink Sunshine and prove the final command uses patched libvpl.a.
Package the Web UI/assets/runtime dependencies into a separate portable directory.
Run only one instance and verify h264_qsv plus the actual Intel device identity.

After encoding works, add one VDD device using the pinned package. Verify hash,
signature, and INF architecture; record added device/INF/trust and keep the
physical display. Configure the real Intel name and phone landscape dimensions.
Verify the actual 60 Hz mode, use the virtual display's real device_id for
output_name, and run WGC in an interactive desktop. Set Moonlight to H.264,
SDR, native size, 60 FPS, initially 8 Mbps, with Optimize game settings enabled.
Run the game on that display and verify rendering versus encoding GPU identities.
Investigate extra outputs before proposing any targeted, reversible cleanup.

Measure at least two minutes of dynamic gameplay: sent FPS, duplicate pictures,
frame intervals, client experience, and audio. Test 720p30/60 and 1080p30/60;
report incomplete soak tests honestly. Verify reconnects, primary-display
restoration, mode changes, and sleep/resume. Do not treat a short encode test,
a static desktop, or low utilization as proof of sustained 60 FPS.
Before changing existing drivers, losing physical output, or trading away other
features, present the concrete action and rollback for my confirmation.
Deliver patches, pinned versions, build commands, sanitized logs, and rollback.

9. Rollback and remaining limits

Stop the new portable Sunshine and restore the original program/configuration to roll back the encoding experiment. Disable only the newly added VDD device and restore the recorded primary monitor and layout. For full removal, identify your own device instance and published INF, removing only the added VDD package and any publisher trust newly introduced for it. Keep physical output and a remote-management route. No Intel/NVIDIA driver replacement is required for this rollback.

MPO, process priority, and power settings were explored during troubleshooting, but none was established as necessary for the working recipe. Adding the VDD does not replace the 30HX driver, though its topology changes still require local validation.

Only H.264, 8-bit, 4:2:0, SDR was validated. HEVC, AV1, and HDR were not. Portrait 720×1612 passed a short encode/decode test but not end-to-end portrait gameplay. Full ten-minute soaks at all four modes, sleep/resume, every display-restoration scenario, and final audio acceptance remain incomplete. Previous intermittent audio/video stutter should not be described as conclusively resolved.

The useful result is a working path on this machine: 30HX renders the game, the Haswell iGPU encodes it, and a phone-sized virtual display delivers a measured near-60-FPS window. The complete patches and verification gates make that result something another person can actually investigate and reproduce.