功能:完善2K串流、显示恢复与输入优化

This commit is contained in:
songyiming committed 2026-11-03 13:43:50 +08:00
1 parent e47b01beec
commit 938ec97cce
95 files changed
+5788 -72

No files matched your search

+74
View File
@@ -0,0 +1,74 @@
#!/bin/bash
# Temporary benchmark stream only. Refuses to replace an existing user stream.
# Run after launching the scoped Windows input benchmark, with VDD mode matched.
set -euo pipefail
task_root="${VDI_RKMPP_BUILD_ROOT:-$HOME/vdi-build/rkmpp-experiment}"
label="${1:?label required}"
fps="${2:?FPS required}"
priority="${3:-0}"
[[ "$label" =~ ^[a-zA-Z0-9-]+$ ]] || exit 2
[[ "$fps" = 30 || "$fps" = 60 ]] || exit 2
[[ "$priority" = 0 || "$priority" = 1 ]] || exit 2
exec 9>"$task_root/mouse-bench.lock"
flock -n 9 || { echo 'Another benchmark is active'; exit 1; }
if pgrep -f '^/home/armbian/vdi-build/rkmpp-experiment/build/moonlight/app/moonlight.* stream ' >/dev/null; then
echo 'A stream is already active; exit it normally before benchmarking' >&2
exit 1
fi
export DISPLAY=:0 XAUTHORITY="$HOME/.Xauthority"
export LD_LIBRARY_PATH="$task_root/runtime/lib:$task_root/deps/usr/lib/aarch64-linux-gnu"
export QML2_IMPORT_PATH="$task_root/deps/usr/lib/aarch64-linux-gnu/qt5/qml"
export QT_PLUGIN_PATH="$task_root/deps/usr/lib/aarch64-linux-gnu/qt5/plugins"
export H264_DECODER_HINT=h264_rkmpp HEVC_DECODER_HINT=hevc_rkmpp
export VDI_LATEST_FRAME=1 VDI_INPUT_TIMING=1 VDI_INPUT_PRIORITY="$priority"
export GL_IS_SLOW=0 SDL_RENDER_DRIVER=opengles2 SDL_VIDEO_X11_NET_WM_BYPASS_COMPOSITOR=1
client="${VDI_RKMPP_CLIENT:-$task_root/build/moonlight/app/moonlight}"
mkdir -p "$task_root/mouse-bench"
log="$task_root/mouse-bench/$label.log"
test ! -e "$log" || { echo 'Label already used; preserve existing evidence'; exit 1; }
child=""
cleanup() {
if [ -n "$child" ] && kill -0 "$child" 2>/dev/null; then
kill -TERM "$child" 2>/dev/null || true
for ((j=0;j<5;j++)); do
kill -0 "$child" 2>/dev/null || break
sleep 1
done
kill -KILL "$child" 2>/dev/null || true
wait "$child" 2>/dev/null || true
fi
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
"$client" stream 192.168.1.70 Desktop --resolution 2560x1440 --fps "$fps" \
--bitrate 30000 --display-mode fullscreen --video-codec H.264 \
--video-decoder hardware --absolute-mouse --keep-awake --no-vsync \
--no-frame-pacing --audio-on-host > "$log" 2>&1 &
child=$!
ready=0
for ((i=0;i<35;i++)); do
if grep -q 'egl_overlay.frag' "$log"; then ready=1; break; fi
kill -0 "$child" || { tail -20 "$log"; exit 1; }
sleep 1
done
test "$ready" = 1 || { echo 'No video readiness'; exit 1; }
echo "Ready: $label; injecting motion"
python3 "$task_root/mouse-bench/inject-mouse-sweep.py"
python3 "$task_root/mouse-bench/inject-input-burst.py"
sleep 12
# Do not send the exit chord into an unrelated foreground application.
focus_pid=$(xdotool getwindowfocus getwindowpid 2>/dev/null || true)
if [ "$focus_pid" != "$child" ]; then
echo 'Stream lost foreground; terminate only benchmark child' >&2
exit 1
fi
xdotool key ctrl+alt+shift+q
for ((i=0;i<15;i++)); do
kill -0 "$child" 2>/dev/null || break
sleep 1
done
kill -0 "$child" 2>/dev/null && { echo 'Stream exit timed out'; exit 1; }
wait "$child"
child=""
grep -E 'VDI input|VDI main-thread|frame rate|Host processing|Frames dropped|Average .*time|Average network|queue delay' "$log"
+166
View File
@@ -0,0 +1,166 @@
#!/bin/bash
# Run on the ARM64 terminal. All sources, libraries and output stay in TASK_ROOT.
# No apt install, system library replacement, desktop restart or sudo is needed.
set -euo pipefail
TASK_ROOT="${VDI_RKMPP_BUILD_ROOT:-$HOME/vdi-build/rkmpp-experiment}"
SDK="$TASK_ROOT/deps/usr"
PREFIX="$TASK_ROOT/runtime"
JOBS="${VDI_BUILD_JOBS:-2}"
mkdir -p "$TASK_ROOT"/{src,debs,build} "$PREFIX"
clone_source() {
local name="$1" ref="$2" url="$3"
if [ ! -d "$TASK_ROOT/src/$name/.git" ]; then
git clone --depth=1 --branch "$ref" "$url" "$TASK_ROOT/src/$name"
fi
test "$(git -C "$TASK_ROOT/src/$name" rev-parse HEAD)" = "$4" || {
echo "Unexpected $name source revision; inspect before building" >&2; exit 1;
}
}
clone_source mpp develop https://github.com/rockchip-linux/mpp.git 7916c3cc000fefc0bdf96636b7d54fe174724f44
clone_source ffmpeg 6.1 https://github.com/nyanmisaka/ffmpeg-rockchip.git 797387de1c9caa9170100d7217ee093d1897f750
clone_source moonlight v6.2.0 https://github.com/moonlight-stream/moonlight-qt.git de2467e433821664cdd2224aad8c89a625be1ad9
git -C "$TASK_ROOT/src/moonlight" submodule update --init --recursive --depth=1
# Versioned VDI interaction patches. Explicit directory supports copying this
# builder to the box without the rest of the repository. Refuse unknown drift.
PATCH_DIR="${VDI_MOONLIGHT_PATCH_DIR:-$(cd "$(dirname "$0")/../terminal/moonlight/patches" && pwd)}"
apply_vdi_patch() {
local source="$1" patch="$2"
test -r "$patch" || { echo "Missing VDI patch: $patch" >&2; exit 1; }
if git -C "$source" apply --reverse --check "$patch" 2>/dev/null; then
return
fi
git -C "$source" apply --check "$patch"
git -C "$source" apply "$patch"
}
apply_vdi_patch "$TASK_ROOT/src/moonlight" "$PATCH_DIR/0001-vdi-input-and-latest-frame.patch"
apply_vdi_patch "$TASK_ROOT/src/moonlight/moonlight-common-c/moonlight-common-c" \
"$PATCH_DIR/0002-vdi-absolute-click-barrier.patch"
apply_vdi_patch "$TASK_ROOT/src/moonlight" "$PATCH_DIR/0003-vdi-input-priority.patch"
# Moonlight 6.2.0 uses std::optional in drm.h but relies on a transitive
# include from Qt 6. Our Qt 5 build needs the standard header explicitly.
export VDI_MOONLIGHT_SOURCE="$TASK_ROOT/src/moonlight"
python3 - <<'PY'
import os
from pathlib import Path
header = Path(os.environ['VDI_MOONLIGHT_SOURCE']) / 'app/streaming/video/ffmpeg-renderers/drm.h'
text = header.read_text()
if '#include <optional>' not in text:
assert '#include <set>' in text
header.write_text(text.replace('#include <set>', '#include <optional>\n#include <set>', 1))
PY
# Download only the required headers and private runtime libraries from the
# configured distribution repository. apt does not install or upgrade anything.
cd "$TASK_ROOT/debs"
apt-get download libdrm-dev libopus-dev libsdl2-dev libsdl2-2.0-0 \
libsdl2-ttf-dev libsdl2-ttf-2.0-0 libudev-dev libxkbcommon-dev \
libegl-dev libgles-dev libgles1 libgbm-dev libwayland-dev libwayland-bin wayland-protocols \
qtdeclarative5-dev qtdeclarative5-dev-tools qt5-qmltooling-plugins \
qtquickcontrols2-5-dev libqt5quickcontrols2-5 libqt5quicktemplates2-5 \
libqt5svg5-dev libqt5svg5 libqt5qmlworkerscript5 libqt5quickparticles5 \
libqt5quickshapes5 libqt5quicktest5 libx264-dev \
qml-module-qtquick2 qml-module-qtquick-window2 qml-module-qtquick-layouts \
qml-module-qtqml-models2 qml-module-qtquick-controls2 qml-module-qtquick-templates2 \
libharfbuzz-dev libfreetype-dev libpng-dev zlib1g-dev libbrotli-dev \
libglib2.0-dev libgraphite2-dev libffi-dev
for package in ./*.deb; do dpkg-deb -x "$package" "$TASK_ROOT/deps"; done
# Relocate pkg-config prefixes, preserving the system /usr and its libraries.
export VDI_PRIVATE_SDK="$SDK"
python3 - <<'PY'
import os
from pathlib import Path
sdk = Path(os.environ['VDI_PRIVATE_SDK'])
for pc in sdk.rglob('*.pc'):
text = pc.read_text()
if str(sdk) not in text:
pc.write_text(text.replace('/usr', str(sdk)))
for pri in sdk.rglob('qt_lib_*.pri'):
pri.write_text(pri.read_text().replace('$$QT_MODULE_LIB_BASE', str(sdk / 'lib/aarch64-linux-gnu'))
.replace('$$QT_MODULE_INCLUDE_BASE', str(sdk / 'include/aarch64-linux-gnu/qt5'))
.replace('$$QT_MODULE_BIN_BASE', str(sdk / 'lib/qt5/bin')))
# A -dev symlink can target a different distro patch version than is installed.
# Link to the existing matching SONAME; never replace the system runtime.
for link in (sdk / 'lib/aarch64-linux-gnu').glob('*.so'):
if link.is_symlink() and not link.exists():
target = Path(os.readlink(link)).name
if '.so.' not in target:
continue
soname = target.split('.so.')[0] + '.so.' + target.split('.so.')[1].split('.')[0]
system_lib = Path('/lib/aarch64-linux-gnu') / soname
if system_lib.exists():
link.unlink()
link.symlink_to(system_lib)
PY
export PKG_CONFIG_PATH="$PREFIX/lib/pkgconfig:$SDK/lib/aarch64-linux-gnu/pkgconfig:$SDK/share/pkgconfig${PKG_CONFIG_PATH:+:$PKG_CONFIG_PATH}"
export LD_LIBRARY_PATH="$PREFIX/lib:$SDK/lib/aarch64-linux-gnu${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
export QMAKEPATH="$SDK/lib/aarch64-linux-gnu/qt5"
export QML2_IMPORT_PATH="$SDK/lib/aarch64-linux-gnu/qt5/qml"
export CPLUS_INCLUDE_PATH="$SDK/include/aarch64-linux-gnu:$SDK/include${CPLUS_INCLUDE_PATH:+:$CPLUS_INCLUDE_PATH}"
export C_INCLUDE_PATH="$SDK/include/aarch64-linux-gnu:$SDK/include${C_INCLUDE_PATH:+:$C_INCLUDE_PATH}"
if [ "${VDI_BUILD_STAGE:-all}" != moonlight ]; then
echo '=== build MPP ==='
cmake -S "$TASK_ROOT/src/mpp" -B "$TASK_ROOT/build/mpp" \
-DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX="$PREFIX" \
-DRKPLATFORM=ON -DHAVE_DRM=ON -DBUILD_SHARED_LIBS=ON -DBUILD_TEST=OFF
cmake --build "$TASK_ROOT/build/mpp" -j "$JOBS"
cmake --install "$TASK_ROOT/build/mpp"
echo '=== build FFmpeg with RKMPP ==='
mkdir -p "$TASK_ROOT/build/ffmpeg"
cd "$TASK_ROOT/build/ffmpeg"
"$TASK_ROOT/src/ffmpeg/configure" --prefix="$PREFIX" \
--enable-gpl --enable-version3 --enable-shared --disable-static \
--enable-libdrm --enable-rkmpp --disable-rkrga --enable-libx264 \
--disable-doc --disable-debug --disable-autodetect --disable-network \
--disable-everything --enable-avcodec --enable-avformat --enable-avfilter \
--enable-swscale --enable-swresample --enable-ffmpeg --enable-ffprobe \
--enable-decoder=h264,hevc,h264_rkmpp,hevc_rkmpp,rawvideo,wrapped_avframe \
--enable-parser=h264,hevc --enable-demuxer=h264,hevc \
--enable-muxer=h264,null,rawvideo --enable-protocol=file \
--enable-encoder=libx264,wrapped_avframe,rawvideo --enable-indev=lavfi \
--enable-filter=testsrc2,null,format,hwdownload,scale \
--extra-cflags="-I$PREFIX/include -I$SDK/include" \
--extra-ldflags="-L$PREFIX/lib -L$SDK/lib/aarch64-linux-gnu -Wl,-rpath,$PREFIX/lib -Wl,-rpath,$SDK/lib/aarch64-linux-gnu"
make -j "$JOBS"
make install
"$PREFIX/bin/ffmpeg" -hide_banner -decoders | grep rkmpp
fi
echo '=== build Moonlight ==='
mkdir -p "$TASK_ROOT/build/moonlight"
cd "$TASK_ROOT/build/moonlight"
qmake "$TASK_ROOT/src/moonlight/moonlight-qt.pro" \
CONFIG+=release CONFIG+=recheck CONFIG+=disable-libva CONFIG+=disable-libvdpau \
CONFIG+=disable-libplacebo CONFIG+=disable-discord \
"QMAKE_LIBDIR+=$SDK/lib/aarch64-linux-gnu" \
"QMAKE_LIBDIR+=$PREFIX/lib" \
"QMAKE_RPATHDIR+=$SDK/lib/aarch64-linux-gnu" \
"QMAKE_RPATHDIR+=$PREFIX/lib" \
"QMAKE_INCDIR+=$SDK/include/aarch64-linux-gnu/qt5" \
"QMAKE_INCDIR+=$SDK/include/aarch64-linux-gnu" \
"QMAKE_INCDIR+=$SDK/include"
make qmake_all
if ! grep -q -- '-DHAVE_EGL' app/Makefile.Release; then
echo 'EGL renderer missing; refusing to build a client without the DRM PRIME display path' >&2
exit 1
fi
# qmake/make does not invalidate old objects when feature defines change.
# In particular a stale ffmpeg.o can silently omit EGL despite a successful
# configure check. Clean the app whenever its compiler/link flags change.
flags_hash="$(grep -E '^(DEFINES|INCPATH|LIBS|CXXFLAGS)[[:space:]]*=' app/Makefile.Release | sha256sum | cut -d ' ' -f 1)"
if [ "$(cat .vdi-build-flags.sha256 2>/dev/null || true)" != "$flags_hash" ]; then
make -C app -f Makefile.Release clean
printf '%s\n' "$flags_hash" > .vdi-build-flags.sha256
fi
make -j "$JOBS"
echo '=== complete; experimental binary only, production unchanged ==='
echo "$TASK_ROOT/build/moonlight/app/moonlight"
git -C "$TASK_ROOT/src/mpp" rev-parse HEAD
git -C "$TASK_ROOT/src/ffmpeg" rev-parse HEAD
git -C "$TASK_ROOT/src/moonlight" rev-parse HEAD
sha256sum "$TASK_ROOT"/debs/*.deb > "$TASK_ROOT/dependency-sha256.txt"
+62
View File
@@ -0,0 +1,62 @@
#!/bin/bash
# Install only X11 display recovery; keep the kiosk/Moonlight session running.
set -euo pipefail
BOX="${VDI_BOX:-armbian@192.168.1.188}"
REPO="$(cd "$(dirname "$0")/.." && pwd)"
SRC="$REPO/terminal/kiosk-shell"
STAGE=$(ssh -o BatchMode=yes -o ConnectTimeout=8 "$BOX" 'mktemp -d /tmp/vdi-display-deploy.XXXXXX')
scp -q "$SRC/scripts/vdi-display-guard" "$SRC/scripts/vdi-kiosk-start" \
"$SRC/etc/vdi-display-guard.service" "$BOX:$STAGE/"
ssh "$BOX" bash -s -- "$STAGE" <<'REMOTE'
set -euo pipefail
stage=$1
backup="$HOME/vdi-terminal/backups/display-guard-$(date +%Y%m%d-%H%M%S)"
mkdir -p "$backup" "$HOME/vdi-terminal/bin" "$HOME/.config/systemd/user"
for file in "$HOME/vdi-terminal/bin/vdi-kiosk-start" \
"$HOME/vdi-terminal/bin/vdi-display-guard" \
"$HOME/.config/systemd/user/vdi-display-guard.service"; do
test ! -f "$file" || cp -p "$file" "$backup/"
done
sed -i 's/\r$//' "$stage/"*
python3 -c 'import ast, sys; ast.parse(open(sys.argv[1]).read())' "$stage/vdi-display-guard"
sh -n "$stage/vdi-kiosk-start"
install -m 0755 "$stage/vdi-display-guard" "$stage/vdi-kiosk-start" "$HOME/vdi-terminal/bin/"
install -m 0644 "$stage/vdi-display-guard.service" "$HOME/.config/systemd/user/"
# Correct only this monitor's known stale layout; preserve all other layouts.
python3 - "$backup" <<'PY'
from pathlib import Path
import shutil
import sys
import xml.etree.ElementTree as ET
path = Path.home() / '.config/monitors.xml'
if path.exists():
tree = ET.parse(path)
changed = False
for output in tree.findall('.//output'):
if (output.get('name') == 'HDMI-1'
and output.findtext('vendor') == 'GWR'
and output.findtext('product') == '0x2708'
and output.findtext('width') == '1920'
and output.findtext('height') == '1440'
and output.findtext('rate') == '75'):
for name, value in [('width', '2560'), ('height', '1440'), ('rate', '60')]:
output.find(name).text = value
changed = True
if changed:
shutil.copy2(path, Path(sys.argv[1]) / 'monitors.xml')
temp = path.with_name('monitors.xml.vdi-new')
tree.write(temp, encoding='utf-8', xml_declaration=True)
temp.replace(path)
print('Updated known stale MATE layout to 2560x1440@60')
PY
systemctl --user daemon-reload
systemctl --user enable vdi-display-guard.service
systemctl --user restart vdi-display-guard.service
systemctl --user is-active vdi-display-guard.service
echo "Backup: $backup"
# Only remove the three files uploaded by this deployment.
rm -- "$stage/vdi-display-guard" "$stage/vdi-kiosk-start" "$stage/vdi-display-guard.service"
rmdir -- "$stage"
REMOTE
+11 -2
View File
@@ -15,21 +15,30 @@ echo "==> 检查盒子连通性"
ssh -o ConnectTimeout=8 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$BOX" 'echo "box ok: $(hostname)"'
echo "==> 同步源码到盒子"
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$BOX" 'rm -rf ~/vdi-build/repo/kiosk-shell/src ~/vdi-build/repo/kiosk-shell/tests && mkdir -p ~/vdi-build/repo/kiosk-shell/src ~/vdi-build/repo/kiosk-shell/tests'
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$BOX" 'rm -rf ~/vdi-build/repo/kiosk-shell/src ~/vdi-build/repo/kiosk-shell/tests && mkdir -p ~/vdi-build/repo/kiosk-shell/src ~/vdi-build/repo/kiosk-shell/tests ~/vdi-build/repo/kiosk-shell/scripts'
scp -q -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$SRC/CMakeLists.txt" "$BOX":~/vdi-build/repo/kiosk-shell/
scp -q -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$SRC"/src/*.cpp "$SRC"/src/*.h "$BOX":~/vdi-build/repo/kiosk-shell/src/
scp -q -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$SRC"/tests/*.cpp "$BOX":~/vdi-build/repo/kiosk-shell/tests/
scp -q "$SRC/tests/test_display_guard.py" "$BOX":~/vdi-build/repo/kiosk-shell/tests/
scp -q "$SRC/scripts/vdi-display-guard" "$BOX":~/vdi-build/repo/kiosk-shell/scripts/
scp -q -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$SRC/scripts/vdi-moonlight" "$SRC/scripts/vdi-kiosk-start" "$BOX":~/vdi-terminal/bin/
scp -q -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$SRC/scripts/vdi-moonlight-rkmpp" "$BOX":~/vdi-terminal/bin/
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$BOX" 'sed -i "s/\r$//" ~/vdi-terminal/bin/vdi-moonlight-rkmpp; chmod +x ~/vdi-terminal/bin/vdi-moonlight-rkmpp'
scp -q "$SRC/etc/70-vdi-rockchip-video.rules" "$BOX":/tmp/70-vdi-rockchip-video.rules
ssh "$BOX" 'sed -i "s/\r$//" /tmp/70-vdi-rockchip-video.rules; sudo -n install -m 0644 /tmp/70-vdi-rockchip-video.rules /etc/udev/rules.d/70-vdi-rockchip-video.rules && sudo -n udevadm control --reload-rules && for device in /dev/mpp_service /dev/rga /dev/dma_heap/*; do test ! -e "$device" || { sudo -n chgrp video "$device" && sudo -n chmod 0660 "$device"; } || exit 1; done'
scp -q -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$SRC/etc/moonlight.conf" "$BOX":/tmp/vdi-moonlight.conf
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$BOX" 'mkdir -p ~/.config/vdi-terminal; test -f ~/.config/vdi-terminal/moonlight.conf || cp /tmp/vdi-moonlight.conf ~/.config/vdi-terminal/moonlight.conf; rm -f /tmp/vdi-moonlight.conf'
scp -q -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$SRC/vdi-kiosk.desktop" "$BOX":~/.config/autostart/vdi-kiosk.desktop
scp -q -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$SRC/vdi-moonlight.desktop" "$BOX":~/.config/autostart/vdi-moonlight.desktop
echo "==> 安装 HDMI 热插拔恢复服务"
bash "$REPO/tools/deploy_display_guard.sh"
echo "==> 在盒子上编译"
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$BOX" 'sed -i "s/\r$//" ~/vdi-build/repo/kiosk-shell/src/*.cpp ~/vdi-build/repo/kiosk-shell/src/*.h ~/vdi-build/repo/kiosk-shell/tests/*.cpp ~/vdi-terminal/bin/vdi-moonlight ~/vdi-terminal/bin/vdi-kiosk-start; chmod +x ~/vdi-terminal/bin/vdi-moonlight ~/vdi-terminal/bin/vdi-kiosk-start; cd ~/vdi-build/repo/kiosk-shell && (cmake -G Ninja -B build -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=ON > /tmp/cmake.log 2>&1 || (tail -30 /tmp/cmake.log; exit 1)) && cmake --build build && QT_QPA_PLATFORM=offscreen ctest --test-dir build --output-on-failure'
echo "==> 安装二进制"
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$BOX" 'monitor_pids=$(pgrep -f "^/home/armbian/vdi-terminal/bin/vdi-kiosk-monitor -- " 2>/dev/null || true); test -z "$monitor_pids" || kill $monitor_pids 2>/dev/null || true; pair_pids=$(pgrep -f "^bash -c tail -f .*(pin|pair)_fifo" 2>/dev/null || true); test -z "$pair_pids" || kill $pair_pids 2>/dev/null || true; tail_pids=$(pgrep -f "^tail -f .*(pin|pair)_fifo" 2>/dev/null || true); test -z "$tail_pids" || kill $tail_pids 2>/dev/null || true; rm -f ~/pair_fifo /tmp/pin_fifo; for name in vdi-moonlight moonlight-qt kiosk-shell; do pids=$(pidof "$name" 2>/dev/null || true); test -z "$pids" || kill $pids 2>/dev/null || true; done; for i in $(seq 1 40); do pgrep -f "^/home/armbian/vdi-terminal/bin/vdi-kiosk-monitor -- " >/dev/null 2>&1 || break; sleep 0.2; done; monitor_pids=$(pgrep -f "^/home/armbian/vdi-terminal/bin/vdi-kiosk-monitor -- " 2>/dev/null || true); test -z "$monitor_pids" || kill -9 $monitor_pids 2>/dev/null || true; cp ~/vdi-build/repo/kiosk-shell/build/kiosk-shell ~/vdi-terminal/bin/kiosk-shell && cp ~/vdi-build/repo/kiosk-shell/build/vdi-kiosk-monitor ~/vdi-terminal/bin/vdi-kiosk-monitor && chmod +x ~/vdi-terminal/bin/kiosk-shell ~/vdi-terminal/bin/vdi-kiosk-monitor && ls -la ~/vdi-terminal/bin/'
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$BOX" 'monitor_pids=$(pgrep -f "^/home/armbian/vdi-terminal/bin/vdi-kiosk-monitor -- " 2>/dev/null || true); test -z "$monitor_pids" || kill $monitor_pids 2>/dev/null || true; pair_pids=$(pgrep -f "^bash -c tail -f .*(pin|pair)_fifo" 2>/dev/null || true); test -z "$pair_pids" || kill $pair_pids 2>/dev/null || true; tail_pids=$(pgrep -f "^tail -f .*(pin|pair)_fifo" 2>/dev/null || true); test -z "$tail_pids" || kill $tail_pids 2>/dev/null || true; rm -f ~/pair_fifo /tmp/pin_fifo; for name in vdi-moonlight moonlight-qt moonlight kiosk-shell; do pids=$(pidof "$name" 2>/dev/null || true); test -z "$pids" || kill $pids 2>/dev/null || true; done; for i in $(seq 1 40); do pgrep -f "^/home/armbian/vdi-terminal/bin/vdi-kiosk-monitor -- " >/dev/null 2>&1 || break; sleep 0.2; done; monitor_pids=$(pgrep -f "^/home/armbian/vdi-terminal/bin/vdi-kiosk-monitor -- " 2>/dev/null || true); test -z "$monitor_pids" || kill -9 $monitor_pids 2>/dev/null || true; cp ~/vdi-build/repo/kiosk-shell/build/kiosk-shell ~/vdi-terminal/bin/kiosk-shell && cp ~/vdi-build/repo/kiosk-shell/build/vdi-kiosk-monitor ~/vdi-terminal/bin/vdi-kiosk-monitor && chmod +x ~/vdi-terminal/bin/kiosk-shell ~/vdi-terminal/bin/vdi-kiosk-monitor && ls -la ~/vdi-terminal/bin/'
echo "==> 收敛自启项(只保留 kiosk-shell 一条链)"
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$BOX" 'mkdir -p ~/.config/autostart; rm -f ~/.config/autostart/vdi-kiosk.desktop.bak; sed -i "s/^Hidden=.*/Hidden=true/;s/^X-GNOME-Autostart-enabled=.*/X-GNOME-Autostart-enabled=false/" ~/.config/autostart/vdi-moonlight.desktop 2>/dev/null || true; grep -H . ~/.config/autostart/*.desktop'
+54
View File
@@ -0,0 +1,54 @@
// Generate a deterministic 1440p H.264 clip without any display/capture dependency.
// Link against the isolated FFmpeg libavcodec/libavutil built for the terminal.
#include <stdio.h>
#include <string.h>
#include <libavcodec/avcodec.h>
#include <libavutil/opt.h>
static int write_packets(AVCodecContext *encoder, AVPacket *packet, FILE *output) {
int result;
while ((result = avcodec_receive_packet(encoder, packet)) == 0) {
if (fwrite(packet->data, 1, packet->size, output) != (size_t)packet->size) return -1;
av_packet_unref(packet);
}
return result == AVERROR(EAGAIN) || result == AVERROR_EOF ? 0 : result;
}
int main(int argc, char **argv) {
if (argc != 2) return 2;
const AVCodec *codec = avcodec_find_encoder_by_name("libx264");
AVCodecContext *encoder = avcodec_alloc_context3(codec);
AVFrame *frame = av_frame_alloc();
AVPacket *packet = av_packet_alloc();
FILE *output = fopen(argv[1], "wb");
if (!codec || !encoder || !frame || !packet || !output) return 1;
encoder->width = frame->width = 2560;
encoder->height = frame->height = 1440;
encoder->pix_fmt = frame->format = AV_PIX_FMT_YUV420P;
encoder->time_base = (AVRational){1, 30};
encoder->framerate = (AVRational){30, 1};
encoder->max_b_frames = 0;
encoder->thread_count = 2;
av_opt_set(encoder->priv_data, "preset", "ultrafast", 0);
av_opt_set(encoder->priv_data, "tune", "zerolatency", 0);
if (avcodec_open2(encoder, codec, NULL) < 0 || av_frame_get_buffer(frame, 32) < 0) return 1;
for (int i = 0; i < 60; i++) {
if (av_frame_make_writable(frame) < 0) return 1;
for (int y = 0; y < frame->height; y++) {
memset(frame->data[0] + y * frame->linesize[0], 32 + (y + i * 4) % 190, frame->width);
}
for (int plane = 1; plane < 3; plane++) {
for (int y = 0; y < frame->height / 2; y++) {
memset(frame->data[plane] + y * frame->linesize[plane], 128, frame->width / 2);
}
}
frame->pts = i;
if (avcodec_send_frame(encoder, frame) < 0 || write_packets(encoder, packet, output) < 0) return 1;
}
if (avcodec_send_frame(encoder, NULL) < 0 || write_packets(encoder, packet, output) < 0) return 1;
if (fclose(output) != 0) return 1;
av_packet_free(&packet);
av_frame_free(&frame);
avcodec_free_context(&encoder);
return 0;
}
+26
View File
@@ -0,0 +1,26 @@
"""X11 test only: inject 32 alternating left/right clicks into the bench window."""
import ctypes
x11 = ctypes.CDLL("libX11.so.6")
xtest = ctypes.CDLL("libXtst.so.6")
x11.XOpenDisplay.argtypes = [ctypes.c_char_p]
x11.XOpenDisplay.restype = ctypes.c_void_p
x11.XFlush.argtypes = [ctypes.c_void_p]
x11.XCloseDisplay.argtypes = [ctypes.c_void_p]
xtest.XTestFakeMotionEvent.argtypes = [ctypes.c_void_p, ctypes.c_int,
ctypes.c_int, ctypes.c_int, ctypes.c_ulong]
xtest.XTestFakeButtonEvent.argtypes = [ctypes.c_void_p, ctypes.c_uint,
ctypes.c_int, ctypes.c_ulong]
display = x11.XOpenDisplay(None)
if not display:
raise RuntimeError("X11 unavailable")
try:
for index in range(32):
x = 640 if index % 2 == 0 else 1920
assert xtest.XTestFakeMotionEvent(display, -1, x, 600, 0)
assert xtest.XTestFakeButtonEvent(display, 1, 1, 0)
assert xtest.XTestFakeButtonEvent(display, 1, 0, 0)
x11.XFlush(display)
finally:
x11.XCloseDisplay(display)
print("Injected 32 clicks, expected L,R alternating; final pointer on R")
+31
View File
@@ -0,0 +1,31 @@
"""X11 benchmark only: 20 seconds of 125 Hz motion, no buttons or keys."""
import ctypes
import math
import time
x11 = ctypes.CDLL("libX11.so.6")
xtest = ctypes.CDLL("libXtst.so.6")
x11.XOpenDisplay.argtypes = [ctypes.c_char_p]
x11.XOpenDisplay.restype = ctypes.c_void_p
x11.XFlush.argtypes = [ctypes.c_void_p]
x11.XCloseDisplay.argtypes = [ctypes.c_void_p]
xtest.XTestFakeMotionEvent.argtypes = [ctypes.c_void_p, ctypes.c_int,
ctypes.c_int, ctypes.c_int, ctypes.c_ulong]
display = x11.XOpenDisplay(None)
if not display:
raise RuntimeError("X11 unavailable")
start = time.monotonic()
count = 0
try:
while time.monotonic() - start < 20:
elapsed = time.monotonic() - start
x = round(1280 + 1000 * math.sin(elapsed * 4))
y = round(720 + 450 * math.cos(elapsed * 3))
if not xtest.XTestFakeMotionEvent(display, -1, x, y, 0):
raise RuntimeError("Motion injection failed")
x11.XFlush(display)
count += 1
time.sleep(max(0, start + count / 125 - time.monotonic()))
finally:
x11.XCloseDisplay(display)
print(f"Injected {count} motion events in {time.monotonic()-start:.2f}s")
+85
View File
@@ -0,0 +1,85 @@
# Temporary interactive test window; exits after ten minutes. No system settings.
$ErrorActionPreference = 'Stop'
$benchDir = 'C:\ProgramData\VDI\input-bench-20261103'
New-Item -ItemType Directory -Force $benchDir | Out-Null
Set-Content -LiteralPath "$benchDir\pid.txt" -Value $PID
Add-Type -ReferencedAssemblies System.Windows.Forms,System.Drawing -TypeDefinition @'
using System;
using System.Diagnostics;
using System.Drawing;
using System.IO;
using System.Runtime.InteropServices;
using System.Windows.Forms;
public class VDIInputBenchForm : Form {
[DllImport("user32.dll")] public static extern bool SetProcessDPIAware();
[DllImport("winmm.dll")] public static extern uint timeBeginPeriod(uint period);
[DllImport("winmm.dll")] public static extern uint timeEndPeriod(uint period);
readonly Stopwatch watch = Stopwatch.StartNew();
readonly Timer timer = new Timer();
readonly string folder;
int paints, clicks;
long lastReport;
long lastFrameIndex = -1;
public VDIInputBenchForm(string path) {
folder = path;
Text = "VDI Input Latency Bench";
FormBorderStyle = FormBorderStyle.None;
Bounds = Screen.PrimaryScreen.Bounds;
TopMost = true;
DoubleBuffered = true;
File.WriteAllText(Path.Combine(folder,"clicks.csv"), "ms,x,y,target\n");
// A WinForms 16ms timer can quantize to ~31ms at the default timer
// resolution and accidentally cap the *source* at 32FPS. Pace by an
// absolute monotonic frame index, not accumulated timer ticks.
timer.Interval = 1;
timer.Tick += delegate {
if(watch.Elapsed.TotalMinutes >= 10) { Close(); return; }
long frameIndex = watch.ElapsedTicks * 60 / Stopwatch.Frequency;
if(frameIndex != lastFrameIndex) { lastFrameIndex = frameIndex; Invalidate(); }
};
timer.Start();
}
protected override void OnMouseDown(MouseEventArgs e) {
base.OnMouseDown(e);
string target = e.X < Width/2 ? "L" : "R";
File.AppendAllText(Path.Combine(folder,"clicks.csv"),
watch.ElapsedMilliseconds+","+e.X+","+e.Y+","+target+"\n");
++clicks;
Invalidate();
}
protected override void OnPaint(PaintEventArgs e) {
base.OnPaint(e);
long ms = watch.ElapsedMilliseconds;
e.Graphics.Clear(Color.FromArgb(16,24,39));
using(Brush left = new SolidBrush(Color.FromArgb(35,85,130)))
using(Brush right = new SolidBrush(Color.FromArgb(50,115,90))) {
e.Graphics.FillRectangle(left,50,180,Width/2-100,Height-260);
e.Graphics.FillRectangle(right,Width/2+50,180,Width/2-100,Height-260);
}
int x = (int)((ms*0.6)%(Width+100))-100;
e.Graphics.FillRectangle(Brushes.Gold,x,0,60,Height);
using(Font font = new Font("Consolas",24)) {
e.Graphics.DrawString("VDI test: moving bars + ordered clicks | "+clicks+" clicks | "+ms+" ms",
font,Brushes.White,70,40);
e.Graphics.DrawString("LEFT",font,Brushes.White,Width/4-50,Height/2);
e.Graphics.DrawString("RIGHT",font,Brushes.White,Width*3/4-50,Height/2);
}
++paints;
if(ms-lastReport>=1000) {
File.WriteAllText(Path.Combine(folder,"status.txt"),
"width="+Width+" height="+Height+" paints="+paints+" elapsed_ms="+ms+" clicks="+clicks);
lastReport=ms;
}
}
protected override void OnFormClosed(FormClosedEventArgs e) {
timer.Stop(); timer.Dispose(); base.OnFormClosed(e);
}
}
'@
[VDIInputBenchForm]::SetProcessDPIAware() | Out-Null
$timerResult=[VDIInputBenchForm]::timeBeginPeriod(1)
try {
[System.Windows.Forms.Application]::Run((New-Object VDIInputBenchForm($benchDir)))
} finally {
if($timerResult -eq 0) { [VDIInputBenchForm]::timeEndPeriod(1) | Out-Null }
}
+67
View File
@@ -0,0 +1,67 @@
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
namespace VdiDiagnostics {
public class AdapterResult {
public string Name;
public uint VendorId;
public int DeviceResult;
}
// Enumerate the actual DXGI adapters and create a D3D11 device on each NVIDIA
// adapter. A WMI/PnP model name alone does not prove that D3D is operational.
public static class D3DProbe {
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private struct AdapterDesc {
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)] public string Description;
public uint VendorId, DeviceId, SubSysId, Revision;
public UIntPtr DedicatedVideoMemory, DedicatedSystemMemory, SharedSystemMemory;
public long AdapterLuid;
}
[UnmanagedFunctionPointer(CallingConvention.StdCall)]
private delegate int EnumAdapters(IntPtr factory, uint index, out IntPtr adapter);
[UnmanagedFunctionPointer(CallingConvention.StdCall)]
private delegate int GetDesc(IntPtr adapter, out AdapterDesc desc);
[DllImport("dxgi.dll")]
private static extern int CreateDXGIFactory1(ref Guid iid, out IntPtr factory);
[DllImport("d3d11.dll")]
private static extern int D3D11CreateDevice(IntPtr adapter, uint driverType,
IntPtr software, uint flags, IntPtr levels, uint levelCount, uint sdkVersion,
out IntPtr device, out uint featureLevel, out IntPtr context);
public static AdapterResult[] Check() {
var results = new List<AdapterResult>();
var iid = new Guid("770aae78-f26f-4dba-a829-253c83d1b387");
IntPtr factory;
Marshal.ThrowExceptionForHR(CreateDXGIFactory1(ref iid, out factory));
try {
var table = Marshal.ReadIntPtr(factory);
var enumerate = (EnumAdapters)Marshal.GetDelegateForFunctionPointer(
Marshal.ReadIntPtr(table, 7 * IntPtr.Size), typeof(EnumAdapters));
for (uint i = 0; i < 32; i++) {
IntPtr adapter;
int hr = enumerate(factory, i, out adapter);
if (hr == unchecked((int)0x887A0002)) break;
Marshal.ThrowExceptionForHR(hr);
try {
var descMethod = (GetDesc)Marshal.GetDelegateForFunctionPointer(
Marshal.ReadIntPtr(Marshal.ReadIntPtr(adapter), 8 * IntPtr.Size), typeof(GetDesc));
AdapterDesc desc;
Marshal.ThrowExceptionForHR(descMethod(adapter, out desc));
if (desc.VendorId != 0x10de) continue;
IntPtr device, context;
uint level;
hr = D3D11CreateDevice(adapter, 0, IntPtr.Zero, 0, IntPtr.Zero,
0, 7, out device, out level, out context);
if (context != IntPtr.Zero) Marshal.Release(context);
if (device != IntPtr.Zero) Marshal.Release(device);
results.Add(new AdapterResult { Name = desc.Description,
VendorId = desc.VendorId, DeviceResult = hr });
} finally { Marshal.Release(adapter); }
}
} finally { Marshal.Release(factory); }
return results.ToArray();
}
}
}
+55
View File
@@ -0,0 +1,55 @@
"""Start/status/stop only the temporary VDI input benchmark through VM100's agent."""
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=["start", "status", "stop"])
args = parser.parse_args()
tools_dir = Path(__file__).resolve().parent
term = runpy.run_path(str(tools_dir / "pve_term_exec.py"))
vm = runpy.run_path(str(tools_dir / "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; no benchmark task changed")
folder = r"C:\ProgramData\VDI\input-bench-20261103"
prefix = "$ErrorActionPreference='Stop'; $folder='" + folder + "'; "
if args.action == "start":
encoded = base64.b64encode((tools_dir / "input-latency-bench.ps1").read_bytes()).decode()
command = prefix + r"""
New-Item -ItemType Directory -Force $folder | Out-Null
if (Get-ScheduledTask -TaskName VDIInputLatencyBench -ErrorAction SilentlyContinue) { throw 'Benchmark task already exists; stop it first' }
[IO.File]::WriteAllBytes("$folder\bench.ps1",[Convert]::FromBase64String('""" + encoded + r"""'))
$action=New-ScheduledTaskAction -Execute powershell.exe -Argument "-NoProfile -ExecutionPolicy Bypass -STA -WindowStyle Hidden -File $folder\bench.ps1"
$principal=New-ScheduledTaskPrincipal -UserId test -LogonType Interactive -RunLevel Limited
$settings=New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Minutes 10)
Register-ScheduledTask -TaskName VDIInputLatencyBench -Action $action -Principal $principal -Settings $settings | Out-Null
Start-ScheduledTask -TaskName VDIInputLatencyBench
'Benchmark launched'
"""
elif args.action == "status":
command = prefix + r"""
Get-Content "$folder\status.txt" -ErrorAction SilentlyContinue
Get-Content "$folder\clicks.csv" -ErrorAction SilentlyContinue
Get-ScheduledTaskInfo -TaskName VDIInputLatencyBench | Select-Object LastTaskResult | ConvertTo-Json -Compress
"""
else:
command = prefix + r"""
Stop-ScheduledTask -TaskName VDIInputLatencyBench -ErrorAction SilentlyContinue
Unregister-ScheduledTask -TaskName VDIInputLatencyBench -Confirm:$false -ErrorAction SilentlyContinue
if (Test-Path "$folder\pid.txt") {
$benchPid=[int](Get-Content "$folder\pid.txt")
$process=Get-CimInstance Win32_Process -Filter "ProcessId=$benchPid"
if ($process.CommandLine -like '*input-bench-20261103*') { Stop-Process -Id $benchPid -ErrorAction SilentlyContinue }
}
'Benchmark stopped; other applications untouched'
"""
out, error = vm["agent"](ticket, csrf, ["powershell.exe", "-NoProfile", "-Command", command])
print(out)
if error:
print(error, file=sys.stderr)
sys.exit(1)
+61
View File
@@ -0,0 +1,61 @@
"""Run the scoped VDD mode probe in VM100's already logged-in user 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", "List", "Apply", "Restore"])
parser.add_argument("--hz", type=int, default=60)
args = parser.parse_args()
if not 24 <= args.hz <= 240:
parser.error("--hz must be in 24..240")
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; no task or display configuration changed")
payload = base64.b64encode((root / "set-vdi-display-mode.ps1").read_bytes()).decode()
command = r"""
$ErrorActionPreference='Stop'
$folder='C:\ProgramData\VDI\mouse-optimization'
$task='VDIMouseDisplayProbe'
if(Get-ScheduledTask -TaskName $task -ErrorAction SilentlyContinue) { throw 'Probe 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 -LiteralPath $folder
$rule=New-Object Security.AccessControl.FileSystemAccessRule($user,'Modify','ContainerInherit,ObjectInherit','None','Allow')
$acl.SetAccessRule($rule)
Set-Acl -LiteralPath $folder -AclObject $acl
[IO.File]::WriteAllBytes("$folder\mode.ps1",[Convert]::FromBase64String('__VDI_PAYLOAD__'))
$run="try { & '$folder\mode.ps1' -Action __VDI_ACTION__ -RefreshRate __VDI_HZ__ | Out-File -Encoding utf8 -Width 300 '$folder\mode-result.txt' } catch { `$_ | Out-String | Out-File -Encoding utf8 -Width 300 '$folder\mode-result.txt'; exit 1 }"
$encoded=[Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($run))
$action=New-ScheduledTaskAction -Execute powershell.exe -Argument "-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -EncodedCommand $encoded"
$principal=New-ScheduledTaskPrincipal -UserId $user -LogonType Interactive -RunLevel Limited
$settings=New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Minutes 1)
Register-ScheduledTask -TaskName $task -Action $action -Principal $principal -Settings $settings | Out-Null
try {
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
}
if((Get-ScheduledTask -TaskName $task).State -eq 'Running') { throw 'Probe timed out' }
Get-Content "$folder\mode-result.txt"
'task_result='+(Get-ScheduledTaskInfo -TaskName $task).LastTaskResult
} finally {
Stop-ScheduledTask -TaskName $task -ErrorAction SilentlyContinue
Unregister-ScheduledTask -TaskName $task -Confirm:$false
}
""".replace("__VDI_ACTION__", args.action).replace("__VDI_HZ__", str(args.hz)).replace("__VDI_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)
+52
View File
@@ -0,0 +1,52 @@
param([switch]$Restore)
# Run in the logged-in desktop user session, not the Guest Agent's Session 0.
$ErrorActionPreference = 'Stop'
$directory = Join-Path $env:LOCALAPPDATA 'VDI'
New-Item -ItemType Directory -Force $directory | Out-Null
$backup = Join-Path $directory 'desktop-responsiveness-before-20261103.json'
Add-Type @'
using System;
using System.ComponentModel;
using System.Runtime.InteropServices;
public static class VDIVisualResponse {
[StructLayout(LayoutKind.Sequential)] struct Animation { public uint size; public int enabled; }
[DllImport("user32.dll", EntryPoint="SystemParametersInfoW", SetLastError=true)]
static extern bool SpiAnimation(uint action, uint size, ref Animation data, uint flags);
[DllImport("user32.dll", EntryPoint="SystemParametersInfoW", SetLastError=true)]
static extern bool SpiGet(uint action, uint unused, out uint value, uint flags);
[DllImport("user32.dll", EntryPoint="SystemParametersInfoW", SetLastError=true)]
static extern bool SpiSet(uint action, uint value, IntPtr unused, uint flags);
static void Check(bool result) { if(!result) throw new Win32Exception(Marshal.GetLastWin32Error()); }
public static int AnimationEnabled() {
Animation data = new Animation { size=8 };
Check(SpiAnimation(0x0048,8,ref data,0)); return data.enabled;
}
public static uint MenuDelay() { uint value; Check(SpiGet(0x006A,0,out value,0)); return value; }
public static void Set(int animation, uint delay) {
Animation data = new Animation { size=8, enabled=animation };
Check(SpiAnimation(0x0049,8,ref data,3));
Check(SpiSet(0x006B,delay,IntPtr.Zero,3));
}
}
'@
$before = @{ animation=[VDIVisualResponse]::AnimationEnabled(); menuDelay=[VDIVisualResponse]::MenuDelay() }
if ($Restore) {
$target = Get-Content -LiteralPath $backup -Raw | ConvertFrom-Json
} else {
if (-not (Test-Path -LiteralPath $backup)) {
$before | ConvertTo-Json | Set-Content -LiteralPath $backup -Encoding UTF8
}
$target = @{ animation=0; menuDelay=100 }
}
try {
[VDIVisualResponse]::Set([int]$target.animation,[uint32]$target.menuDelay)
$after = @{ animation=[VDIVisualResponse]::AnimationEnabled(); menuDelay=[VDIVisualResponse]::MenuDelay() }
if ($after.animation -ne $target.animation -or $after.menuDelay -ne $target.menuDelay) {
throw 'Desktop preferences were not retained'
}
@{ before=$before; after=$after; backup=$backup; restored=[bool]$Restore } |
ConvertTo-Json -Depth 4 | Set-Content -LiteralPath (Join-Path $directory 'desktop-responsiveness-result.json') -Encoding UTF8
} catch {
[VDIVisualResponse]::Set([int]$before.animation,[uint32]$before.menuDelay)
throw
}
+111
View File
@@ -0,0 +1,111 @@
param(
[ValidateSet('Query', 'List', 'Apply', 'Restore')][string]$Action = 'Query',
[ValidateRange(640, 7680)][int]$Width = 2560,
[ValidateRange(480, 4320)][int]$Height = 1440,
[ValidateRange(24, 240)][int]$RefreshRate = 60,
[string]$BackupPath = 'C:\ProgramData\VDI\mouse-optimization\display-before.json'
)
$ErrorActionPreference = 'Stop'
# Run in the logged-in desktop session. Apply is temporary (no registry update).
# Never assumes DISPLAY1, and refuses ambiguous active virtual displays.
Add-Type -TypeDefinition @'
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
public static class VdiModeProbe {
[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;
}
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
static extern bool EnumDisplayDevices(string name, uint index, ref Device dev, 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 window, uint flags, IntPtr param);
public static string Find() {
string found = null;
for (uint i = 0; ; i++) {
var d = new Device(); d.cb = Marshal.SizeOf(typeof(Device));
if (!EnumDisplayDevices(null, i, ref d, 0)) return found;
if ((d.Flags & 1) == 0 || d.Description.IndexOf("Virtual Display Driver", StringComparison.OrdinalIgnoreCase) < 0) continue;
if (found != null) throw new InvalidOperationException("Multiple active virtual displays; select manually");
found = d.Name;
}
}
public static Mode Current(string name) {
var m = new Mode(); m.size = (short)Marshal.SizeOf(typeof(Mode));
if (!EnumDisplaySettings(name, -1, ref m)) throw new InvalidOperationException("Cannot query mode");
return m;
}
public static Mode[] List(string name) {
var modes = new List<Mode>();
for(int i=0; ; ++i) {
var m = new Mode(); m.size = (short)Marshal.SizeOf(typeof(Mode));
if (!EnumDisplaySettings(name, i, ref m)) return modes.ToArray();
modes.Add(m);
}
}
public static void Apply(string name, int w, int h, int hz, int bpp) {
foreach(var m0 in List(name)) {
var m = m0;
if(m.width != w || m.height != h || m.hz != hz || m.bpp != bpp) continue;
m.fields = 0x80000 | 0x100000 | 0x400000 | 0x40000;
int result = ChangeDisplaySettingsEx(name, ref m, IntPtr.Zero, 2, IntPtr.Zero);
if(result != 0) throw new InvalidOperationException("Mode test failed: " + result);
result = ChangeDisplaySettingsEx(name, ref m, IntPtr.Zero, 0, IntPtr.Zero);
if(result != 0) throw new InvalidOperationException("Mode apply failed: " + result);
var actual = Current(name);
if(actual.width != w || actual.height != h || actual.hz != hz || actual.bpp != bpp)
throw new InvalidOperationException("Mode read-back mismatch");
return;
}
throw new InvalidOperationException("Requested mode not advertised");
}
}
'@
$device = [VdiModeProbe]::Find()
if (-not $device) { throw 'No active Virtual Display Driver in this session' }
$current = [VdiModeProbe]::Current($device)
if ($Action -eq 'List') {
[VdiModeProbe]::List($device) | Where-Object { $_.width -eq $Width -and $_.height -eq $Height } |
Select-Object width,height,hz,bpp | ConvertTo-Json -Compress
exit 0
}
if ($Action -eq 'Apply') {
if (-not (Test-Path -LiteralPath $BackupPath)) {
New-Item -ItemType Directory -Force (Split-Path $BackupPath) | Out-Null
@{device=$device; width=$current.width; height=$current.height; hz=$current.hz; bpp=$current.bpp} |
ConvertTo-Json | Set-Content -LiteralPath $BackupPath
}
try {
[VdiModeProbe]::Apply($device,$Width,$Height,$RefreshRate,$current.bpp)
} catch {
[VdiModeProbe]::Apply($device,$current.width,$current.height,$current.hz,$current.bpp)
throw
}
}
if ($Action -eq 'Restore') {
$before = Get-Content -LiteralPath $BackupPath -Raw | ConvertFrom-Json
if ($before.device -ne $device) { throw 'Display identity changed; inspect before restoring' }
[VdiModeProbe]::Apply($device,$before.width,$before.height,$before.hz,$before.bpp)
}
$current = [VdiModeProbe]::Current($device)
@{device=$device; width=$current.width; height=$current.height; hz=$current.hz; bpp=$current.bpp; action=$Action} |
ConvertTo-Json -Compress
+39
View File
@@ -0,0 +1,39 @@
<!doctype html>
<meta charset="utf-8">
<title>VDI stream motion test</title>
<style>
html, body { margin: 0; overflow: hidden; background: #101827; color: white; font: 24px monospace; }
canvas { display: block; width: 100vw; height: 100vh; }
#stats { position: fixed; right: 24px; top: 20px; background: #000c; padding: 12px; }
</style>
<canvas id="scene"></canvas><div id="stats"></div>
<script>
// Deterministic moving bars + checkerboard: creates continuous desktop damage.
// Read the source RAF rate separately from the Moonlight stream/render rate.
const canvas = document.getElementById('scene');
const context = canvas.getContext('2d', { alpha: false });
const stats = document.getElementById('stats');
let count = 0, last = performance.now(), sourceFps = 0;
function render(now) {
if (canvas.width !== innerWidth || canvas.height !== innerHeight) {
canvas.width = innerWidth; canvas.height = innerHeight;
}
const width = canvas.width, height = canvas.height;
context.fillStyle = '#101827'; context.fillRect(0, 0, width, height);
const offset = Math.floor(now * 0.12) % 128;
for (let y = height / 2; y < height; y += 128) {
for (let x = -128; x < width; x += 128) {
context.fillStyle = (Math.round((x + 128) / 128) + Math.floor(y / 128)) % 2 ? '#28517a' : '#132c46';
context.fillRect(x + offset, y, 126, 126);
}
}
const x = (now * 0.6) % (width + 100) - 100;
context.fillStyle = '#ffcf40'; context.fillRect(x, 0, 80, height);
context.fillStyle = '#ffffff'; context.fillRect(width - x, 0, 8, height);
count++;
if (now - last >= 1000) { sourceFps = count * 1000 / (now - last); count = 0; last = now; }
stats.textContent = `${width}x${height} | source RAF ${sourceFps.toFixed(1)} FPS | ${Math.floor(now)} ms`;
requestAnimationFrame(render);
}
requestAnimationFrame(render);
</script>
+31
View File
@@ -0,0 +1,31 @@
#!/bin/bash
# Run against the actual patched source and private runtime on the ARM64 box.
set -euo pipefail
b="${VDI_RKMPP_BUILD_ROOT:-$HOME/vdi-build/rkmpp-experiment}"
sdk="$b/deps/usr"
export LD_LIBRARY_PATH="$b/runtime/lib:$sdk/lib/aarch64-linux-gnu${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
export PKG_CONFIG_PATH="$b/runtime/lib/pkgconfig:$sdk/lib/aarch64-linux-gnu/pkgconfig:$sdk/share/pkgconfig"
export CPLUS_INCLUDE_PATH="$sdk/include/aarch64-linux-gnu:$sdk/include"
export C_INCLUDE_PATH="$sdk/include/aarch64-linux-gnu:$sdk/include"
sanitize=()
if [ "${VDI_TEST_SANITIZE:-0}" = 1 ]; then
sanitize=(-fsanitize=address -g)
fi
g++ "${sanitize[@]}" -std=c++17 -fPIC -UNDEBUG "$b/latency-tests/test_latency.cpp" \
-I"$b/src/moonlight/app/streaming/input" \
-I"$b/src/moonlight/app/streaming/video/ffmpeg-renderers/pacer" \
-I"$b/runtime/include" $(pkg-config --cflags --libs Qt5Core sdl2) \
-L"$b/runtime/lib" -lavutil -o "$b/latency-tests/test_latency"
"$b/latency-tests/test_latency"
g++ "${sanitize[@]}" -std=c++17 -fPIC -UNDEBUG "$b/latency-tests/test_input_priority.cpp" \
-I"$b/src/moonlight/app/streaming/input" $(pkg-config --cflags --libs sdl2) \
-o "$b/latency-tests/test_input_priority"
"$b/latency-tests/test_input_priority"
gcc "${sanitize[@]}" -std=gnu11 -ffunction-sections -fdata-sections -Wl,--gc-sections -UNDEBUG \
-I"$b/src/moonlight/moonlight-common-c/moonlight-common-c/src" \
-I"$b/src/moonlight/moonlight-common-c/moonlight-common-c/enet/include" \
"$b/latency-tests/test_absolute_input.c" \
"$b/build/moonlight/moonlight-common-c/libmoonlight-common-c.a" \
-lpthread -lcrypto -o "$b/latency-tests/test_absolute_input"
"$b/latency-tests/test_absolute_input"
+105
View File
@@ -0,0 +1,105 @@
param(
[string]$SunshineLogPath = "",
[int]$MaxLogAgeMinutes = 30
)
# 只读验收脚本:不重启 VM、设备、VFIO 或 Sunshine,不修改任何配置。
# 退出码 0 才表示当前状态满足“可继续做串流性能验收”的前置条件。
$ErrorActionPreference = "Stop"
$ok = $true
function Report([string]$Name, [bool]$Passed, [string]$Detail) {
$state = if ($Passed) { "PASS" } else { "FAIL" }
Write-Output ("[{0}] {1}: {2}" -f $state, $Name, $Detail)
}
$nvidiaCommand = Get-Command nvidia-smi -ErrorAction SilentlyContinue
if ($null -eq $nvidiaCommand) {
Report "nvidia-smi" $false "命令不存在"
$ok = $false
} else {
$ErrorActionPreference = 'Continue'
$gpuText = & $nvidiaCommand.Source --query-gpu=name,driver_version,pstate --format=csv,noheader 2>&1
$queryExitCode = $LASTEXITCODE
$ErrorActionPreference = 'Stop'
if ($queryExitCode -ne 0 -or [string]::IsNullOrWhiteSpace(($gpuText -join ""))) {
Report "nvidia-smi" $false (($gpuText -join " ").Trim())
$ok = $false
} else {
Report "nvidia-smi" $true (($gpuText -join " ").Trim())
}
}
try {
$displayAdapters = @(Get-CimInstance Win32_VideoController |
Where-Object { $_.Name -match "NVIDIA|GeForce|Quadro|Tesla" })
$healthy = @($displayAdapters | Where-Object { $_.ConfigManagerErrorCode -eq 0 }).Count -gt 0
Report "NVIDIA PnP" $healthy (($displayAdapters | ForEach-Object { "$($_.Name): code=$($_.ConfigManagerErrorCode)" }) -join "; ")
$ok = $ok -and $healthy
if (-not ('VdiDiagnostics.D3DProbe' -as [type])) {
Add-Type -Path (Join-Path $PSScriptRoot 'probe-nvidia-d3d.cs')
}
$dxgi = @([VdiDiagnostics.D3DProbe]::Check())
$healthy = @($dxgi | Where-Object { $_.DeviceResult -eq 0 }).Count -gt 0
$detail = if ($dxgi.Count) { ($dxgi | ForEach-Object { '{0}: HRESULT=0x{1:X8}' -f $_.Name,$_.DeviceResult }) -join '; ' } else { 'DXGI did not enumerate an NVIDIA adapter' }
Report "NVIDIA DXGI + D3D11" $healthy $detail
$ok = $ok -and $healthy
} catch {
Report "NVIDIA DXGI + D3D11" $false $_.Exception.Message
$ok = $false
}
if ([string]::IsNullOrWhiteSpace($SunshineLogPath)) {
$candidates = @(
"C:\Program Files\Sunshine\config\sunshine.log",
"C:\ProgramData\Sunshine\config\sunshine.log"
)
$SunshineLogPath = $candidates | Where-Object { Test-Path -LiteralPath $_ } | Select-Object -First 1
}
if ([string]::IsNullOrWhiteSpace($SunshineLogPath) -or -not (Test-Path -LiteralPath $SunshineLogPath)) {
Report "Sunshine encoder" $false "找不到 sunshine.log;不能证明实际使用 h264_nvenc"
$ok = $false
} else {
# Encoder discovery/probing can mention both NVENC and x264. Inspect only
# the most recent actual creation, with a timestamp, never the entire history.
$pattern = '\[(?<time>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d+)\].*Creating encoder \[(?<encoder>[^\]]+)\]'
$matches = [regex]::Matches((Get-Content -LiteralPath $SunshineLogPath -Raw), $pattern)
$healthy = $false
$detail = '没有实际编码器创建记录;请连接Moonlight并播放测试动画'
if ($matches.Count -gt 0) {
$entry = $matches[$matches.Count - 1]
$encoder = $entry.Groups['encoder'].Value
$createdAt = [datetime]::Parse($entry.Groups['time'].Value, [Globalization.CultureInfo]::InvariantCulture)
$ageMinutes = ((Get-Date) - $createdAt).TotalMinutes
$healthy = $encoder -eq 'h264_nvenc' -and $ageMinutes -ge 0 -and $ageMinutes -le $MaxLogAgeMinutes
$detail = '{0}, created {1}, age {2:N1} min' -f $encoder,$createdAt,$ageMinutes
}
Report "Sunshine active encoder" $healthy $detail
$ok = $ok -and $healthy
}
if ($nvidiaCommand) {
$ErrorActionPreference = 'Continue'
$activity = & $nvidiaCommand.Source --query-gpu=encoder.stats.sessionCount,utilization.encoder --format=csv,noheader,nounits 2>&1
$queryExitCode = $LASTEXITCODE
$ErrorActionPreference = 'Stop'
$healthy = $false
if ($queryExitCode -eq 0) {
foreach ($row in $activity) {
if ([string]$row -match '^\s*(\d+),\s*(\d+)\s*$') {
if ([int]$Matches[1] -gt 0 -and [int]$Matches[2] -gt 0) { $healthy = $true }
}
}
}
Report "NVENC session/utilization" $healthy ($activity -join '; ')
$ok = $ok -and $healthy
}
if ($ok) {
Write-Output "GPU/NVENC 前置验收通过:可以继续做 Moonlight 帧率与延迟测试。"
exit 0
}
Write-Output "GPU/NVENC 前置验收失败:不要把当前状态作为硬编码性能结果。"
exit 1