验证:补充2K刷新率与单显示器验收

This commit is contained in:
宋一鸣 2026-11-03 17:48:21 +08:00
parent a98d9ddeee
commit b29b59c62f
21 changed files with 1195 additions and 4 deletions

View File

@ -0,0 +1,55 @@
# GPU修复后刷新率与鼠标画面优化验收(2026-11-03)
## 结论
Windows虚拟屏已调整为2560×1440、60Hz,登录脚本已备份并更新;客户端默认已设为45FPS,并通过正常选择桌面入口重连。当前盒子的Xorg/EGL路径在请求60FPS时实际只能渲染约46FPS,因此不宣称稳定2K60。45FPS短测实际44.22FPS,持续测试43.14FPS;相对30FPS基线29.83FPS提高约45%~48%,排队约1.3~1.6ms。
## GPU复核
本轮实际查询NVIDIA错误码0,驱动582.78,vBIOS 84.04.2b.00.13。动态串流期间Sunshine实际创建h264_nvenc,NVIDIA有1个编码会话,检查时编码引擎约24%。运维报告的重启测试作为其提供的验收记录,本轮未自行重启VM、宿主或GPU,也未修改直通和ROM配置。
## 同一修正测试源对照
修正后的Windows测试窗口实测约59FPS,避免了原WinForms定时器将源限制到约32FPS的问题。所有测试保持2560×1440、H.264、30Mbps、RKMPP硬解;默认客户端为已有两个输入补丁的版本,第三补丁调度未启用。除同步实验外,均关闭VSync与帧步调。
| 档位 | 接收/渲染FPS | 主机处理平均 | 解码平均 | 帧队列平均 | 客户端队列丢弃指标 |
| --- | --- | --- | --- | --- | --- |
| VDD30 / 客户端30 | 29.95 / 29.83 | 7.5ms | 6.81ms | 0.60ms | 0.41% |
| VDD60 / 客户端60、最新帧队列 | 58.82 / 46.11 | 7.5ms | 7.37ms | 7.54ms | 21.61% |
| VDD60 / 客户端60、原队列 | 58.81 / 46.10 | 7.6ms | 7.13ms | 36.13ms | 10.28% |
| VDD60 / 客户端60、精确尺寸nearest实验 | 58.20 / 42.01 | 7.5ms | 6.94ms | 7.66ms | 27.77% |
| VDD60 / 客户端60、VSync开/帧步调关 | 57.88 / 24.39 | 7.5ms | 7.26ms | 8.12ms | 57.80% |
| VDD60 / 客户端45、最新帧队列 | 44.84 / 44.22 | 7.6ms | 8.54ms | 1.30ms | 1.38% |
| VDD60 / 客户端45、持续测试 | 43.98 / 43.14 | 7.6ms | 7.32ms | 1.55ms | 1.91% |
上表“客户端队列丢弃指标”来自上游标成`Frames dropped due to network jitter`的统计,但实际字段是`pacerDroppedFrames`,本项目最新帧队列也计入其中。不能把它全部归因于网络,原队列溢出统计也并不完整;应结合接收/解码/渲染FPS及帧队列时间评估。上述会话真正的网络丢帧字段均为0%,网络延迟约2~7ms;这些是现场顺序测试,不是严格实验室基准。
60FPS会话采样盒子图形核心约89%~95%负载、800MHz;DDR频率已在786MHz最高公布档位,MATE合成器关闭。测试温度约62~65℃,没有通过提高频率/电压或更改CPU/GPU调速器来制造结果。原队列没有提高帧率却增加排队,nearest采样和VSync实验均未显示收益,未部署。
nearest实验保留在`terminal/moonlight/experiments/0004-vdi-egl-exact-size-sampling.patch`供复查,已从盒子正常源码撤回,不进入默认构建流程。正常二进制SHA256仍为`35de7b1a4ade583823b23b5f2e012307f6d0a3848c8eb97bd6d4de9660afa00f`。
## 配置和回退
- Windows实际活动VDD编号已变为DISPLAY3,工具动态查找,未写死设备号。旧DISPLAY2备份不能直接作为新设备的恢复依据,因此探测器增加`--backup-tag`区分验收批次。
- `tools/set-2k-display.ps1`默认60Hz,支持`-RefreshRate 30`回退;已更新原VDISet2KDisplay登录任务使用的脚本,未修改任务动作或用户。
- Windows部署前原脚本和任务XML备份:`C:\ProgramData\VDI\backups\display-refresh-20261103-142920`。部署工具按本次新生成的结果日志和任务退出码确认成功,随后独立Query确认60Hz。本轮没有重启验证登录触发。
- `tools/run-mouse-display-probe.py Apply --hz 30 --backup-tag nvenc-20261103`可临时恢复30Hz;要保持30Hz应恢复原登录脚本,或将登录任务脚本参数设为`-RefreshRate 30`。
- `tools/set-terminal-fps.py --fps 45 --apply`只更改盒子Windows桌面票据FPS,自动备份和原子写入,不更改其他桌面;回退用`--fps 30 --apply`。
部署工具前两次因Windows任务调度器LastRunTime与墙上时钟不同步而误判成功任务为失败,已自动恢复旧脚本;随后改为新结果日志证明执行新鲜度,部署成功。受配置切换影响的`nvenc45-sustained`不纳入持续测试结论,重新执行`nvenc45-sustained-final`。
## 持续测试和现场收尾
`nvenc45-sustained-final`在视频就绪后注入20秒鼠标移动和32次点击,再持续120秒,正常退出。接收43.98/渲染43.14FPS,主机平均7.6ms、解码7.32ms、排队1.55ms、呈现1.55ms,实际网络丢帧0%。这是约两分半钟的动态会话结果,不是长期稳定性保证。
三组Windows点击记录共256次左右交替点击,脚本校验全部符合预期,包含被排除的持续性能试验中的32次;点击正确性可核验,但其帧率数据不纳入持续性能结论。修正测试源在保存的状态中约59.4~59.8FPS,足够进行本轮对照。
已执行`set-terminal-fps.py --fps 45 --apply`,原30帧票据备份于盒子`~/vdi-terminal/backups/fps-20261103-143333-297521/desktops.json`。只改变Windows卡片FPS,其余桌面、分辨率和编码配置保持原值。
已停止并删除VDIInputLatencyBench临时任务;最终Windows查询确认`benchmark_task_present=False`。通过正常kiosk页面点击Windows连接,实际新进程PID31694带`--resolution 2560x1440 --fps 45`,RKMPP/DRM PRIME/EGL路径正常。再次查询错误码0、NVENC会话1、编码占用18%,Sunshine本次实际创建h264_nvenc。显示守护active,正常客户端二进制未换成实验版本。
证据目录:`docs/evidence/2k60-20261103/`,包含逐会话日志、点击记录、最终GPU/编码查询和页面截图。实际拖窗与鼠标的主观体验仍需用户现场确认;本轮自动点击和统计不能代替物理输入到屏幕测量。
## 后续边界
独立光标尚未实现,仍保留单个Windows远程白色光标。下一阶段要进一步达到稳定2K60,需要验证更低开销的显示路径及正式Weston/Wayland架构,或评估终端硬件;当前不能通过提高FPS参数或安装所谓流畅插件绕过呈现瓶颈。独立光标双方协议与单光标交接设计见`docs/independent-cursor-design-20261103.md`。本轮未测量物理输入到屏幕的P50/P95,不能称零延迟。

