189 lines
7.6 KiB
Python
189 lines
7.6 KiB
Python
"""Run only with the controlled GTK acceptance window filling the stream."""
|
|
import ctypes as C
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import signal
|
|
import statistics
|
|
import struct
|
|
import subprocess
|
|
import time
|
|
|
|
os.environ.update(DISPLAY=':0', XAUTHORITY='/home/armbian/.Xauthority')
|
|
x = C.CDLL('libX11.so.6')
|
|
fix = C.CDLL('libXfixes.so.3')
|
|
test = C.CDLL('libXtst.so.6')
|
|
class Cursor(C.Structure):
|
|
_fields_ = [('x', C.c_short), ('y', C.c_short), ('w', C.c_ushort),
|
|
('h', C.c_ushort), ('hx', C.c_ushort), ('hy', C.c_ushort),
|
|
('serial', C.c_ulong), ('pixels', C.POINTER(C.c_ulong)),
|
|
('atom', C.c_ulong), ('name', C.c_char_p)]
|
|
x.XOpenDisplay.argtypes = [C.c_char_p]
|
|
x.XOpenDisplay.restype = C.c_void_p
|
|
x.XDefaultRootWindow.argtypes = [C.c_void_p]
|
|
x.XDefaultRootWindow.restype = C.c_ulong
|
|
x.XFree.argtypes = [C.c_void_p]
|
|
x.XSync.argtypes = [C.c_void_p, C.c_int]
|
|
x.XWarpPointer.argtypes = [C.c_void_p, C.c_ulong, C.c_ulong, C.c_int,
|
|
C.c_int, C.c_uint, C.c_uint, C.c_int, C.c_int]
|
|
x.XGetImage.argtypes = [C.c_void_p, C.c_ulong, C.c_int, C.c_int,
|
|
C.c_uint, C.c_uint, C.c_ulong, C.c_int]
|
|
x.XGetImage.restype = C.c_void_p
|
|
x.XGetPixel.argtypes = [C.c_void_p, C.c_int, C.c_int]
|
|
x.XGetPixel.restype = C.c_ulong
|
|
x.XDestroyImage.argtypes = [C.c_void_p]
|
|
fix.XFixesGetCursorImage.argtypes = [C.c_void_p]
|
|
fix.XFixesGetCursorImage.restype = C.POINTER(Cursor)
|
|
test.XTestFakeButtonEvent.argtypes = [C.c_void_p, C.c_uint, C.c_int, C.c_ulong]
|
|
d = x.XOpenDisplay(None)
|
|
assert d
|
|
root = x.XDefaultRootWindow(d)
|
|
|
|
|
|
def native():
|
|
p = fix.XFixesGetCursorImage(d)
|
|
assert p
|
|
try:
|
|
c = p.contents
|
|
return dict(x=c.x, y=c.y, w=c.w, h=c.h, hx=c.hx, hy=c.hy,
|
|
pixels=[int(c.pixels[i]) & 0xffffffff for i in range(c.w*c.h)])
|
|
finally:
|
|
x.XFree(p)
|
|
|
|
|
|
def warp(px, py):
|
|
x.XWarpPointer(d, 0, root, 0, 0, 0, 0, px, py)
|
|
x.XSync(d, 0)
|
|
|
|
|
|
def decoded_sequence():
|
|
im = x.XGetImage(d, root, 40, 1370, 640, 1, 0xffffffff, 2)
|
|
assert im
|
|
try:
|
|
values = [x.XGetPixel(im, i*40+20, 0) & 0xffffff for i in range(16)]
|
|
if any(0x202020 <= v <= 0xe0e0e0 for v in values):
|
|
return -1
|
|
return sum((1 << i) for i, v in enumerate(values) if v > 0xe0e0e0)
|
|
finally:
|
|
x.XDestroyImage(im)
|
|
|
|
|
|
def flat_video_crop(px, py):
|
|
im = x.XGetImage(d, root, px-40, py-40, 100, 100, 0xffffffff, 2)
|
|
assert im
|
|
try:
|
|
pixels = [x.XGetPixel(im, a, b) & 0xffffff for b in range(100) for a in range(100)]
|
|
# No baked-in pointer: uniform acceptance-window background at this point.
|
|
ref = pixels[0]
|
|
bad = sum(any(abs(((v >> s) & 255) - ((ref >> s) & 255)) > 8
|
|
for s in (0, 8, 16)) for v in pixels)
|
|
return dict(reference=ref, nonuniform_pixels=bad)
|
|
finally:
|
|
x.XDestroyImage(im)
|
|
|
|
|
|
host = os.sys.argv[1]
|
|
prefix = '/home/armbian/vdi-build/rkmpp-experiment/build/moonlight/app/moonlight stream ' + host + ' '
|
|
items = [l.split(None, 1) for l in subprocess.check_output(['ps','-eo','pid,args'], text=True).splitlines()[1:]
|
|
if len(l.split(None, 1)) == 2 and l.split(None, 1)[1].startswith(prefix)]
|
|
assert len(items) == 1, items
|
|
pid = int(items[0][0])
|
|
focus = subprocess.check_output(['xdotool','getwindowfocus','getwindowpid'], text=True).strip()
|
|
assert focus == str(pid), (focus, pid)
|
|
env = dict(item.split('=', 1) for item in Path('/proc/%d/environ' % pid).read_bytes().decode().split('\0') if '=' in item)
|
|
statepath = Path(env['VDI_NATIVE_CURSOR_STATE'])
|
|
initial_sequence = decoded_sequence()
|
|
assert 0 <= initial_sequence < 60000, 'test window not ready'
|
|
reference = flat_video_crop(180, 600)['reference']
|
|
assert all(abs(((reference >> s) & 255)-v) < 10 for s,v in [(16,240),(8,245),(0,250)]), 'acceptance window not visible'
|
|
shapes = []
|
|
for i, name in enumerate(['default','text','ew-resize','wait','none','crosshair','custom']):
|
|
px = int((i+.5)*2560/7)
|
|
warp(px, 600)
|
|
time.sleep(.6)
|
|
state = statepath.read_bytes()
|
|
header = struct.unpack('<8sQQQIIIIiiII', state[:64])
|
|
local = native()
|
|
source = dict(zip(['w','h','hx','hy','x','y','sw','sh'], header[4:]))
|
|
# Convert expected straight RGBA to XRender premultiplied ARGB.
|
|
expected = []
|
|
for j in range(64, len(state)-8, 4):
|
|
r,g,b,a = state[j:j+4]
|
|
expected.append((a << 24) | ((r*a+127)//255 << 16) |
|
|
((g*a+127)//255 << 8) | ((b*a+127)//255))
|
|
same_geometry = all(local[k] == source[k] for k in ['w','h','hx','hy'])
|
|
# Busy frames animate; these snapshots can legitimately be adjacent frames.
|
|
diff = max((abs(((a >> s) & 255)-((b >> s) & 255))
|
|
for a,b in zip(local['pixels'],expected) for s in [0,8,16,24]), default=0)
|
|
shapes.append(dict(name=name, source=source,
|
|
local={k:v for k,v in local.items() if k != 'pixels'},
|
|
geometry_matches=same_geometry, max_pixel_difference=diff,
|
|
visible_pixels=sum(p >> 24 != 0 for p in local['pixels']),
|
|
video=flat_video_crop(px, 600)))
|
|
|
|
Path('/tmp/native-%s-shapes.json' % host).write_text(json.dumps(shapes))
|
|
samples = []
|
|
for i in range(28):
|
|
px = int(((i % 7)+.5)*2560/7)
|
|
py = 750 if i % 2 == 0 else 850
|
|
warp(px, py)
|
|
time.sleep(.25)
|
|
start = time.monotonic_ns()
|
|
test.XTestFakeButtonEvent(d, 1, 1, 0)
|
|
x.XSync(d, 0)
|
|
expected = initial_sequence+i+1
|
|
seen = -1
|
|
released = False
|
|
try:
|
|
while time.monotonic_ns()-start < 3000000000:
|
|
if not released and time.monotonic_ns()-start >= 80000000:
|
|
test.XTestFakeButtonEvent(d, 1, 0, 0)
|
|
x.XSync(d, 0)
|
|
released = True
|
|
seen = decoded_sequence()
|
|
if seen == expected:
|
|
break
|
|
if seen > expected:
|
|
raise RuntimeError('unexpected duplicate click %d instead of %d' % (seen,expected))
|
|
time.sleep(.002)
|
|
finally:
|
|
if not released:
|
|
time.sleep(max(0, .08-(time.monotonic_ns()-start)/1e9))
|
|
test.XTestFakeButtonEvent(d, 1, 0, 0)
|
|
x.XSync(d, 0)
|
|
samples.append(dict(seq=expected, x=px, y=py, seen=seen,
|
|
decoded_ms=(time.monotonic_ns()-start)/1e6))
|
|
if seen != expected:
|
|
Path('/tmp/native-%s-failed-clicks.json' % host).write_text(json.dumps(samples))
|
|
raise AssertionError(samples[-1])
|
|
time.sleep(.1)
|
|
times = sorted(s['decoded_ms'] for s in samples)
|
|
# A stopped Moonlight cannot capture input, receive video, or draw frames.
|
|
# X11's native pointer must still move and keep its shape while it is stopped.
|
|
warp(200, 600)
|
|
time.sleep(.3)
|
|
before = native()
|
|
try:
|
|
os.kill(pid, signal.SIGSTOP)
|
|
start = time.monotonic_ns()
|
|
steps = []
|
|
for px,py in [(250,650),(280,670),(300,690),(350,700)]:
|
|
step_start = time.monotonic_ns()
|
|
warp(px, py)
|
|
after = native()
|
|
steps.append(dict(target=[px,py],position=[after['x'],after['y']],
|
|
query_ms=(time.monotonic_ns()-step_start)/1e6))
|
|
time.sleep(.06)
|
|
paused_ms = (time.monotonic_ns()-start)/1e6
|
|
finally:
|
|
os.kill(pid, signal.SIGCONT)
|
|
pause = dict(position=[after['x'],after['y']], elapsed_ms=paused_ms,
|
|
same_shape=before['pixels']==after['pixels'], steps=steps)
|
|
assert all(s['target']==s['position'] for s in steps) and pause['same_shape']
|
|
time.sleep(.4)
|
|
|
|
print(json.dumps(dict(host=host,pid=pid,shapes=shapes,paused_client=pause,
|
|
clicks=samples,p50_ms=statistics.median(times),
|
|
p95_ms=times[int(len(times)*.95)],
|
|
method='XTest click-down to unique decoded sequence; excludes USB and scanout')))
|