功能:完善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

+5
View File
@@ -55,6 +55,11 @@ target_link_libraries(vdi-kiosk-monitor PRIVATE Threads::Threads)
if(BUILD_TESTING)
find_package(Qt5 REQUIRED COMPONENTS Test)
if(UNIX)
find_package(Python3 REQUIRED COMPONENTS Interpreter)
add_test(NAME display-guard COMMAND ${Python3_EXECUTABLE}
${CMAKE_CURRENT_SOURCE_DIR}/tests/test_display_guard.py)
endif()
add_executable(test-moonlight-session
tests/test_moonlight_session.cpp
@@ -0,0 +1,4 @@
# RK3528 vendor-kernel media interfaces. Members of video may use the VPU.
KERNEL=="mpp_service", GROUP="video", MODE="0660"
KERNEL=="rga", GROUP="video", MODE="0660"
SUBSYSTEM=="dma_heap", GROUP="video", MODE="0660"
+2
View File
@@ -18,6 +18,7 @@
# NVENC重启故障及各档性能记录见 docs/moonlight-kiosk-20261028.md。
# frame-pacing 1=开启帧率匀速(--frame-pacing),0=关闭(--no-frame-pacing)
# performance-overlay 1=显示性能叠图(调试用)
# profile smooth / low-latency / diagnostic;为空时保留下方显式开关
# hang-timeout 串流假死判定时间(秒),0 表示关闭该检测
host=192.168.1.70
port=47984
@@ -34,6 +35,7 @@ absolute-mouse=1
vsync=1
frame-pacing=1
performance-overlay=0
profile=smooth
retry=3
retry-delay=5
hang-timeout=90
@@ -0,0 +1,14 @@
[Unit]
Description=VDI HDMI mode recovery after monitor power cycle (X11 POC)
[Service]
Type=simple
Environment=DISPLAY=:0
Environment=XAUTHORITY=%h/.Xauthority
ExecStart=%h/vdi-terminal/bin/vdi-display-guard
Restart=on-failure
RestartSec=5
TimeoutStopSec=15
[Install]
WantedBy=default.target
@@ -0,0 +1,170 @@
#!/usr/bin/env python3
"""Restore this RK3528 terminal's verified HDMI timing after X11 hotplug."""
import argparse
import fcntl
import os
from pathlib import Path
import re
import signal
import subprocess
import sys
import time
from dataclasses import dataclass
OUTPUT = "HDMI-1"
MODE = "2560x1440_60_fixed297"
# The vendor PHY rejects the EDID's 241.70 MHz timing. Keep the verified PLL.
TIMING = "297.00 2560 2608 2640 2720 1440 1443 1448 1820 +HSync -VSync".split()
POLL_SECONDS = 2
SETTLE_SECONDS = 2
def log(message):
print(time.strftime("%Y-%m-%d %H:%M:%S"), message, flush=True)
def xrandr(*args):
result = subprocess.run(["xrandr", *args], capture_output=True, text=True,
timeout=10, check=False)
if result.returncode:
raise RuntimeError((result.stderr or result.stdout).strip()[:400])
return result.stdout
@dataclass
class DisplayState:
connected: bool
geometry: str
active_mode: str
modes: set
defined: bool
@property
def healthy(self):
return (self.connected and self.geometry == "2560x1440+0+0"
and self.active_mode == MODE)
def parse_state(query):
connected, geometry, active = False, "", ""
modes, defined, in_output = set(), False, False
for line in query.splitlines():
fields = line.split()
if not fields:
continue
if fields[0] == MODE:
defined = True # Includes a globally defined but detached mode.
if not line[0].isspace():
in_output = fields[0] == OUTPUT
if in_output:
connected = len(fields) > 1 and fields[1] == "connected"
geometry = next((f for f in fields[2:]
if re.fullmatch(r"\d+x\d+\+\d+\+\d+", f)), "")
elif in_output and len(fields) > 1 and re.match(r"^\d+(?:\.\d+)?[+*]*$", fields[1]):
modes.add(fields[0])
if "*" in " ".join(fields[1:]):
active = fields[0]
return DisplayState(connected, geometry, active, modes, defined)
class Guard:
def __init__(self, runner=xrandr, reporter=log):
self.run = runner
self.log = reporter
self.bad_since = None
self.next_retry = 0
self.failures = 0
self.last_status = None
def report(self, status):
if status != self.last_status:
self.log(status)
self.last_status = status
def step(self, now):
try:
# Xorg already receives DRM hotplug events. Read its current state
# without probing every EDID/mode: the vendor driver logs clock
# warnings for those probes, adding unnecessary work while streaming.
state = parse_state(self.run("--current"))
except (OSError, RuntimeError, subprocess.TimeoutExpired) as error:
self.bad_since = None
self.report("X11 unavailable; waiting: " + str(error)[:400])
return False
if not state.connected:
self.bad_since = None
self.next_retry = 0
self.failures = 0
self.report(OUTPUT + " disconnected; no mode changes")
return False
if state.healthy:
self.bad_since = None
self.next_retry = 0
self.failures = 0
self.report("healthy: " + MODE)
return True
self.report("mode drift: " + (state.active_mode or "off") + " " + state.geometry)
if self.bad_since is None:
self.bad_since = now
if now - self.bad_since < SETTLE_SECONDS or now < self.next_retry:
return False
try:
if not state.defined:
self.run("--newmode", MODE, *TIMING)
if MODE not in state.modes:
self.run("--addmode", OUTPUT, MODE)
self.run("--output", OUTPUT, "--mode", MODE, "--pos", "0x0")
if not parse_state(self.run("--current")).healthy:
raise RuntimeError("mode was not retained after apply")
except (OSError, RuntimeError, subprocess.TimeoutExpired) as error:
self.failures = min(self.failures + 1, 6)
delay = min(60, 2 ** self.failures)
# Base backoff on completion, including a slow/timed-out modeset.
self.next_retry = time.monotonic() + delay
self.log("restore failed; retry in %ds: %s" % (delay, str(error)[:400]))
return False
self.bad_since = None
self.next_retry = 0
self.failures = 0
self.report("restored: " + MODE + " (stream process untouched)")
return True
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--once", action="store_true",
help="initial display recovery with a 20s retry window, then exit")
args = parser.parse_args()
os.environ.setdefault("DISPLAY", ":0")
os.environ.setdefault("XAUTHORITY", str(Path.home() / ".Xauthority"))
runtime = Path(os.environ.get("XDG_RUNTIME_DIR", str(Path.home() / ".cache/vdi-terminal")))
runtime.mkdir(parents=True, exist_ok=True)
# Same lock for startup and the systemd service. Never run competing guards.
with (runtime / "vdi-display-guard.lock").open("a") as lock:
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
log("display guard already running")
return 0
stop = False
def request_stop(signum, frame):
nonlocal stop
stop = True
signal.signal(signal.SIGTERM, request_stop)
signal.signal(signal.SIGINT, request_stop)
guard = Guard()
deadline = time.monotonic() + 20
while not stop:
if guard.step(time.monotonic()) and args.once:
return 0
if args.once and time.monotonic() >= deadline:
return 1
time.sleep(POLL_SECONDS)
return 0
if __name__ == "__main__":
sys.exit(main())
+22 -20
View File
@@ -3,26 +3,28 @@
export DISPLAY="${DISPLAY:-:0}"
export XAUTHORITY="${XAUTHORITY:-/home/armbian/.Xauthority}"
# Match the local output to the 2K Moonlight stream.
# This kernel rejects the monitor's 241.70 MHz timing but accepts the 297 MHz
# PLL rate. Longer vertical blanking produces 2560x1440 at approximately 60 Hz.
# HDMI can appear a few seconds after the desktop autostart entry runs.
: >/tmp/vdi-display-mode.log
mode="2560x1440_60_fixed297"
for _ in $(seq 1 20); do
if xrandr --query 2>>/tmp/vdi-display-mode.log | grep -q '^HDMI-1 connected'; then
xrandr --newmode "$mode" 297.00 \
2560 2608 2640 2720 1440 1443 1448 1820 \
+HSync -VSync >>/tmp/vdi-display-mode.log 2>&1 || true
xrandr --addmode HDMI-1 "$mode" >>/tmp/vdi-display-mode.log 2>&1 || true
if xrandr --output HDMI-1 --mode "$mode" >>/tmp/vdi-display-mode.log 2>&1; then
break
fi
xrandr --output HDMI-1 --mode 1920x1080 --rate 60 \
>>/tmp/vdi-display-mode.log 2>&1 || true
fi
sleep 1
done
# Optional verified rendering deployment. Parse an allowlist as data; never
# source/eval configuration. Explicit environment overrides the saved defaults.
render_conf="${VDI_RENDER_CONF:-$HOME/.config/vdi-terminal/render.conf}"
if [ -r "$render_conf" ]; then
while IFS='=' read -r key value || [ -n "$key" ]; do
case "$key" in
profile) export VDI_STREAM_PROFILE="${VDI_STREAM_PROFILE:-$value}" ;;
stats) export VDI_STREAM_STATS="${VDI_STREAM_STATS:-$value}" ;;
program) export VDI_MOONLIGHT_PROGRAM="${VDI_MOONLIGHT_PROGRAM:-$value}" ;;
decoder) export VDI_MOONLIGHT_DECODER="${VDI_MOONLIGHT_DECODER:-$value}" ;;
codec) export VDI_MOONLIGHT_CODEC="${VDI_MOONLIGHT_CODEC:-$value}" ;;
esac
done < "$render_conf"
fi
# Use the same idempotent recovery for boot and HDMI hotplug. The user service
# supervises ongoing recovery; a singleton lock prevents competing instances.
"$(dirname "$0")/vdi-display-guard" --once >>/tmp/vdi-display-mode.log 2>&1 || true
if ! systemctl --user start vdi-display-guard.service >>/tmp/vdi-display-mode.log 2>&1; then
echo 'WARNING: display guard service unavailable; only boot recovery active' \
>>/tmp/vdi-display-mode.log
fi
exec /home/armbian/vdi-terminal/bin/vdi-kiosk-monitor -- \
/home/armbian/vdi-terminal/bin/kiosk-shell
+54 -7
View File
@@ -3,7 +3,7 @@
#
# 职责:
# 1. 读取本地配置(主机、应用、分辨率、帧率、码率、编解码)
# 2. 以软件解码拉起 moonlight-qt,全屏串流云桌面
# 2. 按配置拉起 Moonlight,全屏串流云桌面
# 3. 写心跳文件,供 vdi-kiosk-monitor 判断是否假死
# 4. 检测串流假死(CPU 与网络均无流量)并杀掉重连
#
@@ -12,7 +12,8 @@
#
# 说明:RK3528 当前没有可用的 V4L2 request/m2m 硬解设备,moonlight-qt 的
# --video-decoder auto 会选到 h264_v4l2m2m 并报 "Could not find a valid device",
# 因此配置里显式指定 decoder=software。接入 MPP/VPU 硬解后再改回 auto。
# 因此系统客户端默认 decoder=software;验证后的独立 RKMPP 客户端
# 通过 VDI_MOONLIGHT_PROGRAM 与 decoder=hardware 显式启用。
set -u
@@ -38,9 +39,10 @@ DECODER="software"
DISPLAY_MODE="fullscreen"
AUDIO=1
ABSOLUTE_MOUSE=1
VSYNC=0
FRAME_PACING=0
VSYNC=1
FRAME_PACING=1
PERFORMANCE_OVERLAY=0
PROFILE=""
RETRY=3
RETRY_DELAY=5
HANG_TIMEOUT=90
@@ -76,6 +78,7 @@ load_config() {
vsync) VSYNC="$value" ;;
frame-pacing) FRAME_PACING="$value" ;;
performance-overlay) PERFORMANCE_OVERLAY="$value" ;;
profile) PROFILE="$value" ;;
retry) RETRY="$value" ;;
retry-delay) RETRY_DELAY="$value" ;;
hang-timeout) HANG_TIMEOUT="$value" ;;
@@ -84,6 +87,33 @@ load_config() {
done < "$CONF"
}
apply_profile() {
case "$PROFILE" in
smooth)
VSYNC=1
FRAME_PACING=1
;;
low-latency)
VSYNC=0
FRAME_PACING=0
;;
diagnostic)
VSYNC=1
FRAME_PACING=1
PERFORMANCE_OVERLAY=1
;;
"")
# 兼容旧配置:没有profile时保留moonlight.conf中的显式开关。
;;
*)
log "未知串流 profile=$PROFILE,回退 smooth"
PROFILE=smooth
VSYNC=1
FRAME_PACING=1
;;
esac
}
# 默认路由出接口的累计收字节数,用于判断串流是否还有数据流入
rx_bytes() {
iface=$(awk '$2=="00000000" {print $1; exit}' /proc/net/route)
@@ -145,7 +175,7 @@ disable_screensaver() {
build_cmd() {
# moonlight-qt stream 只接受 <host> <app> 位置参数,没有 --port 选项,
# 服务端端口由 moonlight 自身按 GameStream 协议协商。
CMD="moonlight-qt stream $HOST $APP"
CMD="${VDI_MOONLIGHT_PROGRAM:-moonlight-qt} stream $HOST $APP"
CMD="$CMD --resolution ${WIDTH}x${HEIGHT}"
CMD="$CMD --fps $FPS"
CMD="$CMD --bitrate $BITRATE"
@@ -155,7 +185,7 @@ build_cmd() {
CMD="$CMD --keep-awake"
[ "$ABSOLUTE_MOUSE" = "1" ] && CMD="$CMD --absolute-mouse"
[ "$AUDIO" = "1" ] && CMD="$CMD --audio-on-host" || CMD="$CMD --no-audio-on-host"
# 低延迟开关:默认关闭 vsync 与 frame-pacing,削减帧队列/呈现延迟
# 按profile选择同步策略;默认保持当前稳定档。
[ "$VSYNC" = "1" ] && CMD="$CMD --vsync" || CMD="$CMD --no-vsync"
[ "$FRAME_PACING" = "1" ] && CMD="$CMD --frame-pacing" || CMD="$CMD --no-frame-pacing"
[ "$PERFORMANCE_OVERLAY" = "1" ] && CMD="$CMD --performance-overlay"
@@ -163,7 +193,7 @@ build_cmd() {
run_stream() {
build_cmd
log "启动串流: $CMD"
log "启动串流 profile=${PROFILE:-config} decoder=$DECODER codec=$CODEC vsync=$VSYNC frame-pacing=$FRAME_PACING renderer=${SDL_RENDER_DRIVER:-} video-driver=${SDL_VIDEODRIVER:-}: $CMD"
# shellcheck disable=SC2086
$CMD >> "$LOG_FILE" 2>&1 &
ML_PID=$!
@@ -220,6 +250,19 @@ stop)
esac
load_config
PROFILE="${VDI_STREAM_PROFILE:-$PROFILE}"
apply_profile
DECODER="${VDI_MOONLIGHT_DECODER:-$DECODER}"
CODEC="${VDI_MOONLIGHT_CODEC:-$CODEC}"
case "$DECODER" in
software|hardware|auto) ;;
*) log "未知解码器 $DECODER,回退 software"; DECODER=software ;;
esac
case "$CODEC" in
H.264|HEVC|AV1|auto) ;;
*) log "未知视频编码 $CODEC,回退 H.264"; CODEC=H.264 ;;
esac
PERFORMANCE_OVERLAY="${VDI_STREAM_STATS:-$PERFORMANCE_OVERLAY}"
if [ -z "$HOST" ]; then
log "配置缺失或缺少 host: $CONF"
exit 2
@@ -227,6 +270,10 @@ fi
export DISPLAY="${VDI_DISPLAY:-:0}"
export XAUTHORITY="${VDI_XAUTHORITY:-$HOME/.Xauthority}"
export SDL_RENDER_DRIVER="${VDI_SDL_RENDER_DRIVER:-opengles2}"
export SDL_VIDEO_X11_NET_WM_BYPASS_COMPOSITOR="${VDI_SDL_BYPASS_COMPOSITOR:-1}"
[ -n "${VDI_SDL_FORCE_EGL:-}" ] && export SDL_VIDEO_X11_FORCE_EGL="$VDI_SDL_FORCE_EGL"
[ -n "${VDI_SDL_VIDEODRIVER:-}" ] && export SDL_VIDEODRIVER="$VDI_SDL_VIDEODRIVER"
disable_screensaver
kill_stale
@@ -0,0 +1,20 @@
#!/bin/sh
# Select explicitly after RKMPP decode + real stream validation. Does not replace
# the distribution Moonlight or add a second cursor.
set -eu
task_root="${VDI_RKMPP_BUILD_ROOT:-$HOME/vdi-build/rkmpp-experiment}"
client="${VDI_RKMPP_CLIENT:-$task_root/build/moonlight/app/moonlight}"
if [ ! -x "$client" ] || [ ! -r /dev/mpp_service ] || [ ! -w /dev/mpp_service ]; then
echo 'RKMPP client or device access unavailable' >&2
exit 78
fi
export LD_LIBRARY_PATH="$task_root/runtime/lib:$task_root/deps/usr/lib/aarch64-linux-gnu${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
export QML2_IMPORT_PATH="$task_root/deps/usr/lib/aarch64-linux-gnu/qt5/qml${QML2_IMPORT_PATH:+:$QML2_IMPORT_PATH}"
export QT_PLUGIN_PATH="$task_root/deps/usr/lib/aarch64-linux-gnu/qt5/plugins${QT_PLUGIN_PATH:+:$QT_PLUGIN_PATH}"
export GL_IS_SLOW="${GL_IS_SLOW:-0}"
export H264_DECODER_HINT=h264_rkmpp
export HEVC_DECODER_HINT=hevc_rkmpp
# Only affects unpaced rendering in the patched client. Leave the last presented
# frame and decoder references intact; replace only waiting decoded frames.
export VDI_LATEST_FRAME="${VDI_LATEST_FRAME:-1}"
exec "$client" "$@"
@@ -82,10 +82,10 @@ void DesktopListPage::rebuildCards() {
for (const DesktopInfo& d : desktops_) {
auto* card = new QFrame(this);
card->setObjectName(QStringLiteral("desktopCard"));
card->setMinimumHeight(96);
card->setStyleSheet(QStringLiteral(
"QFrame { background: #1a2233; border: 1px solid #2b3654; border-radius: 10px; }"
"QFrame:hover { border: 1px solid #2f6feb; }"
"QFrame#desktopCard { background: #1a2233; border: 1px solid #2b3654; border-radius: 10px; }"
"QLabel { border: none; background: transparent; }"));
auto* row = new QHBoxLayout(card);
+2 -6
View File
@@ -64,7 +64,6 @@ void MainWindow::setupUi() {
QStringLiteral("QWidget { background: #0f1420; color: #e6e9ef; }"
"QLineEdit { background: #1a2233; border: 1px solid #2b3654; "
"border-radius: 4px; padding: 8px; color: #e6e9ef; }"
"QLineEdit:focus { border: 1px solid #2f6feb; }"
"QPushButton { background: #232d44; border: 1px solid #2b3654; "
"border-radius: 6px; color: #e6e9ef; }"
"QPushButton:hover { background: #2b3654; }"));
@@ -289,11 +288,8 @@ void MainWindow::onEditDesktopRequested(const QString& desktopId) {
}
bool MainWindow::startMoonlightSession(const ConnectionTicket& ticket, quint64 attempt) {
MoonlightSession::Options opts;
opts.decoder = QStringLiteral("software");
opts.videoCodec = QStringLiteral("H.264");
opts.audioOnHost = true;
moonlightSession_->setOptions(opts);
moonlightSession_->setOptions(MoonlightSession::optionsFromEnvironment(
QProcessEnvironment::systemEnvironment()));
phaseClock_.restart();
connectionPhase_ = ConnectionPhase::kProcessStart;
+98 -25
View File
@@ -14,7 +14,11 @@ constexpr int kReadyStableMs = 750;
constexpr int kTerminateGraceMs = 2000;
bool isTestDecodeLine(const QByteArray& line) {
return line.toLower().contains("test decode successful");
const QByteArray lower = line.toLower();
// Hardware decoders use a separate test context. Upstream logs this only
// after that context has successfully decoded and validated a test frame.
return lower.contains("test decode successful") ||
lower.contains("not reusing test decoder for ");
}
bool isRendererLine(const QByteArray& line) {
@@ -105,6 +109,66 @@ void MoonlightSession::setOptions(const Options& options) {
options_ = options;
}
MoonlightSession::Options MoonlightSession::optionsFromEnvironment(
const QProcessEnvironment& environment) {
Options options;
const QString profile = environment.value(QStringLiteral("VDI_STREAM_PROFILE"));
if (profile == QStringLiteral("low-latency")) {
options.profile = profile;
options.vsync = false;
options.framePacing = false;
} else if (profile == QStringLiteral("diagnostic")) {
options.profile = profile;
options.performanceOverlay = true;
}
// Statistics must be independently selectable for a fair profile A/B test.
if (environment.contains(QStringLiteral("VDI_STREAM_STATS"))) {
options.performanceOverlay = environment.value(QStringLiteral("VDI_STREAM_STATS")) == QStringLiteral("1");
}
const QString decoder = environment.value(QStringLiteral("VDI_MOONLIGHT_DECODER"));
if (QStringList{QStringLiteral("software"), QStringLiteral("hardware"),
QStringLiteral("auto")}.contains(decoder)) {
options.decoder = decoder;
}
const QString codec = environment.value(QStringLiteral("VDI_MOONLIGHT_CODEC"));
if (QStringList{QStringLiteral("H.264"), QStringLiteral("HEVC"),
QStringLiteral("AV1"), QStringLiteral("auto")}.contains(codec)) {
options.videoCodec = codec;
}
const QString program = environment.value(QStringLiteral("VDI_MOONLIGHT_PROGRAM"));
if (!program.isEmpty()) {
options.program = program;
}
return options;
}
QStringList MoonlightSession::buildStreamArguments(const ConnectionTicket& ticket,
const Options& options) {
const int bitrateKbps = ticket.width >= 2560 ? 30000 : 20000;
QStringList args;
args << QStringLiteral("stream")
<< ticket.endpoint
<< ticket.app
<< QStringLiteral("--resolution")
<< QStringLiteral("%1x%2").arg(ticket.width).arg(ticket.height)
<< QStringLiteral("--fps") << QString::number(ticket.fps)
<< QStringLiteral("--bitrate") << QString::number(bitrateKbps)
<< QStringLiteral("--display-mode") << QStringLiteral("fullscreen")
<< QStringLiteral("--video-codec") << options.videoCodec
<< QStringLiteral("--video-decoder") << options.decoder
<< QStringLiteral("--absolute-mouse")
<< QStringLiteral("--keep-awake")
<< (options.vsync ? QStringLiteral("--vsync") : QStringLiteral("--no-vsync"))
<< (options.framePacing ? QStringLiteral("--frame-pacing")
: QStringLiteral("--no-frame-pacing"));
if (options.performanceOverlay) {
args << QStringLiteral("--performance-overlay");
}
args << (options.audioOnHost ? QStringLiteral("--audio-on-host")
: QStringLiteral("--no-audio-on-host"));
return args;
}
bool MoonlightSession::isRunning() const {
return process_->state() != QProcess::NotRunning;
}
@@ -156,34 +220,26 @@ bool MoonlightSession::start(const ConnectionTicket& ticket, EventCallback callb
readyTimer_->stop();
killTimer_->stop();
process_->setProgram(QStringLiteral("moonlight-qt"));
process_->setProgram(options_.program);
QProcessEnvironment environment = QProcessEnvironment::systemEnvironment();
environment.insert(QStringLiteral("GL_IS_SLOW"), QStringLiteral("0"));
environment.insert(QStringLiteral("SDL_RENDER_DRIVER"), QStringLiteral("opengles2"));
environment.insert(QStringLiteral("SDL_RENDER_DRIVER"),
environment.value(QStringLiteral("VDI_SDL_RENDER_DRIVER"), QStringLiteral("opengles2")));
// Xorg下绕过桌面合成器;Wayland/KMS部署时可通过SDL_VIDEODRIVER切换,
// 不在客户端里硬编码会话类型,避免当前Xorg镜像无法启动。
environment.insert(QStringLiteral("SDL_VIDEO_X11_NET_WM_BYPASS_COMPOSITOR"),
environment.value(QStringLiteral("VDI_SDL_BYPASS_COMPOSITOR"), QStringLiteral("1")));
const QString forceEgl = qEnvironmentVariable("VDI_SDL_FORCE_EGL");
if (!forceEgl.isEmpty()) {
environment.insert(QStringLiteral("SDL_VIDEO_X11_FORCE_EGL"), forceEgl);
}
const QString sdlVideoDriver = qEnvironmentVariable("VDI_SDL_VIDEODRIVER");
if (!sdlVideoDriver.isEmpty()) {
environment.insert(QStringLiteral("SDL_VIDEODRIVER"), sdlVideoDriver);
}
process_->setProcessEnvironment(environment);
const int bitrateKbps = ticket.width >= 2560 ? 30000 : 20000;
QStringList args;
args << QStringLiteral("stream")
<< ticket.endpoint
<< ticket.app
<< QStringLiteral("--resolution")
<< QStringLiteral("%1x%2").arg(ticket.width).arg(ticket.height)
<< QStringLiteral("--fps") << QString::number(ticket.fps)
<< QStringLiteral("--bitrate") << QString::number(bitrateKbps)
<< QStringLiteral("--display-mode") << QStringLiteral("fullscreen")
<< QStringLiteral("--video-codec") << options_.videoCodec
<< QStringLiteral("--video-decoder") << options_.decoder
<< QStringLiteral("--absolute-mouse")
<< QStringLiteral("--keep-awake");
args << (options_.vsync ? QStringLiteral("--vsync") : QStringLiteral("--no-vsync"));
args << (options_.framePacing ? QStringLiteral("--frame-pacing")
: QStringLiteral("--no-frame-pacing"));
if (options_.performanceOverlay) {
args << QStringLiteral("--performance-overlay");
}
args << (options_.audioOnHost ? QStringLiteral("--audio-on-host")
: QStringLiteral("--no-audio-on-host"));
const QStringList args = buildStreamArguments(ticket, options_);
process_->setArguments(args);
const QString logDir = QDir::homePath() + QStringLiteral("/vdi-terminal/logs");
@@ -191,6 +247,23 @@ bool MoonlightSession::start(const ConnectionTicket& ticket, EventCallback callb
logPath_ = logDir + QStringLiteral("/moonlight-%1.log")
.arg(QDate::currentDate().toString(QStringLiteral("yyyyMMdd")));
// 把最终生效的环境和参数写入日志,避免只看Moonlight叠图时无法判断
// 产品入口到底使用了哪一套呈现策略。
QFile launchLog(logPath_);
if (launchLog.open(QIODevice::WriteOnly | QIODevice::Append | QIODevice::Text)) {
QTextStream stream(&launchLog);
stream << "\n[VDI launch]\n"
<< "profile=" << options_.profile << "\n"
<< "program=" << options_.program << "\n"
<< "decoder=" << options_.decoder << "\n"
<< "codec=" << options_.videoCodec << "\n"
<< "vsync=" << (options_.vsync ? "1" : "0") << "\n"
<< "frame-pacing=" << (options_.framePacing ? "1" : "0") << "\n"
<< "renderer=" << environment.value(QStringLiteral("SDL_RENDER_DRIVER")) << "\n"
<< "video-driver=" << environment.value(QStringLiteral("SDL_VIDEODRIVER")) << "\n"
<< "args=" << args.join(QLatin1Char(' ')) << "\n";
}
process_->start();
return true;
}
@@ -8,6 +8,8 @@
#include <QByteArray>
#include <QProcess>
#include <QProcessEnvironment>
#include <QStringList>
#include <QString>
#include <functional>
@@ -37,7 +39,13 @@ class MoonlightSession : public QObject {
Q_OBJECT
public:
// 产品入口与调试脚本遵循相同的profile语义。
// smooth:优先稳定呈现,适合当前2K20/2K30;
// low-latency:关闭客户端额外的同步等待,适合1080p60 A/B;
// diagnostic:在smooth基础上打开Moonlight性能叠图。
struct Options {
QString program = QStringLiteral("moonlight-qt");
QString profile = QStringLiteral("smooth");
bool vsync = true;
bool framePacing = true;
bool performanceOverlay = false;
@@ -64,6 +72,9 @@ public:
~MoonlightSession() override;
void setOptions(const Options& options);
static Options optionsFromEnvironment(const QProcessEnvironment& environment);
static QStringList buildStreamArguments(const ConnectionTicket& ticket,
const Options& options);
bool start(const ConnectionTicket& ticket, EventCallback callback);
void stop();
bool isRunning() const;
@@ -0,0 +1,140 @@
"""Failure and hotplug scenarios; no real display or sleep required."""
from pathlib import Path
import runpy
import unittest
from unittest.mock import patch
guard_module = runpy.run_path(str(Path(__file__).resolve().parents[1] / "scripts/vdi-display-guard"))
Guard = guard_module["Guard"]
MODE = guard_module["MODE"]
class FakeRandR:
def __init__(self):
self.connected = True
self.defined = True
self.attached = True
self.active = "1920x1440"
self.writes = []
self.fail_apply = False
self.query_error = False
self.keep_wrong_mode = False
def __call__(self, *args):
if args == ("--current",):
if self.query_error:
raise RuntimeError("Can't open display :0")
status = "connected primary " + ("2560x1440+0+0" if self.active == MODE else "1920x1440+0+0")
if not self.connected:
status = "disconnected"
query = "Screen 0: current 1920 x 1440\nHDMI-1 " + status + " (normal)\n"
if self.connected:
query += " 1920x1440 75.00" + ("*" if self.active != MODE else "") + "\n"
if self.attached:
query += " " + MODE + " 60.00" + ("*" if self.active == MODE else "") + "\n"
if self.defined and (not self.attached or not self.connected):
query += " " + MODE + " (0x189) 297.000MHz +HSync -VSync\n"
query += " h: width 2560 start 2608\n v: height 1440 start 1443\n"
return query
self.writes.append(args)
if args[0] == "--newmode":
if self.defined:
raise RuntimeError("BadName: duplicate mode")
self.defined = True
elif args[0] == "--addmode":
assert self.defined
self.attached = True
elif args[0] == "--output":
if self.fail_apply:
raise RuntimeError("Configure crtc failed")
assert self.attached
if not self.keep_wrong_mode:
self.active = MODE
return ""
class DisplayGuardTests(unittest.TestCase):
def setUp(self):
self.xr = FakeRandR()
self.logs = []
self.guard = Guard(self.xr, self.logs.append)
def tick(self, now):
with patch("time.monotonic", return_value=now):
return self.guard.step(now)
def test_mode_drift_restores_after_settling(self):
self.assertFalse(self.tick(0))
self.assertEqual(self.xr.writes, [])
self.assertTrue(self.tick(2))
self.assertEqual(len(self.xr.writes), 1)
self.assertEqual(self.xr.active, MODE)
def test_healthy_display_does_not_modeset_or_repeat_logs(self):
self.xr.active = MODE
for now in range(0, 100, 2):
self.assertTrue(self.tick(now))
self.assertEqual(self.xr.writes, [])
self.assertEqual(len(self.logs), 1)
def test_disconnected_never_writes_and_reconnect_settles(self):
self.tick(0)
self.xr.connected = False
for now in range(2, 20, 2):
self.assertFalse(self.tick(now))
self.assertEqual(self.xr.writes, [])
self.xr.connected = True
self.assertFalse(self.tick(20))
self.assertTrue(self.tick(22))
def test_new_x_server_recreates_and_attaches_mode(self):
self.xr.defined = self.xr.attached = False
self.tick(0)
self.assertTrue(self.tick(2))
self.assertEqual([w[0] for w in self.xr.writes], ["--newmode", "--addmode", "--output"])
def test_hotplug_detached_mode_is_not_created_twice(self):
self.xr.attached = False
self.tick(0)
self.assertTrue(self.tick(2))
self.assertEqual([w[0] for w in self.xr.writes], ["--addmode", "--output"])
def test_late_desktop_settings_overwrite_is_corrected_again(self):
self.tick(0)
self.assertTrue(self.tick(2))
self.xr.active = "1920x1440"
self.assertFalse(self.tick(4))
self.assertTrue(self.tick(6))
self.assertEqual(len(self.xr.writes), 2)
def test_modeset_failure_backs_off_and_eventually_recovers(self):
self.xr.fail_apply = True
self.tick(0)
self.tick(2) # retry at 4
self.tick(4) # retry at 8
self.tick(6) # no write
self.assertEqual(len(self.xr.writes), 2)
self.xr.fail_apply = False
self.assertTrue(self.tick(8))
self.assertEqual(len(self.xr.writes), 3)
def test_apply_success_must_be_verified(self):
self.xr.keep_wrong_mode = True
self.tick(0)
self.assertFalse(self.tick(2))
self.assertIn("not retained", self.logs[-1])
def test_x11_unavailable_does_not_write_and_then_recovers(self):
self.xr.query_error = True
for now in range(0, 20, 2):
self.assertFalse(self.tick(now))
self.assertEqual(self.xr.writes, [])
self.assertEqual(len(self.logs), 1)
self.xr.query_error = False
self.assertFalse(self.tick(20))
self.assertTrue(self.tick(22))
if __name__ == "__main__":
unittest.main()
@@ -10,7 +10,11 @@ class MoonlightSessionTest : public QObject {
private slots:
void parsesSplitReadyMarkers();
void requiresBothReadyMarkers();
void acceptsSeparateHardwareTestDecoder();
void rejectsSecondStart();
void buildsConsistentStreamArguments();
void selectsProfileWithoutLosingStats();
void rejectsInvalidDecoderOverride();
};
void MoonlightSessionTest::parsesSplitReadyMarkers() {
@@ -38,6 +42,19 @@ void MoonlightSessionTest::requiresBothReadyMarkers() {
QVERIFY(!parser.isReady());
}
void MoonlightSessionTest::acceptsSeparateHardwareTestDecoder() {
MoonlightOutputParser parser;
parser.append("Using custom H.264 decoder (H264_DECODER_HINT): h264_rkmpp\n");
QVERIFY(!parser.isReady());
parser.append("Not reusing test decoder for h264_rkmpp\n");
QVERIFY(parser.hasTestDecode());
QVERIFY(!parser.isReady());
parser.append("Renderer 'EGL' with 'DRM' backend chosen\n");
QVERIFY(parser.isReady());
parser.append("Connection terminated: -1\n");
QVERIFY(!parser.isReady());
}
void MoonlightSessionTest::rejectsSecondStart() {
QTemporaryDir tempDir;
QVERIFY(tempDir.isValid());
@@ -90,5 +107,61 @@ void MoonlightSessionTest::rejectsSecondStart() {
qputenv("PATH", oldPath);
}
void MoonlightSessionTest::buildsConsistentStreamArguments() {
ConnectionTicket ticket;
ticket.endpoint = QStringLiteral("192.168.1.70");
ticket.app = QStringLiteral("Desktop");
ticket.width = 2560;
ticket.height = 1440;
ticket.fps = 30;
MoonlightSession::Options options;
options.profile = QStringLiteral("low-latency");
options.decoder = QStringLiteral("software");
options.videoCodec = QStringLiteral("H.264");
options.vsync = false;
options.framePacing = false;
options.performanceOverlay = true;
const QStringList args = MoonlightSession::buildStreamArguments(ticket, options);
QVERIFY(args.contains(QStringLiteral("--resolution")));
QVERIFY(args.contains(QStringLiteral("2560x1440")));
QVERIFY(args.contains(QStringLiteral("--fps")));
QVERIFY(args.contains(QStringLiteral("30")));
QVERIFY(args.contains(QStringLiteral("--bitrate")));
QVERIFY(args.contains(QStringLiteral("30000")));
QVERIFY(args.contains(QStringLiteral("--no-vsync")));
QVERIFY(args.contains(QStringLiteral("--no-frame-pacing")));
QVERIFY(args.contains(QStringLiteral("--performance-overlay")));
QVERIFY(args.contains(QStringLiteral("--absolute-mouse")));
}
void MoonlightSessionTest::selectsProfileWithoutLosingStats() {
QProcessEnvironment environment;
environment.insert(QStringLiteral("VDI_STREAM_PROFILE"), QStringLiteral("low-latency"));
environment.insert(QStringLiteral("VDI_STREAM_STATS"), QStringLiteral("1"));
auto options = MoonlightSession::optionsFromEnvironment(environment);
QVERIFY(!options.vsync);
QVERIFY(!options.framePacing);
QVERIFY(options.performanceOverlay);
environment.insert(QStringLiteral("VDI_STREAM_PROFILE"), QStringLiteral("smooth"));
options = MoonlightSession::optionsFromEnvironment(environment);
QVERIFY(options.vsync);
QVERIFY(options.framePacing);
QVERIFY(options.performanceOverlay);
}
void MoonlightSessionTest::rejectsInvalidDecoderOverride() {
QProcessEnvironment environment;
environment.insert(QStringLiteral("VDI_MOONLIGHT_DECODER"), QStringLiteral("h264_rkmpp"));
environment.insert(QStringLiteral("VDI_STREAM_PROFILE"), QStringLiteral("invalid"));
auto options = MoonlightSession::optionsFromEnvironment(environment);
QCOMPARE(options.decoder, QStringLiteral("software"));
QCOMPARE(options.profile, QStringLiteral("smooth"));
environment.insert(QStringLiteral("VDI_MOONLIGHT_DECODER"), QStringLiteral("hardware"));
options = MoonlightSession::optionsFromEnvironment(environment);
QCOMPARE(options.decoder, QStringLiteral("hardware"));
}
QTEST_GUILESS_MAIN(MoonlightSessionTest)
#include "test_moonlight_session.moc"
+40
View File
@@ -0,0 +1,40 @@
# VDI Moonlight 交互优化补丁
本目录维护当前 RK3528 / Xorg POC 私有客户端的源码修改,不替换系统 Moonlight。
基线:Moonlight Qt v6.2.0 `de2467e433821664cdd2224aad8c89a625be1ad9`,其检出的 moonlight-common-c 子模块保持对应版本。MPP、FFmpeg 版本仍由 `tools/build-rkmpp-client.sh` 固定。
## 补丁
- `0001-vdi-input-and-latest-frame.patch` 在 Moonlight Qt 根目录应用。鼠标移动只合并队首连续事件,遇到按钮、键盘、窗口、呈现事件或其他鼠标设备立即停止;每次最多额外合并 64 个事件。绝对模式在发送按键前使用该按键事件的坐标。低延迟队列可选地把已等待的解码帧替换成新帧。
- `0002-vdi-absolute-click-barrier.patch` 在 `moonlight-common-c/moonlight-common-c` 中应用。鼠标按钮封住此前的位置批次,后续移动只能进入新批次;发送线程只锁定自己的批次,避免旧包出队时误清除新批次。所有包依旧走原可靠鼠标通道,保留 1ms 合并限流和原坐标边界修正。
- `0003-vdi-input-priority.patch` 在 Qt 根目录应用。可选地让帧就绪后的连续输入先执行,最多 32 条或 2ms,不能跨窗口/焦点/其他事件。`VDI_INPUT_PRIORITY=1` 才启用,默认关闭。另提供 `VDI_INPUT_TIMING=1` 的汇总统计,区分 SDL 输入派发年龄与主线程呈现调用耗时,不代表端到端延迟。
2026-11-03 的现场 RKMPP/EGL 使用独立 PacerRender 线程,诊断会话主线程 render 调用数为 0;因此第三补丁的新调度没有已证明的现场收益,未启用。诊断候选二进制保存为 `moonlight.input-priority-20261103`;默认客户端已恢复到本轮前 SHA256 `35de7b1a4ade583823b23b5f2e012307f6d0a3848c8eb97bd6d4de9660afa00f`。详细计划/初测限制见 `docs/mouse-optimization-plan-20261103.md`,独立光标接入设计见 `docs/independent-cursor-design-20261103.md`。
`VDI_LATEST_FRAME=1` 只在没有 VSync pacing source 的呈现队列生效。最多保留一个待呈现解码帧,丢弃数计入原 `pacerDroppedFrames`。不丢压缩包,不改变 H.264 参考链,也不提前释放正在呈现或延迟释放的上一帧。未设置或设为 `0` 时保留上游帧队列策略;kiosk 私有 wrapper 默认设为 `1`。smooth 档仍使用原 pacing 行为。
这些补丁不增加本地光标,不隐藏 Windows 光标,不改变控制协议。远程光标仍随视频显示,其延迟仍受服务器捕获/编码、网络、解码和显示影响。
## 构建与测试
在盒子上运行构建器时,将本目录的 `patches/` 一起复制过去,通过 `VDI_MOONLIGHT_PATCH_DIR` 指定目录。构建器先检查固定提交,再检查补丁:已完整应用则跳过;上下文不匹配则失败,不强行覆盖其他修改。
```bash
VDI_BUILD_STAGE=moonlight \
VDI_MOONLIGHT_PATCH_DIR="$HOME/vdi-build/rkmpp-experiment/patches" \
bash tools/build-rkmpp-client.sh
```
`tests/` 的文件复制到盒子 `$VDI_RKMPP_BUILD_ROOT/latency-tests/` 后运行:
```bash
bash tools/test-moonlight-latency.sh
VDI_TEST_SANITIZE=1 ASAN_OPTIONS=detect_leaks=1 bash tools/test-moonlight-latency.sh
```
测试直接包含实际修改后的 SDL helper、AVFrame 队列 helper 与 `InputStream.c`,并链接客户端实际使用的 native 队列/线程库。覆盖跨点击合并、键盘/呈现边界、工作量上限、触摸隔离、200 帧的积压替换、已呈现帧所有权及 1,000 轮“移动→按下→移动→释放→移动”的发送线程滞后场景。测试显式启用断言,避免上游 release 头文件的 NDEBUG 使检查失效。
新增 `test_input_priority.cpp` 验证优先调度不越过焦点/窗口/退出/解码器重置事件,保持输入 FIFO,隔离其他窗口与合成触摸,并验证数量与时间限制。普通与 ASAN 版本均通过。当前 RKMPP/EGL 没有主线程呈现事件,新调度开关不能用作该路径的性能提升宣传。
现场数据、局限与完整回退入口见 `docs/input-rendering-optimization-20261103.md`。
@@ -0,0 +1,271 @@
--- a/app/streaming/input/mouse.cpp
+++ b/app/streaming/input/mouse.cpp
@@ -1,4 +1,5 @@
#include "input.h"
+#include "vdi_mouse_batch.h"
#include <Limelight.h>
#include "SDL_compat.h"
@@ -84,6 +85,11 @@
button = BUTTON_RIGHT;
}
+ // Button coordinates are captured with this event, not a later mouse state.
+ if (m_AbsoluteMouseMode) {
+ sendAbsoluteMousePosition(event->x, event->y);
+ }
+
LiSendMouseButtonEvent(event->state == SDL_PRESSED ?
BUTTON_ACTION_PRESS :
BUTTON_ACTION_RELEASE,
@@ -101,81 +107,78 @@
return;
}
- // Batch all pending mouse motion events to save CPU time
- Sint32 x = event->x, y = event->y, xrel = event->xrel, yrel = event->yrel;
- SDL_Event nextEvent;
- while (SDL_PeepEvents(&nextEvent, 1, SDL_GETEVENT, SDL_MOUSEMOTION, SDL_MOUSEMOTION) > 0) {
- event = &nextEvent.motion;
-
- // Ignore synthetic mouse events
- if (event->which != SDL_TOUCH_MOUSEID) {
- x = event->x;
- y = event->y;
- xrel += event->xrel;
- yrel += event->yrel;
- }
- }
+ // Coalesce only adjacent motions. Never consume motion past a click/key
+ // or render event, and bound work so a fast mouse cannot starve rendering.
+ SDL_MouseMotionEvent batched = *event;
+ vdiBatchMouseMotion(batched);
+ Sint32 x = batched.x, y = batched.y;
+ Sint32 xrel = batched.xrel, yrel = batched.yrel;
// We should not reference the original event anymore
event = nullptr;
if (m_AbsoluteMouseMode) {
- int windowWidth, windowHeight;
- SDL_GetWindowSize(m_Window, &windowWidth, &windowHeight);
-
- SDL_Rect src, dst;
- bool mouseInVideoRegion;
-
- src.x = src.y = 0;
- src.w = m_StreamWidth;
- src.h = m_StreamHeight;
-
- dst.x = dst.y = 0;
- dst.w = windowWidth;
- dst.h = windowHeight;
-
- // Use the stream and window sizes to determine the video region
- StreamUtils::scaleSourceToDestinationSurface(&src, &dst);
-
- mouseInVideoRegion = isMouseInVideoRegion(x, y, windowWidth, windowHeight);
-
- // Clamp motion to the video region
- x = qMin(qMax(x - dst.x, 0), dst.w);
- y = qMin(qMax(y - dst.y, 0), dst.h);
-
- // Send the mouse position update if one of the following is true:
- // a) it is in the video region now
- // b) it just left the video region (to ensure the mouse is clamped to the video boundary)
- // c) a mouse button is still down from before the cursor left the video region (to allow smooth dragging)
- Uint32 buttonState = SDL_GetMouseState(nullptr, nullptr);
- if (buttonState == 0) {
- if (m_PendingMouseButtonsAllUpOnVideoRegionLeave) {
- if (m_NeedsManualCaptureOnLeave) {
- // Stop capturing the mouse now
- SDL_CaptureMouse(SDL_FALSE);
- }
- m_PendingMouseButtonsAllUpOnVideoRegionLeave = false;
- }
- }
- if (mouseInVideoRegion || m_MouseWasInVideoRegion || m_PendingMouseButtonsAllUpOnVideoRegionLeave) {
- LiSendMousePositionEvent((short)x, (short)y, dst.w, dst.h);
- }
-
- // Adjust the cursor visibility if applicable
- if (mouseInVideoRegion ^ m_MouseWasInVideoRegion) {
- SDL_ShowCursor((mouseInVideoRegion && m_MouseCursorCapturedVisibilityState == SDL_DISABLE) ? SDL_DISABLE : SDL_ENABLE);
- if (!mouseInVideoRegion && buttonState != 0) {
- // If we still have a button pressed on leave, wait for that to come up
- // before we stop sending mouse position events.
- m_PendingMouseButtonsAllUpOnVideoRegionLeave = true;
- }
- }
-
- m_MouseWasInVideoRegion = mouseInVideoRegion;
+ sendAbsoluteMousePosition(x, y);
}
else {
LiSendMouseMoveEvent(xrel, yrel);
}
+}
+
+void SdlInputHandler::sendAbsoluteMousePosition(Sint32 x, Sint32 y)
+{
+ int windowWidth, windowHeight;
+ SDL_GetWindowSize(m_Window, &windowWidth, &windowHeight);
+
+ SDL_Rect src, dst;
+ bool mouseInVideoRegion;
+
+ src.x = src.y = 0;
+ src.w = m_StreamWidth;
+ src.h = m_StreamHeight;
+
+ dst.x = dst.y = 0;
+ dst.w = windowWidth;
+ dst.h = windowHeight;
+
+ // Use the stream and window sizes to determine the video region
+ StreamUtils::scaleSourceToDestinationSurface(&src, &dst);
+
+ mouseInVideoRegion = isMouseInVideoRegion(x, y, windowWidth, windowHeight);
+
+ // Clamp motion to the video region
+ x = qMin(qMax(x - dst.x, 0), dst.w);
+ y = qMin(qMax(y - dst.y, 0), dst.h);
+
+ // Send the mouse position update if one of the following is true:
+ // a) it is in the video region now
+ // b) it just left the video region (to ensure the mouse is clamped to the video boundary)
+ // c) a mouse button is still down from before the cursor left the video region (to allow smooth dragging)
+ Uint32 buttonState = SDL_GetMouseState(nullptr, nullptr);
+ if (buttonState == 0) {
+ if (m_PendingMouseButtonsAllUpOnVideoRegionLeave) {
+ if (m_NeedsManualCaptureOnLeave) {
+ // Stop capturing the mouse now
+ SDL_CaptureMouse(SDL_FALSE);
+ }
+ m_PendingMouseButtonsAllUpOnVideoRegionLeave = false;
+ }
+ }
+ if (mouseInVideoRegion || m_MouseWasInVideoRegion || m_PendingMouseButtonsAllUpOnVideoRegionLeave) {
+ LiSendMousePositionEvent((short)x, (short)y, dst.w, dst.h);
+ }
+
+ // Adjust the cursor visibility if applicable
+ if (mouseInVideoRegion ^ m_MouseWasInVideoRegion) {
+ SDL_ShowCursor((mouseInVideoRegion && m_MouseCursorCapturedVisibilityState == SDL_DISABLE) ? SDL_DISABLE : SDL_ENABLE);
+ if (!mouseInVideoRegion && buttonState != 0) {
+ // If we still have a button pressed on leave, wait for that to come up
+ // before we stop sending mouse position events.
+ m_PendingMouseButtonsAllUpOnVideoRegionLeave = true;
+ }
+ }
+
+ m_MouseWasInVideoRegion = mouseInVideoRegion;
}
void SdlInputHandler::handleMouseWheelEvent(SDL_MouseWheelEvent* event)
--- a/app/streaming/input/input.h
+++ b/app/streaming/input/input.h
@@ -95,6 +95,8 @@
void handleMouseButtonEvent(SDL_MouseButtonEvent* event);
void handleMouseMotionEvent(SDL_MouseMotionEvent* event);
+
+ void sendAbsoluteMousePosition(Sint32 x, Sint32 y);
void handleMouseWheelEvent(SDL_MouseWheelEvent* event);
--- /dev/null
+++ b/app/streaming/input/vdi_mouse_batch.h
@@ -0,0 +1,22 @@
+#pragma once
+#include <SDL.h>
+
+// Only the SDL main thread consumes events; other threads may append events.
+inline void vdiBatchMouseMotion(SDL_MouseMotionEvent& motion)
+{
+ for (int i = 0; i < 64; ++i) {
+ SDL_Event next;
+ if (SDL_PeepEvents(&next, 1, SDL_PEEKEVENT, SDL_FIRSTEVENT, SDL_LASTEVENT) != 1 ||
+ next.type != SDL_MOUSEMOTION || next.motion.which != motion.which ||
+ next.motion.windowID != motion.windowID || next.motion.state != motion.state) {
+ break;
+ }
+ if (SDL_PeepEvents(&next, 1, SDL_GETEVENT, SDL_FIRSTEVENT, SDL_LASTEVENT) != 1) {
+ break;
+ }
+ motion.x = next.motion.x;
+ motion.y = next.motion.y;
+ motion.xrel += next.motion.xrel;
+ motion.yrel += next.motion.yrel;
+ }
+}
--- a/app/streaming/video/ffmpeg-renderers/pacer/pacer.cpp
+++ b/app/streaming/video/ffmpeg-renderers/pacer/pacer.cpp
@@ -1,4 +1,5 @@
#include "pacer.h"
+#include "vdi_latest_frame.h"
#include "streaming/streamutils.h"
#ifdef Q_OS_WIN32
@@ -34,6 +35,7 @@
m_VsyncThread(nullptr),
m_DeferredFreeFrame(nullptr),
m_Stopping(false),
+ m_LatestFrameOnly(qEnvironmentVariableIntValue("VDI_LATEST_FRAME") == 1),
m_VsyncSource(nullptr),
m_VsyncRenderer(renderer),
m_MaxVideoFps(0),
@@ -178,7 +180,14 @@
void Pacer::enqueueFrameForRenderingAndUnlock(AVFrame *frame)
{
- dropFrameForEnqueue(m_RenderQueue);
+ if (m_LatestFrameOnly && m_VsyncSource == nullptr) {
+ // Drop only decoded, never presented frames. Keep the decoder reference
+ // chain and the deferred free of the last presented frame intact.
+ m_VideoStats->pacerDroppedFrames += vdiDiscardQueuedFrames(m_RenderQueue);
+ }
+ else {
+ dropFrameForEnqueue(m_RenderQueue);
+ }
m_RenderQueue.enqueue(frame);
m_FrameQueueLock.unlock();
@@ -261,6 +270,8 @@
bool Pacer::initialize(SDL_Window* window, int maxVideoFps, bool enablePacing)
{
+ SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "VDI latest-frame queue: %s",
+ (m_LatestFrameOnly && !enablePacing) ? "enabled" : "disabled");
m_MaxVideoFps = maxVideoFps;
m_DisplayFps = StreamUtils::getDisplayRefreshRate(window);
m_RendererAttributes = m_VsyncRenderer->getRendererAttributes();
--- a/app/streaming/video/ffmpeg-renderers/pacer/pacer.h
+++ b/app/streaming/video/ffmpeg-renderers/pacer/pacer.h
@@ -68,6 +68,7 @@
SDL_Thread* m_VsyncThread;
AVFrame* m_DeferredFreeFrame;
bool m_Stopping;
+ bool m_LatestFrameOnly;
IVsyncSource* m_VsyncSource;
IFFmpegRenderer* m_VsyncRenderer;
--- /dev/null
+++ b/app/streaming/video/ffmpeg-renderers/pacer/vdi_latest_frame.h
@@ -0,0 +1,16 @@
+#pragma once
+#include <QQueue>
+extern "C" {
+#include <libavutil/frame.h>
+}
+
+inline unsigned vdiDiscardQueuedFrames(QQueue<AVFrame*>& queue)
+{
+ unsigned dropped = 0;
+ while (!queue.isEmpty()) {
+ AVFrame* stale = queue.dequeue();
+ av_frame_free(&stale);
+ ++dropped;
+ }
+ return dropped;
+}
@@ -0,0 +1,143 @@
--- a/src/InputStream.c
+++ b/src/InputStream.c
@@ -86,11 +86,16 @@
int deltaX, deltaY;
bool dirty; // Update ready to send (queued packet holder in packetQueue)
} currentRelativeMouseState;
-static struct {
- int x, y;
- int width, height;
- bool dirty; // Update ready to send (queued packet holder in packetQueue)
-} currentAbsoluteMouseState;
+// Only this packet may absorb newer positions. A button event seals the batch.
+static PPACKET_HOLDER currentQueuedAbsoluteMousePacket;
+
+static void latchAbsoluteMousePacket(PPACKET_HOLDER holder) {
+ PltLockMutex(&batchedInputMutex);
+ if (currentQueuedAbsoluteMousePacket == holder) {
+ currentQueuedAbsoluteMousePacket = NULL;
+ }
+ PltUnlockMutex(&batchedInputMutex);
+}
// Initializes the input stream
int initializeInputStream(void) {
@@ -120,7 +125,7 @@
memset(currentGamepadSensorState, 0, sizeof(currentGamepadSensorState));
memset(&currentRelativeMouseState, 0, sizeof(currentRelativeMouseState));
- memset(&currentAbsoluteMouseState, 0, sizeof(currentAbsoluteMouseState));
+ currentQueuedAbsoluteMousePacket = NULL;
PltCreateMutex(&batchedInputMutex);
return 0;
@@ -444,24 +449,9 @@
now = PltGetMillis();
}
- PltLockMutex(&batchedInputMutex);
-
- // Populate the packet with the latest state
- holder->packet.mouseMoveAbs.x = BE16(currentAbsoluteMouseState.x);
- holder->packet.mouseMoveAbs.y = BE16(currentAbsoluteMouseState.y);
-
- // There appears to be a rounding error in GFE's scaling calculation which prevents
- // the cursor from reaching the far edge of the screen when streaming at smaller
- // resolutions with a higher desktop resolution (like streaming 720p with a desktop
- // resolution of 1080p, or streaming 720p/1080p with a desktop resolution of 4K).
- // Subtracting one from the reference dimensions seems to work around this issue.
- holder->packet.mouseMoveAbs.width = BE16(currentAbsoluteMouseState.width - 1);
- holder->packet.mouseMoveAbs.height = BE16(currentAbsoluteMouseState.height - 1);
-
- // The state change is no longer pending
- currentAbsoluteMouseState.dirty = false;
-
- PltUnlockMutex(&batchedInputMutex);
+ // Payload was filled by the producer under the same mutex. Latch
+ // only our packet; a later batch may already be queued after a click.
+ latchAbsoluteMousePacket(holder);
lastMousePacketTime = now;
}
@@ -780,52 +770,37 @@
}
PltLockMutex(&batchedInputMutex);
-
- // Overwrite the previous mouse location with the new one
- currentAbsoluteMouseState.x = x;
- currentAbsoluteMouseState.y = y;
- currentAbsoluteMouseState.width = referenceWidth;
- currentAbsoluteMouseState.height = referenceHeight;
-
- // Queue a packet holder if this is the only pending absolute mouse event
- if (!currentAbsoluteMouseState.dirty) {
- // Set the dirty flag to claim ownership of inserting the packet holder
- // and unlock to allow other threads to enqueue or process input.
- currentAbsoluteMouseState.dirty = true;
- PltUnlockMutex(&batchedInputMutex);
-
+ holder = currentQueuedAbsoluteMousePacket;
+ bool newPacket = holder == NULL;
+ if (newPacket) {
holder = allocatePacketHolder(0);
if (holder == NULL) {
- currentAbsoluteMouseState.dirty = false;
+ PltUnlockMutex(&batchedInputMutex);
return -1;
}
-
holder->channelId = CTRL_CHANNEL_MOUSE;
-
- // TODO: Send this as unreliable sequenced when we have a delayed reliable retransmission thread
holder->enetPacketFlags = ENET_PACKET_FLAG_RELIABLE;
-
holder->packet.mouseMoveAbs.header.size = BE32(sizeof(NV_ABS_MOUSE_MOVE_PACKET) - sizeof(uint32_t));
holder->packet.mouseMoveAbs.header.magic = LE32(MOUSE_MOVE_ABS_MAGIC);
holder->packet.mouseMoveAbs.unused = 0;
-
- // Remaining fields are set in the input thread based on the latest currentAbsoluteMouseState values
-
+ }
+ holder->packet.mouseMoveAbs.x = BE16(x);
+ holder->packet.mouseMoveAbs.y = BE16(y);
+ // Preserve upstream's far-edge rounding workaround.
+ holder->packet.mouseMoveAbs.width = BE16(referenceWidth - 1);
+ holder->packet.mouseMoveAbs.height = BE16(referenceHeight - 1);
+ err = 0;
+ if (newPacket) {
+ currentQueuedAbsoluteMousePacket = holder;
err = LbqOfferQueueItem(&packetQueue, holder, &holder->entry);
if (err != LBQ_SUCCESS) {
+ currentQueuedAbsoluteMousePacket = NULL;
LC_ASSERT(err == LBQ_BOUND_EXCEEDED);
Limelog("Input queue reached maximum size limit\n");
freePacketHolder(holder);
-
- // We weren't able to insert the entry, so let the next call try again
- currentAbsoluteMouseState.dirty = false;
- }
- }
- else {
- // There's already a packet holder queued to send this event
- PltUnlockMutex(&batchedInputMutex);
- err = 0;
- }
+ }
+ }
+ PltUnlockMutex(&batchedInputMutex);
// This is not thread safe, but it's not a big deal because callers that want to
// use LiSendRelativeMotionAsMousePositionEvent() must not mix these function
@@ -872,7 +847,11 @@
holder->packet.mouseButton.header.magic = LE32(holder->packet.mouseButton.header.magic);
holder->packet.mouseButton.button = (uint8_t)button;
+ PltLockMutex(&batchedInputMutex);
+ // Do not let a later position overwrite the move preceding this click.
+ currentQueuedAbsoluteMousePacket = NULL;
err = LbqOfferQueueItem(&packetQueue, holder, &holder->entry);
+ PltUnlockMutex(&batchedInputMutex);
if (err != LBQ_SUCCESS) {
LC_ASSERT(err == LBQ_BOUND_EXCEEDED);
Limelog("Input queue reached maximum size limit\n");
@@ -0,0 +1,179 @@
--- a/app/streaming/session.cpp
+++ b/app/streaming/session.cpp
@@ -39,6 +39,7 @@
#include <QImage>
#include <QGuiApplication>
#include <QCursor>
+#include "input/vdi_input_priority.h"
#include <QScreen>
#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)
@@ -1962,8 +1963,14 @@
// Hijack this thread to be the SDL main thread. We have to do this
// because we want to suspend all Qt processing until the stream is over.
+ VdiInputPriority vdiInputPriority(qEnvironmentVariableIntValue("VDI_INPUT_PRIORITY") == 1,
+ SDL_CODE_FRAME_READY);
+ VdiInputTiming vdiInputTiming(qEnvironmentVariableIntValue("VDI_INPUT_TIMING") == 1);
+ SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "VDI input priority: %s",
+ qEnvironmentVariableIntValue("VDI_INPUT_PRIORITY") == 1 ? "enabled" : "disabled");
SDL_Event event;
for (;;) {
+ if (!vdiInputPriority.takeDeferred(event, SDL_GetWindowID(m_Window))) {
#if SDL_VERSION_ATLEAST(2, 0, 18) && !defined(STEAM_LINK)
// SDL 2.0.18 has a proper wait event implementation that uses platform
// support to block on events rather than polling on Windows, macOS, X11,
@@ -1995,6 +2002,11 @@
continue;
}
#endif
+ if (vdiInputPriority.deferFrame(event)) {
+ continue;
+ }
+ }
+ vdiInputTiming.observe(event, SDL_GetWindowID(m_Window));
switch (event.type) {
case SDL_QUIT:
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION,
@@ -2005,7 +2017,9 @@
switch (event.user.code) {
case SDL_CODE_FRAME_READY:
if (m_VideoDecoder != nullptr) {
+ const auto renderStart = vdiInputTiming.startRender();
m_VideoDecoder->renderFrameOnMainThread();
+ vdiInputTiming.endRender(renderStart);
}
break;
case SDL_CODE_FLUSH_WINDOW_EVENT_BARRIER:
@@ -2308,6 +2322,7 @@
}
DispatchDeferredCleanup:
+ vdiInputTiming.report(vdiInputPriority.prioritized());
// Switch back to synchronous logging mode
StreamUtils::exitAsyncLoggingMode();
--- /dev/null
+++ b/app/streaming/input/vdi_input_priority.h
@@ -0,0 +1,121 @@
+#pragma once
+#include <SDL.h>
+#include <array>
+#include <algorithm>
+#include <cstdint>
+
+// SDL main thread only. Inputs may pass one frame-ready notification, never a
+// window/focus/quit/other notification or another frame. Input FIFO is preserved.
+class VdiInputPriority {
+public:
+ VdiInputPriority(bool enabled, Sint32 frameCode)
+ : m_Enabled(enabled), m_FrameCode(frameCode) {}
+
+ bool deferFrame(const SDL_Event& event) {
+ if (!m_Enabled || event.type != SDL_USEREVENT || event.user.code != m_FrameCode)
+ return false;
+ SDL_assert(!m_Pending);
+ m_Frame = event;
+ m_Pending = true;
+ m_Count = 0;
+ m_Start = SDL_GetPerformanceCounter();
+ return true;
+ }
+
+ bool takeDeferred(SDL_Event& event, Uint32 windowId) {
+ if (!m_Pending) return false;
+ SDL_Event next;
+ const auto elapsed = SDL_GetPerformanceCounter() - m_Start;
+ if (m_Count < 32 && elapsed < SDL_GetPerformanceFrequency() / 500 &&
+ SDL_PeepEvents(&next, 1, SDL_PEEKEVENT, SDL_FIRSTEVENT, SDL_LASTEVENT) == 1 &&
+ isInputForWindow(next, windowId) &&
+ SDL_PeepEvents(&event, 1, SDL_GETEVENT, SDL_FIRSTEVENT, SDL_LASTEVENT) == 1) {
+ ++m_Count;
+ ++m_Prioritized;
+ return true;
+ }
+ event = m_Frame;
+ m_Pending = false;
+ return true;
+ }
+
+ uint64_t prioritized() const { return m_Prioritized; }
+
+ static bool isInputForWindow(const SDL_Event& event, Uint32 windowId) {
+ switch (event.type) {
+ case SDL_MOUSEMOTION:
+ return event.motion.windowID == windowId && event.motion.which != SDL_TOUCH_MOUSEID;
+ case SDL_MOUSEBUTTONDOWN:
+ case SDL_MOUSEBUTTONUP:
+ return event.button.windowID == windowId && event.button.which != SDL_TOUCH_MOUSEID;
+ case SDL_MOUSEWHEEL:
+ return event.wheel.windowID == windowId && event.wheel.which != SDL_TOUCH_MOUSEID;
+ case SDL_KEYDOWN:
+ case SDL_KEYUP:
+ return event.key.windowID == windowId;
+ default:
+ return false;
+ }
+ }
+
+private:
+ bool m_Enabled;
+ Sint32 m_FrameCode;
+ bool m_Pending = false;
+ SDL_Event m_Frame{};
+ unsigned m_Count = 0;
+ Uint64 m_Start = 0;
+ uint64_t m_Prioritized = 0;
+};
+
+// Aggregated only when explicitly requested. Ages are SDL queue/dispatch ages,
+// not network RTT or physical input-to-photon latency. 0.1 ms histogram bins.
+class VdiInputTiming {
+public:
+ explicit VdiInputTiming(bool enabled) : m_Enabled(enabled) {}
+ void observe(const SDL_Event& event, Uint32 windowId) {
+ if (!m_Enabled || !event.common.timestamp ||
+ !VdiInputPriority::isInputForWindow(event, windowId)) return;
+ const Uint32 age = SDL_GetTicks() - event.common.timestamp;
+ // Ignore invalid/future synthetic timestamps; subtraction handles wrap.
+ if (age < 60000) m_Input.add(Uint64(age) * 10);
+ }
+ Uint64 startRender() const { return m_Enabled ? SDL_GetPerformanceCounter() : 0; }
+ void endRender(Uint64 start) {
+ if (m_Enabled) m_Render.add((SDL_GetPerformanceCounter() - start) * 10000 / SDL_GetPerformanceFrequency());
+ }
+ void report(uint64_t prioritized) const {
+ if (!m_Enabled) return;
+ SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION,
+ "VDI input timing: SDL dispatch age only; prioritized=%llu",
+ (unsigned long long)prioritized);
+ m_Input.report("input dispatch age");
+ m_Render.report("main-thread render call");
+ }
+private:
+ struct Histogram {
+ std::array<uint64_t, 1024> bins{};
+ uint64_t count = 0, total = 0, max = 0;
+ void add(uint64_t tenths) {
+ ++bins[std::min<uint64_t>(tenths, bins.size()-1)];
+ ++count; total += tenths; max = std::max(max, tenths);
+ }
+ double quantile(unsigned percent) const {
+ const uint64_t rank = (count * percent + 99) / 100;
+ uint64_t accumulated = 0;
+ for (size_t i = 0; i < bins.size(); ++i) {
+ accumulated += bins[i];
+ if (accumulated >= rank) return i / 10.0;
+ }
+ return 0;
+ }
+ void report(const char* label) const {
+ SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION,
+ "VDI %s: samples=%llu avg=%.2f p50=%.1f p95=%.1f max=%.1f ms (percentiles capped at 102.3ms)",
+ label, (unsigned long long)count, count ? total / (10.0*count) : 0,
+ count ? quantile(50) : 0, count ? quantile(95) : 0, max / 10.0);
+ }
+ };
+ bool m_Enabled;
+ Histogram m_Input, m_Render;
+};
@@ -0,0 +1,63 @@
// Include the production implementation so batching can be inspected without
// starting a real network connection. Unused transport code is linker-collected.
#include "InputStream.c"
#undef NDEBUG
#include <assert.h>
static PPACKET_HOLDER pop(void) {
PPACKET_HOLDER holder = NULL;
assert(LbqPollQueueElement(&packetQueue, (void**)&holder) == LBQ_SUCCESS);
return holder;
}
static void expectPosition(int x) {
PPACKET_HOLDER holder = pop();
assert(holder->packet.header.magic == LE32(MOUSE_MOVE_ABS_MAGIC));
latchAbsoluteMousePacket(holder);
assert(holder->packet.mouseMoveAbs.x == BE16(x));
assert((uint16_t)holder->packet.mouseMoveAbs.width == (uint16_t)BE16(2559));
freePacketHolder(holder);
}
int main(void) {
AppVersionQuad[0] = 7;
LbqInitializeLinkedBlockingQueue(&packetQueue, MAX_QUEUED_INPUT_PACKETS);
LbqInitializeLinkedBlockingQueue(&packetHolderFreeList, MAX_QUEUED_INPUT_PACKETS);
PltCreateMutex(&batchedInputMutex);
initialized = true;
for (int i = 0; i < 1000; ++i) {
// A deliberately stalled sender must preserve each click's coordinates.
assert(LiSendMousePositionEvent(10, 10, 2560, 1440) == 0);
assert(LiSendMousePositionEvent(20, 20, 2560, 1440) == 0);
assert(LiSendMouseButtonEvent(BUTTON_ACTION_PRESS, BUTTON_LEFT) == 0);
assert(LiSendMousePositionEvent(30, 30, 2560, 1440) == 0);
assert(LiSendMouseButtonEvent(BUTTON_ACTION_RELEASE, BUTTON_LEFT) == 0);
assert(LiSendMousePositionEvent(40, 40, 2560, 1440) == 0);
expectPosition(20);
PPACKET_HOLDER press = pop();
assert(press->packet.header.magic == LE32(BUTTON_ACTION_PRESS + 1));
freePacketHolder(press);
expectPosition(30);
PPACKET_HOLDER release = pop();
assert(release->packet.header.magic == LE32(BUTTON_ACTION_RELEASE + 1));
freePacketHolder(release);
// Latching an older packet above must not prevent the latest batch
// from still absorbing newer positions.
assert(LiSendMousePositionEvent(50, 50, 2560, 1440) == 0);
expectPosition(50);
PPACKET_HOLDER empty;
assert(LbqPollQueueElement(&packetQueue, (void**)&empty) == LBQ_NO_ELEMENT);
}
initialized = false;
PLINKED_BLOCKING_QUEUE_ENTRY entry = LbqDestroyLinkedBlockingQueue(&packetQueue);
assert(entry == NULL);
entry = LbqDestroyLinkedBlockingQueue(&packetHolderFreeList);
while (entry) {
PLINKED_BLOCKING_QUEUE_ENTRY next = entry->flink;
free(entry->data);
entry = next;
}
PltDeleteMutex(&batchedInputMutex);
puts("PASS: 1000 queued move/press/move/release cycles preserve positions");
return 0;
}
@@ -0,0 +1,86 @@
#include "vdi_input_priority.h"
#undef NDEBUG
#include <cassert>
#include <cstdio>
constexpr Sint32 frameCode = 123;
static SDL_Event frame() {
SDL_Event e{}; e.type = SDL_USEREVENT; e.user.code = frameCode; return e;
}
static SDL_Event input(Uint32 type, Uint32 window = 1) {
SDL_Event e{}; e.type = type; e.key.windowID = window; return e;
}
static void push(SDL_Event e) { assert(SDL_PushEvent(&e) == 1); }
static SDL_Event pop() {
SDL_Event e{};
assert(SDL_PeepEvents(&e,1,SDL_GETEVENT,SDL_FIRSTEVENT,SDL_LASTEVENT)==1);
return e;
}
int main() {
assert(SDL_Init(SDL_INIT_EVENTS)==0);
SDL_Event out{};
VdiInputPriority disabled(false, frameCode);
push(input(SDL_MOUSEMOTION));
assert(!disabled.deferFrame(frame()) && !disabled.takeDeferred(out,1));
assert(pop().type == SDL_MOUSEMOTION);
// Only a contiguous input prefix can pass a frame. No focus/quit/decoder
// barrier may be skipped, even if input is waiting further down the queue.
for (Uint32 barrier : {Uint32(SDL_WINDOWEVENT),Uint32(SDL_QUIT),
Uint32(SDL_USEREVENT),Uint32(SDL_RENDER_DEVICE_RESET),
Uint32(SDL_CONTROLLERDEVICEADDED)}) {
VdiInputPriority scheduler(true, frameCode);
push(input(SDL_MOUSEBUTTONDOWN)); push(input(SDL_MOUSEBUTTONUP));
push(input(barrier)); push(input(SDL_KEYDOWN));
assert(scheduler.deferFrame(frame()));
assert(scheduler.takeDeferred(out,1) && out.type==SDL_MOUSEBUTTONDOWN);
assert(scheduler.takeDeferred(out,1) && out.type==SDL_MOUSEBUTTONUP);
assert(scheduler.takeDeferred(out,1) && out.type==SDL_USEREVENT && out.user.code==frameCode);
assert(!scheduler.takeDeferred(out,1));
assert(pop().type==barrier && pop().type==SDL_KEYDOWN);
assert(scheduler.prioritized()==2);
}
// Preserve all input order, including keyboard releases and drag edges.
{
VdiInputPriority scheduler(true,frameCode);
for (Uint32 t : {Uint32(SDL_KEYDOWN),Uint32(SDL_MOUSEMOTION),Uint32(SDL_MOUSEBUTTONDOWN),
Uint32(SDL_MOUSEMOTION),Uint32(SDL_MOUSEBUTTONUP),Uint32(SDL_KEYUP)}) push(input(t));
assert(scheduler.deferFrame(frame()));
for (Uint32 t : {Uint32(SDL_KEYDOWN),Uint32(SDL_MOUSEMOTION),Uint32(SDL_MOUSEBUTTONDOWN),
Uint32(SDL_MOUSEMOTION),Uint32(SDL_MOUSEBUTTONUP),Uint32(SDL_KEYUP)})
assert(scheduler.takeDeferred(out,1) && out.type==t);
assert(scheduler.takeDeferred(out,1) && out.type==SDL_USEREVENT);
}
for (bool synthetic : {false,true}) {
VdiInputPriority scheduler(true,frameCode);
auto e=input(SDL_MOUSEMOTION,synthetic?1:2);
if(synthetic) e.motion.which=SDL_TOUCH_MOUSEID;
push(e); assert(scheduler.deferFrame(frame()));
assert(scheduler.takeDeferred(out,1) && out.type==SDL_USEREVENT);
assert(pop().type==SDL_MOUSEMOTION);
}
// A high-rate input device cannot starve video.
{
VdiInputPriority scheduler(true,frameCode);
for(int i=0;i<1000;++i) push(input(SDL_MOUSEWHEEL));
assert(scheduler.deferFrame(frame()));
unsigned n=0;
while(scheduler.takeDeferred(out,1) && out.type!=SDL_USEREVENT) ++n;
assert(n<=32 && n>0 && out.type==SDL_USEREVENT);
SDL_FlushEvents(SDL_FIRSTEVENT,SDL_LASTEVENT);
}
{
VdiInputPriority scheduler(true,frameCode);
push(input(SDL_MOUSEBUTTONUP));
assert(scheduler.deferFrame(frame())); SDL_Delay(4);
assert(scheduler.takeDeferred(out,1) && out.type==SDL_USEREVENT);
assert(pop().type==SDL_MOUSEBUTTONUP);
}
// Instrumentation never consumes events or changes cursor visibility.
VdiInputTiming timing(true);
auto e=input(SDL_MOUSEMOTION); e.common.timestamp=SDL_GetTicks();
timing.observe(e,1); auto start=timing.startRender(); timing.endRender(start);
timing.report(0);
SDL_Quit();
std::puts("PASS: input FIFO, focus/quit/reset/window barriers, disabled mode, device/window isolation, count/time bounds");
}
+75
View File
@@ -0,0 +1,75 @@
#include <SDL.h>
#include <cstdio>
#include "vdi_mouse_batch.h"
#include "vdi_latest_frame.h"
#undef NDEBUG
#include <cassert>
static SDL_Event motion(int x, Uint32 which = 1)
{
SDL_Event event{};
event.type = SDL_MOUSEMOTION;
event.motion.which = which;
event.motion.windowID = 1;
event.motion.x = x;
event.motion.y = 100;
event.motion.xrel = 1;
return event;
}
int main()
{
assert(SDL_Init(SDL_INIT_EVENTS) == 0);
for (Uint32 boundary : {Uint32(SDL_MOUSEBUTTONDOWN), Uint32(SDL_MOUSEBUTTONUP),
Uint32(SDL_KEYDOWN), Uint32(SDL_USEREVENT)}) {
SDL_FlushEvents(SDL_FIRSTEVENT, SDL_LASTEVENT);
SDL_Event first = motion(10), adjacent = motion(20), later = motion(90), barrier{};
barrier.type = boundary;
assert(SDL_PushEvent(&adjacent) == 1);
assert(SDL_PushEvent(&barrier) == 1);
assert(SDL_PushEvent(&later) == 1);
vdiBatchMouseMotion(first.motion);
assert(first.motion.x == 20 && first.motion.xrel == 2);
SDL_Event next;
assert(SDL_PeepEvents(&next, 1, SDL_GETEVENT, SDL_FIRSTEVENT, SDL_LASTEVENT) == 1);
assert(next.type == boundary);
assert(SDL_PeepEvents(&next, 1, SDL_GETEVENT, SDL_FIRSTEVENT, SDL_LASTEVENT) == 1);
assert(next.motion.x == 90);
}
SDL_FlushEvents(SDL_FIRSTEVENT, SDL_LASTEVENT);
SDL_Event first = motion(0);
for (int i = 1; i <= 200; ++i) {
SDL_Event next = motion(i);
assert(SDL_PushEvent(&next) == 1);
}
vdiBatchMouseMotion(first.motion);
assert(first.motion.x == 64); // Work per dispatch is bounded.
assert(SDL_PeepEvents(nullptr, 0, SDL_PEEKEVENT, SDL_FIRSTEVENT, SDL_LASTEVENT) == 136);
SDL_FlushEvents(SDL_FIRSTEVENT, SDL_LASTEVENT);
first = motion(10);
SDL_Event synthetic = motion(99, SDL_TOUCH_MOUSEID);
assert(SDL_PushEvent(&synthetic) == 1);
vdiBatchMouseMotion(first.motion);
assert(first.motion.x == 10);
AVFrame* presented = av_frame_alloc();
assert(presented);
presented->buf[0] = av_buffer_alloc(16);
assert(presented->buf[0]);
QQueue<AVFrame*> queue;
assert(vdiDiscardQueuedFrames(queue) == 0);
for (int i = 0; i < 200; ++i) {
unsigned dropped = vdiDiscardQueuedFrames(queue);
assert(dropped == (i == 0 ? 0u : 1u));
AVFrame* frame = av_frame_alloc();
assert(frame);
frame->pts = i;
queue.enqueue(frame);
assert(queue.size() == 1 && queue.head()->pts == i);
assert(av_buffer_get_ref_count(presented->buf[0]) == 1);
}
assert(vdiDiscardQueuedFrames(queue) == 1);
av_frame_free(&presented);
SDL_Quit();
std::puts("PASS: SDL event boundaries, bounded motion work, latest decoded frame ownership");
}