View File

@ -0,0 +1,199 @@
width=2560 height=1440 paints=25330 elapsed_ms=425839 clicks=96
ms,x,y,target
42057,639,600,L
42058,1920,600,R
42093,639,600,L
42094,1920,600,R
42095,639,600,L
42096,1920,600,R
42097,639,600,L
42098,1920,600,R
42099,639,600,L
42100,1920,600,R
42101,639,600,L
42102,1920,600,R
42103,639,600,L
42104,1920,600,R
42105,639,600,L
42106,1920,600,R
42107,639,600,L
42141,1920,600,R
42142,639,600,L
42143,1920,600,R
42144,639,600,L
42145,1920,600,R
42147,639,600,L
42148,1920,600,R
42148,639,600,L
42149,1920,600,R
42150,639,600,L
42151,1920,600,R
42152,639,600,L
42153,1920,600,R
42154,639,600,L
42154,1920,600,R
112701,639,600,L
112702,1920,600,R
112704,639,600,L
112706,1920,600,R
112707,639,600,L
112709,1920,600,R
112711,639,600,L
112712,1920,600,R
112732,639,600,L
112733,1920,600,R
112735,639,600,L
112736,1920,600,R
112737,639,600,L
112759,1920,600,R
112762,639,600,L
112763,1920,600,R
112765,639,600,L
112766,1920,600,R
112768,639,600,L
112769,1920,600,R
112788,639,600,L
112790,1920,600,R
112791,639,600,L
112792,1920,600,R
112793,639,600,L
112815,1920,600,R
112818,639,600,L
112820,1920,600,R
112822,639,600,L
112823,1920,600,R
112824,639,600,L
112825,1920,600,R
302705,639,600,L
302706,1920,600,R
302707,639,600,L
302710,1920,600,R
302711,639,600,L
302712,1920,600,R
302713,639,600,L
302732,1920,600,R
302733,639,600,L
302734,1920,600,R
302735,639,600,L
302754,1920,600,R
302777,639,600,L
302778,1920,600,R
302779,639,600,L
302780,1920,600,R
302782,639,600,L
302783,1920,600,R
302784,639,600,L
302785,1920,600,R
302786,639,600,L
302805,1920,600,R
302809,639,600,L
302812,1920,600,R
302813,639,600,L
302814,1920,600,R
302815,639,600,L
302817,1920,600,R
302819,639,600,L
302820,1920,600,R
302821,639,600,L
302838,1920,600,R
{"LastTaskResult":267009}

View File

@ -0,0 +1,199 @@
width=2560 height=1440 paints=29513 elapsed_ms=493665 clicks=96
ms,x,y,target
145117,639,600,L
145118,1920,600,R
145122,639,600,L
145123,1920,600,R
145144,639,600,L
145145,1920,600,R
145148,639,600,L
145150,1920,600,R
145151,639,600,L
145153,1920,600,R
145175,639,600,L
145180,1920,600,R
145183,639,600,L
145185,1920,600,R
145186,639,600,L
145188,1920,600,R
145190,639,600,L
145191,1920,600,R
145193,639,600,L
145213,1920,600,R
145215,639,600,L
145217,1920,600,R
145219,639,600,L
145220,1920,600,R
145221,639,600,L
145223,1920,600,R
145225,639,600,L
145245,1920,600,R
145246,639,600,L
145247,1920,600,R
145248,639,600,L
145250,1920,600,R
324992,639,600,L
324993,1920,600,R
324994,639,600,L
324995,1920,600,R
325013,639,600,L
325015,1920,600,R
325016,639,600,L
325017,1920,600,R
325017,639,600,L
325034,1920,600,R
325038,639,600,L
325040,1920,600,R
325042,639,600,L
325044,1920,600,R
325045,639,600,L
325047,1920,600,R
325065,639,600,L
325067,1920,600,R
325069,639,600,L
325070,1920,600,R
325072,639,600,L
325074,1920,600,R
325075,639,600,L
325092,1920,600,R
325095,639,600,L
325097,1920,600,R
325100,639,600,L
325102,1920,600,R
325103,639,600,L
325104,1920,600,R
325121,639,600,L
325123,1920,600,R
434053,639,600,L
434055,1920,600,R
434058,639,600,L
434061,1920,600,R
434063,639,600,L
434063,1920,600,R
434079,639,600,L
434081,1920,600,R
434084,639,600,L
434085,1920,600,R
434086,639,600,L
434088,1920,600,R
434089,639,600,L
434105,1920,600,R
434108,639,600,L
434109,1920,600,R
434110,639,600,L
434112,1920,600,R
434129,639,600,L
434131,1920,600,R
434132,639,600,L
434148,1920,600,R
434152,639,600,L
434155,1920,600,R
434157,639,600,L
434158,1920,600,R
434160,639,600,L
434162,1920,600,R
434163,639,600,L
434164,1920,600,R
434166,639,600,L
434167,1920,600,R
{"LastTaskResult":267009}

