Free cloud gaming got more complicated this autumn. Microsoft announced on September 3 that Game Pass cloud play moves to monthly allowances of 5, 10 and 15 hours, and on October 21 it began testing standalone cloud playtime packs that start at $3.99 for 10 hours. Amazon still bundles Luna with Prime, and NVIDIA still lists a Free GeForce NOW membership. Every one of those offers lands on the same weak link: the device in front of you has to decode a 1080p, 60 frames-per-second video stream in real time.
Most guides to free cloud gaming stop at ping and bandwidth. We covered those angles already in our full free cloud gaming setup walkthrough, and they matter. But an old laptop on perfect fiber still stutters if its browser decodes video on the CPU. This tutorial builds the missing half: a small Python tool called decodecheck that tests how well your machine decodes video, then reads the frame statistics your browser records during a real GeForce NOW, Xbox Cloud Gaming or Amazon Luna session.
You will finish with a working project of about 300 lines, three passing tests, and a JSON report per service. The whole thing takes roughly 55 minutes, and most of that is typing and waiting for one-time test clips to encode. Nothing here costs money. If a free tier asks you to pay, you stop and the tool still works with the sessions you captured.
Don’t miss new tech stories on Google
Add Tech Insider once in the Google app and our stories appear in your news suggestions.
Add Now
Free Cloud Gaming in November 2026: What Each Service Gives You
Before you measure anything, you need to know which free route you are actually testing. The word free hides three different things in this market: a permanent free membership, an included perk of a subscription you already pay for, and a limited test. Mixing them up wastes your evening, so the table below separates them using only offers confirmed on official pages in the past two weeks.
| Service | Free route right now | Cost | What to check before you start |
|---|---|---|---|
| NVIDIA GeForce NOW | Free membership, listed next to the Performance and Ultimate plans | $0 | Session length and queue rules in NVIDIA’s FAQ |
| Xbox Cloud Gaming | Ad-supported cloud test for Xbox Insiders without Game Pass (preview), plus selected free-to-play games | $0 for the ad-supported test | Availability depends on game, market, device and account |
| Xbox cloud playtime packs (Insider preview) | Not free: 10 hours for $3.99, 25 hours for $7.99, 50 hours for $11.99 (introductory) | Paid | Final prices and markets were not confirmed on November 8 |
| Xbox Game Pass Premium | Not free: 10 cloud hours a month | $14.99 a month | Allowance resets monthly |
| Xbox Game Pass Ultimate | Not free: 15 cloud hours a month | $22.99 a month | Allowance resets monthly |
| Amazon Luna | Luna Standard, a rotating catalog of more than 80 games for Prime members | No extra gaming charge with Prime | You need an active Prime membership |
Two details in that table deserve a closer look. First, Xbox is the service in the middle of a rebuild. Game Pass Essential now carries 5 cloud hours a month, and PC Game Pass at $13.99 a month does not include cloud play at all, according to the Game Pass Premium store listing and Xbox’s own cloud page. The playtime packs are also still an Insider preview. Microsoft described them as ad-free with shorter wait times, and purchased hours stay available until you use them. Microsoft also announced a faster, simpler Xbox experience for PC, web and cloud on November 5, which The Verge covered as a redesign of those apps that rolls out over the following weeks. Treat all of that as a moving target.
Second, Amazon’s offer is the most stable of the three. Amazon says Luna is included with Prime worldwide at no additional gaming charge, a point it repeated in its one-year anniversary post on October 23. The November 5 content update put the Luna Standard catalog at more than 80 games. For a side-by-side of the paid ceilings, see our Amazon Luna vs GeForce NOW vs Xbox Cloud Gaming comparison. This guide stays on the free end.
One more honesty note. NVIDIA’s pages confirm that a Free membership remains available, but I found no dated, official figure for free-tier session length or queue priority. Reviewers have long described a one-hour session cap on the free tier, yet I will not hard-code that number into the tool. Read NVIDIA’s FAQ, and put whatever it says into your own notes. The decode test works the same either way.
Why Decoding, Not Just Ping, Decides Free Cloud Gaming Quality
A cloud game travels through five stages. The provider’s server renders the frame, encodes it into video, and sends it across the internet. Your device receives the packets, decodes them back into pixels, and shows them on screen. Ping and bandwidth cover the middle stages. Decoding is the fourth stage, and it is the one that runs on hardware you own.
Decoding can happen in two places. A dedicated video block on your GPU or processor can do it with almost no CPU load and a tiny, predictable delay. If the browser cannot reach that block, it falls back to software decoding on the CPU. On a modern desktop that fallback is fine. On a five-year-old budget laptop, a Chromebook or a phone running hot, it turns into dropped frames and added milliseconds on every single frame. The server did nothing wrong, and your network was clean, yet the picture still judders.
That is why our guide for Chromebooks, smart TVs and old laptops keeps coming back to the client device. This tutorial gives you numbers instead of vibes. It measures two things: how fast your machine can decode test clips in H.264, HEVC and AV1 with and without hardware help, and what your browser actually did during a live stream.
The second measurement comes from a built-in Chromium page called webrtc-internals. Browser versions of the major services stream video over WebRTC, and Chromium records counters for every stream. The fields that matter are listed below. If a service uses a different transport, or you play in a native app, the page shows nothing for that session and the script says so. That limit is real, and I flag it again in the pitfalls.
| WebRTC field | What it counts | Why it matters for cloud gaming |
|---|---|---|
| framesDecoded | Frames your browser successfully decoded | The denominator for every ratio |
| framesDropped | Frames the browser threw away before display | Visible stutter. Above a few percent you will feel it |
| totalDecodeTime | Cumulative seconds spent decoding | Divide by framesDecoded for milliseconds per frame |
| decoderImplementation | Name of the decoder Chromium picked | Software names such as libvpx or FFmpeg point to CPU decoding |
| powerEfficientDecoder | True when a hardware decoder ran | A false value is a second hint of software decoding |
| frameWidth / frameHeight | Resolution of the last decoded frame | Shows whether the service downscaled you |
Firefox gained official GeForce NOW support, which NVIDIA announced on September 3. Firefox users can run the benchmark half of this project but not the dump reader, because the dump format belongs to Chromium browsers such as Chrome and Edge. That is a fair trade for a tool you can finish in an hour.
Prerequisites and Tested Versions
You need a PC, Mac or Linux box with a modern browser, plus three free accounts for the services you want to test. Skip any service whose account you do not have. The project does not depend on a particular provider.
| Requirement | Tested version | Notes |
|---|---|---|
| Python | 3.12.3 | 3.10 or newer should work. The code uses only the standard library |
| FFmpeg | Not listed | Needs libx264 and libx265 for clips, plus libsvtav1 or libaom-av1 for AV1 |
| pytest | Not listed | Only for Step 13 |
| Browser | Current stable Chrome or Edge | Required for the webrtc-internals dump. Firefox covers the benchmark only |
| Operating system | Ubuntu 24.04 kernel 6.8 (benchmark run), Windows 11 and macOS supported by the probe code | The GPU probe uses PowerShell on Windows, lspci on Linux and system_profiler on macOS |
| Accounts | NVIDIA account, Microsoft account, Amazon account with Prime | All optional, test only what you have |
| Time | 55 minutes | About 15 minutes of setup, 25 of coding, 15 of capture and review |
If you play on Windows 11 and want to launch cloud play from the console-style interface, our Xbox Mode on Windows 11 guide covers that shell. It does not change anything in this tutorial, because the decode test runs in an ordinary browser tab.
A word on honesty about the benchmark. The test clips come from FFmpeg’s testsrc2 generator with a layer of noise. That produces a fair, repeatable load, but it is not a recorded game. Real games vary more from frame to frame. Read the benchmark as a ranking of your decoders against each other, and read the dump numbers as the ground truth about a live session.
Build the Workspace and Hardware Probe (Steps 1-3)
Step 1: Create the project folder and a virtual environment
Pick a folder you can find later. The commands below create a project called decodecheck-lab with a virtual environment, the package folder and a tests folder. On Windows, activate the environment with the second line instead of the first.
mkdir decodecheck-lab && cd decodecheck-lab
python3 -m venv .venv
source .venv/bin/activate # Windows PowerShell: .venv\Scripts\Activate.ps1
python -m pip install --upgrade pip pytest
mkdir decodecheck tests
touch decodecheck/__init__.py # Windows: New-Item decodecheck\__init__.py
Put the line __version__ = “1.0.0” inside decodecheck/__init__.py. The command line tool reads it for the –version flag.
Step 2: Install FFmpeg and confirm it can encode and decode
FFmpeg does the heavy lifting. It makes the test clips and times the decoders, so the tool never needs a video library of its own. Install it with your system package manager.
# Ubuntu or Debian
sudo apt install ffmpeg
# macOS with Homebrew
brew install ffmpeg
# Windows 11
winget install Gyan.FFmpeg
Open a fresh terminal after the install so the new PATH takes effect. Then check what your build can do. You want to see at least one H.264 and one HEVC encoder, plus an AV1 encoder if you want the AV1 row.
ffmpeg -hide_banner -encoders | grep -E "libx264|libx265|libsvtav1|libaom"
ffmpeg -hide_banner -hwaccels
On the Linux machine I used for this tutorial, a headless cloud VM with no GPU, the output looked like this. The hardware methods listed are the ones FFmpeg was compiled with. They do not prove that a usable device exists, which the benchmark tests later.
V....D libaom-av1 libaom AV1 (codec av1)
V..... libsvtav1 SVT-AV1(Scalable Video Technology for AV1) encoder (codec av1)
V....D libx264 libx264 H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10 (codec h264)
V....D libx265 libx265 H.265 / HEVC (codec hevc)
Hardware acceleration methods:
vdpau
cuda
vaapi
qsv
drm
opencl
vulkan
Step 3: Write the probe module
The probe answers a simple question: what is this machine? It reads the CPU name and GPU names with a platform-specific command, then asks FFmpeg which hardware decode methods and encoders it has. Every external call goes through one run helper with a timeout, so a missing tool returns an error string instead of crashing the program. Save this as decodecheck/probe.py.
"""Hardware and ffmpeg capability probe."""
import platform
import re
import shutil
import subprocess
def run(cmd, timeout=30):
try:
done = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
return done.stdout + done.stderr
except (OSError, subprocess.TimeoutExpired) as exc:
return f"ERROR: {exc}"
def gpu_names():
system = platform.system()
if system == "Windows":
raw = run(["powershell", "-NoProfile", "-Command",
"Get-CimInstance Win32_VideoController | Select-Object -ExpandProperty Name"])
return [ln.strip() for ln in raw.splitlines() if ln.strip()]
if system == "Linux":
raw = run(["lspci"])
return [ln.split(": ", 1)[1] for ln in raw.splitlines()
if re.search(r"VGA|3D|Display", ln) and ": " in ln]
if system == "Darwin":
raw = run(["system_profiler", "SPDisplaysDataType"])
return [ln.split(":", 1)[1].strip() for ln in raw.splitlines() if "Chipset Model" in ln]
return []
def cpu_name():
system = platform.system()
if system == "Linux":
for ln in open("/proc/cpuinfo", encoding="utf-8", errors="ignore"):
if ln.startswith("model name"):
return ln.split(":", 1)[1].strip()
if system == "Darwin":
return run(["sysctl", "-n", "machdep.cpu.brand_string"]).strip()
return platform.processor() or "unknown"
def ffmpeg_info():
exe = shutil.which("ffmpeg")
if not exe:
return {"path": None}
first = run([exe, "-version"]).splitlines()[0]
hwaccels = [ln.strip() for ln in run([exe, "-hide_banner", "-hwaccels"]).splitlines()[1:]
if ln.strip()]
encoders = run([exe, "-hide_banner", "-encoders"])
return {
"path": exe,
"version": first,
"hwaccels": hwaccels,
"can_make": {
"h264": "libx264" in encoders,
"hevc": "libx265" in encoders,
"av1": "libsvtav1" in encoders or "libaom-av1" in encoders,
},
"av1_encoder": "libsvtav1" if "libsvtav1" in encoders else "libaom-av1",
}
def snapshot():
return {
"os": f"{platform.system()} {platform.release()}",
"cpu": cpu_name(),
"gpus": gpu_names(),
"ffmpeg": ffmpeg_info(),
}
Two choices here are worth defending. The probe never raises on a missing GPU tool, because a headless server or minimal Linux image often lacks lspci. And the AV1 step accepts whichever AV1 encoder your FFmpeg build provides, so the clip test does not depend on one specific encoder.
Write the Benchmark, Reader and Verdict Modules (Steps 4-7)
Step 4: Write the benchmark module
The benchmark makes three clips (H.264, HEVC and AV1), each 10 seconds of 1080p at 60 frames per second, and caches them in a working folder so reruns skip the encode. Then it decodes each clip to a null output and records FFmpeg’s speed figure. A speed of 2.0x means the machine decoded 10 seconds of video in about 5 seconds.
The hardware pass picks one method per operating system instead of whatever FFmpeg lists first. That fix came from a real mistake in my first draft, which grabbed vdpau on a machine where only vaapi could ever work. The preferred order is d3d11va, dxva2 then cuda on Windows, vaapi, cuda then vdpau on Linux, and videotoolbox on macOS. The code also watches FFmpeg’s log for phrases that signal a silent fallback to software, so a failed hardware attempt cannot masquerade as a fast one. Save this as decodecheck/bench.py.
"""Generate test clips and time software versus hardware decoding."""
import platform
import re
import subprocess
from pathlib import Path
PREFERRED = {
"Windows": ["d3d11va", "dxva2", "cuda"],
"Linux": ["vaapi", "cuda", "vdpau"],
"Darwin": ["videotoolbox"],
}
FALLBACK_MARKERS = ("hwaccel initialisation returned error", "failed setup for format",
"device creation failed", "failed to set value")
ENCODE = {
"h264": ["-c:v", "libx264", "-preset", "veryfast", "-b:v", "20M", "-maxrate", "20M",
"-bufsize", "40M", "-pix_fmt", "yuv420p"],
"hevc": ["-c:v", "libx265", "-preset", "veryfast", "-b:v", "16M", "-pix_fmt", "yuv420p",
"-x265-params", "log-level=error"],
"av1": ["-b:v", "12M", "-pix_fmt", "yuv420p"],
}
SPEED = re.compile(r"speed=\s*([\d.]+)x")
RTIME = re.compile(r"rtime=([\d.]+)s")
def make_clip(ffmpeg, codec, outdir, seconds=10, width=1920, height=1080, fps=60, av1_encoder="libsvtav1"):
outdir = Path(outdir)
outdir.mkdir(parents=True, exist_ok=True)
clip = outdir / f"clip_{codec}_{height}p{fps}.mp4"
if clip.exists():
return clip
args = list(ENCODE[codec])
if codec == "av1":
args = ["-c:v", av1_encoder] + args
if av1_encoder == "libaom-av1":
args += ["-cpu-used", "8"]
cmd = [ffmpeg, "-hide_banner", "-loglevel", "error", "-y",
"-f", "lavfi", "-i", f"testsrc2=size={width}x{height}:rate={fps}",
"-vf", "noise=alls=12:allf=t", "-t", str(seconds)] + args + [str(clip)]
subprocess.run(cmd, check=True, capture_output=True)
return clip
def time_decode(ffmpeg, clip, hwaccel=None, timeout=300):
cmd = [ffmpeg, "-hide_banner", "-benchmark", "-stats", "-loglevel", "info"]
if hwaccel:
cmd += ["-hwaccel", hwaccel]
cmd += ["-i", str(clip), "-an", "-f", "null", "-"]
done = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
text = done.stderr
speeds = SPEED.findall(text)
rtimes = RTIME.findall(text)
return {
"ok": done.returncode == 0 and bool(speeds),
"speed": float(speeds[-1]) if speeds else 0.0,
"wall_s": float(rtimes[-1]) if rtimes else None,
"fell_back": any(m in text.lower() for m in FALLBACK_MARKERS),
"tail": text.strip().splitlines()[-1] if text.strip() else "",
}
def run_matrix(ffmpeg, info, outdir, fps=60):
rows = []
wanted = PREFERRED.get(platform.system(), [])
hw = [h for h in wanted if h in info["hwaccels"]]
for codec in ("h264", "hevc", "av1"):
if not info["can_make"][codec]:
rows.append({"codec": codec, "mode": "skipped", "speed": 0.0, "note": "no encoder"})
continue
clip = make_clip(ffmpeg, codec, outdir, fps=fps, av1_encoder=info["av1_encoder"])
sw = time_decode(ffmpeg, clip)
rows.append({"codec": codec, "mode": "software", "speed": sw["speed"], "note": ""})
if not hw:
rows.append({"codec": codec, "mode": "hw:none", "speed": 0.0, "note": "no usable hwaccel listed"})
for accel in hw[:1]:
hwres = time_decode(ffmpeg, clip, hwaccel=accel)
note = ""
if not hwres["ok"]:
note = "hwaccel failed to start"
elif hwres["fell_back"]:
note = "fell back to software"
rows.append({"codec": codec, "mode": f"hw:{accel}", "speed": hwres["speed"], "note": note})
return rows
Step 5: Write the WebRTC dump reader
The dump file Chromium writes is JSON. Under PeerConnections, each connection has a stats object whose keys look like RTCInboundRTPVideoStream_1-framesDecoded, and each value holds a time series stored as a JSON string. The reader groups keys by stream, keeps only inbound-rtp video streams with at least one decoded frame, and takes the final value of each counter. Audio streams get skipped automatically because they never report framesDecoded. Save this as decodecheck/webrtc.py.
"""Read a Chrome or Edge webrtc-internals dump taken during a cloud gaming session."""
import json
from pathlib import Path
def _series(entry):
values = entry.get("values", [])
if isinstance(values, str):
try:
values = json.loads(values)
except json.JSONDecodeError:
return []
return values
def parse_dump(path):
data = json.loads(Path(path).read_text(encoding="utf-8"))
results = []
for conn_id, conn in data.get("PeerConnections", {}).items():
stats = conn.get("stats", {})
groups = {}
for key, entry in stats.items():
if entry.get("statsType") != "inbound-rtp":
continue
stream_id, _, metric = key.rpartition("-")
groups.setdefault(stream_id, {})[metric] = _series(entry)
for stream_id, metrics in groups.items():
decoded = metrics.get("framesDecoded") or []
if not decoded or decoded[-1] == 0:
continue
dropped = (metrics.get("framesDropped") or [0])[-1]
total_decode = (metrics.get("totalDecodeTime") or [0.0])[-1]
impl = (metrics.get("decoderImplementation") or ["unknown"])[-1]
efficient = (metrics.get("powerEfficientDecoder") or [None])[-1]
width = (metrics.get("frameWidth") or [0])[-1]
height = (metrics.get("frameHeight") or [0])[-1]
results.append({
"connection": conn_id,
"stream": stream_id,
"frames_decoded": decoded[-1],
"frames_dropped": dropped,
"drop_pct": round(100 * dropped / (decoded[-1] + dropped), 2),
"avg_decode_ms": round(1000 * total_decode / decoded[-1], 2),
"decoder": impl,
"power_efficient": efficient,
"resolution": f"{width}x{height}",
})
return results
The drop percentage divides dropped frames by decoded plus dropped, so it reads as the share of frames the stream wanted to show. The average decode time divides total decode seconds by decoded frames and converts to milliseconds. Both come straight from counters, with no estimation.
Step 6: Write the verdict rules
Numbers need a rule to become advice. The verdict module holds every threshold in constants at the top, so you can tune them without touching logic. These are my own heuristics for this tool, not an industry standard, and the table shows what each one means.
| Check | PASS | MARGINAL | FAIL |
|---|---|---|---|
| Benchmark decode speed (video seconds per wall second) | 2.0x or faster | 1.2x to 2.0x | Under 1.2x |
| Session drop rate | Under 1% | 1% to 3% | 3% or more |
| Session average decode time | Under 8 ms per frame | 8 ms or more flags MARGINAL | Not used alone |
A 60 fps stream gives your device a short, fixed slice of time for each frame. An 8 ms decode leaves a large share of that for everything else, including the display pipeline, which is why I set the warning line there. Save the module as decodecheck/verdict.py.
"""Turn raw numbers into plain verdicts. Thresholds are this script's own heuristics."""
COMFORT = 2.0
MARGINAL = 1.2
DROP_WARN = 1.0
DROP_FAIL = 3.0
DECODE_MS_WARN = 8.0
SOFTWARE_HINTS = ("libvpx", "dav1d", "ffmpeg", "openh264", "libaom", "software")
def is_software(row):
name = str(row.get("decoder", "")).lower()
return row.get("power_efficient") is False or any(h in name for h in SOFTWARE_HINTS)
def bench_verdict(speed):
if speed >= COMFORT:
return "PASS"
if speed >= MARGINAL:
return "MARGINAL"
return "FAIL"
def session_verdict(row):
if row["drop_pct"] >= DROP_FAIL:
return "FAIL"
if row["drop_pct"] >= DROP_WARN or row["avg_decode_ms"] >= DECODE_MS_WARN:
return "MARGINAL"
return "PASS"
def advise(bench_rows, sessions):
tips = []
best = {}
for r in bench_rows:
if r["mode"] != "skipped" and r["speed"] > best.get(r["codec"], 0):
best[r["codec"]] = r["speed"]
for codec, speed in best.items():
if bench_verdict(speed) == "FAIL":
tips.append(f"{codec.upper()} decodes slower than real time here. Prefer a stream that uses another codec, or lower the resolution.")
for s in sessions:
if session_verdict(s) != "PASS" and is_software(s):
tips.append("Browser decoded in software. Turn on hardware acceleration and update the GPU driver.")
if session_verdict(s) == "FAIL":
tips.append(f"{s['drop_pct']}% of frames dropped. Lower resolution before blaming the network.")
if not tips and any(session_verdict(s) != "PASS" for s in sessions):
tips.append("Session shows marginal decode numbers. Close other tabs and retest at a lower resolution.")
return sorted(set(tips)) or ["No decode problems found. If the stream still feels slow, test the network next."]
Step 7: Write the command line entry point
The CLI ties the modules together. It prints the machine summary, runs the benchmark unless you pass –skip-bench, reads a dump if you pass –dump, prints advice, and saves everything to a JSON report. Save the first file as decodecheck/cli.py and the second as decodecheck/__main__.py so that python -m decodecheck works.
import argparse
import json
import sys
import tempfile
from pathlib import Path
from . import __version__, bench, probe, verdict, webrtc
def main(argv=None):
ap = argparse.ArgumentParser(prog="decodecheck", description="Client decode check for free cloud gaming")
ap.add_argument("--dump", help="webrtc-internals dump JSON from a live session")
ap.add_argument("--fps", type=int, default=60)
ap.add_argument("--workdir", default=str(Path(tempfile.gettempdir()) / "decodecheck"))
ap.add_argument("--out", default="decodecheck-report.json")
ap.add_argument("--skip-bench", action="store_true")
ap.add_argument("--version", action="version", version=__version__)
args = ap.parse_args(argv)
info = probe.snapshot()
print(f"OS : {info['os']}\nCPU : {info['cpu']}\nGPU : {', '.join(info['gpus']) or 'unknown'}")
ff = info["ffmpeg"]
if not ff.get("path"):
print("ffmpeg not found on PATH. Install it first.")
return 2
print(f"ffmpeg: {ff['version']}\nhwaccels: {', '.join(ff['hwaccels']) or 'none'}\n")
rows = []
if not args.skip_bench:
rows = bench.run_matrix(ff["path"], ff, args.workdir, fps=args.fps)
print(f"{'codec':<6} {'mode':<14} {'speed':>7} verdict")
for r in rows:
v = "-" if r["speed"] == 0 else verdict.bench_verdict(r["speed"])
print(f"{r['codec']:<6} {r['mode']:<14} {r['speed']:>6.2f}x {v} {r['note']}")
print()
sessions = []
if args.dump:
sessions = webrtc.parse_dump(args.dump)
for s in sessions:
s["verdict"] = verdict.session_verdict(s)
print(f"stream {s['stream']}: {s['resolution']} decoder={s['decoder']} "
f"drop={s['drop_pct']}% decode={s['avg_decode_ms']}ms -> {s['verdict']}")
print()
tips = verdict.advise(rows, sessions)
print("Advice:")
for t in tips:
print(f" - {t}")
Path(args.out).write_text(json.dumps(
{"machine": info, "bench": rows, "sessions": sessions, "advice": tips}, indent=2), encoding="utf-8")
print(f"\nReport saved to {args.out}")
return 0
if __name__ == "__main__":
sys.exit(main())
import sys
from .cli import main
sys.exit(main())
Run the Local Decode Benchmark (Step 8)
Step 8: Run the first benchmark and read the table
Run the tool with no arguments. The first run encodes three clips, which is the slow part. On my 16-thread cloud VM the full first run took about 73 seconds with most of that spent on the AV1 encode. Later runs reuse the cached clips and finish in a few seconds. On an old dual-core laptop, expect several minutes the first time.
python -m decodecheck --out report.json
Here is the real output from that cloud VM. It has no GPU, so the hardware rows fail by design, which makes it a useful picture of what a failure looks like.
OS : Linux 6.8.0-147-generic
CPU : Intel(R) Xeon(R) Platinum 8280 CPU @ 2.70GHz
GPU : Red Hat, Inc. Virtio 1.0 GPU (rev 01)
ffmpeg: ffmpeg version 6.1.1-3ubuntu5 Copyright (c) 2000-2023 the FFmpeg developers
hwaccels: vdpau, cuda, vaapi, qsv, drm, opencl, vulkan
codec mode speed verdict
h264 software 6.46x PASS
h264 hw:vaapi 0.00x - hwaccel failed to start
hevc software 2.71x PASS
hevc hw:vaapi 0.00x - hwaccel failed to start
av1 software 2.66x PASS
av1 hw:vaapi 0.00x - hwaccel failed to start
Read it row by row. Software H.264 decodes many times faster than real time, so even this CPU-only box handles H.264 with ease. HEVC and AV1 come in lower, which still passes but with less headroom. A large server CPU is not a fair stand-in for a thin laptop, so your numbers will likely be lower. On a machine with a working decoder, the hardware row prints a speed and a verdict instead of the failure note, and it should beat the software row for HEVC and AV1 by a wide margin.
What you are hunting for is a codec where software decode falls under 1.2x and hardware decode is missing. That combination means the stream will arrive faster than you can show it. If the service lets you pick H.264, pick it. If it picks for you, you need the hardware path working, which is the next step.
Turn On Browser Hardware Decoding (Step 9)
Step 9: Check and fix hardware acceleration in your browser
FFmpeg proving that your GPU can decode does nothing for the browser unless the browser uses it. Chrome and Edge keep their own status page. Open chrome://gpu in Chrome or edge://gpu in Edge and scroll to the Graphics Feature Status list. Find the row named Video Decode.
- Hardware accelerated means the browser can hand decoding to your GPU. Move on to Step 10.
- Software only, hardware acceleration unavailable means something blocks it. The usual cause is a switch in settings, so continue below.
- Disabled usually means the browser blocklisted your driver, or a remote desktop session is hiding the GPU.
To flip the switch, open the browser’s System settings and turn on the option to use graphics acceleration when available. Restart the browser, then reload the gpu page. If it still reports software, update the graphics driver from your GPU vendor, reboot, and check again. On a laptop with both integrated and discrete graphics, also open Windows Graphics settings and set the browser to the power-saving or high-performance GPU, then compare. Either one decodes video, and sometimes only one of them is allowed to.
Firefox shows decoding support in the Media section of about:support. It is a quick way to confirm what your machine offers, even though the dump reader cannot parse Firefox sessions.
Capture Real Free-Tier Sessions (Steps 10-11)
Step 10: Capture a GeForce NOW Free session
Now the real test. Sign in to a GeForce NOW Free membership in Chrome or Edge, and pick a game you already own or a free-to-play title. Our GeForce NOW free tier and Steam setup guide walks through linking a library if you have not done that. The free tier may put you in a queue at busy hours, so start the capture routine before you sit down to play.
- Open a new browser tab and type chrome://webrtc-internals (edge://webrtc-internals in Edge). Leave it open.
- In a second tab, launch the game. Wait for the queue and the opening ad, then reach a stretch of normal gameplay.
- Play for five minutes. A calm menu produces flattering numbers, so pick a scene with camera movement.
- Switch to the webrtc-internals tab. Expand the section named for creating a dump, and download the data. Save the file as gfn-free.json.
- Close the game properly so your session ends and the free-tier clock stops.
Keep the tab open before you begin. The page records connections that start while it is open, and starting from a blank tab removes any doubt about what got captured. Browsers name the downloaded file something like webrtc_internals_dump.txt. The extension is irrelevant, because the reader opens it as plain text.
Step 11: Capture Xbox Cloud Gaming and Amazon Luna sessions
Repeat the routine for the other services you can reach. For Xbox, open xbox.com/play, sign in with your Microsoft account, and choose a game that your account can stream. Remember what the allowance table said: a Game Pass Essential plan holds 5 cloud hours a month, so a five-minute test spends a small slice of the month. That is cheap but not free of consequence, so test once, not ten times. Save the dump as xbox-cloud.json.
For Luna, you need an active Prime membership. The catalog has more than 80 games in Luna Standard, and Luna games now surface inside Prime Video. Jeff Gattis, whom Insider Gaming identifies as the general manager of Amazon Luna, described the point of that move in a statement reported by Insider Gaming: “Bringing Luna inside Prime Video allows Prime members to discover games more naturally, and if they see one they like, they click it, and they’re in.” If your Prime plan is current, our Luna free tier setup walkthrough covers the account steps. Save the dump as luna-prime.json.
Do the three captures on the same day, on the same connection, with the same browser and the same display. Otherwise you compare conditions instead of services. If one service refuses to produce a video stream in webrtc-internals, note that and move on. It might use a different transport in that client.
Read the Results and Lock Them In (Steps 12-13)
Step 12: Analyze the dumps with decodecheck
Run the tool once per dump with the benchmark skipped. The command takes the dump path and writes a separate report for each service.
python -m decodecheck --skip-bench --dump gfn-free.json --out gfn-free-report.json
python -m decodecheck --skip-bench --dump xbox-cloud.json --out xbox-cloud-report.json
python -m decodecheck --skip-bench --dump luna-prime.json --out luna-prime-report.json
If you want to see the output before spending any cloud hours, generate a synthetic dump. The helper below writes a fake session with a fixed set of decoded frames, a few dropped frames and a software decoder. It exists only to test the parser. Do not mistake it for a real service measurement.
import json
def s(vals, t="inbound-rtp"): return {"statsType": t, "startTime": "0", "endTime": "1", "values": json.dumps(vals)}
dump = {"PeerConnections": {"12-1": {"stats": {
"RTCInboundRTPVideoStream_1-framesDecoded": s([100, 1800, 3600]),
"RTCInboundRTPVideoStream_1-framesDropped": s([0, 12, 70]),
"RTCInboundRTPVideoStream_1-totalDecodeTime": s([0.4, 7.2, 14.9]),
"RTCInboundRTPVideoStream_1-decoderImplementation": s(["libvpx", "libvpx"]),
"RTCInboundRTPVideoStream_1-frameWidth": s([1920]),
"RTCInboundRTPVideoStream_1-frameHeight": s([1080]),
"RTCInboundRTPAudioStream_2-totalSamplesReceived": s([10, 20]),
}}}}
json.dump(dump, open("sample-dump.json", "w"))
Run python make_fake_dump.py, then python -m decodecheck –skip-bench –dump sample-dump.json. This is the output I got on the cloud VM, with the benchmark rows left out:
stream RTCInboundRTPVideoStream_1: 1920x1080 decoder=libvpx drop=1.91% decode=4.14ms -> MARGINAL
Advice:
- Browser decoded in software. Turn on hardware acceleration and update the GPU driver.
Report saved to report.json
The drop rate is the dropped frames divided by the total frames, and the per-frame decode time is the total decode time divided by the decoded frames. The drop rate lands in the MARGINAL band, and the decoder name libvpx points at CPU decoding, so the tool tells you to fix acceleration first. The saved JSON keeps every number for later comparison:
{
"sessions": [
{
"stream": "RTCInboundRTPVideoStream_1",
"frames_decoded": 3600,
"frames_dropped": 70,
"drop_pct": 1.91,
"avg_decode_ms": 4.14,
"decoder": "libvpx",
"resolution": "1920x1080",
"verdict": "MARGINAL"
}
],
"advice": [
"Browser decoded in software. Turn on hardware acceleration and update the GPU driver."
]
}
When your real dumps arrive, compare them as a set. If all three services show the same decoder name and a similar drop rate, the problem sits in your browser or GPU path, not in any one provider. If only one service drops frames, look at that provider’s resolution and stream settings, then at your network. Our guide to GeForce NOW connection errors handles the network side.
Step 13: Add tests so the thresholds cannot drift
Three small tests protect the parts most likely to break: the threshold boundaries, the drop-rate math on a hand-built dump, and the rule that audio streams get ignored. The drop test uses a hand-built dump with a large share of dropped frames, which should read as a FAIL. Save this as tests/test_decodecheck.py.
import json
from decodecheck import verdict, webrtc
def test_bench_thresholds():
assert verdict.bench_verdict(2.5) == "PASS"
assert verdict.bench_verdict(1.5) == "MARGINAL"
assert verdict.bench_verdict(0.9) == "FAIL"
def test_session_drop_rate(tmp_path):
def s(v):
return {"statsType": "inbound-rtp", "values": json.dumps(v)}
dump = {"PeerConnections": {"1-1": {"stats": {
"RTCInboundRTPVideoStream_9-framesDecoded": s([1000, 5000]),
"RTCInboundRTPVideoStream_9-framesDropped": s([0, 250]),
"RTCInboundRTPVideoStream_9-totalDecodeTime": s([1.0, 20.0]),
"RTCInboundRTPVideoStream_9-decoderImplementation": s(["ExternalDecoder"]),
}}}}
path = tmp_path / "dump.json"
path.write_text(json.dumps(dump))
row = webrtc.parse_dump(path)[0]
assert row["drop_pct"] == 4.76
assert row["avg_decode_ms"] == 4.0
assert verdict.session_verdict(row) == "FAIL"
def test_audio_streams_ignored(tmp_path):
dump = {"PeerConnections": {"1-1": {"stats": {
"RTCInboundRTPAudioStream_2-totalSamplesReceived": {"statsType": "inbound-rtp", "values": "[10, 20]"},
}}}}
path = tmp_path / "dump.json"
path.write_text(json.dumps(dump))
assert webrtc.parse_dump(path) == []
python -m pytest -q
Expect a clean run:
... [100%]
3 passed in 0.03s
The Complete Project: File Tree and Run Commands
Your folder should now match the tree below. Every file appeared in an earlier step, so nothing is missing. The sample dump and the fake dump helper sit at the top level because they are scratch files, not part of the package.
decodecheck-lab/
.venv/
decodecheck/
__init__.py
__main__.py
bench.py
cli.py
probe.py
verdict.py
webrtc.py
tests/
test_decodecheck.py
make_fake_dump.py
gfn-free.json # your captures
xbox-cloud.json
luna-prime.json
More guides in this vein live in our gaming section. The everyday workflow takes three commands. Run the benchmark once whenever you change hardware, drivers or browsers. Capture a dump whenever a stream looks wrong. Then read the report and change one thing at a time.
python -m decodecheck # machine summary and benchmark
python -m decodecheck --skip-bench --dump session.json # live session only
python -m decodecheck --fps 30 --out slow-pc.json # lighter 30 fps benchmark
| Flag | Default | What it does |
|---|---|---|
| –dump | none | Path to a webrtc-internals dump from a live session |
| –fps | 60 | Frame rate of the generated clips. Use 30 for weak devices |
| –workdir | system temp folder | Where the cached test clips live |
| –out | decodecheck-report.json | Where the JSON report is saved |
| –skip-bench | off | Skips clip generation and decoding, useful when you only have a dump |
| –version | n/a | Prints the package version and exits |
Common Pitfalls That Skew Free Cloud Gaming Tests
Most bad test results trace back to a short list of mistakes. I made several of them while building this project, so none of this is theoretical.
- Testing on battery saver. Laptops throttle the CPU and sometimes the video block in power-saving mode. Plug in and switch to a balanced or performance profile, or your benchmark will blame the wrong thing.
- Trusting a silent fallback. FFmpeg often falls back to software decoding when a hardware method fails, and it still exits cleanly. The fell_back check in bench.py exists because of this. Never treat a quick hardware row as proof without the note column being empty.
- Reading the benchmark as a game. Synthetic clips tell you which decoder is faster. They do not tell you how a particular game looks when it streams. Use the dump for that.
- Capturing from a native app. The GeForce NOW desktop app and console apps do not expose webrtc-internals. If the dump is empty, that is why, and it does not mean your session was perfect.
- Running tests over Remote Desktop. A remote session often hides the real GPU and forces software rendering. Test at the physical machine.
- Comparing across days. Free tiers queue differently at peak hours, and the server hardware behind a stream varies. Capture all services in one sitting.
- Letting other tabs run. A video call or a heavy web app in the background competes for the same decoder. Close everything except the test tab and webrtc-internals.
Troubleshooting: 9 Problems and Their Fixes
When something fails, find the symptom in the table first. If the decode numbers look fine but streams still stutter, the cause probably sits in the network, and our Xbox Cloud Gaming not working fixes cover that ground.
| Symptom | Likely cause | Fix |
|---|---|---|
| ffmpeg not found on PATH | Install finished but the terminal predates it | Open a new terminal, run ffmpeg -version, and on Windows confirm winget finished |
| Clip creation raises CalledProcessError | Your FFmpeg build lacks an encoder such as libx265 | Run the encoders check in Step 2. Install a full build, or drop the codec from ENCODE |
| AV1 row shows skipped | No AV1 encoder in the build | Normal on minimal builds. Install a build with an AV1 encoder if you want that row |
| Every hardware row says hwaccel failed to start | No usable device, a missing driver, or a virtual machine | Update the GPU driver, check you are on the physical machine, then rerun |
| Hardware row says fell back to software | The method started but the codec is unsupported on your GPU | Compare codecs. If only AV1 falls back, your GPU lacks AV1 decode and H.264 is the safer stream |
| The first run takes many minutes | Software encoding of three clips on a slow CPU | Run once with –fps 30, let it finish, and keep the cached clips |
| GPU shows unknown or an empty list | lspci or PowerShell is missing or blocked | Install pciutils on minimal Linux. On Windows, allow PowerShell to run local commands |
| Dump parses but prints no streams | Native app, a service using another transport, or the page opened after the stream started | Open webrtc-internals first in a fresh tab, then start the stream in Chrome or Edge |
| JSONDecodeError on the dump | A partial download or a text editor added extra content | Download the dump again and point –dump at the untouched file |
If you hit something not listed, run the failing FFmpeg command by hand against the clip in your working folder and read the last lines it prints. Nine times out of ten they name the missing piece.
Advanced Tips for Power Users
Once the basic loop works, a few upgrades make the tool more useful without making it complicated.
- Log every session to a CSV. Add a few lines to cli.py that append drop rate, decode time and decoder to a running file. After a month you will see which service and which time of day treat your machine best. It pairs naturally with our free cloud gaming data use tracker.
- Benchmark more resolutions. The make_clip function already accepts width and height. Call it for 1440p and 4K clips to learn the ceiling of your decoder before a service offers those streams.
- Compare two browsers. Capture the same service in Chrome and in Edge on the same machine. Their decoders and GPU blocklists differ, and one can pass where the other fails.
- Change the thresholds for your display. A 120 Hz screen leaves a much shorter time budget for each frame. Cut DECODE_MS_WARN in half if you play at that rate.
- Pin the GPU on hybrid laptops. Set the browser to the integrated GPU for decoding and compare against the discrete one. Many laptops decode more efficiently on the integrated chip and save battery as a bonus.
- Archive your dumps. Keep the raw JSON next to the report. When a driver update arrives, you can diff before and after instead of trusting memory.
If you want a wider view of the category after this, our best cloud gaming services roundup ranks the paid options too. For this project, though, the useful next step is a real week of captures from your own desk.
Frequently Asked Questions
Is free cloud gaming really free in November 2026?
Some of it is. NVIDIA still lists a Free GeForce NOW membership, Amazon includes Luna with Prime at no extra gaming charge, and Xbox is testing an ad-supported route for Insiders. Xbox cloud play through Game Pass now has monthly hour limits, and Microsoft’s standalone playtime packs are paid and still in preview.
Does a decode problem look different from a network problem?
Yes. Network trouble shows up as packet loss, jitter and sudden quality drops that come and go. A decode problem shows up as steady frame drops, high average decode time and a software decoder name, often while your ping looks fine. The dump separates the two because it records both sides.
Do I need a GPU for the benchmark to be useful?
No. The software rows run on any CPU, and they tell you whether you can decode a codec in real time without help. A GPU only adds the hardware rows. If those fail on a physical machine, a driver update is the first thing to try.
Why does the tool only parse Chrome and Edge dumps?
The webrtc-internals dump format belongs to Chromium browsers. Firefox exposes its own diagnostics in a different layout. You can still run the benchmark in full on any browser machine, because that half only needs FFmpeg.
Can this tool harm my account or break a service’s terms?
It does not touch any service. The tool reads a file your own browser wrote and runs FFmpeg on files it generated locally. Normal play is the only interaction with the providers, so you act as an ordinary user.
How many cloud hours will a test burn?
A five-minute capture is a small slice of a 5-hour monthly allowance on Game Pass Essential and an even smaller slice of a 15-hour Ultimate allowance. Free tiers have their own caps, so check the provider’s current rules and keep each capture short.
Which codec should I prefer if a service lets me choose?
Pick the one your benchmark passes comfortably with hardware help. For most older machines that is H.264. HEVC and AV1 can look better at the same bitrate, but only if your device decodes them in hardware or has plenty of CPU headroom.