diff --git a/docs/2k60-validation-20261103.md b/docs/2k60-validation-20261103.md new file mode 100644 index 0000000..5014a36 --- /dev/null +++ b/docs/2k60-validation-20261103.md @@ -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,不能称零延迟。 diff --git a/docs/evidence/2k60-20261103/clicks-first-three.txt b/docs/evidence/2k60-20261103/clicks-first-three.txt new file mode 100644 index 0000000..b370d32 --- /dev/null +++ b/docs/evidence/2k60-20261103/clicks-first-three.txt @@ -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} + + diff --git a/docs/evidence/2k60-20261103/clicks-second-series-after45.txt b/docs/evidence/2k60-20261103/clicks-second-series-after45.txt new file mode 100644 index 0000000..8139d9c --- /dev/null +++ b/docs/evidence/2k60-20261103/clicks-second-series-after45.txt @@ -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} + + diff --git a/docs/evidence/2k60-20261103/clicks-second-series-before45.txt b/docs/evidence/2k60-20261103/clicks-second-series-before45.txt new file mode 100644 index 0000000..962bda5 --- /dev/null +++ b/docs/evidence/2k60-20261103/clicks-second-series-before45.txt @@ -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} + + diff --git a/docs/evidence/2k60-20261103/clicks-sustained.txt b/docs/evidence/2k60-20261103/clicks-sustained.txt new file mode 100644 index 0000000..90174ac --- /dev/null +++ b/docs/evidence/2k60-20261103/clicks-sustained.txt @@ -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} + + diff --git a/docs/evidence/2k60-20261103/desktop-selection.png b/docs/evidence/2k60-20261103/desktop-selection.png new file mode 100644 index 0000000..a88da16 Binary files /dev/null and b/docs/evidence/2k60-20261103/desktop-selection.png differ diff --git a/docs/evidence/2k60-20261103/final-host.txt b/docs/evidence/2k60-20261103/final-host.txt new file mode 100644 index 0000000..f9e33fe --- /dev/null +++ b/docs/evidence/2k60-20261103/final-host.txt @@ -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 + + + diff --git a/docs/evidence/2k60-20261103/normal-windows-45fps.png b/docs/evidence/2k60-20261103/normal-windows-45fps.png new file mode 100644 index 0000000..74605ca Binary files /dev/null and b/docs/evidence/2k60-20261103/normal-windows-45fps.png differ diff --git a/docs/evidence/single-display-20261103/after-desktop.png b/docs/evidence/single-display-20261103/after-desktop.png new file mode 100644 index 0000000..0cf6385 Binary files /dev/null and b/docs/evidence/single-display-20261103/after-desktop.png differ diff --git a/docs/evidence/single-display-20261103/after-health.txt b/docs/evidence/single-display-20261103/after-health.txt new file mode 100644 index 0000000..13ccc77 --- /dev/null +++ b/docs/evidence/single-display-20261103/after-health.txt @@ -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 + + + diff --git a/docs/evidence/single-display-20261103/after-query.txt b/docs/evidence/single-display-20261103/after-query.txt new file mode 100644 index 0000000..8210a95 --- /dev/null +++ b/docs/evidence/single-display-20261103/after-query.txt @@ -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 + + diff --git a/docs/evidence/single-display-20261103/after-terminal.txt b/docs/evidence/single-display-20261103/after-terminal.txt new file mode 100644 index 0000000..f02de40 --- /dev/null +++ b/docs/evidence/single-display-20261103/after-terminal.txt @@ -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 diff --git a/docs/evidence/single-display-20261103/apply.txt b/docs/evidence/single-display-20261103/apply.txt new file mode 100644 index 0000000..8210a95 --- /dev/null +++ b/docs/evidence/single-display-20261103/apply.txt @@ -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 + + diff --git a/docs/evidence/single-display-20261103/before.txt b/docs/evidence/single-display-20261103/before.txt new file mode 100644 index 0000000..0f152af --- /dev/null +++ b/docs/evidence/single-display-20261103/before.txt @@ -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 + + diff --git a/docs/single-display-20261103.md b/docs/single-display-20261103.md new file mode 100644 index 0000000..491520d --- /dev/null +++ b/docs/single-display-20261103.md @@ -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)。 diff --git a/terminal/moonlight/experiments/0004-vdi-egl-exact-size-sampling.patch b/terminal/moonlight/experiments/0004-vdi-egl-exact-size-sampling.patch new file mode 100644 index 0000000..0621063 --- /dev/null +++ b/terminal/moonlight/experiments/0004-vdi-egl-exact-size-sampling.patch @@ -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); diff --git a/tools/deploy-vdi-display-refresh.py b/tools/deploy-vdi-display-refresh.py new file mode 100644 index 0000000..f9974d8 --- /dev/null +++ b/tools/deploy-vdi-display-refresh.py @@ -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) diff --git a/tools/run-single-vdi-display.py b/tools/run-single-vdi-display.py new file mode 100644 index 0000000..b7fa25b --- /dev/null +++ b/tools/run-single-vdi-display.py @@ -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") diff --git a/tools/set-2k-display.ps1 b/tools/set-2k-display.ps1 index d965154..25e2353 100644 --- a/tools/set-2k-display.ps1 +++ b/tools/set-2k-display.ps1 @@ -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 } diff --git a/tools/set-single-vdi-display.ps1 b/tools/set-single-vdi-display.ps1 new file mode 100644 index 0000000..c50d8c9 --- /dev/null +++ b/tools/set-single-vdi-display.ps1 @@ -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(); + 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 +} diff --git a/tools/set-terminal-fps.py b/tools/set-terminal-fps.py new file mode 100644 index 0000000..a46ffd2 --- /dev/null +++ b/tools/set-terminal-fps.py @@ -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)