View File

@ -0,0 +1,135 @@
width=2560 height=1440 paints=24646 elapsed_ms=412170 clicks=64
ms,x,y,target
145117,639,600,L
145118,1920,600,R
145122,639,600,L
145123,1920,600,R
145144,639,600,L
145145,1920,600,R
145148,639,600,L
145150,1920,600,R
145151,639,600,L
145153,1920,600,R
145175,639,600,L
145180,1920,600,R
145183,639,600,L
145185,1920,600,R
145186,639,600,L
145188,1920,600,R
145190,639,600,L
145191,1920,600,R
145193,639,600,L
145213,1920,600,R
145215,639,600,L
145217,1920,600,R
145219,639,600,L
145220,1920,600,R
145221,639,600,L
145223,1920,600,R
145225,639,600,L
145245,1920,600,R
145246,639,600,L
145247,1920,600,R
145248,639,600,L
145250,1920,600,R
324992,639,600,L
324993,1920,600,R
324994,639,600,L
324995,1920,600,R
325013,639,600,L
325015,1920,600,R
325016,639,600,L
325017,1920,600,R
325017,639,600,L
325034,1920,600,R
325038,639,600,L
325040,1920,600,R
325042,639,600,L
325044,1920,600,R
325045,639,600,L
325047,1920,600,R
325065,639,600,L
325067,1920,600,R
325069,639,600,L
325070,1920,600,R
325072,639,600,L
325074,1920,600,R
325075,639,600,L
325092,1920,600,R
325095,639,600,L
325097,1920,600,R
325100,639,600,L
325102,1920,600,R
325103,639,600,L
325104,1920,600,R
325121,639,600,L
325123,1920,600,R
{"LastTaskResult":267009}

View File

@ -0,0 +1,135 @@
width=2560 height=1440 paints=26163 elapsed_ms=440146 clicks=64
ms,x,y,target
36986,639,600,L
36987,1920,600,R
36989,639,600,L
37005,1920,600,R
37023,639,600,L
37026,1920,600,R
37030,639,600,L
37031,1920,600,R
37033,639,600,L
37034,1920,600,R
37036,639,600,L
37037,1920,600,R
37037,639,600,L
37038,1920,600,R
37056,639,600,L
37058,1920,600,R
37060,639,600,L
37062,1920,600,R
37082,639,600,L
37084,1920,600,R
37085,639,600,L
37086,1920,600,R
37087,639,600,L
37088,1920,600,R
37106,639,600,L
37110,1920,600,R
37112,639,600,L
37113,1920,600,R
37114,639,600,L
37114,1920,600,R
37115,639,600,L
37116,1920,600,R
264310,639,600,L
264311,1920,600,R
264314,639,600,L
264315,1920,600,R
264316,639,600,L
264317,1920,600,R
264334,639,600,L
264337,1920,600,R
264338,639,600,L
264339,1920,600,R
264340,639,600,L
264358,1920,600,R
264361,639,600,L
264362,1920,600,R
264363,639,600,L
264364,1920,600,R
264368,639,600,L
264370,1920,600,R
264388,639,600,L
264392,1920,600,R
264393,639,600,L
264394,1920,600,R
264396,639,600,L
264415,1920,600,R
264418,639,600,L
264420,1920,600,R
264422,639,600,L
264424,1920,600,R
264425,639,600,L
264427,1920,600,R
264428,639,600,L
264429,1920,600,R
{"LastTaskResult":267009}

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

View File

@ -0,0 +1,11 @@
{"Name":"NVIDIA GeForce GTX 965M","ConfigManagerErrorCode":0}
NVIDIA GeForce GTX 965M, 84.04.2b.00.13, 1, 18 %
Capture size : 2560x1440
[2026-11-03 14:34:22.850]: Info: Requested frame rate [45fps]
[2026-11-03 14:34:22.851]: Info: Creating encoder [h264_nvenc]
2026-11-03T14:29:21.7186993+08:00 device=\\.\DISPLAY3 result=0 mode=2560x1440@60
benchmark_task_present=False

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

View File

@ -0,0 +1,19 @@
Stopped scoped encoder-session monitor
[{"Name":"Microsoft Basic Display Driver","ConfigManagerErrorCode":0},{"Name":"Virtual Display Driver","ConfigManagerErrorCode":0},{"Name":"NVIDIA GeForce GTX 965M","ConfigManagerErrorCode":0},{"Name":"Microsoft Basic Display Adapter","ConfigManagerErrorCode":0}]
NVIDIA GeForce GTX 965M, 582.78, 10 %
[2026-11-03 16:57:40.500]: Error: Couldn't get default audio endpoint [0x80070490]
[2026-11-03 16:57:40.500]: Error: Unable to initialize audio capture. The stream will not have audio.
Capture size : 2560x1440
[2026-11-03 16:57:41.165]: Info: Desktop resolution [2560x1440]
[2026-11-03 16:57:41.165]: Info: Display refresh rate [60Hz]
[2026-11-03 16:57:41.166]: Info: Creating encoder [h264_nvenc]
Capture size : 2560x1440
[2026-11-03 17:14:13.305]: Info: Desktop resolution [2560x1440]
[2026-11-03 17:14:13.306]: Info: Display refresh rate [60Hz]
[2026-11-03 17:14:13.308]: Info: Creating encoder [h264_nvenc]
probe_task_present=False
backup_exists=True

