summaryrefslogtreecommitdiffstats
path: root/tools
diff options
context:
space:
mode:
Diffstat (limited to 'tools')
-rw-r--r--tools/guest-diag/gl_verify.sh30
-rwxr-xr-xtools/guest-diag/gpa.py82
-rwxr-xr-xtools/guest-diag/guest_mode.sh285
-rwxr-xr-xtools/guest-diag/gzdoom_pbuf_run.sh94
-rwxr-xr-xtools/guest-diag/push_gl_tools.sh74
-rw-r--r--tools/guest-diag/test_tri.c112
-rwxr-xr-xtools/guest-diag/tick_gen.py97
7 files changed, 774 insertions, 0 deletions
diff --git a/tools/guest-diag/gl_verify.sh b/tools/guest-diag/gl_verify.sh
new file mode 100644
index 0000000..edfaab2
--- /dev/null
+++ b/tools/guest-diag/gl_verify.sh
@@ -0,0 +1,30 @@
+#!/bin/bash
+# SPDX-License-Identifier: BSD-3-Clause
+# Copyright (c) 2026 Open-IOV contributors
+# gl_verify.sh - host-side: (re)start X in the running guest, verify xdpyinfo +
+# glxinfo -B + shared nvClip modeNum. Prints a compact summary.
+#
+# Usage: bash tools/guest-diag/gl_verify.sh [--no-restart]
+# --no-restart : check the current X without killing/restarting it.
+set -u
+
+HERE="$(cd "$(dirname "$0")" && pwd)"
+VMEXEC=(sudo python3 "$HERE/../../scripts/vgpu_vm.py")
+RESTART=1
+[ "${1:-}" = "--no-restart" ] && RESTART=0
+
+gx() { "${VMEXEC[@]}" exec --timeout "${1:-45}" "$2" 2>&1 | tail -n "${3:-40}"; }
+
+if [ "$RESTART" = "1" ]; then
+ # The DDX ScreenInit is a per-start lottery ("GPU exception"): retry up to 3x.
+ for try in 1 2 3; do
+ gx 30 "pkill -f '[X]org :0' 2>/dev/null; sleep 1; systemctl reset-failed xorg0 2>/dev/null; systemd-run --unit=xorg0 --collect --setenv=HOME=/root /usr/bin/Xorg :0 -config /etc/X11/xorg.conf >/dev/null 2>&1; echo XSTART-ISSUED" >/dev/null
+ sleep 8
+ ok=$(gx 20 "timeout 12 env DISPLAY=:0 xdpyinfo >/dev/null 2>&1 && echo XDPYINFO-OK || echo X-DEAD" 3 | tail -1)
+ echo "try $try: $ok"
+ [ "$ok" = "XDPYINFO-OK" ] && break
+ sleep 4
+ done
+fi
+
+gx 40 "pgrep -c Xorg || echo NO_XORG; timeout 15 env DISPLAY=:0 xdpyinfo >/dev/null 2>&1 && echo XDPYINFO-OK || echo X-HUNG; timeout 20 env DISPLAY=:0 glxinfo -B 2>&1 | grep -E 'direct rendering|OpenGL (renderer|version|core profile version)'; timeout 20 env DISPLAY=:0 glxinfo -B >/dev/null 2>&1 && echo GLXINFO-OK || echo GLX-HUNG; bash /tmp/guest_mode.sh 2>/dev/null || echo NO_MODENUM_SCRIPT" 60
diff --git a/tools/guest-diag/gpa.py b/tools/guest-diag/gpa.py
new file mode 100755
index 0000000..61ba27b
--- /dev/null
+++ b/tools/guest-diag/gpa.py
@@ -0,0 +1,82 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: BSD-3-Clause
+# Copyright (c) 2026 Open-IOV contributors
+# gpa.py - translate a guest virtual address to a guest physical address via
+# the QEMU monitor's HMP `gva2gpa`, reusing the QMP client in semfix_watch.py.
+#
+# Usage: sudo python3 tools/guest-diag/gpa.py <gva_hex> [--offset]
+# <gva_hex> guest VA, e.g. 0xffffc90000000000 or 0x7f...
+# --offset also print the /dev/shm/vgpu-guest-ram file offset
+# (gpa - 0x80000000)
+#
+# The monitor's `gva2gpa` translates with the selected CPU's CR3; while a CPU
+# runs user code under PTI the kernel half is hidden, so retry across CPUs
+# exactly like semfix_watch.Qmp.gva2gpa does. On failure the raw monitor text
+# is printed so the operator can see the reason (unmapped VA, no guest, ...).
+import argparse
+import os
+import re
+import sys
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+sys.path.insert(0, HERE)
+from semfix_watch import Qmp # noqa: E402
+
+QMP_SOCK = os.environ.get("QMP_SOCK", "/tmp/qemu-qmp.sock")
+RAM_BASE = 0x80000000
+
+
+def parse_va(text):
+ text = text.strip()
+ try:
+ return int(text, 0)
+ except ValueError:
+ return int(text, 16)
+
+
+def translate(q, va):
+ """Return (gpa, raw_hmp_text) or (None, last_raw_text)."""
+ last = ""
+ for cpu in q._cpu_order():
+ q._set_cpu(cpu)
+ out = q.hmp("gva2gpa 0x%x" % va)
+ last = out.strip()
+ m = re.search(r"gpa:\s*0x([0-9a-fA-F]+)", out)
+ if m:
+ return int(m.group(1), 16), last
+ return None, last
+
+
+def main():
+ ap = argparse.ArgumentParser(add_help=True)
+ ap.add_argument("gva", help="guest virtual address (hex, 0x prefix optional)")
+ ap.add_argument("--offset", action="store_true",
+ help="also print gpa - 0x80000000 (guest RAM file offset)")
+ ap.add_argument("--qmp", default=QMP_SOCK, help="QMP socket path")
+ args = ap.parse_args()
+
+ va = parse_va(args.gva)
+ if not os.path.exists(args.qmp):
+ print("gpa: QMP socket not found: %s" % args.qmp, file=sys.stderr)
+ return 2
+ try:
+ q = Qmp(args.qmp)
+ except Exception as exc: # noqa: BLE001 - diagnostic surface
+ print("gpa: cannot connect to %s: %s" % (args.qmp, exc), file=sys.stderr)
+ return 2
+
+ gpa, text = translate(q, va)
+ if gpa is None:
+ print("gpa: gva2gpa 0x%x failed" % va, file=sys.stderr)
+ if text:
+ print(text, file=sys.stderr)
+ return 1
+
+ print("GPA 0x%x" % gpa)
+ if args.offset:
+ print("OFFSET 0x%x" % (gpa - RAM_BASE))
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/tools/guest-diag/guest_mode.sh b/tools/guest-diag/guest_mode.sh
new file mode 100755
index 0000000..2460131
--- /dev/null
+++ b/tools/guest-diag/guest_mode.sh
@@ -0,0 +1,285 @@
+#!/bin/bash
+# guest_mode.sh - read the NVIDIA X driver's nvClip screens[0].modeNum.
+#
+# The X driver (nvidia_drv.so) creates the nvClip shared area through its
+# anonymous-file helper: memfd_create first (name derived from
+# "/.nvidia_drv.XXXXXX"), then O_TMPFILE, then mkstemp+unlink. 570.144 size
+# is 0x245000 bytes. The fd is handed to GLX clients over X SEND_FDS.
+# Layout (570.144, verified against nvidia_drv.so 570.144):
+# +0x00 u32 grabbed
+# +0x04 u32 token (unique X-server token, sanity check)
+# +0x08 u32 screens[0].modeNum <-- this script
+# screens[] stride 0x84 (132) bytes
+#
+# modeNum parity is the GL client's __GL_DEV_MODESWITCH_TEST_COMPLETE bit:
+# even = device enabled / mode switch complete
+# odd = mode switch in progress / device disabled
+# GZDoom's GL init must observe an EVEN value; odd makes __glNVStateInit
+# clear initBits, buffer objects get no GPU memory, and GZDoom crashes at
+# 0x20 in FFlatVertexBuffer::Copy.
+#
+# STDOUT contract (parse this, nothing else):
+# MODENUM <n>
+# NO_XORG
+#
+# Exit codes:
+# 0 MODENUM printed
+# 3 NO_XORG (no Xorg process with an nvClip fd)
+# 4 permission / unexpected error (NO_XORG still printed)
+#
+# Usage:
+# sudo ./guest_mode.sh # one shot, display from $DISPLAY (default :0)
+# sudo ./guest_mode.sh --wait 15 # poll up to 15 s for Xorg's nvClip fd
+# sudo ./guest_mode.sh --pid 1515 # force one Xorg pid (testing/forensics)
+# sudo ./guest_mode.sh --interval 50 # poll interval ms (with --wait)
+# sudo ./guest_mode.sh --debug # candidate details on stderr
+#
+# Requires: bash, python3 (stdlib only), root (or a user that can read
+# /proc/<Xorg>/fd; Xorg normally runs as root in the guest).
+
+set -u
+
+if ! command -v python3 >/dev/null 2>&1; then
+ echo "guest_mode.sh: python3 not found" >&2
+ echo "NO_XORG"
+ exit 4
+fi
+
+exec python3 - "$@" <<'PY'
+import argparse
+import os
+import re
+import struct
+import sys
+import time
+
+NVCLIP_TARGET_RE = re.compile(r'(?:^|/)\.nvidia_drv\.[^/]*$|memfd:.*nvidia_drv')
+# 570.144 initial size is 0x245000. ExtendSharedArea() ftruncates upward
+# when a second channel block is needed, so accept a range, prefer exact.
+NVCLIP_SIZE_EXACT = 0x245000
+NVCLIP_SIZE_MIN = 0x80000
+NVCLIP_SIZE_MAX = 0x800000
+MODE_OFF = 0x8
+TOKEN_OFF = 0x4
+
+
+def eprint(msg):
+ print(msg, file=sys.stderr)
+
+
+def parse_args():
+ p = argparse.ArgumentParser(add_help=True)
+ p.add_argument('--pid', type=int, default=0,
+ help='only inspect this pid (bypasses the Xorg comm check)')
+ p.add_argument('--display', default=os.environ.get('DISPLAY', ':0'),
+ help='X display to prefer (default $DISPLAY or :0)')
+ p.add_argument('--wait', type=float, default=0.0,
+ help='poll up to SECONDS for the fd (default 0 = one shot)')
+ p.add_argument('--interval', type=float, default=200.0,
+ help='poll interval in milliseconds (default 200)')
+ p.add_argument('--debug', action='store_true',
+ help='print candidate details to stderr')
+ return p.parse_args()
+
+
+def read_text(path):
+ with open(path, 'rb') as f:
+ return f.read()
+
+
+def proc_args(pid):
+ try:
+ raw = read_text('/proc/%d/cmdline' % pid).split(b'\0')
+ except OSError:
+ return []
+ return [x.decode('utf-8', 'replace') for x in raw if x]
+
+
+def proc_comm(pid):
+ try:
+ return read_text('/proc/%d/comm' % pid).decode('utf-8', 'replace').strip()
+ except OSError:
+ return ''
+
+
+def proc_starttime(pid):
+ # /proc/pid/stat field 22 (1-based). Fields after the comm field:
+ # rest[0] is field 3 (state), so starttime is rest[19].
+ try:
+ data = read_text('/proc/%d/stat' % pid).decode('utf-8', 'replace')
+ rest = data.rsplit(')', 1)[1].split()
+ return int(rest[19])
+ except (OSError, IndexError, ValueError):
+ return 0
+
+
+def display_number(display):
+ # ":0", ":0.0", "host:0.0" -> 0
+ try:
+ tail = display.rsplit(':', 1)[1]
+ return int(tail.split('.', 1)[0])
+ except (IndexError, ValueError):
+ return None
+
+
+def is_xorg(pid):
+ comm = proc_comm(pid)
+ if comm in ('Xorg', 'X'):
+ return True
+ args = proc_args(pid)
+ if not args:
+ return False
+ base = os.path.basename(args[0])
+ return base in ('Xorg', 'X')
+
+
+def display_matches(pid, disp):
+ if disp is None:
+ return False
+ needle = ':%d' % disp
+ for arg in proc_args(pid):
+ if arg == needle or arg.startswith(needle + '.'):
+ return True
+ return False
+
+
+def candidate_fds(pid):
+ """Yield (fd_name, target, size) for nvClip-like fds of pid."""
+ fd_dir = '/proc/%d/fd' % pid
+ try:
+ names = os.listdir(fd_dir)
+ except PermissionError:
+ raise
+ except OSError:
+ return
+ for name in names:
+ if not name.isdigit():
+ continue
+ path = os.path.join(fd_dir, name)
+ try:
+ target = os.readlink(path)
+ st = os.stat(path)
+ except PermissionError:
+ raise
+ except OSError:
+ continue
+ if not NVCLIP_TARGET_RE.search(target):
+ continue
+ if not (NVCLIP_SIZE_MIN <= st.st_size <= NVCLIP_SIZE_MAX):
+ continue
+ yield name, target, st.st_size
+
+
+def read_candidate(pid, fd_name, target, size):
+ path = '/proc/%d/fd/%s' % (pid, fd_name)
+ try:
+ fd = os.open(path, os.O_RDONLY)
+ except OSError as exc:
+ return None, str(exc)
+ try:
+ blob = os.pread(fd, 12, 0)
+ except OSError as exc:
+ return None, str(exc)
+ finally:
+ os.close(fd)
+ if len(blob) < 12:
+ return None, 'short read (%d bytes)' % len(blob)
+ grabbed, token, mode = struct.unpack_from('<III', blob, 0)
+ if mode > 0xFFFF:
+ return None, 'implausible modeNum 0x%x' % mode
+ return {
+ 'pid': pid,
+ 'fd': int(fd_name),
+ 'target': target,
+ 'size': size,
+ 'grabbed': grabbed,
+ 'token': token,
+ 'mode': mode,
+ 'start': proc_starttime(pid),
+ 'disp': display_matches(pid, display_number(args.display)),
+ }, None
+
+
+def scan(debug=False):
+ """Return (best_candidate_or_None, saw_permission_error)."""
+ best = None
+ perm_error = False
+
+ try:
+ pids = [int(x) for x in os.listdir('/proc') if x.isdigit()]
+ except OSError as exc:
+ eprint('guest_mode.sh: cannot list /proc: %s' % exc)
+ return None, False
+
+ for pid in pids:
+ if args.pid and pid != args.pid:
+ continue
+ if not args.pid and not is_xorg(pid):
+ continue
+ try:
+ fds = list(candidate_fds(pid))
+ except PermissionError:
+ perm_error = True
+ if debug:
+ eprint('pid %d: permission denied listing fd/' % pid)
+ continue
+ for fd_name, target, size in fds:
+ cand, err = read_candidate(pid, fd_name, target, size)
+ if cand is None:
+ if debug:
+ eprint('pid %d fd %s: %s' % (pid, fd_name, err))
+ continue
+ if debug:
+ eprint('candidate pid=%d fd=%s size=0x%x token=0x%x mode=%d '
+ 'start=%d display_match=%s target=%s' %
+ (cand['pid'], cand['fd'], cand['size'], cand['token'],
+ cand['mode'], cand['start'], cand['disp'], cand['target']))
+ # Score: display match, exact size, nonzero token, newest Xorg.
+ key = (cand['disp'],
+ 1 if cand['size'] == NVCLIP_SIZE_EXACT else 0,
+ 1 if cand['token'] != 0 else 0,
+ cand['start'],
+ -cand['pid'])
+ if best is None or key > best[0]:
+ best = (key, cand)
+
+ return (best[1] if best else None), perm_error
+
+
+def main():
+ global args
+ args = parse_args()
+ deadline = time.monotonic() + max(0.0, args.wait)
+ interval = max(0.01, args.interval / 1000.0)
+ perm_error = False
+
+ while True:
+ cand, pe = scan(args.debug)
+ perm_error = perm_error or pe
+ if cand is not None:
+ print('MODENUM %d' % cand['mode'])
+ return 0
+ if time.monotonic() >= deadline:
+ break
+ time.sleep(interval)
+
+ if perm_error:
+ eprint('guest_mode.sh: Xorg exists but /proc/<pid>/fd is not readable; '
+ 'run as root (sudo).')
+ print('NO_XORG')
+ return 4
+ print('NO_XORG')
+ return 3
+
+
+if __name__ == '__main__':
+ try:
+ sys.exit(main())
+ except KeyboardInterrupt:
+ print('NO_XORG')
+ sys.exit(4)
+ except Exception as exc: # keep the stdout contract even on bugs
+ eprint('guest_mode.sh: unexpected error: %s' % exc)
+ print('NO_XORG')
+ sys.exit(4)
+PY \ No newline at end of file
diff --git a/tools/guest-diag/gzdoom_pbuf_run.sh b/tools/guest-diag/gzdoom_pbuf_run.sh
new file mode 100755
index 0000000..b152bdf
--- /dev/null
+++ b/tools/guest-diag/gzdoom_pbuf_run.sh
@@ -0,0 +1,94 @@
+#!/bin/bash
+# SPDX-License-Identifier: BSD-3-Clause
+# Copyright (c) 2026 Open-IOV contributors
+# gzdoom_pbuf_run.sh - in-guest GZDoom run recipe for the offscreen SDL2
+# GLX-pbuffer harness. Run this inside the guest after push_gl_tools.sh has
+# placed the shims in /tmp. It prints the exact command line, runs GZDoom
+# under timeout, and leaves the output in $GZLOG.
+#
+# Environment (all overridable):
+# GZDOOM_BIN default /opt/gzdoom/gzdoom
+# WAD default /usr/share/games/doom/freedoom2.wad
+# GZCAP default /tmp/gzframes.bin (sdl_cap.so capture file)
+# GZPBUF_W/H default 640/480 (gz_pbuf.so pbuffer size)
+# GZLOG default /tmp/gzdoom-pbuf.log
+# TIMEOUT default 300 seconds
+# GZ_ARGS extra GZDoom args appended after the base set
+# GZ_DETACH default 0; set 1 to setsid/nohup the run and return
+# DISPLAY default :0
+#
+# The base args are windowed GL: +set vid_fullscreen 0, 640x480, OpenGL
+# backend 0, +vid_rendermode 4 (GL mode), MAP01. The preload order matters:
+# sdl_cap.so first (captures SDL_GL_SwapWindow), gz_pbuf.so second (turns the
+# SDL GL context into a GLX pbuffer and the swap into glFinish).
+set -u
+
+GZDOOM_BIN="${GZDOOM_BIN:-/opt/gzdoom/gzdoom}"
+WAD="${WAD:-/usr/share/games/doom/freedoom2.wad}"
+GZCAP="${GZCAP:-/tmp/gzframes.bin}"
+GZPBUF_W="${GZPBUF_W:-640}"
+GZPBUF_H="${GZPBUF_H:-480}"
+GZLOG="${GZLOG:-/tmp/gzdoom-pbuf.log}"
+TIMEOUT="${TIMEOUT:-300}"
+GZ_ARGS="${GZ_ARGS:-}"
+GZ_DETACH="${GZ_DETACH:-0}"
+DISPLAY="${DISPLAY:-:0}"
+SDL_VIDEODRIVER="${SDL_VIDEODRIVER:-offscreen}"
+LD_PRELOAD="${LD_PRELOAD:-/tmp/sdl_cap.so /tmp/gz_pbuf.so}"
+
+export DISPLAY SDL_VIDEODRIVER LD_PRELOAD GZCAP GZPBUF_W GZPBUF_H
+
+if [ ! -x "$GZDOOM_BIN" ]; then
+ echo "gzdoom_pbuf_run: GZDOOM_BIN not executable: $GZDOOM_BIN" >&2
+ exit 2
+fi
+if [ ! -f "$WAD" ]; then
+ echo "gzdoom_pbuf_run: WAD not found: $WAD" >&2
+ exit 2
+fi
+for so in /tmp/sdl_cap.so /tmp/gz_pbuf.so; do
+ if [ ! -f "$so" ]; then
+ echo "gzdoom_pbuf_run: shim missing: $so" >&2
+ exit 2
+ fi
+done
+
+CMD=(
+ "$GZDOOM_BIN"
+ -iwad "$WAD"
+ +set vid_fullscreen 0
+ +set vid_defwidth "$GZPBUF_W"
+ +set vid_defheight "$GZPBUF_H"
+ +set vid_preferbackend 0
+ +vid_rendermode 4
+ -nosound
+ -nomusic
+ +vid_activeinbackground 1
+ +vid_maxfps 60
+ +map MAP01
+)
+if [ -n "$GZ_ARGS" ]; then
+ read -r -a GZ_EXTRA <<< "$GZ_ARGS"
+ CMD+=("${GZ_EXTRA[@]}")
+fi
+
+echo "gzdoom_pbuf_run: DISPLAY=$DISPLAY SDL_VIDEODRIVER=$SDL_VIDEODRIVER"
+echo "gzdoom_pbuf_run: LD_PRELOAD=$LD_PRELOAD"
+echo "gzdoom_pbuf_run: GZCAP=$GZCAP GZPBUF_W=$GZPBUF_W GZPBUF_H=$GZPBUF_H"
+echo "gzdoom_pbuf_run: LOG=$GZLOG TIMEOUT=$TIMEOUT DETACH=$GZ_DETACH"
+printf 'CMD:'
+printf ' %q' "${CMD[@]}"
+echo
+
+if [ "$GZ_DETACH" = "1" ]; then
+ setsid nohup timeout "$TIMEOUT" "${CMD[@]}" > "$GZLOG" 2>&1 < /dev/null &
+ echo "PID=$!"
+ echo "LOG=$GZLOG"
+ exit 0
+fi
+
+timeout "$TIMEOUT" "${CMD[@]}" > "$GZLOG" 2>&1
+rc=$?
+echo "RC=$rc"
+echo "LOG=$GZLOG"
+exit "$rc"
diff --git a/tools/guest-diag/push_gl_tools.sh b/tools/guest-diag/push_gl_tools.sh
new file mode 100755
index 0000000..4064d58
--- /dev/null
+++ b/tools/guest-diag/push_gl_tools.sh
@@ -0,0 +1,74 @@
+#!/bin/bash
+# SPDX-License-Identifier: BSD-3-Clause
+# Copyright (c) 2026 Open-IOV contributors
+# push_gl_tools.sh - push the guest-side GL/GZDoom harness binaries and scripts
+# into the running guest's /tmp and chmod +x them. Uses the established QGA
+# guest-exec + base64 mechanism from push_guest_tools.sh (scripts/vgpu_vm.py).
+#
+# Usage:
+# bash tools/guest-diag/push_gl_tools.sh # fixed list below
+# bash tools/guest-diag/push_gl_tools.sh FILE... # arbitrary host files
+# bash tools/guest-diag/push_gl_tools.sh --dry-run # list only, no push
+#
+# Destination is /tmp/<basename>. The guest's /tmp is wiped on boot; run this
+# after every VM start. It does NOT run the pushed programs.
+set -u
+
+HERE="$(cd "$(dirname "$0")" && pwd)"
+VMEXEC="$HERE/../../scripts/vgpu_vm.py"
+BUILD="$HERE/build"
+DEST_DIR="${DEST_DIR:-/tmp}"
+
+DRY_RUN=0
+if [ "${1:-}" = "--dry-run" ]; then
+ DRY_RUN=1
+ shift
+fi
+
+FIXED=(
+ "$BUILD/gl_frames"
+ "$BUILD/gz_pbuf.so"
+ "$BUILD/sdl_cap.so"
+ "$BUILD/test_tri"
+ "$HERE/guest_mode.sh"
+ "$HERE/gzf2_video.py"
+ "$HERE/gzdoom_pbuf_run.sh"
+)
+
+if [ "$#" -gt 0 ]; then
+ FILES=("$@")
+else
+ FILES=("${FIXED[@]}")
+fi
+
+if [ ! -x "$VMEXEC" ] && [ ! -f "$VMEXEC" ]; then
+ echo "push_gl_tools: vgpu_vm.py not found: $VMEXEC" >&2
+ exit 2
+fi
+
+push_b64() { # push_b64 <local file> <remote file>
+ local src="$1" dst="$2" b64
+ b64=$(base64 -w0 "$src")
+ if [ "$DRY_RUN" = "1" ]; then
+ echo "PUSH-DRY $src -> $dst ($(stat -c%s "$src") bytes)"
+ return 0
+ fi
+ if ! sudo "$VMEXEC" exec --timeout 120 \
+ "echo '$b64' | base64 -d > '$dst' && chmod +x '$dst'" \
+ >/dev/null 2>&1; then
+ echo "PUSH-FAIL $src -> $dst" >&2
+ return 1
+ fi
+ echo "PUSHED $src -> $dst"
+}
+
+rc=0
+for f in "${FILES[@]}"; do
+ if [ ! -f "$f" ]; then
+ echo "PUSH-SKIP missing $f" >&2
+ rc=1
+ continue
+ fi
+ push_b64 "$f" "$DEST_DIR/$(basename "$f")" || rc=1
+done
+exit "$rc"
diff --git a/tools/guest-diag/test_tri.c b/tools/guest-diag/test_tri.c
new file mode 100644
index 0000000..dc42876
--- /dev/null
+++ b/tools/guest-diag/test_tri.c
@@ -0,0 +1,112 @@
+// SPDX-License-Identifier: BSD-3-Clause
+// Copyright (c) 2026 Open-IOV contributors
+// test_tri.c - minimal SDL2 + OpenGL triangle for the offscreen pbuffer
+// harness. Intended to be run with SDL_VIDEODRIVER=offscreen and the
+// gz_pbuf.so shim preloaded (plus sdl_cap.so to capture the swaps):
+//
+// SDL_VIDEODRIVER=offscreen DISPLAY=:0 \
+// LD_PRELOAD="/tmp/sdl_cap.so /tmp/gz_pbuf.so" GZCAP=/tmp/testframes.bin \
+// ./test_tri 20 640 480
+//
+// It creates an SDL2 GL window/context (the shim turns the context into a
+// GLX pbuffer), clears to a colour, draws a rotating triangle, reads the
+// centre pixel back (non-black proof), and calls SDL_GL_SwapWindow each
+// frame. The sdl_cap.so hook captures the swap; the gz_pbuf.so hook turns
+// the swap into glFinish. Build:
+// gcc -O2 -o test_tri test_tri.c $(pkg-config --cflags --libs sdl2) -lGL
+#include <SDL2/SDL.h>
+#include <GL/gl.h>
+#include <math.h>
+#include <stdio.h>
+#include <stdlib.h>
+
+int main(int argc, char **argv)
+{
+ int frames = argc > 1 ? atoi(argv[1]) : 20;
+ int w = argc > 2 ? atoi(argv[2]) : 640;
+ int h = argc > 3 ? atoi(argv[3]) : 480;
+ SDL_Window *win;
+ SDL_GLContext ctx;
+ int i;
+
+ setvbuf(stdout, NULL, _IONBF, 0);
+
+ if (SDL_Init(SDL_INIT_VIDEO) != 0) {
+ fprintf(stderr, "test_tri: SDL_Init: %s\n", SDL_GetError());
+ return 1;
+ }
+ printf("test_tri: videodriver=%s\n",
+ SDL_GetCurrentVideoDriver() ? SDL_GetCurrentVideoDriver() : "?");
+
+ SDL_GL_SetAttribute(SDL_GL_RED_SIZE, 8);
+ SDL_GL_SetAttribute(SDL_GL_GREEN_SIZE, 8);
+ SDL_GL_SetAttribute(SDL_GL_BLUE_SIZE, 8);
+ SDL_GL_SetAttribute(SDL_GL_ALPHA_SIZE, 8);
+ SDL_GL_SetAttribute(SDL_GL_DOUBLEBUFFER, 1);
+
+ win = SDL_CreateWindow("test_tri", SDL_WINDOWPOS_UNDEFINED,
+ SDL_WINDOWPOS_UNDEFINED, w, h,
+ SDL_WINDOW_OPENGL | SDL_WINDOW_HIDDEN);
+ if (!win) {
+ fprintf(stderr, "test_tri: SDL_CreateWindow: %s\n", SDL_GetError());
+ SDL_Quit();
+ return 2;
+ }
+
+ ctx = SDL_GL_CreateContext(win);
+ if (!ctx) {
+ fprintf(stderr, "test_tri: SDL_GL_CreateContext: %s\n",
+ SDL_GetError());
+ SDL_DestroyWindow(win);
+ SDL_Quit();
+ return 3;
+ }
+ if (SDL_GL_MakeCurrent(win, ctx) != 0)
+ fprintf(stderr, "test_tri: SDL_GL_MakeCurrent: %s\n",
+ SDL_GetError());
+ SDL_GL_SetSwapInterval(0);
+
+ printf("test_tri: GL %s | %s | %s\n",
+ (const char *)glGetString(GL_VENDOR),
+ (const char *)glGetString(GL_RENDERER),
+ (const char *)glGetString(GL_VERSION));
+
+ for (i = 0; i < frames; i++) {
+ float a = (float)i * 0.10f;
+ unsigned char px[4] = { 0, 0, 0, 0 };
+
+ glViewport(0, 0, w, h);
+ glClearColor(0.05f, 0.05f, 0.10f, 1.0f);
+ glClear(GL_COLOR_BUFFER_BIT);
+
+ glMatrixMode(GL_PROJECTION);
+ glLoadIdentity();
+ glOrtho(-1.0, 1.0, -1.0, 1.0, -1.0, 1.0);
+ glMatrixMode(GL_MODELVIEW);
+ glLoadIdentity();
+ glRotatef(a * 57.29578f, 0.0f, 0.0f, 1.0f);
+
+ glBegin(GL_TRIANGLES);
+ glColor3f(1.0f, 0.2f, 0.1f);
+ glVertex2f(0.0f, 0.8f);
+ glColor3f(0.1f, 1.0f, 0.3f);
+ glVertex2f(-0.8f, -0.6f);
+ glColor3f(0.2f, 0.3f, 1.0f);
+ glVertex2f(0.8f, -0.6f);
+ glEnd();
+ glFinish();
+
+ glReadPixels(w / 2, h / 2, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, px);
+ printf("test_tri: frame=%d centre=%02x%02x%02x%02x err=0x%x\n",
+ i, px[0], px[1], px[2], px[3], glGetError());
+
+ SDL_GL_SwapWindow(win);
+ SDL_Delay(20);
+ }
+
+ printf("test_tri: done frames=%d %dx%d\n", frames, w, h);
+ SDL_GL_DeleteContext(ctx);
+ SDL_DestroyWindow(win);
+ SDL_Quit();
+ return 0;
+}
diff --git a/tools/guest-diag/tick_gen.py b/tools/guest-diag/tick_gen.py
new file mode 100755
index 0000000..d2c176b
--- /dev/null
+++ b/tools/guest-diag/tick_gen.py
@@ -0,0 +1,97 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: BSD-3-Clause
+# Copyright (c) 2026 Open-IOV contributors
+# tick_gen.py - keep the guest-RM timer tick page live in the host-side
+# /dev/shm/vgpu-guest-ram image. The guest's timer vmap page is read as a
+# u32 millisecond counter; writing a strictly increasing u32 there at 1 ms
+# (or --ms) keeps the guest RM's timer path moving.
+#
+# Usage: sudo python3 tools/guest-diag/tick_gen.py <gpa_hex> [--ms 1] [--file PATH]
+# <gpa_hex> guest physical address of the timer tick u32 (the guest's
+# timer vmap base + 0x6c004)
+# --ms N write interval in milliseconds (default 1)
+# --file P guest RAM file (default /dev/shm/vgpu-guest-ram)
+#
+# The file offset is gpa - 0x80000000. The mapping is created once; the loop
+# only writes the u32. SIGINT stops cleanly.
+import argparse
+import mmap
+import os
+import signal
+import struct
+import sys
+import time
+
+RAM_BASE = 0x80000000
+DEFAULT_FILE = "/dev/shm/vgpu-guest-ram"
+stop = False
+
+
+def on_sigint(signum, frame):
+ global stop
+ stop = True
+
+
+def main():
+ ap = argparse.ArgumentParser(add_help=True)
+ ap.add_argument("gpa", help="guest physical address (hex, 0x prefix optional)")
+ ap.add_argument("--ms", type=float, default=1.0,
+ help="write interval in milliseconds (default 1)")
+ ap.add_argument("--file", default=DEFAULT_FILE, help="guest RAM file")
+ args = ap.parse_args()
+
+ try:
+ gpa = int(args.gpa, 0)
+ except ValueError:
+ gpa = int(args.gpa, 16)
+ if gpa < RAM_BASE:
+ print("tick_gen: gpa 0x%x is below 0x%x" % (gpa, RAM_BASE),
+ file=sys.stderr)
+ return 2
+ off = gpa - RAM_BASE
+
+ fd = os.open(args.file, os.O_RDWR)
+ try:
+ size = os.fstat(fd).st_size
+ if off + 4 > size:
+ print("tick_gen: offset 0x%x beyond %s (0x%x bytes)"
+ % (off, args.file, size), file=sys.stderr)
+ return 2
+ mm = mmap.mmap(fd, size, access=mmap.ACCESS_WRITE)
+ try:
+ signal.signal(signal.SIGINT, on_sigint)
+ interval = max(0.0, args.ms / 1000.0)
+ counter = time.monotonic_ns() // 1_000_000
+ iterations = 0
+ last_print = time.monotonic()
+ print("tick_gen: file=%s gpa=0x%x off=0x%x interval=%.3fms"
+ % (args.file, gpa, off, args.ms), flush=True)
+ while not stop:
+ now_ms = time.monotonic_ns() // 1_000_000
+ if now_ms <= counter:
+ now_ms = counter + 1
+ counter = now_ms
+ val = counter & 0xFFFFFFFF
+ struct.pack_into("<I", mm, off, val)
+ iterations += 1
+ t = time.monotonic()
+ if t - last_print >= 2.0:
+ print("tick_gen: iterations=%d value=%d (0x%08x)"
+ % (iterations, val, val), flush=True)
+ last_print = t
+ if interval:
+ time.sleep(interval)
+ print("tick_gen: stopped iterations=%d value=0x%08x"
+ % (iterations, counter & 0xFFFFFFFF), flush=True)
+ finally:
+ mm.close()
+ finally:
+ os.close(fd)
+ return 0
+
+
+if __name__ == "__main__":
+ try:
+ sys.exit(main())
+ except KeyboardInterrupt:
+ sys.exit(0)