View File

@ -0,0 +1,7 @@
[{"width":0,"height":0,"description":"Microsoft Basic Display Adapter","primary":false,"active":false,"y":0,"hz":0,"device":"\\\\.\\DISPLAY1","x":0},{"width":0,"height":0,"description":"NVIDIA GeForce GTX 965M","primary":false,"active":false,"y":0,"hz":0,"device":"\\\\.\\DISPLAY2","x":0},{"width":2560,"height":1440,"description":"Virtual Display Driver","primary":true,"active":true,"y":0,"hz":60,"device":"\\\\.\\DISPLAY4","x":0}]
task_result=0

View File

@ -0,0 +1,12 @@
Screen 0: minimum 320 x 200, current 2560 x 1440, maximum 16384 x 16384
HDMI-1 connected primary 2560x1440+0+0 (normal left inverted right x axis y axis) 597mm x 336mm
1920x1440 75.00
2048x1152 60.00
1920x1080 100.00 60.00 60.00 50.00 59.94
1920x1080i 60.00 50.00 59.94
1600x1200 60.00
1680x1050 59.88
1600x900 60.00
1280x1024 60.02
46934 /home/armbian/vdi-build/rkmpp-experiment/build/moonlight/app/moonlight stream 192.168.1.70 Desktop --resolution 2560x1440 --fps 45 --bitrate 30000 --display-mode fullscreen --video-codec H.264 --video-decoder hardware --absolute-mouse --keep-awake --no-vsync --no-frame-pacing --audio-on-host
active

View File

@ -0,0 +1,7 @@
[{"width":0,"height":0,"description":"Microsoft Basic Display Adapter","primary":false,"active":false,"y":0,"hz":0,"device":"\\\\.\\DISPLAY1","x":0},{"width":0,"height":0,"description":"NVIDIA GeForce GTX 965M","primary":false,"active":false,"y":0,"hz":0,"device":"\\\\.\\DISPLAY2","x":0},{"width":2560,"height":1440,"description":"Virtual Display Driver","primary":true,"active":true,"y":0,"hz":60,"device":"\\\\.\\DISPLAY4","x":0}]
task_result=0

View File

@ -0,0 +1,7 @@
[{"width":2560,"height":1440,"description":"Microsoft Basic Display Adapter","primary":false,"active":true,"y":1440,"hz":64,"device":"\\\\.\\DISPLAY1","x":-1024},{"width":0,"height":0,"description":"NVIDIA GeForce GTX 965M","primary":false,"active":false,"y":0,"hz":0,"device":"\\\\.\\DISPLAY2","x":0},{"width":2560,"height":1440,"description":"Virtual Display Driver","primary":true,"active":true,"y":0,"hz":60,"device":"\\\\.\\DISPLAY4","x":0}]
task_result=0

View File

@ -0,0 +1,35 @@
# Windows仅保留2K主屏(2026-11-03)
用户条件:只有在不影响当前2K屏幕使用时才调整。已完成并读回:Windows仅一个活动桌面输出,保留Virtual Display Driver主屏2560×1440@60,盒子原Moonlight进程46934继续2K45/RKMPP/EGL,截图正常。GTX965M错误码0,Sunshine布局变化后仍创建h264_nvenc,最终编码占用10%。没有重启Windows/盒子、禁用显卡设备、卸载驱动或修改PVE/GPU直通。
## 调整前后
| 输出 | 原布局 | 现布局 |
| --- | --- | --- |
| DISPLAY4 / Virtual Display Driver | 主屏,2560×1440@60,坐标0,0 | 保留原模式和主屏身份,唯一活动输出 |
| DISPLAY1 / Microsoft Basic Display Adapter | 扩展屏,2560×1440@64,坐标-1024,1440 | 从Windows桌面布局停用 |
| DISPLAY2 / NVIDIA GTX965M直接输出 | 原本未激活 | 保持未激活;GPU本身仍正常用于硬件编码 |
这里的“停用”仅指显示输出不参与Windows扩展桌面。设备管理器仍保留显示适配器,Windows设置也可能列出未连接的输出,不能将此操作说成卸载了多余显卡或移除了所有显示设备条目。
本轮VDD编号已是DISPLAY4,早先DISPLAY3记录不能用于写死设备号。工具按唯一活动VDD动态选择,并在修改前强制核对它已是2560×1440@60主屏,不满足条件不修改。
## 实现与验证
新增`tools/set-single-vdi-display.ps1`,Query只查询;Apply先保存设备身份、模式、主屏、布局为XML,使用ChangeDisplaySettingsEx逐项暂存,最后一次提交停用其他活动输出。申请为当前用户保存布局;不添加持续守护或额外登录任务。读回若不是唯一VDD主屏或模式改变,立即尝试恢复本次原布局。已有备份拒绝覆盖。
新增`tools/run-single-vdi-display.py`,通过已有VM100代理,在当前已登录用户会话运行一次性任务。任务已删除,最终`probe_task_present=False`。初次Query编译因缺少System.Xml引用失败,未执行显示修改;补齐引用后查询、应用、独立再次查询均成功。
当前状态已经验证;未通过重启/重新登录检验持久性,以避免中断用户使用。原2K登录脚本仍动态选择VDD,未改分辨率策略。盒子原配置、显示守护和串流参数未修改。
复核时误使用了会持续运行的`nvidia-smi encodersessions`,读取超时后已按进程及父命令精确停止本次监视子进程,再以一次性查询确认GPU健康;没有终止Sunshine或用户应用。Sunshine日志中的16:57音频端点错误早于本次显示调整,不是本次产生的新故障。
## 回退和证据
Windows原布局备份:`C:\ProgramData\VDI\single-display-20261103\before.xml`。
如需恢复,使用`python tools/run-single-vdi-display.py Restore`。恢复前核对显示设备名称、ID和注册表Key,身份变更时拒绝套用旧布局;本轮没有为演示回退再次打断当前单屏状态。
证据目录`docs/evidence/single-display-20261103/`包含before、apply、独立after-query、显卡/编码健康查询及实际2560×1440桌面截图。
API依据:[Microsoft ChangeDisplaySettingsEx](https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-changedisplaysettingsexw)。

View File

@ -0,0 +1,49 @@
--- a/app/streaming/video/ffmpeg-renderers/eglvid.h
+++ b/app/streaming/video/ffmpeg-renderers/eglvid.h
@@ -57,6 +57,8 @@
int m_GlesMajorVersion;
int m_GlesMinorVersion;
bool m_HasExtUnpackSubimage;
+ bool m_VdiNearestOneToOne;
+ int m_VdiTextureFilter;
#define NV12_PARAM_YUVMAT 0
#define NV12_PARAM_OFFSET 1
--- a/app/streaming/video/ffmpeg-renderers/eglvid.cpp
+++ b/app/streaming/video/ffmpeg-renderers/eglvid.cpp
@@ -75,7 +75,9 @@
m_eglClientWaitSync(nullptr),
m_GlesMajorVersion(0),
m_GlesMinorVersion(0),
- m_HasExtUnpackSubimage(false)
+ m_HasExtUnpackSubimage(false),
+ m_VdiNearestOneToOne(qEnvironmentVariableIntValue("VDI_EGL_NEAREST_1TO1") == 1),
+ m_VdiTextureFilter(GL_LINEAR)
{
SDL_assert(backendRenderer);
SDL_assert(backendRenderer->canExportEGL());
@@ -812,6 +814,24 @@
dst.w = drawableWidth;
dst.h = drawableHeight;
StreamUtils::scaleSourceToDestinationSurface(&src, &dst);
+
+ // At exact pixel size no resampling is needed. Keep linear filtering for
+ // scaled video and all other backends. Never change overlay filtering.
+ if (m_VdiNearestOneToOne && m_EGLImagePixelFormat == AV_PIX_FMT_DRM_PRIME) {
+ const int filter = dst.w == frame->width && dst.h == frame->height ? GL_NEAREST : GL_LINEAR;
+ if (filter != m_VdiTextureFilter) {
+ for (ssize_t i = 0; i < plane_count; ++i) {
+ glActiveTexture(GL_TEXTURE0 + i);
+ glBindTexture(GL_TEXTURE_EXTERNAL_OES, m_Textures[i]);
+ glTexParameteri(GL_TEXTURE_EXTERNAL_OES, GL_TEXTURE_MIN_FILTER, filter);
+ glTexParameteri(GL_TEXTURE_EXTERNAL_OES, GL_TEXTURE_MAG_FILTER, filter);
+ }
+ m_VdiTextureFilter = filter;
+ EGL_LOG(Info, "VDI video texture filter: %s (%dx%d -> %dx%d)",
+ filter == GL_NEAREST ? "nearest" : "linear",
+ frame->width, frame->height, dst.w, dst.h);
+ }
+ }
glViewport(dst.x, dst.y, dst.w, dst.h);
glUseProgram(m_ShaderProgram);

View File

@ -0,0 +1,75 @@
"""Back up and deploy the VDD login refresh script in VM100, without rebooting."""
import argparse
import base64
from pathlib import Path
import runpy
import sys
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
parser = argparse.ArgumentParser(description=__doc__)
parser.parse_args()
root = Path(__file__).resolve().parent
term = runpy.run_path(str(root / "pve_term_exec.py"))
vm = runpy.run_path(str(root / "pve_vm.py"))
ticket, csrf = term["login"]()
state = vm["api"]("/api2/json/nodes/starhe/qemu/100/status/current", ticket=ticket)
if state.get("status") != "running":
sys.exit("VM100 is not running; nothing changed")
payload = base64.b64encode((root / "set-2k-display.ps1").read_bytes()).decode()
command = r"""
$ErrorActionPreference='Stop'
$target='C:\ProgramData\VDI\set-2k-display.ps1'
$task='VDISet2KDisplay'
$entry=Get-ScheduledTask -TaskName $task
if($entry.State -eq 'Running') { throw 'Display task is already running; inspect before deployment' }
if(-not (Test-Path -LiteralPath $target)) { throw 'Original display script is missing' }
if(($entry.Actions | Select-Object -First 1).Arguments -notlike '*set-2k-display.ps1*') {
throw 'Unexpected display task action; do not change it'
}
$backup='C:\ProgramData\VDI\backups\display-refresh-'+(Get-Date -Format 'yyyyMMdd-HHmmss')
New-Item -ItemType Directory -Path $backup | Out-Null
Copy-Item -LiteralPath $target -Destination "$backup\set-2k-display.ps1"
Export-ScheduledTask -TaskName $task | Set-Content -Encoding UTF8 "$backup\VDISet2KDisplay.xml"
if(Test-Path 'C:\ProgramData\VDI\set-2k-result.txt') {
Copy-Item 'C:\ProgramData\VDI\set-2k-result.txt' "$backup\previous-result.txt"
}
$temporary=$target+'.new'
try {
[IO.File]::WriteAllBytes($temporary,[Convert]::FromBase64String('__PAYLOAD__'))
Move-Item -LiteralPath $temporary -Destination $target -Force
# Prove freshness by a newly created result file; scheduler LastRunTime can
# lag wall-clock time after VM clock correction and is not a reliable token.
Remove-Item -LiteralPath 'C:\ProgramData\VDI\set-2k-result.txt' -ErrorAction SilentlyContinue
Start-ScheduledTask -TaskName $task
Start-Sleep -Seconds 2
for($i=0;$i -lt 20;$i++) {
if((Get-ScheduledTask -TaskName $task).State -ne 'Running') { break }
Start-Sleep -Seconds 1
}
$info=Get-ScheduledTaskInfo -TaskName $task
if((Get-ScheduledTask -TaskName $task).State -eq 'Running' -or
$info.LastTaskResult -ne 0) {
throw 'New display task did not complete successfully'
}
$log=Get-Content 'C:\ProgramData\VDI\set-2k-result.txt' -Raw
if($log -notmatch 'result=0 mode=2560x1440@60') { throw '60Hz read-back missing in display task log' }
@{backup=$backup; task_result=$info.LastTaskResult; result=$log.Trim()} | ConvertTo-Json -Compress
} catch {
if(Test-Path 'C:\ProgramData\VDI\set-2k-result.txt') {
Copy-Item 'C:\ProgramData\VDI\set-2k-result.txt' "$backup\failed-result.txt"
Get-Content "$backup\failed-result.txt"
}
if($info) { $info | Select-Object LastTaskResult,LastRunTime | ConvertTo-Json -Compress }
Stop-ScheduledTask -TaskName $task -ErrorAction SilentlyContinue
Copy-Item -LiteralPath "$backup\set-2k-display.ps1" -Destination $target -Force
Start-ScheduledTask -TaskName $task
throw
} finally {
Remove-Item -LiteralPath $temporary -ErrorAction SilentlyContinue
}
""".replace("__PAYLOAD__", payload)
out, error = vm["agent"](ticket, csrf, ["powershell.exe", "-NoProfile", "-Command", command])
print(out)
if error or '"task_result":0' not in out:
print(error, file=sys.stderr)
sys.exit(1)

View File

@ -0,0 +1,55 @@
"""Query or detach extra Windows desktop outputs in VM100's interactive session."""
import argparse
import base64
from pathlib import Path
import runpy
import sys
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument("action", choices=["Query","Apply","Restore"])
args=parser.parse_args()
root=Path(__file__).resolve().parent
t=runpy.run_path(str(root/"pve_term_exec.py")); v=runpy.run_path(str(root/"pve_vm.py"))
ticket,csrf=t["login"]()
if v["api"]("/api2/json/nodes/starhe/qemu/100/status/current",ticket=ticket).get("status")!="running":
sys.exit("VM100 is not running; nothing changed")
payload=base64.b64encode((root/"set-single-vdi-display.ps1").read_bytes()).decode()
command=r"""
$ErrorActionPreference='Stop'
$folder='C:\ProgramData\VDI\single-display-20261103'
$task='VDISingleDisplayProbe'
if(Get-ScheduledTask -TaskName $task -ErrorAction SilentlyContinue) { throw 'Task already exists' }
$user=(Get-CimInstance Win32_ComputerSystem).UserName
if(-not $user) { throw 'No interactive user' }
New-Item -ItemType Directory -Force $folder | Out-Null
$acl=Get-Acl $folder
$acl.SetAccessRule((New-Object Security.AccessControl.FileSystemAccessRule($user,'Modify','ContainerInherit,ObjectInherit','None','Allow')))
Set-Acl $folder $acl
[IO.File]::WriteAllBytes("$folder\single.ps1",[Convert]::FromBase64String('__PAYLOAD__'))
Remove-Item "$folder\result.txt" -ErrorAction SilentlyContinue
$run="try { & '$folder\single.ps1' -Action __ACTION__ | Out-File -Encoding utf8 -Width 500 '$folder\result.txt' } catch { `$_ | Out-String | Out-File -Encoding utf8 -Width 500 '$folder\result.txt'; exit 1 }"
$encoded=[Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($run))
$a=New-ScheduledTaskAction -Execute powershell.exe -Argument "-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -EncodedCommand $encoded"
$p=New-ScheduledTaskPrincipal -UserId $user -LogonType Interactive -RunLevel Limited
$s=New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Minutes 2)
Register-ScheduledTask -TaskName $task -Action $a -Principal $p -Settings $s | Out-Null
try {
Start-ScheduledTask -TaskName $task
Start-Sleep -Seconds 2
for($i=0;$i-lt 30;$i++) {
if((Get-ScheduledTask -TaskName $task).State-ne 'Running') { break }
Start-Sleep -Seconds 1
}
if((Get-ScheduledTask -TaskName $task).State-eq 'Running') { throw 'Probe timed out' }
Get-Content "$folder\result.txt" -Raw
'task_result='+(Get-ScheduledTaskInfo -TaskName $task).LastTaskResult
} finally {
Stop-ScheduledTask -TaskName $task -ErrorAction SilentlyContinue
Unregister-ScheduledTask -TaskName $task -Confirm:$false
}
""".replace("__PAYLOAD__",payload).replace("__ACTION__",args.action)
out,err=v["agent"](ticket,csrf,["powershell.exe","-NoProfile","-Command",command])
print(out)
if err or "task_result=0" not in out:
sys.exit(err or "Display operation did not succeed")

View File

@ -1,3 +1,4 @@
param([ValidateSet(30, 60)][int]$RefreshRate = 60)
$ErrorActionPreference = 'Stop'
Add-Type @'
using System;
@ -45,12 +46,12 @@ public static class VdiDisplayMode {
if (!EnumDisplaySettings(name, -1, ref m)) return "unavailable";
return m.width + "x" + m.height + "@" + m.hz;
}
public static int Apply(string name) {
public static int Apply(string name, int refreshRate) {
for (int i = 0; ; i++) {
var m = new Mode();
m.size = (short)Marshal.SizeOf(typeof(Mode));
if (!EnumDisplaySettings(name, i, ref m)) return -99;
if (m.width == 2560 && m.height == 1440 && m.hz == 30) {
if (m.width == 2560 && m.height == 1440 && m.hz == refreshRate) {
m.fields = 0x80000 | 0x100000 | 0x400000;
int test = ChangeDisplaySettingsEx(name, ref m, IntPtr.Zero, 2, IntPtr.Zero);
if (test != 0) return test;
@ -61,13 +62,18 @@ public static class VdiDisplayMode {
}
'@
$log = 'C:/ProgramData/VDI/set-2k-result.txt'
$expectedMode = "2560x1440@$RefreshRate"
try {
for ($i = 0; $i -lt 12; $i++) {
$dev = [VdiDisplayMode]::Find()
if ($dev) {
$result = [VdiDisplayMode]::Apply($dev)
if ([VdiDisplayMode]::Current($dev) -eq $expectedMode) {
"$(Get-Date -Format o) device=$dev result=0 mode=$expectedMode (already correct)" | Set-Content $log
exit 0
}
$result = [VdiDisplayMode]::Apply($dev, $RefreshRate)
$mode = [VdiDisplayMode]::Current($dev)
if ($result -eq 0 -and $mode -eq '2560x1440@30') {
if ($result -eq 0 -and $mode -eq $expectedMode) {
"$(Get-Date -Format o) device=$dev result=0 mode=$mode" | Set-Content $log
exit 0
}

View File

@ -0,0 +1,134 @@
param(
[ValidateSet('Query','Apply','Restore')][string]$Action='Query',
[string]$BackupPath='C:\ProgramData\VDI\single-display-20261103\before.xml'
)
$ErrorActionPreference='Stop'
# Run as the logged-in desktop user. Detach desktop outputs, never disable GPUs.
Add-Type -ReferencedAssemblies System.Xml,System.Core,System -TypeDefinition @'
using System;
using System.Collections.Generic;
using System.IO;
using System.Runtime.InteropServices;
using System.Xml.Serialization;
public static class VdiSingleDisplay {
[StructLayout(LayoutKind.Sequential,CharSet=CharSet.Unicode)]
public struct Device {
public int cb;
[MarshalAs(UnmanagedType.ByValTStr,SizeConst=32)] public string Name;
[MarshalAs(UnmanagedType.ByValTStr,SizeConst=128)] public string Description;
public int Flags;
[MarshalAs(UnmanagedType.ByValTStr,SizeConst=128)] public string Id;
[MarshalAs(UnmanagedType.ByValTStr,SizeConst=128)] public string Key;
}
[StructLayout(LayoutKind.Sequential,CharSet=CharSet.Unicode)]
public struct Mode {
[MarshalAs(UnmanagedType.ByValTStr,SizeConst=32)] public string name;
public short spec,driver,size,extra;
public int fields,x,y,orientation,fixedOutput;
public short color,duplex,yres,tt,collate;
[MarshalAs(UnmanagedType.ByValTStr,SizeConst=32)] public string form;
public short logPixels;
public int bpp,width,height,flags,hz,icmMethod,icmIntent,media,dither,reserved1,reserved2,panWidth,panHeight;
}
public class Record { public Device device; public Mode mode; public bool hasMode; }
[DllImport("user32.dll",CharSet=CharSet.Unicode)] static extern bool EnumDisplayDevices(string name,uint index,ref Device device,uint flags);
[DllImport("user32.dll",CharSet=CharSet.Unicode)] static extern bool EnumDisplaySettings(string name,int index,ref Mode mode);
[DllImport("user32.dll",CharSet=CharSet.Unicode)] static extern int ChangeDisplaySettingsEx(string name,ref Mode mode,IntPtr hwnd,uint flags,IntPtr param);
[DllImport("user32.dll",CharSet=CharSet.Unicode,EntryPoint="ChangeDisplaySettingsExW")]
static extern int Commit(string name,IntPtr mode,IntPtr hwnd,uint flags,IntPtr param);
public static Record[] Query() {
var result=new List<Record>();
for(uint i=0;;i++) {
Device d=new Device(); d.cb=Marshal.SizeOf(typeof(Device));
if(!EnumDisplayDevices(null,i,ref d,0)) return result.ToArray();
if((d.Flags&8)!=0) continue;
Mode m=new Mode(); m.size=(short)Marshal.SizeOf(typeof(Mode));
bool ok=EnumDisplaySettings(d.Name,-1,ref m);
result.Add(new Record {device=d,mode=m,hasMode=ok});
}
}
static void Check(int code,string operation) {
if(code!=0) throw new InvalidOperationException(operation+" failed: "+code+" (no reboot will be requested)");
}
public static string Target(Record[] records) {
string found=null;
foreach(var r in records) if((r.device.Flags&1)!=0 && r.device.Description.IndexOf("Virtual Display Driver",StringComparison.OrdinalIgnoreCase)>=0) {
if(found!=null) throw new InvalidOperationException("Multiple active VDD outputs; refuse ambiguous selection");
if(!r.hasMode || r.mode.width<640 || r.mode.height<480) throw new InvalidOperationException("Invalid active VDD mode");
found=r.device.Name;
}
if(found==null) throw new InvalidOperationException("No active VDD; do not detach anything");
return found;
}
public static void Save(Record[] records,string path) {
using(var stream=new FileStream(path,FileMode.CreateNew,FileAccess.Write)) new XmlSerializer(typeof(Record[])).Serialize(stream,records);
}
public static Record[] Load(string path) {
using(var stream=File.OpenRead(path)) return (Record[])new XmlSerializer(typeof(Record[])).Deserialize(stream);
}
static void Stage(Record record,bool active,bool primary) {
Mode m=record.mode; m.size=(short)Marshal.SizeOf(typeof(Mode)); m.extra=0;
if(active) {
if(!record.hasMode) throw new InvalidOperationException("Missing original mode");
m.fields=0x20|0x40000|0x80000|0x100000|0x400000;
} else {
m.width=m.height=0; m.fields=0x20|0x80000|0x100000;
}
Check(ChangeDisplaySettingsEx(record.device.Name,ref m,IntPtr.Zero,0x10000001u|(primary?0x10u:0u),IntPtr.Zero),"Stage "+record.device.Name);
}
public static void Apply(Record[] before,string keep) {
foreach(var r0 in before) if(r0.device.Name==keep) {
Record r=new Record {device=r0.device,mode=r0.mode,hasMode=r0.hasMode};
Mode m=r.mode; m.x=m.y=0; r.mode=m; Stage(r,true,true);
}
foreach(var r in before) if(r.device.Name!=keep && (r.device.Flags&1)!=0) Stage(r,false,false);
Check(Commit(null,IntPtr.Zero,IntPtr.Zero,0,IntPtr.Zero),"Commit single display");
}
public static void Restore(Record[] before) {
Record[] now=Query();
foreach(var old in before) {
bool matched=false;
foreach(var r in now) if(r.device.Name==old.device.Name && r.device.Id==old.device.Id && r.device.Key==old.device.Key) matched=true;
if(!matched) throw new InvalidOperationException("Display identity changed; refuse stale restore: "+old.device.Name);
}
foreach(var r in before) if((r.device.Flags&4)!=0) Stage(r,true,true);
foreach(var r in before) if((r.device.Flags&4)==0) {
bool currentlyActive=false;
foreach(var current in now) if(current.device.Name==r.device.Name) currentlyActive=(current.device.Flags&1)!=0;
if((r.device.Flags&1)!=0 || currentlyActive) Stage(r,(r.device.Flags&1)!=0,false);
}
Check(Commit(null,IntPtr.Zero,IntPtr.Zero,0,IntPtr.Zero),"Restore topology");
}
}
'@
function Report {
@([VdiSingleDisplay]::Query() | ForEach-Object {
@{device=$_.device.Name; description=$_.device.Description; active=(($_.device.Flags-band 1)-ne 0);
primary=(($_.device.Flags-band 4)-ne 0); width=$_.mode.width; height=$_.mode.height; hz=$_.mode.hz; x=$_.mode.x; y=$_.mode.y}
}) | ConvertTo-Json -Depth 4 -Compress
}
if($Action -eq 'Query') { Report; exit 0 }
if($Action -eq 'Restore') { [VdiSingleDisplay]::Restore([VdiSingleDisplay]::Load($BackupPath)); Report; exit 0 }
$before=[VdiSingleDisplay]::Query()
$keep=[VdiSingleDisplay]::Target($before)
$target=@($before | Where-Object {$_.device.Name -eq $keep})[0]
if($target.mode.width -ne 2560 -or $target.mode.height -ne 1440 -or $target.mode.hz -ne 60 -or ($target.device.Flags-band 4)-eq 0) {
throw 'Required existing 2560x1440@60 VDD primary is absent; nothing changed'
}
$active=@($before | Where-Object {($_.device.Flags-band 1)-ne 0})
if($active.Count -eq 1 -and ($active[0].device.Flags-band 4)-ne 0) { Report; exit 0 }
New-Item -ItemType Directory -Force (Split-Path $BackupPath) | Out-Null
[VdiSingleDisplay]::Save($before,$BackupPath)
try {
[VdiSingleDisplay]::Apply($before,$keep)
Start-Sleep -Seconds 2
$actual=@([VdiSingleDisplay]::Query() | Where-Object {($_.device.Flags-band 1)-ne 0})
if($actual.Count -ne 1 -or $actual[0].device.Name -ne $keep -or ($actual[0].device.Flags-band 4)-eq 0) { throw 'Single VDD primary read-back failed' }
$old=@($before | Where-Object {$_.device.Name -eq $keep})[0]
if($actual[0].mode.width-ne $old.mode.width -or $actual[0].mode.height-ne $old.mode.height -or $actual[0].mode.hz-ne $old.mode.hz) { throw 'VDD mode changed unexpectedly' }
Report
} catch {
$failure=$_
[VdiSingleDisplay]::Restore($before)
throw $failure
}

51
tools/set-terminal-fps.py Normal file
View File

@ -0,0 +1,51 @@
"""Query or change only the Windows desktop FPS in the box's local POC config."""
import argparse
import subprocess
import sys
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--fps", type=int, choices=[30, 45, 60], required=True)
parser.add_argument("--apply", action="store_true")
args = parser.parse_args()
program = r'''
from pathlib import Path
from datetime import datetime
import json, os, shutil, stat, tempfile
path=Path.home()/'.config/vdi-terminal/desktops.json'
original=path.read_bytes()
data=json.loads(original)
matches=[d for d in data['desktops'] if d.get('id')=='desk-win10-01']
if len(matches)!=1 or matches[0].get('endpoint')!='192.168.1.70':
raise RuntimeError('Unexpected Windows desktop identity; inspect before changing')
desktop=matches[0]
before=desktop.get('fps')
backup=None
if __APPLY__:
backup=Path.home()/'vdi-terminal/backups'/('fps-'+datetime.now().strftime('%Y%m%d-%H%M%S-%f'))
backup.mkdir(parents=True)
shutil.copy2(path,backup/'desktops.json')
desktop['fps']=__FPS__
name=None
try:
with tempfile.NamedTemporaryFile(dir=path.parent,prefix='.desktops-',delete=False) as stream:
name=stream.name
stream.write((json.dumps(data,ensure_ascii=False,indent=2)+'\n').encode())
os.chmod(name,stat.S_IMODE(path.stat().st_mode))
if path.read_bytes()!=original:
raise RuntimeError('Desktop configuration changed concurrently; left it intact')
os.replace(name,path)
name=None
if json.loads(path.read_bytes())!=data:
raise RuntimeError('Desktop configuration read-back mismatch')
finally:
if name is not None: os.unlink(name)
print(json.dumps({'desktop':desktop['id'],'before':before,'fps':desktop['fps'],
'requested':__FPS__,'applied':__APPLY__,
'backup':str(backup) if backup else None}))
'''.replace("__FPS__", str(args.fps)).replace("__APPLY__", str(args.apply))
result = subprocess.run(["ssh", "-o", "ConnectTimeout=8", "armbian@192.168.1.188", "python3 -"],
input=program, capture_output=True, text=True, encoding="utf-8")
print(result.stdout, end="")
if result.stderr:
print(result.stderr, end="", file=sys.stderr)
sys.exit(result.returncode)