The full writeup behind Handing my only GPU to a VM, and getting it back. Internal specifics are scrubbed; everything here is generic enough to run on your own gear.

Hand the only GPU on a Linux host to a Windows VM, tune for native-grade performance, and spoof the guest hard enough that the OS and most anti-cheats see real hardware. Covers Intel and AMD CPUs, NVIDIA and AMD GPUs, kernel 6.10+ on Arch with Plasma 6.7 Wayland. Includes TPM 2.0 passthrough for Vanguard-class anti-cheats.

Table of Contents#

  1. Overview
  2. Architecture Choice
  3. Prerequisites
  4. Host Kernel Setup
  5. VFIO Module Binding
  6. Libvirt Hooks
  7. GPU-Specific Notes
  8. Domain XML: Foundation
  9. Domain XML: Performance Tuning
  10. Anti-Cheat Landscape
  11. Domain XML: Spoofing Tiers
  12. Sourcing Real Values
  13. Custom QEMU and OVMF Build (Tier C)
  14. Custom KVM Kernel Patches (Tier D)
  15. TPM 2.0 Passthrough
  16. Audio, Input, USB
  17. Host Session on Plasma 6.7
  18. Hybrid Setups
  19. Verification
  20. Troubleshooting
  21. Sources

1. Overview#

Single-GPU passthrough: host owns the GPU at boot, releases it to a VM on start, reclaims on shutdown. The host has no display while the VM is running. This guide targets Arch Linux, kernel 6.10+ (Arch ships 6.16+ in mid-2026), libvirt 10.x, QEMU 10.x, OVMF/edk2 stable202602, KDE Plasma 6.7 on Wayland, AMD or Intel CPU with IOMMU support, AMD or NVIDIA GPU, Windows 11 guest with UEFI + Secure Boot + TPM 2.0.

What works in 2026:

  • Repeated hand-off cycles on RTX 20/30/40 and RX 6000/9000 series.
  • Four-tier spoofing that defeats EAC, BattlEye, and most non-kernel anti-cheats.
  • TPM 2.0 host passthrough for kernel anti-cheats that validate EK certificate chain (Vanguard, FACEIT). Caveat: structural detection beyond the TPM gate still requires Tier C/D and patched QEMU/OVMF.
  • Hook-based runtime CPU isolation and dynamic 2M hugepage allocation. Host stays unrestricted outside VM sessions.
  • AVIC/APICv default-on, virtio-blk + iothread-vq-mapping, MWAIT/WAITPKG passthrough.

What does not:

  • RTX 50 (Blackwell) reset is broken; one VM run per host boot. NVIDIA acknowledged, no clean fix.
  • RX 7000 (RDNA3) reset is broken; AMD officially does not support PCI passthrough on 7900 XTX.
  • Keeping Plasma session alive while the GPU is gone. Plasma 6.7's session-management protocol restores window geometry on app relaunch, not in-flight survival. Use iGPU + dGPU (section 18) if "host stays usable" is the goal.

Risk note: passing kernel anti-cheats from a VM is a Terms of Service violation for every game that runs them. Account hardware bans propagate across publishers (Riot bans roll across Valorant + LoL + Wild Rift). Use throwaway accounts for experiments.


2. Architecture Choice#

Three configurations are viable. Pick before buying hardware.

ConfigurationHost displayVM displayAnti-cheat ceilingHardware requirement
Single-GPU passthrough (this guide's default)Killed during VM, restored on stopDirect on passed GPUVanguard with TPM passthrough + Tier Cone dGPU, optional TPM 2.0
iGPU + dGPU (section 18)Stays alive on iGPUDirect on passed dGPUSame as single-GPU plus simpler workflowCPU with iGPU, one dGPU
Looking Glass on iGPU + dGPUWindow into guest on hostMirrored via IVSHMEMSame as aboveiGPU host, dGPU for VM

Single-GPU is the constrained-hardware path. Anyone with an APU (Ryzen 5/7/8000G/9000) or a non-F Intel SKU should pick iGPU + dGPU. Looking Glass is the productivity-plus-gaming sweet spot for two-GPU setups.


3. Prerequisites#

3.1 Hardware#

  • AMD CPU with SVM, or Intel CPU with VT-d.
  • Motherboard with IOMMU support and decent IOMMU groups.
  • AMD or NVIDIA dGPU. RTX 50 and RX 7000 have reset bugs that limit usefulness.
  • For Vanguard / FACEIT: real TPM 2.0 chip (Intel PTT, AMD fTPM, or discrete Infineon/STMicro/Nuvoton dTPM). The motherboard's fTPM via BIOS is sufficient if the EK certificate chains to the SoC manufacturer CA. See section 15.

3.2 BIOS Settings#

Required:

  • SVM (AMD) or VT-x + VT-d (Intel) enabled.
  • IOMMU enabled.
  • Above 4G Decoding enabled (for GPU MMIO).
  • Re-Size BAR Support enabled (or disabled if it causes Code 43 on the guest; see section 7.3).

For Vanguard / FACEIT:

  • fTPM or Intel PTT enabled.
  • Secure Boot enabled.

Optional perf:

  • CSM disabled (UEFI-only boot).
  • Power supply idle control to Typical Current Idle on AMD if you see VM hangs.
  • Global C-states enabled to allow C-state passthrough.

3.3 Packages#

sudo pacman -S --needed qemu-full libvirt edk2-ovmf swtpm dnsmasq \
                        virt-manager iptables-nft bridge-utils \
                        tpm2-tools tpm2-tss     # for TPM 2.0 work (section 15)
# Optional:
yay -S looking-glass looking-glass-module-dkms     # Hybrid setups (section 18)
yay -S vendor-reset-dkms                           # AMD reset bug fix (section 7.2)

Add yourself to relevant groups:

sudo usermod -aG libvirt,kvm,input "$USER"

3.4 IOMMU Verification#

After reboot with kernel cmdline configured (section 4):

dmesg | grep -i 'iommu.*enabled\|DMAR\|AMD-Vi'

Then enumerate groups:

for d in /sys/kernel/iommu_groups/*/devices/*; do
  n=${d#*/iommu_groups/}; printf 'group %s: %s\n' "${n%%/*}" "$(lspci -nns ${d##*/})"
done | sort -n

The dGPU and its HDMI audio function must be in their own group, separate from anything you want to keep on the host. Bad groups are usually a chipset-PCIe-lane vs CPU-direct-lane issue; see section 7.4.


4. Host Kernel Setup#

4.1 Mandatory Kernel Cmdline#

The minimum that works. No permanent CPU isolation, no permanent hugepage pool - those are runtime via the hook (section 6).

AMD host:

amd_iommu=on iommu=pt initcall_blacklist=sysfb_init kvm.ignore_msrs=1 tsc=reliable clocksource=tsc

Intel host:

intel_iommu=on iommu=pt initcall_blacklist=sysfb_init kvm.ignore_msrs=1 tsc=reliable clocksource=tsc

Apply via /etc/default/grub (GRUB_CMDLINE_LINUX_DEFAULT) or /etc/kernel/cmdline (UKI / mkinitcpio).

ParameterPurpose
amd_iommu=on / intel_iommu=onEnable IOMMU. Some kernels default it on, explicit is safe.
iommu=ptPassthrough mode for host DMA. Mandatory for performance.
initcall_blacklist=sysfb_initPrevent simple-framebuffer.0 registration. Replaces legacy video=efifb:off,vesafb:off. Verified at drivers/firmware/sysfb.c.
kvm.ignore_msrs=1Silence Windows MSR reads of unimplemented MSRs.
tsc=reliable clocksource=tscKeep host on TSC, avoid HPET fallback that kills guest latency.

4.2 NVIDIA Host Additions#

nvidia-drm.modeset=1 nvidia-drm.fbdev=1

Lets nvidia-drm take over the firmware framebuffer instead of simpledrm. Requires driver 560+ with the KMS+fbdev patch (Arch default since 560.35.03-5).

4.3 Optional Performance Knobs#

Only add if you need them:

  • pcie_aspm=off - suppresses AER chatter on consumer boards.
  • pcie_acs_override=downstream,multifunction - requires an ACS-patched kernel (out of tree). Prefer choosing a board where GPU and target USB controller sit on CPU-direct lanes.
  • mitigations=off - 1-3% gain on Zen 4/5, 3-6% on Raptor Lake. Security tradeoff. Generally not worth it.

Optional permanent CPU isolation, not default in this guide. Only if hook-based runtime isolation (section 6) leaves residual jitter that affects competitive eSports timing:

isolcpus=domain,managed_irq,2-15,18-31 nohz_full=2-15,18-31 rcu_nocbs=2-15,18-31

Optional permanent hugepage reservation, not default. Reserves the memory at boot whether the VM runs or not:

default_hugepagesz=1G hugepagesz=1G hugepages=16

4.4 Do Not Add#

  • video=efifb:off,vesafb:off - dead on kernel 6.10+ since simpledrm replaced both.
  • nofb, module_blacklist=simpledrm - do not work because simpledrm is built-in.
  • pci=realloc - only if BAR cannot reserve errors appear.
  • clearcpuid=514 - bit 514 is X86_FEATURE_UMIP, not hypervisor. Hypervisor is bit 159. Modern syntax: clearcpuid=hypervisor.

4.5 Memory Backing Default#

Use transparent hugepages with madvise. Zero ceremony, no boot-time reservation.

# /etc/tmpfiles.d/thp.conf
w /sys/kernel/mm/transparent_hugepage/enabled  - - - - madvise
w /sys/kernel/mm/transparent_hugepage/defrag   - - - - defer+madvise

THP delivers ~95% of static 1G hugepage performance at zero static cost. For the last few percent and assured allocation, the hook (section 6) allocates 2M hugepages dynamically at VM start.

4.6 Regenerate Bootloader#

sudo grub-mkconfig -o /boot/grub/grub.cfg     # GRUB
sudo mkinitcpio -P                            # UKI / dracut: matching command
sudo reboot

Verify IOMMU active per section 3.4 after reboot.


5. VFIO Module Binding#

5.1 Identify the GPU#

lspci -nn | grep -iE 'vga|audio|usb'

Note PCI BDFs (e.g. 01:00.0 GPU, 01:00.1 HDMI audio) and the [vendor:device] pair (e.g. [10de:2684] and [10de:22ba]).

5.2 Dynamic Binding via Hook (Default)#

For the standard "host display normal, hand over at VM start" workflow: do not put GPU IDs in /etc/modprobe.d/vfio.conf. Let the host claim the GPU normally; the libvirt hook (section 6) detaches and reattaches it.

Preload VFIO modules:

# /etc/modules-load.d/vfio.conf
vfio
vfio_iommu_type1
vfio_pci

5.3 Early-Bind via initramfs#

Use this only if you want the GPU permanently bound to vfio-pci from boot. This means no host display at all - SSH-only host, dGPU sits idle until a VM claims it.

# /etc/modprobe.d/vfio.conf
options vfio-pci ids=10de:2684,10de:22ba disable_idle_d3=1
softdep nvidia pre: vfio-pci
softdep nvidia_drm pre: vfio-pci
softdep amdgpu pre: vfio-pci
softdep drm pre: vfio-pci
# /etc/mkinitcpio.conf
MODULES=(vfio_pci vfio vfio_iommu_type1 nvidia nvidia_drm nvidia_modeset)
# or for AMD GPU: MODULES=(vfio_pci vfio vfio_iommu_type1 amdgpu)
sudo mkinitcpio -P

softdep lines force vfio-pci to load before the GPU driver. The vfio-pci.ids=... kernel cmdline form is too late with nvidia-drm.modeset=1 and loses the race.

For single-GPU, use 5.2 (dynamic) unless you have a specific reason.


6. Libvirt Hooks#

The hook layer is where runtime isolation happens. CPU constraint via cgroup v2 cpuset, dynamic 2M hugepage allocation, GPU detach, display manager management - all triggered by VM start, reversed on VM stop. Host stays unrestricted outside VM sessions.

6.1 Install Dispatcher#

Use the PassthroughPOST dispatcher (de-facto standard, libvirt 6.5+):

sudo curl -L https://raw.githubusercontent.com/PassthroughPOST/VFIO-Tools/master/libvirt_hooks/qemu \
     -o /etc/libvirt/hooks/qemu
sudo chmod +x /etc/libvirt/hooks/qemu
sudo systemctl restart libvirtd

Per-VM hooks live in /etc/libvirt/hooks/qemu.d/<vmname>/<state>/<phase>/:

sudo mkdir -p /etc/libvirt/hooks/qemu.d/win11/{prepare/begin,release/end}
sudo mkdir -p /run/libvirt/hooks

6.2 Configuration#

A single sourced shell file, edit for your topology:

# /etc/libvirt/hooks/qemu.d/win11/config
VM_CPUS="2-15,18-31"        # host CPUs given to the VM
HOST_CPUS="0-1,16-17"       # host CPUs left for housekeeping
VM_MEM_GB=32                # for dynamic hugepage allocation
USE_HUGEPAGES=1             # 1 to allocate 2M hugepages, 0 for THP only
GPU_BDF="pci_0000_01_00_0"
GPU_AUDIO_BDF="pci_0000_01_00_1"

6.3 Start Script#

/etc/libvirt/hooks/qemu.d/win11/prepare/begin/start.sh:

#!/bin/bash
set -e
source /etc/libvirt/hooks/qemu.d/win11/config

# 1. Inhibit host sleep (see 6.5)
systemctl start libvirt-nosleep@win11.service || true

# 2. Stop display manager
systemctl stop display-manager.service

# 3. Unbind VT consoles
for vtcon in /sys/class/vtconsole/vtcon*; do
    [ -e "$vtcon" ] && [ "$(grep -c 'frame buffer' "$vtcon/name")" = 1 ] \
        && echo 0 > "$vtcon/bind"
done

# 4. Unbind firmware framebuffer (covers legacy efifb and modern simpledrm)
for fb in efi-framebuffer simple-framebuffer; do
    drv=/sys/bus/platform/drivers/$fb
    [ -d "$drv" ] || continue
    for dev in "$drv"/*.0; do
        [ -e "$dev" ] && echo "$(basename "$dev")" > "$drv/unbind"
    done
done

sleep 1

# 5. Unload GPU driver stack
if lspci -nn | grep -qi 'vga.*nvidia'; then
    modprobe -r nvidia_uvm nvidia_drm nvidia_modeset nvidia
    lsmod | grep -q '^i2c_nvidia_gpu ' && modprobe -r i2c_nvidia_gpu || true
fi
if lspci -nn | grep -qi 'vga.*amd'; then
    modprobe -r amdgpu
fi

# 6. Load VFIO
modprobe vfio
modprobe vfio_iommu_type1
modprobe vfio_pci

# 7. Constrain host workload to housekeeping cores (cgroup v2 cpuset)
for slice in system.slice user.slice init.scope; do
    cg=/sys/fs/cgroup/$slice
    [ -d "$cg" ] || continue
    cat "$cg/cpuset.cpus.effective" > "/run/libvirt/hooks/win11-${slice//./-}-cpus.bak" 2>/dev/null || true
    echo "$HOST_CPUS" > "$cg/cpuset.cpus" 2>/dev/null || true
done

# 8. Free host RAM: drop file caches, suspend memory-heavy apps
#    SIGSTOP backgrounds the process without killing it - memory stays allocated
#    but the apps stop allocating more. Resumed in the stop hook.
echo 3 > /proc/sys/vm/drop_caches
for proc in brave chromium chrome firefox steam discord slack; do
    pkill -STOP -x "$proc" 2>/dev/null || true
done

# 9. Allocate 2M hugepages dynamically (only if USE_HUGEPAGES=1 AND the VM XML
#    uses <hugepages> memory backing - else this just locks RAM the VM never touches)
if [ "$USE_HUGEPAGES" = "1" ]; then
    PAGES=$(( VM_MEM_GB * 512 ))
    echo 1 > /proc/sys/vm/compact_memory
    sleep 1
    echo $PAGES > /sys/kernel/mm/hugepages/hugepages-2048kB/nr_hugepages
    GOT=$(cat /sys/kernel/mm/hugepages/hugepages-2048kB/nr_hugepages)
    if [ "$GOT" -lt "$PAGES" ]; then
        echo "WARNING: only got $GOT/$PAGES hugepages, VM will use THP for remainder" \
            >> /var/log/libvirt/vfio-hooks.log
    fi
fi

# 10. Stop tpm2-abrmd if using TPM passthrough (see section 15)
systemctl is-active tpm2-abrmd >/dev/null 2>&1 && \
    systemctl stop tpm2-abrmd.service tpm2-abrmd.socket 2>/dev/null || true

# 11. Detach GPU + audio function
virsh nodedev-detach "$GPU_BDF"
virsh nodedev-detach "$GPU_AUDIO_BDF"

Make executable:

sudo chmod +x /etc/libvirt/hooks/qemu.d/win11/prepare/begin/start.sh

Notes:

  • Do not unload drm_kms_helper, drm, ttm, gpu_sched. They are shared and will refuse, or break other subsystems.
  • AMD-specific: modprobe -r amdgpu must run before virsh nodedev-detach, otherwise amdgpu rebinds on detach on some RDNA cards.
  • Step 7 is the "vfio-isolate" pattern. Narrows existing slices' allowed CPU set without cgroup partition transitions.
  • Step 8 frees host RAM before the VM starts touching its allocation. SIGSTOP to memory-hungry apps keeps them alive (their RAM stays mapped) but stops them from allocating more. The stop hook sends SIGCONT to resume them. Add or remove process names per your desktop.
  • Step 9 hugepage allocation should only run if the VM XML uses <memoryBacking><hugepages>...</hugepages></memoryBacking>. If the XML uses <source type='memfd'/> (THP path), set USE_HUGEPAGES=0 - otherwise the hook reserves a pool the VM never touches and you lose that RAM until the stop hook releases it.
  • Step 9 uses 2M pages because 1G dynamic allocation after host uptime > 30 min fails reliably due to fragmentation. For 1G, see section 4.3's optional boot-time path.

6.4 Stop Script#

/etc/libvirt/hooks/qemu.d/win11/release/end/stop.sh:

#!/bin/bash
set -e
source /etc/libvirt/hooks/qemu.d/win11/config

# 1. Reattach GPU + audio
virsh nodedev-reattach "$GPU_AUDIO_BDF"
virsh nodedev-reattach "$GPU_BDF"

# 2. Free hugepages
if [ "$USE_HUGEPAGES" = "1" ]; then
    echo 0 > /sys/kernel/mm/hugepages/hugepages-2048kB/nr_hugepages
fi

# 3. Restore host slices to all cores
ALL_CPUS=$(seq -s, 0 $(($(nproc) - 1)))
for slice in system.slice user.slice init.scope; do
    cg=/sys/fs/cgroup/$slice
    [ -d "$cg" ] || continue
    saved="/run/libvirt/hooks/win11-${slice//./-}-cpus.bak"
    if [ -s "$saved" ]; then
        cat "$saved" > "$cg/cpuset.cpus" 2>/dev/null || \
            echo "$ALL_CPUS" > "$cg/cpuset.cpus"
        rm -f "$saved"
    else
        echo "$ALL_CPUS" > "$cg/cpuset.cpus"
    fi
done

# 4. Unload VFIO
modprobe -r vfio_pci vfio_iommu_type1 vfio

# 5. Reload GPU driver
if lspci -nn | grep -qi 'vga.*nvidia'; then
    modprobe nvidia nvidia_modeset nvidia_drm nvidia_uvm || true
fi
if lspci -nn | grep -qi 'vga.*amd'; then
    modprobe amdgpu
fi

# 6. Rebind VT consoles
for vtcon in /sys/class/vtconsole/vtcon*; do
    [ -e "$vtcon/bind" ] && echo 1 > "$vtcon/bind"
done

# 7. Restart display manager (fresh session, see section 17)
systemctl start display-manager.service

# 8. Resume host apps suspended in the start hook
for proc in brave chromium chrome firefox steam discord slack; do
    pkill -CONT -x "$proc" 2>/dev/null || true
done

# 9. Restore TPM access daemon if it was running
systemctl start tpm2-abrmd.socket 2>/dev/null || true

# 10. Release sleep inhibitor
systemctl stop libvirt-nosleep@win11.service || true
sudo chmod +x /etc/libvirt/hooks/qemu.d/win11/release/end/stop.sh

6.5 Sleep Inhibitor#

/etc/systemd/system/libvirt-nosleep@.service:

[Unit]
Description=Inhibit sleep while libvirt domain "%i" is running

[Service]
Type=simple
ExecStart=/usr/bin/systemd-inhibit --what=sleep --why="VM %i running" \
          --who=libvirt --mode=block sleep infinity
sudo systemctl daemon-reload

7. GPU-Specific Notes#

7.1 NVIDIA#

Code 43 is dead since driver R465 (2021). Consumer cards work without <kvm><hidden/> or vendor_id. Keep the spoofing anyway as defense in depth for anti-cheat (section 11).

Reset reliability per generation:

GenerationResetNotes
Turing (RTX 20)FLR cleanRepeat hand-off works.
Ampere (RTX 30)FLR cleanRepeat hand-off works.
Ada (RTX 40)FLR cleanSome 4060/4060 Ti need disable_idle_d3=1 to wake.
Blackwell (RTX 50)Brokennot ready 65535ms after FLR. Host reboot required. No fix. Linux guest shutdown triggers it more than Windows.

Module unbind chain on driver 575/580+ (proprietary or nvidia-open, same symbol names):

modprobe -r nvidia_uvm nvidia_drm nvidia_modeset nvidia

i2c_nvidia_gpu is the in-tree kernel I2C driver for USB-C/VirtualLink controllers on RTX 20/30. Only unload if actually loaded:

lsmod | grep -q '^i2c_nvidia_gpu ' && modprobe -r i2c_nvidia_gpu

nvidia_peermem (GPUDirect RDMA) and nvidia_vgpu_vfio ship only in data-center configurations. Not produced by Arch's nvidia-open package.

Known issue: driver 595+ unbind can hang after long host uptime (NVIDIA/open-gpu-kernel-modules#1119). Reboot before VM session if hangs occur.

7.2 AMD#

GenerationResetNotes
Polaris, Vega, Navi 10 (5700 XT)BrokenUse vendor-reset DKMS.
RDNA2 (RX 6000)FLR cleanSome OEM 6700/6800 need echo bus > reset_method.
RDNA3 (RX 7000)BrokenAMD officially does not support passthrough on 7900 XTX. Avoid.
RDNA4 (RX 9070 XT, 9070)Working on 6.13+Pass GPU + audio together. Some boards need BAR2 resized to 8 MB pre-bind.

vendor-reset activation:

sudo pacman -S vendor-reset-dkms     # or AUR: vendor-reset-lowell80-dkms-git for 6.14+
sudo modprobe vendor-reset
echo device_specific | sudo tee /sys/bus/pci/devices/0000:01:00.0/reset_method

Persistent:

# /etc/modules-load.d/vendor-reset.conf
vendor-reset
# /etc/udev/rules.d/99-vendor-reset.rules
ACTION=="add", SUBSYSTEM=="pci", ATTR{vendor}=="0x1002", \
  ATTR{class}=="0x030000", ATTR{reset_method}="device_specific"

amdgpu.reset_method values (kernel docs):

ValueNameUse
-1autoDefault.
0legacy
1mode0Hardware reset mode 0.
2mode1Warm reset, common Navi default.
3mode2Renoir/APU style.
4bacoBus Active Chip Off.

5 (pci) exists in some kernels and not others; do not rely on it. The string form (device_specific, mode1, baco) is what sysfs accepts and what vendor-reset hooks via.

AMD module teardown is just modprobe -r amdgpu. Do not unload drm, ttm, drm_kms_helper, gpu_sched - they are shared.

7.3 Resizable BAR / SAM#

QEMU still does not virtualize ReBAR capability (QEMU #703). Three workable paths:

  1. Disable ReBAR in host UEFI, accept the small perf hit. Universal.

  2. Set BAR size statically via sysfs before vfio-pci binds:

    # /etc/udev/rules.d/01-gpu-rebar.rules
    ACTION=="add", SUBSYSTEM=="pci", ATTR{vendor}=="0x10de", \
      ATTR{device}=="0x2684", ATTR{resource0_resize}="13"

    resource0_resize=N writes 2^N MB. 13 = 8 GB BAR0. Kernel 6.1+.

  3. Enable Above-4G Decoding, disable ReBAR. Most boards split these.

Kernel 6.12+ regression: ReBAR-enabled host BIOS that worked on 6.11 now throws Code 43 in the guest. Moving target.

7.4 IOMMU Groups#

AM5 status mixed in 2026. CPU-direct lanes (PCIe 5.0 x16, NVMe lanes) are isolated by hardware ACS. Chipset lanes share one group. Plan slot layout: GPU and target USB controller both on CPU-direct lanes.

Known regression: X870E boards on AGESA 1.2.2.* request 1:1 RMRR mapping on the GPU and vfio-pci refuses to bind. Roll AGESA back or wait for vendor BIOS fix. ASUS ProArt X870E-CREATOR WIFI is known-good.

7.5 HDMI Audio Function#

The GPU's function 0x1 (e.g. 01:00.1) is the HDMI audio device. Always in the same IOMMU group as the GPU. Pass both. Skipping the audio function makes Windows audio service hang.

7.6 vBIOS Dumping#

Required only when the GPU is the host's primary boot device and the host has touched the framebuffer. Dump:

echo 1 | sudo tee /sys/bus/pci/devices/0000:01:00.0/rom
sudo cat /sys/bus/pci/devices/0000:01:00.0/rom > /var/lib/libvirt/vbios/gpu.rom
echo 0 | sudo tee /sys/bus/pci/devices/0000:01:00.0/rom

NVIDIA: strip the PCI ROM expansion header with Matoking/NVIDIA-vBIOS-VFIO-Patcher - the NV signature must be at byte 0.

Reference in the domain:

<hostdev mode='subsystem' type='pci' managed='yes'>
  <source><address domain='0x0000' bus='0x01' slot='0x00' function='0x0'/></source>
  <rom file='/var/lib/libvirt/vbios/gpu.rom'/>
</hostdev>

Fallback if in-system dumping fails: TechPowerUp's VBIOS database has matching ROMs for most cards.


8. Domain XML: Foundation#

The minimum that boots a working Windows 11 VM. Build the rest on top.

8.1 Machine Type and Skeleton#

<domain type='kvm' xmlns:qemu='http://libvirt.org/schemas/domain/qemu/1.0'>
  <name>win11</name>
  <uuid>...</uuid>
  ...
</domain>

xmlns:qemu namespace is required if you use <qemu:commandline> for arbitrary QEMU args (Tier B/C spoofing).

8.2 Memory Backing#

THP-only default (no static hugepages, no upfront reservation):

<memory unit='GiB'>12</memory>
<currentMemory unit='GiB'>12</currentMemory>
<memoryBacking>
  <source type='memfd'/>
  <access mode='shared'/>
  <allocation mode='ondemand'/>
  <nosharepages/>
</memoryBacking>
  • <source type='memfd'/> + <access mode='shared'/> works with Looking Glass and virtiofs.
  • <allocation mode='ondemand'/> lets the kernel fault pages in as the guest touches them. THP-madvise then promotes contiguous 4K runs to 2M hugepages opportunistically. No upfront commit, no locked pool.
  • <nosharepages/> opts out of KSM (page deduplication).
  • unit='GiB' is clearer than KiB and avoids mistakes like 16384000 KiB (which is 15.625 GiB, not 16).

Sizing math. Windows guests commit nearly all assigned RAM during boot (memory test, kernel paging structures). Plan host-vs-guest split on the assumption the VM eats its full allocation while running:

Host RAMSafe VM sizeComfortable VM sizeReckless
16 GiB6 GiB8 GiB10+ GiB
32 GiB12 GiB16 GiB20+ GiB
64 GiB32 GiB40 GiB48+ GiB

Reckless = host gets OOM-killed if a browser is open. Add 8-16 GiB of swap (file or zram) if you want more headroom without disk-backing extra RAM.

Two consistent hugepage paths, never mix them:

PathMemory backing XMLHook configBehavior
THP (default)<source type='memfd'/> + <allocation mode='ondemand'/>USE_HUGEPAGES=0Kernel fault-in + opportunistic THP promotion. No upfront commit. Releases naturally on VM stop.
HugeTLB pool<hugepages><page size='2048' unit='KiB' nodeset='0'/></hugepages> + <locked/> + <allocation mode='immediate'/>USE_HUGEPAGES=1, VM_MEM_GB=<match VM>Hook reserves exact-sized 2M pool, QEMU maps onto it. Pool freed on VM stop.

Mixing them (memfd backing + USE_HUGEPAGES=1) reserves a HugeTLB pool the VM never touches - the pool sits locked, the VM still runs on memfd+THP, and you waste the pool's RAM. Pick one path and keep it consistent.

For the HugeTLB path:

<memoryBacking>
  <hugepages><page size='2048' unit='KiB' nodeset='0'/></hugepages>
  <nosharepages/>
  <locked/>
  <allocation mode='immediate'/>
</memoryBacking>

<locked/> requires LimitMEMLOCK=infinity on the libvirt-qemu service unit (Arch default works). The hook's VM_MEM_GB must match the <memory> value exactly.

Recommendation: stay on THP unless you measure a TLB-bound bottleneck. The gap is 1-3% in real games and you keep the host workable.

8.3 vCPU and iothreads#

<vcpu placement='static'>8</vcpu>
<iothreads>1</iothreads>

Pinning goes in <cputune> (section 9.1). The vcpu count here is just the count; the layout (cores × threads) is in <topology> inside <cpu> (section 8.4).

8.4 CPU Mode and Features#

<cpu mode='host-passthrough' check='none' migratable='off'>
  <topology sockets='1' dies='1' cores='4' threads='2'/>
  <cache mode='passthrough'/>
  <feature policy='require' name='topoext'/>     <!-- AMD only -->
  <feature policy='require' name='invtsc'/>
  <feature policy='require' name='monitor'/>
  <feature policy='require' name='waitpkg'/>     <!-- Intel 12th gen+ -->
  <feature policy='disable' name='hypervisor'/>  <!-- spoofing, see section 11 -->
</cpu>
  • migratable='off' unlocks invtsc. Verified at target/i386/cpu.c: CPUID_APM_INVTSC is the only feature flagged unmigratable_flags in master.
  • topoext is mandatory on AMD or Windows sees a monolithic socket and breaks SMT scheduling.
  • <topology> shape determines how Windows schedules. Always cores=N threads=2, never cores=2N threads=1. Windows handles pairs differently.
  • monitor and waitpkg enable MWAIT/UMWAIT/TPAUSE in the guest; combine with -overcommit cpu-pm=on (section 9.6) to let them actually put the host core into C-states.
  • <feature policy='disable' name='hypervisor'/> is a spoofing choice (clears CPUID 1.ECX[31]). Drop it if you do not need anti-detection.

8.5 Clock#

<clock offset='localtime'>
  <timer name='rtc' tickpolicy='catchup'/>
  <timer name='pit' tickpolicy='delay'/>
  <timer name='hpet' present='no'/>
  <timer name='kvmclock' present='no'/>
  <timer name='hypervclock' present='yes'/>
  <timer name='tsc' present='yes' mode='native'/>
</clock>
  • tsc mode='native' lets the guest read host TSC without trapping. invtsc allows it safely.
  • hpet present='no' removes HPET emulation and the HPET ACPI table (QEMU 8.0+).
  • kvmclock off so Windows uses Hyper-V reference TSC page instead.

8.6 Features Block#

Foundation includes what makes Windows happy. Spoofing tiers (section 11) add/override on top.

<features>
  <acpi/>
  <apic/>
  <hyperv mode='passthrough'/>   <!-- shortest for non-migrating perf-only; switch to mode='custom' for spoofing -->
  <vmport state='off'/>
  <ioapic driver='kvm'/>
  <smm state='on'/>               <!-- required for Secure Boot -->
  <pmu state='on'/>
</features>

<hyperv mode='passthrough'/> exports host KVM's full enlightenment set without bookkeeping. For spoofing setups that need vendor_id, switch to mode='custom' and enumerate every child explicitly - passthrough overwrites user-set vendor_id from host CPUID (verified in target/i386/kvm/kvm.c:kvm_hyperv_expand_features).

8.7 OS / OVMF#

<os firmware='efi'>
  <type arch='x86_64' machine='pc-q35-11.0'>hvm</type>
  <firmware>
    <feature enabled='yes' name='enrolled-keys'/>
    <feature enabled='yes' name='secure-boot'/>
  </firmware>
  <loader readonly='yes' secure='yes' type='pflash' format='raw'>/usr/share/edk2/x64/OVMF_CODE.secboot.4m.fd</loader>
  <nvram template='/usr/share/edk2/x64/OVMF_VARS.4m.fd' templateFormat='raw' format='raw'>/var/lib/libvirt/qemu/nvram/win11_VARS.fd</nvram>
  <boot dev='hd'/>
  <smbios mode='sysinfo'/>      <!-- references <sysinfo> block when spoofing -->
</os>

Required for Windows 11. Use the .4m.fd (4 MB) variant; legacy .fd is 2 MB and tight.

8.8 Lifecycle and PM#

<on_poweroff>destroy</on_poweroff>
<on_reboot>restart</on_reboot>
<on_crash>destroy</on_crash>
<pm>
  <suspend-to-mem enabled='no'/>
  <suspend-to-disk enabled='no'/>
</pm>

Suspend-to-mem with passed-through GPU is asking for trouble. Disable.

8.9 vTPM#

Default: emulated (swtpm):

<devices>
  <tpm model='tpm-crb'>
    <backend type='emulator' version='2.0'/>
  </tpm>
</devices>

Required for Windows 11 install. EK certificate chains to SWTPM CA, which is unknown to anti-cheats. Sufficient for EAC, BattlEye, most titles.

For Vanguard and FACEIT, swap to host passthrough - see section 15.

8.10 Storage Skeleton#

<devices>
  <emulator>/usr/bin/qemu-system-x86_64</emulator>
  <disk type='file' device='disk'>
    <driver name='qemu' type='qcow2' cache='writethrough' discard='unmap'/>
    <source file='/var/lib/libvirt/images/win11.qcow2'/>
    <target dev='vda' bus='virtio'/>
  </disk>
  <disk type='file' device='cdrom'>
    <driver name='qemu' type='raw'/>
    <source file='/path/to/Win11.iso'/>
    <target dev='sdb' bus='sata'/>
    <readonly/>
  </disk>
  <disk type='file' device='cdrom'>
    <driver name='qemu' type='raw'/>
    <source file='/path/to/virtio-win-0.1.285.iso'/>
    <target dev='sdc' bus='sata'/>
    <readonly/>
  </disk>
</devices>

The virtio-win ISO ships drivers for the installer to see virtio-blk; needed once.

Performance tweaks (multiqueue, iothread mapping, io_uring) in section 9.4. Spoofing tweaks (SATA bus, vendor/product strings) in section 11.

8.11 Network Skeleton#

<interface type='network'>
  <source network='default'/>
  <mac address='00:1b:21:00:00:01'/>
  <model type='e1000e'/>
</interface>

e1000e emulates Intel 82574L (PCI 8086:10D3). Gigabit, well-supported, real-vendor PCI ID. Use an Intel OUI MAC (00:1b:21:, 00:15:17:, b4:96:91:, a0:36:9f:) to match.

virtio-net is faster but has Red Hat PCI vendor 0x1AF4 - a tell for anti-cheat. See section 11 for tradeoffs.

8.12 Input and Graphics#

<devices>
  <input type='tablet' bus='usb'/>
  <input type='mouse' bus='ps2'/>
  <input type='keyboard' bus='ps2'/>
  <graphics type='spice' autoport='yes'>
    <listen type='address'/>
    <image compression='off'/>
  </graphics>
  <video><model type='none'/></video>
  <channel type='spicevmc'>
    <target type='virtio' name='com.redhat.spice.0'/>
  </channel>
</devices>

<video><model type='none'/></video> means no emulated GPU - the guest relies entirely on the passed-through dGPU for display once Windows loads the driver. During install, briefly switch to <model type='qxl'/> or <model type='virtio'/> so you see boot screens.

<input type='tablet'> enables absolute cursor sync over SPICE. Removes mouse drift in windowed mode. Drops mouse with Looking Glass - if you plan to use LG, remove the tablet.

8.13 PCIe Root Ports#

Q35 needs a pcie-root-port controller for each PCIe device. libvirt generates these on virsh edit save when devices reference unmapped buses; you can also pre-allocate:

<controller type='pci' index='0' model='pcie-root'/>
<controller type='pci' index='1' model='pcie-root-port'>
  <target chassis='1' port='0x10'/>
  <address type='pci' domain='0x0000' bus='0x00' slot='0x02' function='0x0' multifunction='on'/>
</controller>
<!-- ... add as many as the device count needs ... -->

Allocate one per PCIe hostdev (GPU, GPU audio, USB controller, passed NIC). Unused root ports add PCI clutter that anti-cheats can fingerprint - keep the count matching real usage.

8.14 GPU Passthrough hostdev#

<hostdev mode='subsystem' type='pci' managed='yes'>
  <source><address domain='0x0000' bus='0x01' slot='0x00' function='0x0'/></source>
  <address type='pci' domain='0x0000' bus='0x06' slot='0x00' function='0x0'/>
</hostdev>
<hostdev mode='subsystem' type='pci' managed='yes'>
  <source><address domain='0x0000' bus='0x01' slot='0x00' function='0x1'/></source>
  <address type='pci' domain='0x0000' bus='0x07' slot='0x00' function='0x0'/>
</hostdev>

Two hostdevs: GPU and HDMI audio. managed='yes' lets libvirt handle bind/unbind through the hook chain. Source BDF is the host address; target <address> is the bus inside the guest (must reference an allocated pcie-root-port).

For vBIOS injection, add <rom file='...'/> to the GPU hostdev (section 7.6).


9. Domain XML: Performance Tuning#

Add these on top of the foundation. Each subsection is independent; pick what applies to your hardware.

9.1 CPU Pinning per Architecture#

Verify host topology first:

lscpu -e
cat /sys/devices/system/cpu/cpu*/topology/{core_id,physical_package_id,die_id}

On modern AMD and Intel, even-id CPUs (0-N) are thread 0 of each core; odd-id (N-2N) are SMT siblings (thread 1). So core 0 = (cpu 0, cpu N), core 1 = (cpu 1, cpu N+1), etc.

Ryzen 7 5800X (8c/16t), pin 4 cores (8 vCPUs) to the VM:

<vcpu placement='static'>8</vcpu>
<iothreads>1</iothreads>
<cputune>
  <vcpupin vcpu='0' cpuset='0'/>   <vcpupin vcpu='1' cpuset='8'/>
  <vcpupin vcpu='2' cpuset='1'/>   <vcpupin vcpu='3' cpuset='9'/>
  <vcpupin vcpu='4' cpuset='2'/>   <vcpupin vcpu='5' cpuset='10'/>
  <vcpupin vcpu='6' cpuset='3'/>   <vcpupin vcpu='7' cpuset='11'/>
  <emulatorpin cpuset='4,12'/>
  <iothreadpin iothread='1' cpuset='5,13'/>
  <vcpusched vcpus='0-7' scheduler='fifo' priority='1'/>
  <iothreadsched iothreads='1' scheduler='batch'/>
  <emulatorsched scheduler='batch'/>
</cputune>

Ryzen 7950X (16c/32t), pin 8 cores (16 vCPUs):

<vcpu placement='static'>16</vcpu>
<cputune>
  <vcpupin vcpu='0'  cpuset='0'/>   <vcpupin vcpu='1'  cpuset='16'/>
  <vcpupin vcpu='2'  cpuset='1'/>   <vcpupin vcpu='3'  cpuset='17'/>
  <vcpupin vcpu='4'  cpuset='2'/>   <vcpupin vcpu='5'  cpuset='18'/>
  <vcpupin vcpu='6'  cpuset='3'/>   <vcpupin vcpu='7'  cpuset='19'/>
  <vcpupin vcpu='8'  cpuset='4'/>   <vcpupin vcpu='9'  cpuset='20'/>
  <vcpupin vcpu='10' cpuset='5'/>   <vcpupin vcpu='11' cpuset='21'/>
  <vcpupin vcpu='12' cpuset='6'/>   <vcpupin vcpu='13' cpuset='22'/>
  <vcpupin vcpu='14' cpuset='7'/>   <vcpupin vcpu='15' cpuset='23'/>
  <emulatorpin cpuset='8-9,24-25'/>
  <iothreadpin iothread='1' cpuset='10-11,26-27'/>
  <vcpusched vcpus='0-15' scheduler='fifo' priority='1'/>
  <iothreadsched iothreads='1' scheduler='batch'/>
  <emulatorsched scheduler='batch'/>
</cputune>

Ryzen 7950X3D: same shape as 7950X but pin the V-Cache CCD (CCD0, cores 0-7 on most boards). Verify with lscpu -e. Emulator/iothread land on CCD1 (non-V-Cache).

Intel hybrid (13900K/14900K/15900K): P-cores typically CPUs 0-15 (8c × 2t), E-cores 16-31 (16c × 1t). Pin only P-cores to the guest. Emulator and iothread on E-cores. Do not mix P and E into one guest - IntelThreadDirector MSRs are not virtualized; expose homogeneous SMT cores.

<topology> must match the vcpu count: cores=4 threads=2 = 8 vCPUs; cores=8 threads=2 = 16 vCPUs.

9.2 Scheduler Classes#

Policylibvirt nameBehavior
SCHED_NORMALnone (default)Fair CFS, nice-driven.
SCHED_BATCHbatchCFS without wake-up boost, longer time slices. Background work.
SCHED_IDLEidleWeaker than nice 19. Only when nothing else wants CPU.
SCHED_FIFOfifoRT priority 1-99. Runs until yield/block/preempted by higher RT.
SCHED_RRrrFIFO with time slicing among same-priority tasks.
SCHED_DEADLINEnot supportedlibvirt has no XML for it.

Recommended assignments:

ThreadClassPriorityReason
vCPU (gaming, isolated cores)fifo1Above SCHED_OTHER, below kernel RT housekeeping (which runs at MAX_RT_PRIO/2 = 50).
vCPU (shared cores)nonen/aRT on shared cores starves everything else.
iothreadfifo 1 or none1RT only if a housekeeping core is dedicated.
emulatorbatchn/aReduces preemption for control-plane work.

Priority 1 keeps the vCPU above SCHED_OTHER but below kernel housekeeping. priority='99' will preempt the kernel migration thread - watchdog fire, then deadlock.

9.3 Disk: virtio-blk + iothread-vq-mapping#

Raw block device on NVMe is within 1-2% of bare metal. virtio-blk + iothread-vq-mapping (QEMU 9.0+) is the modern default:

<iothreads>4</iothreads>
<iothreadids>
  <iothread id='1'/><iothread id='2'/><iothread id='3'/><iothread id='4'/>
</iothreadids>
<disk type='block' device='disk'>
  <driver name='qemu' type='raw' cache='none' io='io_uring' discard='unmap' queues='8'>
    <iothreads>
      <iothread id='1'/><iothread id='2'/><iothread id='3'/><iothread id='4'/>
    </iothreads>
  </driver>
  <source dev='/dev/disk/by-id/nvme-Samsung_SSD_980_PRO_1TB_S6XYZ'/>
  <target dev='vda' bus='virtio'/>
</disk>

Cache modes:

ModeUse
cache='none' io='io_uring'Default for raw block on NVMe.
cache='writeback'Better write IOPS, unsafe on power loss. Fine for gaming.
cache='unsafe'Ignores fsync. Scratch disks only.
cache='writethrough'Safe but slowest. Default for qcow2 file backings if unsure.

For Tier B+ spoofing replace bus='virtio' with bus='sata' and add <vendor>, <product>, <serial>, <wwn> children. See section 11.

9.4 Network Multiqueue#

<interface type='network'>
  <source network='default'/>
  <mac address='00:1b:21:00:00:01'/>
  <model type='virtio'/>
  <driver name='vhost' queues='8'>
    <host tso='on'/>
    <guest tso4='on' tso6='on'/>
  </driver>
</interface>

queues=N should equal the vCPU count. Windows Server 2025 hangs when vhost queues > cores (virtio-win issue #1360); stick with N = vCPU count.

For e1000e + Intel OUI (foundation default), no queues attribute - e1000e does not support multiqueue.

9.5 KVM Module Options#

# /etc/modprobe.d/kvm.conf
options kvm ignore_msrs=1 report_ignored_msrs=0 halt_poll_ns=0

# AMD Zen 4+:
options kvm_amd avic=1 nested=0 npt=1 pause_filter_thresh=0

# Intel:
options kvm_intel ept=1 enable_apicv=1 nested=0 ple_gap=0
  • AVIC (AMD) and APICv (Intel) are default-on in mainline 2026 kernels; pin explicit.
  • AVIC + x2APIC coexist on Zen 4+ via x2AVIC. On Zen 3 and earlier, either disable x2apic in the guest or disable AVIC.
  • ple_gap=0 / pause_filter_thresh=0 disables PAUSE-Loop Exiting. For dedicated-core VMs PLE buys nothing and the VMEXIT spikes are timing-detectable (anti-cheat).
  • halt_poll_ns=0 on host. Guest-side halt-polling (Microsoft VPCI driver) is the path with measurable wins.

9.6 vfio-pci Options#

# /etc/modprobe.d/vfio.conf
options vfio-pci disable_idle_d3=1

disable_idle_d3=1 prevents D3cold hangs on RTX 40 and some AMD RDNA. Costs 20-30 W idle on the GPU; only set if you actually hit D3 failures.

For MWAIT/HLT/PAUSE/UMWAIT passthrough, add to the domain:

<qemu:commandline xmlns:qemu='http://libvirt.org/schemas/domain/qemu/1.0'>
  <qemu:arg value='-overcommit'/>
  <qemu:arg value='cpu-pm=on'/>
</qemu:commandline>

Required when +monitor/+waitpkg are exposed (section 8.4). Safe only with dedicated pinned cores (no overcommit).

9.7 Host CPU Governor#

# AMD Zen 4/5 with amd_pstate=active:
for c in /sys/devices/system/cpu/cpu*/cpufreq/energy_performance_preference; do
    echo performance | sudo tee "$c"
done

# Older AMD or Intel:
for c in /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor; do
    echo performance | sudo tee "$c"
done

Persistent via systemd unit or the hook (run from start.sh).

9.8 IRQ Steering#

sudo systemctl disable --now irqbalance

Append to the start hook after the cpuset step:

for irq in /proc/irq/*/smp_affinity_list; do
    [ -w "$irq" ] && echo "$HOST_CPUS" > "$irq" 2>/dev/null || true
done

Some IRQs are managed (kernel claims affinity); those silently fail and that is fine. Only boot-time isolcpus=managed_irq,... from section 4.3 fully removes managed IRQs.


10. Anti-Cheat Landscape#

Understand the target before configuring the defense. Then go to section 11.

10.1 Tier Map#

Anti-cheatTier requiredTPM passthrough?Notes
EACANoDefeated by SMBIOS sysinfo + <hyperv><vendor_id> + <feature policy='disable' name='hypervisor'/>.
BattlEyeA or BNoTolerates well-hidden VMs. R6S historically banned; others tolerant. Reserves right to ban for active circumvention - Tier C patches qualify.
nProtect, mhyprot, ACEBNoTier B reliably; Tier C if title-specific checks bite.
FACEIT (FAC-INT)C + TPM passthroughYesTPM 2.0 mandatory since Nov 2025. VBS + IOMMU required. Hyper-V on host blocks loader.
ESEASame as FACEITYesSame shape.
Vanguard (Valorant, LoL, Wild Rift)C + Phantom kernel + TPM passthroughYesHardest target. Hardware-ban risk on detection.

10.2 Vanguard Architecture#

vgk.sys is a SERVICE_BOOT_START kernel driver loaded before system32 drivers. Writes C:\Windows\VGKbootstatus.dat on DriverEntry and cross-checks the file's existence + timestamp later to verify boot-path loading (not post-facto SCM start).

Hooks moved in 2024-2025 from SSDT patching to surgical HalPrivateDispatchTable entries: HalClearLastBranchRecordStack (offset 0x400) and HalCollectPmcCounters (offset 0x248). The LBR-clear hook fires from nt!SwapContext, giving observation of every context switch. The PMC hook is PatchGuard-compliant because the kernel itself dispatches the function pointer.

Standard kernel callbacks registered: PsSetCreateProcessNotifyRoutineEx, PsSetCreateThreadNotifyRoutine, PsSetLoadImageNotifyRoutine, ObRegisterCallbacks (PsProcessType/PsThreadType), CmRegisterCallbackEx.

10.3 What Vanguard Checks#

Boot environment:

  • UEFI Secure Boot via runtime variable SecureBoot.
  • TPM 2.0 device presence + EK certificate chain validation (the cryptographic gate; see section 15).
  • IOMMU pre-boot DMA protection state matching runtime IOMMU state.
  • VBS / HVCI presence and consistency.

CPUID / MSR:

  • 1.ECX[31] hypervisor bit.
  • 0x40000000 vendor string.
  • 0x40000001-0x40000006 Hyper-V interface/features.
  • 0x40000100-0x40000101 secondary KVM range.
  • MSR_LSTAR write/read retention test.
  • MSR_IA32_DEBUGCTL LBR control - test functional LBR.
  • KVM PV MSR 0x4b564d00 - on real silicon #GP, stock QEMU services it regardless.
  • IA32_APERF / IA32_MPERF timing side-channel.

Timing:

  • RDTSC; CPUID; RDTSC delta. BattlEye benchmarks 0x6694 iterations.
  • MWAIT/MONITOR semantics. Stock KVM NOPs MWAIT and lies about CPUID MONITOR bit.
  • PAUSE-loop exit fingerprint.
  • HPET vs TSC ratio cross-check.
  • ACPI PMTMR vs TSC cross-check.

ACPI:

  • OEM ID BOCHS , table ID BXPCxxxx, creator BXPC.
  • FADT Hypervisor Vendor Identity field (8 bytes, stamped QEMU in rev 6 FADT).
  • Preferred PM Profile (0 Unspecified on stock QEMU).
  • WAET ACPI table presence (hypervisor-only).
  • DSDT/SSDT object naming patterns - _SB.PCI0.S08/S10/S18.

PCI:

  • Red Hat / Qumranet vendors 0x1AF4, 0x1B36.
  • Bochs VGA 0x1234.

Storage / network:

  • QEMU HARDDISK / QEMU NVMe Ctrl strings.
  • netkvm.sys, vioscsi.sys driver presence.
  • MAC OUI 52:54:00:.

Hyper-V partition privilege check (the 2024-era spoiler): NtQuerySystemInformation(SystemHypervisorDetailInformation) returns HvFeatures.PartitionPrivilegeMask. On real hardware running VBS, Windows is root partition with root-partition privileges. A KVM guest exposing Hyper-V enlightenments looks like a guest partition; the mask differs. This is why "spoof everything to look like Hyper-V" stopped working in 2024.

10.4 The TPM Gate#

The most important gate added in 2024 is the TPM 2.0 EK certificate chain validation. Real hardware TPMs ship with an EK certificate signed by a manufacturer CA (Intel PTT, AMD fTPM Microsoft CA, Infineon, STMicro, Nuvoton). swtpm issues from SWTPM CA, which is on no vendor allow-list.

The path through this gate is passing through the host's real TPM 2.0 to the guest. See section 15. With host TPM passthrough + Tier C custom builds + the kernel-level Hyper-V partition spoofing Hypervisor-Phantom does, you have a credible Vanguard-attempt setup. Account ban risk remains.

10.5 FACEIT FAC-INT#

KMDF driver doing real-time VAD scanning, thread monitoring, explicit Hyper-V/VM blocks. From November 25, 2025: TPM 2.0 mandatory for all FACEIT players. VBS + IOMMU partial since April 2025; full for >3000 Elo since August 2025. Hyper-V on the host blocks FAC-INT loader (bcdedit /set hypervisorlaunchtype off required in the guest).

FAC-INT does not appear to do the deep ACPI parsing Vanguard does. EK chain validation is unconfirmed but plausible given the 2025 TPM mandate. Same approach as Vanguard: host TPM passthrough + Tier C is the credible attempt.

10.6 EAC and BattlEye#

EAC tolerates well-hidden VMs. The minimum stack that works for most titles: <features><kvm><hidden state='on'/></kvm><smm state='on'/><vmport state='off'/></features> + <cpu><feature policy='disable' name='hypervisor'/></cpu> + <hyperv mode='custom'><vendor_id state='on' value='AuthenticAMD'/></hyperv> + <smbios mode='host'/> or mode='sysinfo' + populated <sysinfo> block. AHCI for disk instead of virtio-blk if a specific title bites.

BattlEye is tolerant but historically banned for Rainbow Six Siege. Reserves right to ban for active circumvention. Reports of unprovoked VFIO bans dropped sharply in 2024-2025. Don't use it on your main account.


11. Domain XML: Spoofing Tiers#

Four tiers, ordered by intrusiveness. Apply on top of the foundation (section 8). None bypass Vanguard or FACEIT on their own - those require section 15 (TPM passthrough) in addition.

11.1 Detection Surface#

SurfaceDefault valueOverride
CPUID 1.ECX[31] hypervisor bit1<feature policy='disable' name='hypervisor'/>
CPUID 0x40000000 vendor stringKVMKVMKVM\0\0\0 or Microsoft Hv<hyperv mode='custom'><vendor_id state='on' value='...'/></hyperv>
CPUID 0x40000100-0x40000101 KVM IDpopulated<kvm><hidden state='on'/></kvm>
KVM PV MSR 0x4b564d00-0x4b564d08services readsrequires patched QEMU kvm-pv-enforce-cpuid=true
SMBIOS Type 0/1/2/3EDK II / QEMU / Standard PC (Q35 + ICH9, 2009)<sysinfo type='smbios'> or <smbios mode='host'/>
ACPI RSDP / FADT OEM IDBOCHS<qemu:arg> second -machine with x-oem-id=X,x-oem-table-id=Y (mainline QEMU 11.x)
ACPI OEM Table IDBXPCxxxxsame
FADT Hypervisor Vendor IdentityQEMU (rev 6)patched QEMU only
DSDT/SSDT object naming_SB.PCI0.S08/S10/S18patched QEMU get_mimic_pci_name()
WAET table presencepresentpatched QEMU removes
HPET ACPI tablepresent<timer name='hpet' present='no'/>
PM Profile0 Unspecifiedpatched QEMU + custom SSDT
PCI vendor 0x1B36 Red Hat PCIe bridgesbridge/root/xhcipatched QEMU
PCI vendor 0x1AF4 virtio devicesvirtiosubstitute SATA/AHCI/e1000e
PCI vendor 0x1234 Bochs VGAstd-vgause passed-through GPU
FW_CFG signatureQEMU CFGpatched QEMU
SCSI/IDE/NVMe modelQEMU HARDDISK etc.<vendor>/<product>/<serial>/<wwn> for SCSI/IDE, patched QEMU for NVMe
USB device stringsQEMU USB Tablet etc.patched QEMU
MAC OUI52:54:00:<mac address='...'/> real OUI
BGRT logoOVMF defaultcustom OVMF with Logo.bmp
Memballoon PCI devicepresent<memballoon model='none'/>
RDTSC delta~5-20x bare metalpartially via invtsc + tsc mode='native'
HPET vs TSC ratiorigid emulatedhide both
APERF/MPERFhost values pre-6.13, virtualized 6.13+upgrade host kernel
MWAIT semanticstrapped to NOP-overcommit cpu-pm=on + +monitor
PAUSE-loop exitbursts cause VMEXIT spikeple_gap=0 / pause_filter_thresh=0
MSR_SMI_COUNT0not solvable publicly
TPM 2.0 EK certificateSWTPM CAsection 15 host TPM passthrough

11.2 Tier A: Performance-First#

Zero perf cost. Passes EAC and most BattlEye titles.

Replace the foundation's <features> block:

<features>
  <acpi/>
  <apic/>
  <hyperv mode='custom'>
    <relaxed state='on'/>
    <vapic state='on'/>
    <spinlocks state='on' retries='8191'/>
    <vpindex state='on'/>
    <runtime state='on'/>
    <synic state='on'/>
    <stimer state='on'><direct state='on'/></stimer>
    <reset state='on'/>
    <vendor_id state='on' value='AuthenticAMD'/>
    <frequencies state='on'/>
    <reenlightenment state='on'/>
    <tlbflush state='on'><direct state='on'/><extended state='on'/></tlbflush>
    <ipi state='on'/>
  </hyperv>
  <kvm><hidden state='on'/></kvm>
  <vmport state='off'/>
  <ioapic driver='kvm'/>
  <smm state='on'/>
  <pmu state='on'/>
  <vmcoreinfo state='off'/>
</features>

Keep <feature policy='disable' name='hypervisor'/> in <cpu> from the foundation.

Add a populated SMBIOS sysinfo block (section 12 for sourcing values). Drop the <sound>/<audio> defaults if not needed - emulated ich9 sound is a known fingerprint.

Add to <devices>:

<memballoon model='none'/>

Critical details:

  • vendor_id length: 0 to 12 chars, not exactly 12. libvirt schema domaincommon.rng defines pattern [^,]{0,12} and validates via STRLIM(..., VIR_DOMAIN_HYPERV_VENDOR_ID_MAX) where VIR_DOMAIN_HYPERV_VENDOR_ID_MAX = 12. Strings >12 rejected at XML parse; <12 zero-padded by QEMU to the 12-byte CPUID register triple. Pick a 12-char value to fully populate EBX:ECX:EDX of CPUID 0x40000000. Anything except literal Microsoft Hv works.
  • mode='custom' not mode='passthrough' when using vendor_id. Passthrough overwrites the user value from host CPUID (verified in target/i386/kvm/kvm.c).

11.3 Tier B: Balanced#

Tier A plus disk/network/MAC adjustments and ACPI OEM overrides. Passes title-specific BattlEye checks.

Add <qemu:commandline>:

<qemu:commandline xmlns:qemu='http://libvirt.org/schemas/domain/qemu/1.0'>
  <qemu:arg value='-overcommit'/>
  <qemu:arg value='cpu-pm=on'/>
</qemu:commandline>

ACPI OEM ID override. The pc-q35 machine type exposes x-oem-id and x-oem-table-id properties (the x- prefix is QEMU's experimental/unstable API marker; works on 11.x). Pass via a second -machine arg through <qemu:commandline> - libvirt emits its own -machine line plus yours, and QEMU merges them into the live machine:

<qemu:commandline xmlns:qemu='http://libvirt.org/schemas/domain/qemu/1.0'>
  <qemu:arg value='-machine'/>
  <qemu:arg value='x-oem-id=ALASKA,x-oem-table-id=A M I   '/>
</qemu:commandline>

This rewrites the OEM ID (6 bytes) and OEM Table ID (8 bytes, space-padded) in every ACPI table QEMU generates - FADT, DSDT, SSDT, APIC, MCFG, HPET, etc. Verify via qemu-system-x86_64 -machine 'pc-q35-11.0,help' | grep oem.

For per-table content overrides (replacing QEMU's generated DSDT/SSDT with real binary dumps from a reference machine), use -acpitable:

<qemu:arg value='-acpitable'/>
<qemu:arg value='sig=SSDT,file=/var/lib/libvirt/acpi/ssdt1.bin'/>

Tables are replaced wholesale; injected SSDTs are added to QEMU's table list. Boot risk: real SSDTs reference DSDT methods for hardware (EC, thermal zones, GPIO) that the VM does not have. Test with one SSDT at a time, drop offending ones. Inject DSDT only as a last resort - replacing the DSDT structure breaks Windows device enumeration for QEMU's virtual chipset.

Disk: switch from virtio-blk to LSI SAS (recommended Tier B default). libvirt's <vendor>, <product>, and <wwn> elements are accepted by the schema but rejected at runtime on bus='sata' with Only ide and scsi disks support vendor/product/wwn. The only bus types that pass all three are ide (i440FX-only legacy, not Q35) and scsi. SCSI requires a controller; lsisas1078 is the cleanest choice - real Broadcom/LSI vendor PCI 0x1000:0x0060, no virtio fingerprint, Windows 11 has the driver built in (MegaSR).

<controller type='pci' index='6' model='pcie-root-port'>
  <model name='pcie-root-port'/>
  <target chassis='6' port='0x15'/>
  <address type='pci' domain='0x0000' bus='0x00' slot='0x02' function='0x5'/>
</controller>
<controller type='scsi' index='1' model='lsisas1078'>
  <address type='pci' domain='0x0000' bus='0x06' slot='0x00' function='0x0'/>
</controller>
<disk type='file' device='disk'>
  <driver name='qemu' type='qcow2' cache='writeback' io='io_uring' discard='unmap'/>
  <source file='/var/lib/libvirt/images/win11.qcow2'/>
  <target dev='sda' bus='scsi'/>
  <vendor>ATA</vendor>
  <product>ST8000VN004-3CP1</product>
  <serial>00000000</serial>
  <wwn>5000c500ed5804c6</wwn>
  <address type='drive' controller='1' bus='0' target='0' unit='0'/>
</disk>

Field constraints:

  • <vendor> max 8 chars (SCSI INQUIRY VendorID field). Common SATA-drive value is ATA (real drives report this when accessed via the AHCI/SCSI translation layer).
  • <product> max 16 chars. Long model strings need truncation: ST8000VN004-3CP101 (18 chars) becomes ST8000VN004-3CP1. libvirt's schema is permissive but QEMU's SCSI emulation truncates at 16.
  • <serial> no fixed length, free string.
  • <wwn> exactly 16 hex digits. The first hex digit is the NAA value (5 = IEEE Registered), followed by the 24-bit IEEE OUI (5000c5 = HGST/Seagate, 500253 = Samsung, 50014ee = WDC), then device-specific bytes.

Fallback path: SATA with serial-only. If you have a reason to keep the disk on the SATA controller (one less PCIe device, no extra root port), accept losing vendor/product/wwn:

<disk type='file' device='disk'>
  <driver name='qemu' type='qcow2' cache='writeback' io='io_uring' discard='unmap'/>
  <source file='/var/lib/libvirt/images/win11.qcow2'/>
  <target dev='sda' bus='sata'/>
  <serial>00000000</serial>
  <address type='drive' controller='0' bus='0' target='0' unit='0'/>
</disk>

The guest's Win32_DiskDrive.Model will report QEMU HARDDISK in this case - a fingerprint Tier C custom-QEMU patches fix at the source level.

Network: e1000e + Intel OUI MAC (foundation default already does this). If using virtio for perf, accept the 0x1AF4 PCI vendor tell.

Guest boot args (Windows, elevated cmd):

bcdedit /set useplatformclock no
bcdedit /set tscsyncpolicy Enhanced
bcdedit /set hypervisorlaunchtype off

OEM ID and Table ID notes:

  • x-oem-id is 6 bytes (pad with spaces if shorter); x-oem-table-id is 8 bytes.
  • ALASKA is AMI generic; matches most AMI-firmware boards. Confirmed match for MSI X470, ASUS X470, ASRock X470, Gigabyte X470 series.
  • A M I is AMI desktop generic - 5 chars A M I + 3 trailing spaces = exactly 8 bytes. QEMU rejects values longer than 8 with User specified x-oem-table-id value is bigger than 8 bytes in size. Real per-board values vary - see section 12 for extraction.
  • The x- prefix is QEMU's "experimental/unstable" API marker. The properties have been stable in pc-q35 since QEMU 6.0 and continue through 11.x. May get renamed in a future major version.

SLIC and MSDM: do not inject these for DIY motherboard personas. SLIC (OEM SLP activation) and MSDM (Win8+ OEM key) only appear on prebuilt OEM machines (Dell, HP, Lenovo, ASUS Notebooks, MSI prebuilts). A retail X470/X670/X870 motherboard does not ship them. Adding them creates a contradiction.

Only add SLIC/MSDM if you are impersonating a prebuilt OEM and have the real table files extracted from a matching system:

<qemu:arg value='-acpitable'/>
<qemu:arg value='sig=SLIC,oem_id=DELL  ,oem_table_id=Inspiron,file=/var/lib/libvirt/slic.bin'/>
<qemu:arg value='-acpitable'/>
<qemu:arg value='sig=MSDM,oem_id=DELL  ,oem_table_id=Inspiron,file=/var/lib/libvirt/msdm.bin'/>

11.4 Tier C: Custom QEMU + OVMF#

Tier B plus patched QEMU and OVMF binaries. Defeats anti-cheats that parse DSDT object names, check FADT Hypervisor Vendor Identity, or fingerprint FW_CFG strings. Combined with TPM 2.0 passthrough (section 15) it is the path to kernel anti-cheats; without TPM passthrough it sits at "EAC + BattlEye + nProtect cleanly, FACEIT/Vanguard not".

Patched binaries live in /opt/ and have no impact on the host when the VM is not running. No host kernel patches, no permanent isolation.

Inventory of patches (Hypervisor-Phantom / qemu-anti-detection):

  • QEMU SCSI/IDE/NVMe/USB device strings replaced with real-vendor strings.
  • QEMU PCI vendor IDs rewritten: 0x1B36->0x8086 (Intel-style PCIe bridges), 0x1234->0x8086 (VGA), Red Hat HDA 0x1AF4->0x10EC (Realtek).
  • FW_CFG signature rewritten.
  • ACPI table generation: S%.02X PCI naming replaced with MCHC/GFX0/HDAU/XHC1/EHC1/LPCB/SAT0/SBUS. FADT Hypervisor Vendor Identity blanked.
  • kvm-pv-enforce-cpuid=true default - KVM PV MSRs #GP unless KVM is advertised in CPUID.
  • CPUID 6.ECX[0] (APERFMPERF) exposed + KVM_X86_DISABLE_EXITS_APERFMPERF enabled.
  • OVMF: PcdFirmwareVendor from host; PcdAcpiDefaultOemId/OemTableId/CreatorId from host /sys/firmware/acpi/tables/FACP; SMBIOS Platform DXE strings from host DMI.

The x-oem-id/x-oem-table-id machine override (section 11.3) and real ACPI table injection via -acpitable work without Tier C and stay in your domain XML on top of it.

See section 13 for the Arch build flow.

Reference patched binaries in the domain:

<devices>
  <emulator>/opt/qemu-phantom/bin/qemu-system-x86_64</emulator>
  ...
</devices>
<os>
  <type arch='x86_64' machine='pc-q35-11.0'>hvm</type>
  <loader readonly='yes' secure='yes' type='pflash'>/opt/ovmf-phantom/OVMF_CODE.secboot.fd</loader>
  <nvram template='/opt/ovmf-phantom/OVMF_VARS.secboot.fd'>/var/lib/libvirt/qemu/nvram/win11_VARS.fd</nvram>
  <smbios mode='sysinfo'/>
</os>

Optional host TPM passthrough (section 15) is independent of Tier C - skip it if you don't need kernel anti-cheats or want to keep TPM available on the host.

11.5 Tier D: Experimental#

Patched KVM kernel to mask TSC timing, PMU off, RDTSC exiting tricks. None reliably defeat modern cross-check detection. Documented for completeness.

  • <pmu state='off'/> hides the virtual PMU. Breaks PMC instruction-count probing. A missing PMU on a host-passthrough CPU is itself suspicious. Net detection win is small.
  • WCharacter/RDTSC-KVM-Handler kernel patch (repo). Hooks EXIT_REASON_RDTSC to scale RDTSC delta by 1/16 (Intel) or 1/20 (AMD). Trivially defeated by HPET cross-check, PMTMR cross-check, APERF/MPERF cross-check (post-kernel-6.13), or a two-thread software clock (what VMAware uses). Worse: scales TSC linearly but not APERF/MPERF/HPET/PMTMR, creating a divergence that is a stronger fingerprint.
  • TSC offset adjustment via QEMU tsc_offset - cannot lie about CPUID-triggered VMEXIT timing.

Recommendation: skip Tier D unless researching detection. The RDTSC handler is detected by everything serious in 2026.

11.6 Timing Detection Mitigations#

DetectionMitigation in 2026
RDTSC; CPUID; RDTSCNot solvable. mode='native' TSC + invtsc removes per-RDTSC overhead but CPUID still exits.
RDTSC; NOP; RDTSCAlready passes (no VMEXIT).
MWAIT timing+monitor + -overcommit cpu-pm=on. Defeats.
WAITPKG TPAUSE+waitpkg + -overcommit cpu-pm=on (Intel 12th-gen+). Defeats.
PAUSE-loop exit (PLE)ple_gap=0 / pause_filter_thresh=0. Defeats for dedicated cores.
HPET vs TSC ratio<timer name='hpet' present='no'/> (QEMU 8.0+ removes ACPI table too).
ACPI PMTMR vs TSCHard. Patch FADT PM_TMR_BLK=0.
APERF/MPERF probingKernel 6.13+ virtualizes them (Arch linux 6.16+ qualifies). Mostly mitigated.
RDPMC instruction-countingPMU passthrough (6.10+ KVM, <pmu state='on'/> + host-passthrough). Mostly mitigated.
MSR_SMI_COUNT reading 0Not solvable.
TPM 2.0 EK certificate chainHost TPM passthrough (section 15).
Two-thread software clockNot solvable.

11.7 Cost vs Defeats#

KnobTierPerf costDefeats
<feature policy='disable' name='hypervisor'/>A0CPUID 1.ECX[31]
<kvm><hidden/></kvm>A0KVM CPUID 0x40000100-1
<hyperv mode='custom'> + vendor_idA0CPUID 0x40000000
Full hyperv enlightenmentsA-0% (faster Windows)Slow Windows timing paths
migratable='off' + invtsc + tsc mode='native'A-0%Per-RDTSC overhead, TSC freq mismatch
+monitor + +waitpkg + cpu-pm=onA/B-0%MWAIT/UMWAIT/TPAUSE probes
ple_gap=0 / pause_filter_thresh=0A-0% on dedicated coresPLE VMEXIT spikes
<smbios mode='sysinfo'> + full blockA0DMI QEMU/BOCHS/Standard PC
<memballoon model='none'/>A-0%virtio-balloon PCI device
Real-vendor MAC OUIA052:54:00:
ACPI oem_id/oem_table_id overrideB0RSDP/FADT BOCHS/BXPC
SATA bus + disk vendor/product/wwnB~5-10% diskQEMU HARDDISK, virtio PCI 0x1AF4
e1000e instead of virtio-netB~5-10% netvirtio PCI
Patched QEMU stringsC0Every hardcoded QEMU in source
Patched QEMU PCI vendor IDsC00x1B36, 0x1234, Red Hat namespace
Patched QEMU DSDT/SSDT namingC0_SB.PCI0.S08 fingerprint
Patched QEMU kvm-pv-enforce-cpuid=trueC0KVM PV MSR probing
Patched OVMF firmware stringsC0OVMF/EDK II firmware vendor
Patched OVMF ACPI OEM PCDsC0Built-in ACPI OEM defaults
Custom BGRT BMPC0OVMF boot logo
Host TPM 2.0 passthroughChost loses TPM during VMEK certificate chain
<pmu state='off'/>DminorPMC probing (suspicious by absence)
RDTSC-KVM-HandlerDbreaks timingNaive RDTSC+CPUID only; detected by cross-check

12. Sourcing Real Values#

The values you spoof must be coherent. Pick a real reference machine, pull its SMBIOS and ACPI, use them consistently.

12.1 SMBIOS from Real dmidecode#

On the reference machine (Linux):

sudo dmidecode -t 0 -t 1 -t 2 -t 3 > smbios-ref.txt

On Windows reference:

wmic bios get manufacturer,version,releasedate,smbiosbiosversion
wmic computersystem get manufacturer,model,version
wmic baseboard get manufacturer,product,version,serialnumber

Map dmidecode fields to libvirt <sysinfo> entries:

dmidecode Type 0 (BIOS)libvirt <bios>
Vendor<entry name='vendor'>
Version<entry name='version'>
Release Date<entry name='date'>
dmidecode Type 1 (System)libvirt <system>
Manufacturer<entry name='manufacturer'>
Product Name<entry name='product'>
Version<entry name='version'>
Serial Number<entry name='serial'>
UUID<entry name='uuid'>
SKU Number<entry name='sku'>
Family<entry name='family'>
dmidecode Type 2 (Base Board)libvirt <baseBoard>
Manufacturer<entry name='manufacturer'>
Product Name<entry name='product'>
Version<entry name='version'>
Serial Number<entry name='serial'>
dmidecode Type 3 (Chassis)libvirt <chassis>
Manufacturer<entry name='manufacturer'>
Version<entry name='version'>
Serial Number<entry name='serial'>

12.1.1 No-Dependencies Extraction Script#

Pull SMBIOS strings directly from /sys/class/dmi/id/ and CPUID(1) from /dev/cpu/0/cpuid. No external tools, just coreutils. Some fields (product_uuid, product_serial, board_serial, chassis_serial) are root-readable only on modern kernels - run with sudo for the full output.

#!/bin/bash
# Print libvirt <sysinfo> block + matching QEMU -smbios type=4 from host /sys.
# Run with sudo to include the protected fields.

D=/sys/class/dmi/id
r() { [ -r "$D/$1" ] && cat "$D/$1" 2>/dev/null || printf '?'; }

cat <<EOF
<!-- Paste inside <domain>...</domain>, before <os> -->
<sysinfo type='smbios'>
  <bios>
    <entry name='vendor'>$(r bios_vendor)</entry>
    <entry name='version'>$(r bios_version)</entry>
    <entry name='date'>$(r bios_date)</entry>
  </bios>
  <system>
    <entry name='manufacturer'>$(r sys_vendor)</entry>
    <entry name='product'>$(r product_name)</entry>
    <entry name='version'>$(r product_version)</entry>
    <entry name='serial'>$(r product_serial)</entry>
    <entry name='uuid'>$(r product_uuid)</entry>
    <entry name='sku'>$(r product_sku)</entry>
    <entry name='family'>$(r product_family)</entry>
  </system>
  <baseBoard>
    <entry name='manufacturer'>$(r board_vendor)</entry>
    <entry name='product'>$(r board_name)</entry>
    <entry name='version'>$(r board_version)</entry>
    <entry name='serial'>$(r board_serial)</entry>
  </baseBoard>
  <chassis>
    <entry name='manufacturer'>$(r chassis_vendor)</entry>
    <entry name='version'>$(r chassis_version)</entry>
    <entry name='serial'>$(r chassis_serial)</entry>
  </chassis>
</sysinfo>
EOF

# CPUID(1) for SMBIOS Type 4 processor-id
# Bytes 0-3: CPUID(1).EAX (LE).  Bytes 4-7: CPUID(1).EDX (LE).
# 64-bit LE integer of that = (EDX << 32) | EAX.
[ -e /dev/cpu/0/cpuid ] || modprobe cpuid 2>/dev/null

if [ -r /dev/cpu/0/cpuid ]; then
    # /dev/cpu/N/cpuid driver treats file position as the CPUID *leaf number*.
    # To read leaf 1: read leaves 0 and 1, take the second 16-byte block.
    hex=$(dd if=/dev/cpu/0/cpuid bs=16 count=2 2>/dev/null | tail -c 16 | od -An -tx4 -v | tr -d ' \n')
    eax="${hex:0:8}"
    edx="${hex:24:8}"
    if [ -n "$eax" ] && [ -n "$edx" ]; then
        # bash 64-bit arithmetic. EDX in high 32 bits, EAX in low 32 bits, interpreted LE.
        id_dec=$(( (16#$edx << 32) | 16#$eax ))
        cpu_brand=$(awk -F: '/^model name/ {sub(/^ +/,"",$2); print $2; exit}' /proc/cpuinfo)
        cpu_vendor=$(awk -F: '/^vendor_id/ {sub(/^ +/,"",$2); print $2; exit}' /proc/cpuinfo)
        case "$cpu_vendor" in
            AuthenticAMD) mfg='Advanced Micro Devices,, Inc.' ;;
            GenuineIntel) mfg='Intel(R) Corporation' ;;
            *)            mfg="$cpu_vendor" ;;
        esac
        cat <<EOF

<!-- Paste inside <qemu:commandline>...</qemu:commandline> -->
<qemu:arg value='-smbios'/>
<qemu:arg value='type=4,manufacturer=${mfg},version=${cpu_brand},sock_pfx=AM4,processor-id=${id_dec}'/>
<qemu:arg value='-overcommit'/>
<qemu:arg value='cpu-pm=on'/>
EOF
        printf '\n<!-- CPUID(1).EAX=0x%s  EDX=0x%s  combined LE64=%s decimal -->\n' "$eax" "$edx" "$id_dec"
    else
        echo "WARNING: could not read CPUID. Try sudo, or modprobe cpuid first."
    fi
else
    echo "WARNING: /dev/cpu/0/cpuid not readable. Run: sudo modprobe cpuid && rerun this script as root."
fi

What this gives you:

  • processor-id is the canonical 64-bit decimal interpretation that QEMU's -smbios type=4,processor-id=N expects. It is not the dmidecode hex output. The encoding is: SMBIOS Type 4 Processor ID is 8 bytes - bytes 0-3 hold CPUID(1).EAX in little-endian, bytes 4-7 hold CPUID(1).EDX in little-endian. Interpreted as a single little-endian 64-bit integer, that is (EDX << 32) | EAX.
  • sock_pfx defaults to AM4 in the script. Change to AM5, LGA1700, LGA1851, etc. per your host.
  • The protected fields (product_uuid, serials) show ? when run without root. Run with sudo to get the real values.

The cpuid module is built into the Arch kernel. If /dev/cpu/0/cpuid does not exist, modprobe cpuid (no package install needed) creates it.

dd seek gotcha: the kernel's /dev/cpu/N/cpuid driver treats the file position as the CPUID leaf number, not byte offset. dd bs=16 skip=1 reads leaf 16 (RDT/Resource Director Technology), not leaf 1. The correct read for CPUID(1) is dd bs=16 count=2 | tail -c 16 (read leaves 0 and 1, keep only leaf 1).

12.2 ACPI OEM Extraction#

ACPI OEM ID lives at offset 0x0a in the FADT (6 bytes). OEM Table ID at offset 0x10 (8 bytes). Creator ID at offset 0x1c (4 bytes).

On the reference machine:

# Quick dump
sudo cp /sys/firmware/acpi/tables/FACP ./FACP.bin
hexdump -C FACP.bin | head -3

# Extract individual fields
dd if=FACP.bin bs=1 skip=10 count=6 2>/dev/null  # OEM ID
dd if=FACP.bin bs=1 skip=16 count=8 2>/dev/null  # OEM Table ID
dd if=FACP.bin bs=1 skip=28 count=4 2>/dev/null  # Creator ID

Or use acpidump + iasl:

sudo pacman -S acpica
sudo acpidump -b
iasl -d FACP.dat
head -20 FACP.dsl
# Shows OEM ID, OEM Table ID, OEM Revision, ASL Compiler ID, ASL Compiler Revision

Global OEM override is possible without patching anything via the pc-q35 machine's x-oem-id and x-oem-table-id properties. Pass via a second -machine arg through <qemu:commandline> - libvirt emits its own -machine line plus yours, and QEMU merges them:

<qemu:arg value='-machine'/>
<qemu:arg value='x-oem-id=ALASKA,x-oem-table-id=A M I   '/>

Rewrites OEM ID (6 bytes) and OEM Table ID (8 bytes, space-padded) in every ACPI table QEMU generates - FADT, DSDT, SSDT, APIC, MCFG, HPET, etc. Pad shorter strings with spaces. Per-table content overrides via -acpitable file=<binary> are independent of this and override the full table contents.

12.3 DSDT/SSDT Dumps#

Two paths depending on whether the VFIO host is the reference machine.

Path A: dump from the VFIO host itself. No external tools, kernel exposes ACPI tables at /sys/firmware/acpi/tables/. Run on the VFIO host:

sudo mkdir -p /var/lib/libvirt/acpi
sudo ls -la /sys/firmware/acpi/tables/    # see what's there (DSDT, SSDT1..N, FACP, etc.)

# Copy and rename to match QEMU's expected lowercase .bin convention
sudo bash -c '
for src in /sys/firmware/acpi/tables/SSDT*; do
    [ -r "$src" ] || continue
    name=$(basename "$src" | tr "[:upper:]" "[:lower:]")
    cp "$src" "/var/lib/libvirt/acpi/${name}.bin"
done
# Optionally include DSDT (higher boot risk - replaces QEMU's structure):
# cp /sys/firmware/acpi/tables/DSDT /var/lib/libvirt/acpi/dsdt.bin
'
sudo chmod 644 /var/lib/libvirt/acpi/*.bin
ls -la /var/lib/libvirt/acpi/

Path B: dump from a separate reference machine. Same kernel sysfs path, then transfer:

# On the reference machine:
sudo tar czf /tmp/acpi.tar.gz -C /sys/firmware/acpi/tables \
    DSDT SSDT1 SSDT2 SSDT3 SSDT4 SSDT5 SSDT6 SSDT7
sudo chown "$SUDO_USER" /tmp/acpi.tar.gz

# Transfer:
scp REFERENCE:/tmp/acpi.tar.gz /tmp/

# On VFIO host:
sudo mkdir -p /var/lib/libvirt/acpi
sudo tar xzf /tmp/acpi.tar.gz -C /var/lib/libvirt/acpi/
cd /var/lib/libvirt/acpi
sudo bash -c 'for f in DSDT SSDT[1-9]*; do
    [ -f "$f" ] || continue
    n=$(echo "$f" | tr "[:upper:]" "[:lower:]")
    mv "$f" "${n}.bin"
done'
sudo chmod 644 /var/lib/libvirt/acpi/*.bin

Reference in the domain XML:

<qemu:arg value='-acpitable'/>
<qemu:arg value='sig=SSDT,file=/var/lib/libvirt/acpi/ssdt1.bin'/>
<qemu:arg value='-acpitable'/>
<qemu:arg value='sig=SSDT,file=/var/lib/libvirt/acpi/ssdt2.bin'/>
<!-- ... one pair per SSDT file ... -->

One <qemu:arg value='-acpitable'/> + <qemu:arg value='sig=...,file=...'/> pair per SSDT. The filename in the XML must exist on disk - QEMU refuses to start if any referenced file is missing.

DSDT injection is high-risk: real DSDTs enumerate hardware (EC controller, GPIO, thermal zones, OEM-specific ACPI methods) that QEMU's virtual chipset does not provide. Windows tries to load drivers for the phantom devices and may BSOD, hang, or show dozens of "unknown device" entries in Device Manager. Inject DSDT only after confirming SSDT injection works and the host's DSDT methods are tame enough.

Mixing host-source ACPI with foreign-board persona: if you dump SSDTs from your VFIO host but spoof SMBIOS as a different motherboard, the SSDTs match your real hardware while SMBIOS lies. Anti-cheats below FACEIT/Vanguard don't cross-reference, but it's a logical inconsistency for the SMBIOS persona. Cleanest path: dump from a machine matching the SMBIOS persona. Pragmatic path: dump from the VFIO host and accept the mismatch.

Hypervisor-Phantom (section 13) does this dump-and-inject step automatically at build time using the host's ACPI tables.

12.4 MAC OUI Lookup#

The MAC's upper 24 bits identify the manufacturer. The lower 24 bits are device-specific.

Real OUI requirement: globally-administered bit (second-LSB of first octet) must be 0. 52:54:00: has bit 1 set (0x52 & 0x02 = 0x02) - locally administered.

Sources:

# IEEE registry (canonical)
curl https://standards-oui.ieee.org/oui/oui.csv | grep -iE 'asus|intel|realtek|dell|gigabyte|asrock|msi|supermicro'

# From your own NIC
ip -o link | awk '/ether/ {print substr($(NF-2),1,8)}'

Stable vendor OUIs:

OUIVendor
00:1b:21Intel Corporate
00:15:17Intel Corporate
b4:96:91Intel Corporate
a0:36:9fIntel Corporate
00:e0:4cRealtek Semiconductor
52:7a:5bRealtek Semiconductor
1c:1b:0dASUSTek COMPUTER INC.
d8:5e:d3Dell Inc.
e0:d5:5eGiga-byte Technology
00:25:90Super Micro Computer
4c:cc:6aASRock Incorporation

Match the chosen NIC model:

  • <model type='e1000e'/> -> Intel OUI
  • <model type='e1000'/> -> Intel OUI
  • <model type='rtl8139'/> -> Realtek OUI (note: 100 Mbit only)
  • <model type='virtio'/> -> any (but virtio PCI vendor is 0x1AF4 which is itself a tell)

Generate a final MAC:

OUI="00:1b:21"
SUFFIX=$(printf '%02x:%02x:%02x' $((RANDOM%256)) $((RANDOM%256)) $((RANDOM%256)))
echo "${OUI}:${SUFFIX}"

12.5 Consistency Rules#

  • DIY motherboard persona: no SLIC, no MSDM. Retail boards sold separately do not include OEM activation tables.
  • Prebuilt OEM persona (Dell, HP, Lenovo, MSI laptop): SLIC and MSDM yes, extracted from a real matching system.
  • Single vendor across all fields: AMI BIOS + MSI baseboard + MSI chassis. Mixing (e.g. AMI BIOS + ASUS baseboard + MSI chassis) is suspicious.
  • CPU brand string matches the host: with host-passthrough, the brand string comes from the host CPUID leaves 0x80000002-0x80000004. The -smbios type=4 arg should match (manufacturer, version=actual CPU name, processor-id).
  • NIC vendor matches PCI vendor: e1000e (Intel PCI vendor) -> Intel OUI MAC. rtl8139 (Realtek PCI vendor) -> Realtek OUI MAC.
  • System UUID: must match the libvirt domain UUID, or be deliberately consistent across reboots. Random per-boot UUIDs are themselves a fingerprint.
  • "To be filled by O.E.M.": legitimate placeholder on real MSI/ASUS DIY boards. Keep as-is when the reference machine has it.

12.6 Worked Example: MSI X470 GAMING PLUS MAX#

Real reference machine, AMD Ryzen 7 5800X. DIY motherboard, no SLIC, no MSDM.

<sysinfo type='smbios'>
  <bios>
    <entry name='vendor'>American Megatrends International, LLC.</entry>
    <entry name='version'>H.N2</entry>
    <entry name='date'>09/19/2025</entry>
  </bios>
  <system>
    <entry name='manufacturer'>Micro-Star International Co., Ltd.</entry>
    <entry name='product'>X470 GAMING PLUS MAX (MS-7B79)</entry>
    <entry name='version'>3.0</entry>
    <entry name='serial'>To be filled by O.E.M.</entry>
    <entry name='uuid'>00000000-0000-0000-0000-000000000000</entry>
    <entry name='sku'>To be filled by O.E.M.</entry>
    <entry name='family'>To be filled by O.E.M.</entry>
  </system>
  <baseBoard>
    <entry name='manufacturer'>Micro-Star International Co., Ltd.</entry>
    <entry name='product'>X470 GAMING PLUS MAX (MS-7B79)</entry>
    <entry name='version'>3.0</entry>
    <entry name='serial'>0000000000</entry>
  </baseBoard>
  <chassis>
    <entry name='manufacturer'>Micro-Star International Co., Ltd.</entry>
    <entry name='version'>3.0</entry>
    <entry name='serial'>To be filled by O.E.M.</entry>
  </chassis>
</sysinfo>

Matching <qemu:commandline>:

<qemu:commandline xmlns:qemu='http://libvirt.org/schemas/domain/qemu/1.0'>
  <qemu:arg value='-machine'/>
  <qemu:arg value='x-oem-id=ALASKA,x-oem-table-id=A M I   '/>
  <qemu:arg value='-smbios'/>
  <qemu:arg value='type=4,manufacturer=Advanced Micro Devices,, Inc.,version=AMD Ryzen 7 5800X 8-Core Processor,sock_pfx=AM4,processor-id=0000000000000000'/>
  <qemu:arg value='-overcommit'/>
  <qemu:arg value='cpu-pm=on'/>
</qemu:commandline>

NIC: e1000e model + Intel OUI MAC (00:1b:21:00:00:01). The real MSI X470 ships Realtek RTL8111H; anti-cheats do not cross-check NIC vendor against SMBIOS board model.

Disk (Tier B+): the reference machine has a Seagate IronWolf 8TB SATA drive. With SATA bus, drop <wwn> (libvirt limitation - see section 11.3):

<disk type='file' device='disk'>
  <driver name='qemu' type='qcow2' cache='writeback' io='io_uring' discard='unmap'/>
  <source file='/var/lib/libvirt/images/win11.qcow2'/>
  <target dev='sda' bus='sata'/>
  <vendor>ATA</vendor>
  <product>ST8000VN004-3CP101</product>
  <serial>00000000</serial>
</disk>

To preserve WWN reporting (5000c500ed5804c6, HGST/Seagate IEEE NAA-5 OUI), use the LSI SAS controller path from section 11.3.

Notes on the SMBIOS strings:

  • American Megatrends International, LLC. is the canonical AMI string with single comma. If reference dmidecode shows ,, LLC. with double comma, keep that - firmware occasionally has the typo. Verify against the actual reference machine.
  • ,, in the -smbios type=4 QEMU arg is comma-escaping; Advanced Micro Devices,, Inc. becomes literal Advanced Micro Devices, Inc. in the SMBIOS. XML <sysinfo> has no comma escaping - ,, there is literal.
  • processor-id=0000000000000000 is the Ryzen 7 5800X B2 stepping value (EAX=0x00A20F12, EDX=0x178BFBFF, packed as little-endian 64-bit). B0 stepping is 1696726757280976656. Must match what host-passthrough reports on the actual host CPU.

13. Custom QEMU and OVMF Build (Tier C)#

Required for the QEMU/OVMF half of Tier C. Defeats every hardcoded QEMU/BOCHS/Red Hat string in the QEMU and OVMF binaries that the standard <sysinfo>, x-oem-id, and <vendor>/<product> knobs cannot reach: FW_CFG signature, PCI vendor IDs 0x1B36/0x1234 for the Q35 chipset's own emulated devices, DSDT object naming, OVMF firmware vendor string, USB device descriptors.

Builds live in /opt/ and have zero impact on the host when the VM is not running. The default Arch qemu-full and edk2-ovmf packages stay installed alongside (only used by other VMs or virt-manager defaults).

git clone https://github.com/Scrut1ny/Hypervisor-Phantom.git
cd Hypervisor-Phantom
./run.sh

The script:

  1. Detects host CPU vendor, DMI strings from /sys/class/dmi/id/, ACPI OEM from /sys/firmware/acpi/tables/FACP.
  2. Downloads QEMU and edk2 source matching pinned versions.
  3. Applies patches/QEMU/{Intel,AMD}-vXX.X.X.patch and OVMF patch.
  4. Builds QEMU with --prefix=/opt/qemu-phantom.
  5. Builds OVMF, installs to /opt/ovmf-phantom/.
  6. Generates spoofed_devices.dsl SSDT with PWRB/SLPB/ACAD/EC0/TZ0, compiles via iasl.
  7. Offers a tkg kernel with RDTSC handler - skip the kernel option here, kernel patches are covered separately in section 14 with their detection caveats.

What the QEMU patch changes (verified against patches/QEMU/Intel-v11.0.0.patch):

  • SCSI/IDE/NVMe/USB device strings: QEMU HARDDISK, QEMU CD-ROM, QEMU NVMe Ctrl, QEMU USB Tablet/Mouse/Keyboard -> real vendor strings (Samsung, ASUS, Logitech, etc.).
  • PCI vendor IDs: 0x1234 Bochs VGA -> 0x8086 Intel, 0x1B36 Red Hat PCIe bridges -> 0x8086 Intel with realistic device IDs (0x4641 host bridge, 0x51b0 root port, 0x51ed xHCI, 0x463D LPC bridge, 0x51a3 SMBus).
  • HDA audio vendor: 0x1AF4 Red Hat -> 0x10EC Realtek.
  • FW_CFG signature: QEMU CFG -> QCOM CFG.
  • ACPI DSDT/SSDT object naming: S%.02X PCI device names -> realistic Intel-chipset names (MCHC, GFX0, HDAU, XHC1, EHC1, LPCB, SAT0, SBUS).
  • FADT Hypervisor Vendor Identity field blanked.
  • ICH9 LPC revision 0x2 -> 0x51, ICH9 SMB revision 0x02 -> 0x71.
  • kvm-pv-enforce-cpuid=true by default - KVM PV MSRs (0x4b564d00-0x4b564d08) #GP unless KVM is advertised in CPUID 0x40000000 (real hardware behavior).
  • CPUID 6.ECX[0] (APERFMPERF) exposed plus KVM_X86_DISABLE_EXITS_APERFMPERF enabled.

What the OVMF patch changes:

  • PcdFirmwareVendor: EDK II -> host BIOS vendor (e.g. American Megatrends International, LLC.).
  • PcdAcpiDefaultOemId / PcdAcpiDefaultOemTableId / PcdAcpiDefaultCreatorId: extracted from host /sys/firmware/acpi/tables/FACP offsets 10/16/28.
  • SMBIOS Platform DXE hardcoded 02/02/2022 BIOS date and unknown vendor -> host DMI values.

13.2 Point libvirt at the Patched Binaries#

After the build, update kvm.xml:

<devices>
  <emulator>/opt/qemu-phantom/bin/qemu-system-x86_64</emulator>
  ...
</devices>
<os firmware='efi'>
  <type arch='x86_64' machine='pc-q35-11.0'>hvm</type>
  <firmware>
    <feature enabled='yes' name='enrolled-keys'/>
    <feature enabled='yes' name='secure-boot'/>
  </firmware>
  <loader readonly='yes' secure='yes' type='pflash'>/opt/ovmf-phantom/OVMF_CODE.secboot.fd</loader>
  <nvram template='/opt/ovmf-phantom/OVMF_VARS.secboot.fd'>/var/lib/libvirt/qemu/nvram/win11_VARS.fd</nvram>
  <boot dev='hd'/>
  <smbios mode='sysinfo'/>
</os>

Existing <qemu:commandline> blocks (x-oem-id, -smbios type=4, -acpitable, -overcommit cpu-pm=on) stay - they layer on top of the patched binaries.

13.3 Manual QEMU Build#

For when Phantom lags upstream or you want to combine different patches.

sudo pacman -S base-devel git ninja meson python pkgconf glib2 \
               libusb libslirp libfdt libpng libjpeg-turbo \
               libpulse alsa-lib spice-protocol spice usbredir \
               libcap-ng libseccomp libssh libxkbcommon libepoxy \
               sdl2 gtk3 numactl rdma-core gnutls nettle pixman \
               liburing virglrenderer

git clone --depth=1 -b v11.0.0 https://gitlab.com/qemu-project/qemu.git
cd qemu

# Apply patches
patch -p1 < /path/to/qemu-anti-detection.patch
# Or Hypervisor-Phantom:
patch -p1 < /path/to/Hypervisor-Phantom/patches/QEMU/Intel-v11.0.0.patch

./configure \
    --target-list=x86_64-softmmu \
    --prefix=/opt/qemu-spoofed \
    --enable-kvm --enable-vhost-net --enable-vhost-user \
    --enable-virtfs --enable-spice --enable-usb-redir \
    --enable-libusb --enable-tools --enable-gnutls \
    --enable-libssh --enable-numa --enable-rdma \
    --enable-snappy --enable-bzip2 --enable-libxml2 \
    --enable-attr --enable-cap-ng --enable-seccomp \
    --enable-liburing --enable-virglrenderer

make -j$(nproc)
sudo make install

13.4 Manual OVMF (edk2) Build#

sudo pacman -S base-devel git python nasm acpica iasl

git clone --depth=1 -b edk2-stable202602 https://github.com/tianocore/edk2.git
cd edk2
git submodule update --init --recursive

# Apply patches
patch -p1 < /path/to/Hypervisor-Phantom/patches/OVMF/edk2-stable202602.patch

source edksetup.sh
make -C BaseTools

# The Phantom patch reads host DMI/ACPI from env vars at build time
export HOST_VENDOR=$(cat /sys/class/dmi/id/bios_vendor)
export HOST_VERSION=$(cat /sys/class/dmi/id/bios_version)
export HOST_DATE=$(cat /sys/class/dmi/id/bios_date)
export HOST_OEM_ID=$(dd if=/sys/firmware/acpi/tables/FACP bs=1 skip=10 count=6 2>/dev/null)

build -a X64 -t GCC5 -p OvmfPkg/OvmfPkgX64.dsc -b RELEASE \
      -D SECURE_BOOT_ENABLE -D TPM_ENABLE -D TPM2_ENABLE \
      -D NETWORK_HTTP_BOOT_ENABLE=FALSE

sudo mkdir -p /opt/ovmf-phantom
sudo cp Build/OvmfX64/RELEASE_GCC5/FV/OVMF_CODE.fd \
        /opt/ovmf-phantom/OVMF_CODE.secboot.fd
sudo cp Build/OvmfX64/RELEASE_GCC5/FV/OVMF_VARS.fd \
        /opt/ovmf-phantom/OVMF_VARS.secboot.fd

13.5 AUR Packages#

yay -S qemu-anti-detection-git
yay -S ovmf-phantom-git          # if available

AUR PKGBUILDs occasionally lag the upstream patches. Verify the patch source matches before installing.

13.6 Verify#

After build, from inside Windows (also see section 19 for full verification):

Get-CimInstance Win32_BIOS         | Format-List Manufacturer, Version, ReleaseDate
Get-CimInstance Win32_ComputerSystem | Format-List Manufacturer, Model
Get-CimInstance -Namespace root\WMI -Class MSAcpi_TableHeader |
    Select Signature, OEMID, OEMTABLEID | Sort Signature
Get-CimInstance Win32_DiskDrive    | Select Model, FirmwareRevision, SerialNumber
Get-CimInstance Win32_Processor    | Select Manufacturer, Name, ProcessorId

None should contain QEMU, Bochs, BOCHS, Standard PC, Red Hat, or virtio. If any do, the patch did not apply - search the binaries:

grep -rl 'QEMU HARDDISK' /opt/qemu-phantom/      # should return nothing
grep -rl 'BOCHS '       /opt/ovmf-phantom/       # should return nothing
strings /opt/qemu-phantom/bin/qemu-system-x86_64 | grep -E 'QEMU|Bochs' | head

13.7 Maintenance#

Patches break on every QEMU/edk2 minor release. Phantom typically lags upstream by 2-4 weeks. Pin via IgnorePkg in /etc/pacman.conf so the system packages do not move while you maintain the patched fork:

# /etc/pacman.conf
IgnorePkg = qemu-full edk2-ovmf

Or accept the lag and rebuild on Phantom's schedule.


14. Custom KVM Kernel Patches (Tier D)#

Patches the host's kvm.ko module to mask VMEXIT timing, hide additional KVM signatures, and align APERF/MPERF with scaled TSC. This tier has serious caveats - read them before deciding.

14.1 What Tier D Buys You#

Defeats one specific check: Pafish's "RDTSC-via-forced-VMEXIT" timing test, plus a few similar checks in al-khaser. With the RDTSC handler patch, the cycle delta around a CPUID/CPL-3 VMEXIT is scaled by 1/16 (Intel) or 1/20 (AMD) before the guest sees the second TSC read. Pafish reports [*] rdtsc forcing vm exit using cpuid: not detected.

14.2 What Tier D Breaks (Read This)#

The TSC handler scales only what comes out of RDTSC/RDTSCP. It does not scale:

  • HPET counter reads (memory-mapped, no instruction trap).
  • ACPI PM timer reads (IO port).
  • kvmclock paravirt clocksource page.
  • Hyper-V reference TSC page (already disabled via <timer name='hpet' present='no'/> + <timer name='kvmclock' present='no'/> and other clock tweaks, but still).
  • IA32_APERF/IA32_MPERF MSR reads.
  • RDPRU on AMD (user-level APERF/MPERF read).
  • Two-thread software clock (what VMAware uses on line 5506 of vmaware.hpp).

So a guest that reads HPET-and-TSC together, does work, reads both again, and compares ratios catches the patch in one check. VMAware's VM::TIMER exists specifically because TSC offsets/scaling are common. Net effect: Tier D removes one Pafish detection and adds multiple VMAware detections via cross-checks.

For mainstream anti-cheats (EAC, BattlEye, nProtect): RDTSC-via-VMEXIT timing alone is not actioned. They have to see a stack of indicators before flagging. So Tier D mostly buys you a clean Pafish line for cosmetic reasons.

For FACEIT/Vanguard: their timing detection uses two-thread software clocks anyway. Tier D does not help.

Recommendation: skip Tier D unless you specifically need Pafish to show 0 RDTSC detections (e.g. you are demoing the spoof) and you are not running VMAware as part of your verification. The cross-check failures it introduces are worse than the symptom it cures.

14.3 Build the Patched kvm.ko#

The patch ships in WCharacter/RDTSC-KVM-Handler - drop-in vmx.c and svm.c replacements for the KVM kernel module. They hook the EXIT_REASON_RDTSC handler at arch/x86/kvm/vmx/vmx.c:5964-5995 (Intel) and SVM_EXIT_RDTSC at arch/x86/kvm/svm/svm.c:3142-3174 (AMD).

The cleanest deployment on Arch is via the linux kernel package's PKGBUILD:

# Fetch the Arch kernel sources
asp checkout linux               # or git clone https://gitlab.archlinux.org/archlinux/packaging/packages/linux
cd linux/trunk

# Get the patch files
git clone --depth=1 https://github.com/WCharacter/RDTSC-KVM-Handler.git /tmp/rdtsc-patch

# Generate a unified diff vs upstream kernel
KERNEL_VER=$(awk '/^pkgver/{print $3}' PKGBUILD | head -1)
cd /tmp
git clone --depth=1 -b "v${KERNEL_VER}" https://github.com/torvalds/linux.git linux-orig 2>/dev/null
diff -u linux-orig/arch/x86/kvm/vmx/vmx.c /tmp/rdtsc-patch/Linux\ kernel/vmx.c > /tmp/rdtsc-vmx.patch
diff -u linux-orig/arch/x86/kvm/svm/svm.c /tmp/rdtsc-patch/Linux\ kernel/svm.c > /tmp/rdtsc-svm.patch

# Apply in the Arch kernel PKGBUILD
cd ~/linux
cp /tmp/rdtsc-vmx.patch /tmp/rdtsc-svm.patch .
# Edit PKGBUILD: add to source=() and prepare()
# source=(... rdtsc-vmx.patch rdtsc-svm.patch)
# prepare() { ... patch -Np1 -i ../rdtsc-vmx.patch; patch -Np1 -i ../rdtsc-svm.patch; }

makepkg -si

Or simpler if you do not want to track Arch's main kernel: build a DKMS module out of the standalone kvm + kvm-amd + kvm-intel sources. The kvm-amd and kvm-intel modules in mainline are not buildable as out-of-tree DKMS without significant work - they tightly couple to the in-tree kernel. So Arch-kernel rebuild is the path.

linux-tkg-vfio (AUR) is a pre-patched fork that includes this and several other VFIO-friendly patches. Lower maintenance, higher trust footprint:

yay -S linux-tkg-bmq            # or other tkg variant; check the PKGBUILD includes RDTSC patches

14.4 Frequency Tuning#

The divisor in the patch is hardcoded to 16 (Intel) or 20 (AMD), calibrated for ~4.2 GHz / ~3.2 GHz nominal CPUs. If your CPU runs at a substantially different frequency, the scaling is off and the guest's TSC drifts visibly from wall-clock time.

Edit vmx.c/svm.c before building:

// In vmx.c, find:
u64 fake_diff = diff / 16;
// Change 16 to match your host's nominal-GHz * ~3-4.

// In svm.c, find:
u64 fake_diff = diff / 20;
// Same calibration.

A Ryzen 7 5800X at 3.8 GHz base / 4.7 GHz boost: leave AMD at 20 (close enough). Ryzen 7950X at 4.5 GHz base / 5.7 GHz boost: try 16-18. The "right" value is: divisor ≈ host_GHz * (bare_metal_cycles / VMEXIT_cycles). Tune empirically by running pafish.exe and watching the reported RDTSC delta - target 200-500 cycles.

14.5 Boot the Patched Kernel#

Add the patched kernel to your bootloader:

# GRUB
sudo grub-mkconfig -o /boot/grub/grub.cfg

# systemd-boot / UKI
sudo bootctl update

The patched kernel must be the running kernel when the VM boots. Verify after reboot:

uname -r
# Should show your custom kernel version
dmesg | grep -i rdtsc
# Or: modinfo kvm | grep -i rdtsc

The patch is only active when the kvm module is in use (i.e., while a VM is running). When no VM is running, the host has no VMEXITs, so the handler is dormant - zero performance impact on the host when the VM is down.

14.6 Reverting#

If something breaks or the patch causes guest timing weirdness:

# Boot stock Arch kernel from GRUB menu (keep it installed as fallback)
# Then remove the patched one
sudo pacman -R linux-spoofed   # or whatever you named your patched package
sudo grub-mkconfig -o /boot/grub/grub.cfg
sudo reboot

14.7 Where Tier D Sits in the Detection Map#

DetectionStock KVMTier D RDTSC handler
Pafish: rdtsc forcing vm exitDetectedNot detected
Pafish: rdtsc difference lockyOften detectedSometimes not detected
VMAware: timing (software thread)DetectedStill detected
VMAware: HPET vs TSCNot detected (HPET off)Stays not detected (HPET still off)
VMAware: APERF/MPERF vs TSCDetected if kernel <6.13Worse: now they disagree by the scale factor
VMAware: PMTMR vs TSCDetected on most setupsWorse: scale-factor disagreement
EAC / BattlEye runtime detectionTolerant of timing aloneTolerant of timing alone
FACEIT / VanguardDetected via partition-privilege check + TPMStill detected

If you run only Pafish for verification, Tier D looks clean. If you run VMAware, Tier D regresses some checks. Pick the verification posture you actually care about.


15. TPM 2.0 Passthrough#

Required for FACEIT and Vanguard. Optional for everything else (swtpm emulator is fine for EAC, BattlEye, Windows 11 install).

15.1 When You Need This#

Anti-cheatEK chain validated?Action
Windows 11 installNoswtpm emulator is fine.
EAC, BattlEye, nProtect, ACENoswtpm fine.
FACEIT FAC-INT (Nov 2025+)YesHost TPM passthrough required.
VanguardYesHost TPM passthrough required + Tier C.

The cryptographic gate is the EK certificate chain. swtpm issues from SWTPM CA, on no vendor allow-list. A real hardware TPM ships with an EK certificate signed by a manufacturer CA (Intel PTT, AMD fTPM MS-signed CA, Infineon, STMicro, Nuvoton). Anti-cheats that walk the chain reject swtpm. Passing through the host's real TPM gives the guest a real EK.

15.2 Verify Host TPM#

ls /dev/tpm0 /dev/tpmrm0
cat /sys/class/tpm/tpm0/tpm_version_major   # expect "2"
sudo dmesg | grep -i tpm

If /dev/tpm0 does not exist: enable fTPM or Intel PTT in BIOS, or install a discrete TPM module.

Verify EK certificate is present:

sudo pacman -S tpm2-tools
sudo tpm2_getekcertificate -X out.cert
file out.cert
# Should report DER or PEM certificate, not "empty"

If tpm2_getekcertificate returns nothing, your firmware did not load an EK from the manufacturer. Common on older firmware. Update BIOS.

15.3 Prepare the Host#

Stop the TPM Access Broker (it holds exclusive control):

sudo systemctl stop tpm2-abrmd.service tpm2-abrmd.socket
sudo systemctl disable tpm2-abrmd.service tpm2-abrmd.socket   # or stop dynamically from hook

The hook in section 6.3 step 10 stops it on VM start and section 6.4 step 9 restarts on VM stop.

Set /dev/tpm0 ownership for libvirt-qemu:

# /etc/udev/rules.d/99-libvirt-tpm.rules
KERNEL=="tpm0",   GROUP="kvm", MODE="0660"
KERNEL=="tpmrm0", GROUP="kvm", MODE="0660"
sudo udevadm control --reload && sudo udevadm trigger

AppArmor: the default libvirt profile may block /dev/tpm0. Edit /etc/apparmor.d/local/abstractions/libvirt-qemu:

/dev/tpm0  rw,
/dev/tpmrm0  rw,

Then reload:

sudo systemctl reload apparmor

If you do not run AppArmor (Arch default), skip this step.

15.4 Libvirt XML#

<devices>
  <tpm model='tpm-crb'>
    <backend type='passthrough' device='/dev/tpm0'/>
  </tpm>
</devices>

Alternative via resource manager (allows other host consumers to share):

<tpm model='tpm-crb'>
  <backend type='passthrough' device='/dev/tpmrm0'/>
</tpm>

/dev/tpm0 (raw) is more authentic but exclusive. /dev/tpmrm0 (resource manager) is shareable but adds a software layer that some anti-cheats might fingerprint. Use /dev/tpm0 for Vanguard.

Replace any existing <tpm><backend type='emulator'>...</backend></tpm>.

15.5 Caveats and Tradeoffs#

Host loses TPM access during VM runtime. While the VM owns the TPM:

  • systemd-cryptenroll --tpm2-device= LUKS unlocks fail.
  • Clevis with TPM2 pin fails.
  • tpm2_* commands on the host fail or block.
  • Anything that opens /dev/tpm0 waits.

Most Linux desktops don't rely on TPM for anything load-bearing. Verify:

sudo lsof /dev/tpm0 /dev/tpmrm0 2>/dev/null
sudo systemd-cryptenroll --list-devices  # check for tpm2-device entries
ls /etc/luks/  # check for cryptenroll TPM2 binding files

If LUKS is bound to TPM2, either:

  • Add a passphrase fallback (systemd-cryptenroll /dev/sdaN --password) before passing through.
  • Use /dev/tpmrm0 mode (resource manager allows concurrent access for some operations).
  • Decide TPM passthrough is not worth the maintenance.

Existing host TPM state stays bound to the guest. Once you pass the TPM to a Windows guest, Windows creates its own keys in the TPM's NV storage. These persist. If you ever pass the same TPM to a different VM, that VM sees the prior keys. Use one VM per TPM, or clear with tpm2_clear -c platform between VMs (destroys host's stored keys too).

Firmware fTPM erratum (Ryzen 3000-5000): stuttering when the TPM is heavily accessed. AMD AGESA 1.2.0.7+ fixes most cases. Update BIOS before troubleshooting.

15.6 What It Satisfies, What It Does Not#

Host TPM passthrough satisfies:

  • TPM 2.0 presence check (Windows 11 install passes without it via swtpm; FACEIT/Vanguard require real).
  • EK certificate chain validation against manufacturer CAs.
  • Measured boot PCR existence (real measurements from real firmware).

Host TPM passthrough alone does NOT satisfy:

  • CPUID hypervisor bit / 0x40000000 vendor checks - still need section 11 Tier A.
  • ACPI OEM ID / DSDT object naming - still need Tier B/C.
  • Hyper-V partition privilege check - requires Tier C custom QEMU patches.
  • APERF/MPERF coherence with RDTSC timing - needs kernel 6.13+ host (or Tier D, with its caveats).
  • MSR_SMI_COUNT returning realistic values - unsolved publicly.

Minimum stack for Vanguard credible attempt:

  1. Host kernel 6.13+ (APERF/MPERF virtualized).
  2. Host TPM 2.0 passthrough (this section).
  3. Tier C custom QEMU + OVMF (section 13).
  4. Full Hyper-V enlightenments + Secure Boot + VBS-aware spoofing in the guest.
  5. Real SMBIOS + ACPI from a real reference machine (section 12).

Even with all of the above, Vanguard updates can break the bypass between sessions. Maintenance burden is high. Hardware bans are real. Use throwaway accounts.


16. Audio, Input, USB#

16.1 Audio#

Options ranked by latency:

BackendLatencySetup complexity
PipeWire JACK via QEMU audiodev jack3-8 msMedium
PipeWire-pulse via SPICE10-25 msEasy (default)
Scream (network UDP)5-15 msEasy in guest, network setup
USB audio passthroughhardware limitEasy if you have a free USB controller

PipeWire-pulse via SPICE (foundation default):

<sound model='ich9'>
  <address type='pci' domain='0x0000' bus='0x00' slot='0x1b' function='0x0'/>
</sound>
<audio id='1' type='spice'/>

PipeWire JACK direct (lower latency):

<qemu:commandline xmlns:qemu='http://libvirt.org/schemas/domain/qemu/1.0'>
  <qemu:arg value='-audiodev'/>
  <qemu:arg value='jack,id=jack0,out.client-name=vm-out,in.client-name=vm-in,server-name=default'/>
</qemu:commandline>

Then remove the <audio> libvirt element (only one audio backend allowed).

ich9 sound model emulates Intel ICH9 HD Audio. Real fingerprint for an Intel chipset. On AMD board personas the sound device is typically Realtek ALC892 - cosmetic mismatch but anti-cheats do not cross-check.

16.2 Input: evdev Passthrough#

For gaming, evdev passthrough gives raw mouse/keyboard input with no SPICE overhead. Add via <qemu:commandline>:

<qemu:arg value='-object'/>
<qemu:arg value='input-linux,id=mouse0,evdev=/dev/input/by-id/usb-Logitech_G502_HERO-event-mouse'/>
<qemu:arg value='-object'/>
<qemu:arg value='input-linux,id=kbd0,evdev=/dev/input/by-id/usb-Keychron_K2-event-kbd,grab_all=on,repeat=on'/>

grab_all=on plus repeat=on on the keyboard makes both Ctrl keys at once toggle capture between host and guest. Configurable via key-tag=.

Find device paths:

ls /dev/input/by-id/

Add input to the user's groups and to libvirt-qemu access:

sudo usermod -aG input "$USER"
# /etc/libvirt/qemu.conf - uncomment and add:
cgroup_device_acl = [
    "/dev/null", "/dev/full", "/dev/zero",
    "/dev/random", "/dev/urandom",
    "/dev/ptmx", "/dev/kvm",
    "/dev/input/by-id/usb-Logitech_G502_HERO-event-mouse",
    "/dev/input/by-id/usb-Keychron_K2-event-kbd"
]
sudo systemctl restart libvirtd

Remove the <input type='tablet'> to avoid double cursor capture if you use evdev.

16.3 USB Passthrough#

Two patterns: individual USB devices, or an entire USB controller.

Individual device (cheap, recommended for casual use):

<hostdev mode='subsystem' type='usb' managed='yes'>
  <source>
    <vendor id='0x046d'/>
    <product id='0xc08b'/>
  </source>
</hostdev>

Find vendor/product:

lsusb

Notes:

  • Device disconnects from the host when the VM starts.
  • Multi-function devices (gaming headsets with HID + audio) pass cleanly.
  • Some devices (3D mice, fingerprint readers) hate being yanked.

USB controller (better isolation, requires spare controller):

<hostdev mode='subsystem' type='pci' managed='yes'>
  <source><address domain='0x0000' bus='0x03' slot='0x00' function='0x0'/></source>
</hostdev>

The entire USB controller and all its ports go to the VM. Host keeps any other USB controllers it has. Better latency, no individual-device drama. Requires the controller to be in its own IOMMU group - check section 3.4.

For passthrough of input devices specifically, prefer evdev passthrough (15.2) over USB passthrough. evdev is exclusive only while the VM is up; USB passthrough holds the device until you stop the VM, and you cannot use the keyboard on the host without unplugging.

16.4 Optional Startup Policy#

<hostdev mode='subsystem' type='usb' managed='yes'>
  <source startupPolicy='optional'>
    <vendor id='0x090c'/>
    <product id='0x1000'/>
  </source>
</hostdev>

startupPolicy='optional' means the VM starts even if the device is not connected. Handy for thumb drives or occasionally-plugged peripherals.


17. Host Session on Plasma 6.7#

Single-GPU passthrough destroys the host session. Reasons matter.

kwin_wayland opens the DRM device, calls drmSetMaster, and renders against it. When vfio-pci claims the device, the file descriptor is invalid; the next atomic commit fails; the compositor exits. kwin_wayland has no "primary GPU is gone, idle until it returns" code path and none is planned.

What does not work, despite reasonable hope:

  • Stop only sddm.service and keep the session alive. systemd-logind reaps the session scope when the compositor exits and SIGTERMs everything in session-c1.scope. Apps die.
  • loginctl enable-linger to keep user@.service alive. Linger keeps the user manager running, not the session. Wayland clients lose their socket regardless.
  • Plasma 6.7's xdg-session-management-v1. Restores window geometry on the next launch of an app. Does not survive compositor death.
  • Qt 6.6+ socket handover. Lets Qt apps survive kwin_wayland --replace. Requires the new compositor on the same DRM device. Useless when GPU is gone for minutes.
  • Plasma 6.x AMD GPU reset hardening. Plasma survives a driver-internal GPU reset. Not survival of the device being unbound to a different PCI driver.

App behavior on Wayland disconnect:

AppSurvives?
FirefoxNo, restored via own session-restore on next launch.
Chromium / Electron (Discord, VS Code, Spotify)No.
Qt 6.6+ (Konsole, Kate, Dolphin, Krita)Survives kwin --replace, dies on GPU detach.
GTK appsNo.
foot, alacritty, kitty, weztermNo. Shell inside survives if run under tmux + SSH.
SteamNo.
mpvNo.

Practical advice: anything that must survive a VM session, run inside tmux reachable via SSH. Save editor state before starting the VM.

The only "session stays alive" path is iGPU + dGPU (section 18). Single-GPU does not have a software trick that recovers what physics took away.


18. Hybrid Setups#

If the goal is "game in a VM while Linux stays usable", get a second rendering target.

18.1 Hardware Options#

  • AMD Ryzen 5/7/8000G/9000-series APU with integrated graphics, dGPU for the VM.
  • Intel CPU with iGPU (most non-F SKUs), dGPU for the VM.
  • Two dGPUs, cheaper card for host.

18.2 Pin KWin to the iGPU#

# /etc/environment - applies to all sessions
KWIN_DRM_DEVICES=/dev/dri/by-path/pci-0000:00:02.0-card

Adjust the PCI path to your iGPU.

The dGPU is bound to vfio-pci from boot via section 5.3 early-bind. Host session permanent on the iGPU. No display teardown on VM start, no session destruction.

18.3 Looking Glass#

Looking Glass B7 gives a low-latency window into the guest via IVSHMEM-shared framebuffer.

Install:

yay -S looking-glass looking-glass-module-dkms
# /etc/modules-load.d/kvmfr.conf
kvmfr

# /etc/modprobe.d/kvmfr.conf
options kvmfr static_size_mb=128

# /etc/udev/rules.d/99-kvmfr.rules
SUBSYSTEM=="kvmfr", OWNER="sandwich", GROUP="kvm", MODE="0660"

Shared memory size: width * height * 4 * 2 + 10 MB, rounded up:

ResolutionSize
1080p32 MiB
1440p64 MiB
4K SDR128 MiB
4K HDR256 MiB

Libvirt XML:

<qemu:commandline xmlns:qemu='http://libvirt.org/schemas/domain/qemu/1.0'>
  <qemu:arg value='-device'/>
  <qemu:arg value="{'driver':'ivshmem-plain','id':'shmem0','memdev':'looking-glass'}"/>
  <qemu:arg value='-object'/>
  <qemu:arg value="{'qom-type':'memory-backend-file','id':'looking-glass','mem-path':'/dev/kvmfr0','size':134217728,'share':true}"/>
</qemu:commandline>

Drop any <shmem> element; it conflicts.

Transports:

  • DMABUF: right choice on AMD and Intel hosts (zero-copy via dma-buf).
  • NVIDIA host: DMABUF unsupported (closed driver). Fall back to plain SHM via /dev/shm.

Single-GPU + Looking Glass is incoherent - Looking Glass needs a host rendering surface and single-GPU has none during VM runtime.


19. Verification#

19.1 Inside the Guest#

PowerShell elevated:

# Hypervisor presence bit
(Get-CimInstance Win32_OperatingSystem).HypervisorPresent     # False is good
[Environment]::OSVersion

# SMBIOS
Get-CimInstance Win32_BIOS         | Format-List *
Get-CimInstance Win32_ComputerSystem | Format-List Manufacturer, Model, Version
Get-CimInstance Win32_BaseBoard    | Format-List Manufacturer, Product, Version, SerialNumber

# ACPI tables
Get-CimInstance -Namespace root\WMI -Class MSAcpi_TableHeader |
  Select Signature, OEMID, OEMTABLEID | Sort Signature

# Disks
Get-CimInstance Win32_DiskDrive | Select Model, FirmwareRevision, SerialNumber

# CPU brand
(Get-CimInstance Win32_Processor).Name

# TPM EK certificate (Vanguard path)
Get-Tpm
Get-TpmEndorsementKeyInfo

None of the outputs should contain QEMU, Bochs, BOCHS, Standard PC, Red Hat, virtio (where Tier C+ applies).

19.2 Anti-VM Tools#

Two tools run inside the guest; both single-binary, no install needed.

Pafish (Paranoid Fish, a0rtega) - canonical baseline VM detection probe. Quick to run, prints a checklist of which detection vectors triggered.

Download: github.com/a0rtega/pafish/releases -> pafish.exe.

Run as Administrator inside the guest:

pafish.exe

Output is one line per check. Lines marked [+] Detected: are problems you still need to fix. Lines starting with [*] passed. Pafish checks: CPUID hypervisor bit + 0x40000000 vendor, SMBIOS strings, registry HKLM\HARDWARE\Description\System\BIOS\*, PCI device names, process names (vmtoolsd, qemu-ga), disk model strings, MAC OUI, ACPI table OEM IDs, RDTSC timing, plus debugger/sandbox tricks.

Target scores by tier:

TierExpected Pafish detections (out of ~60 checks)
Foundation only (no spoofing)30-45
Tier A10-15
Tier B (LSI SAS, e1000e + Intel OUI)5-10
Tier C (custom QEMU + OVMF)1-3

VMAware (kernelwernel) - more aggressive probe with ~100+ checks. Uses a two-thread software clock that defeats RDTSC handler patches; useful for measuring what an advanced anti-cheat might see.

Download: github.com/kernelwernel/VMAware -> VMAwareCLI.exe.

VMAwareCLI.exe --all

Output includes a percentage score and an enumerated list of triggered checks. Tier B should land in the 30-50% range, Tier C 10-25%. 0% is not achievable in practice without TPM passthrough.

al-khaser (LordNoteworthy) - mix of anti-debug + anti-VM + anti-sandbox; broader test surface, less VM-focused than Pafish.

Download: github.com/LordNoteworthy/al-khaser.

Run all three after every meaningful change to kvm.xml or QEMU/OVMF builds. A clean Pafish run does not mean an anti-cheat won't catch you - but a dirty Pafish run guarantees one will.

19.3 Anti-Cheat Test Path#

Order of escalating risk to your account:

  1. Install the game inside the VM via official launcher.
  2. Boot. The launcher's anti-cheat starts. Watch for VAN9xxx (Vanguard), BattlEye startup popups, EAC integrity errors.
  3. Connect to a single-player or offline mode first. Many anti-cheats run only against multiplayer matchmaking.
  4. Hop into a low-stakes lobby. Wait 10-30 minutes for kernel scans to complete.
  5. If kicked: read the error code, return to section 11.

Detection often happens minutes into a session, not at launch. A clean launch does not mean a clean session.


20. Troubleshooting#

Host hangs on VM start.

  • Display manager not stopped first. Check the start hook's systemctl stop display-manager.service runs cleanly.
  • nvidia-drm.modeset=1 set without fbdev=1: simpledrm stays bound. Add fbdev=1 or initcall_blacklist=sysfb_init.
  • nvidia module in use: a user process (Steam, browser) holds it. fuser /dev/nvidia* to find. Session-stop should kill these; if not, the user session is hung.

vfio-pci: not ready 65535ms after FLR; giving up.

  • RTX 50 reset bug. Host reboot required. No workaround in 2026.

Code 43 on a working setup after kernel update.

  • Kernel 6.12+ ReBAR regression. Disable ReBAR in host UEFI or roll back.

AMD GPU works once, hangs on second boot.

  • RDNA1/Vega/Polaris: vendor-reset not active. Verify cat /sys/bus/pci/devices/0000:01:00.0/reset_method shows device_specific.
  • RDNA3 (RX 7000): not fixable. Avoid.

virsh nodedev-detach returns "device in use".

  • AMD: amdgpu is still bound. Run modprobe -r amdgpu before nodedev-detach.

Firmware has requested this device have a 1:1 IOMMU mapping.

  • AGESA 1.2.2.* regression on X870E. Roll AGESA back or wait for vendor BIOS fix.

Guest sees broken topology / Windows scheduling tanks.

  • topoext missing on AMD. <feature policy='require' name='topoext'/> in <cpu>.
  • cores=N threads=2 mistakenly set as cores=2N threads=1.

no usable hugepage source at VM start.

  • Hook dynamic allocation failed (memory fragmentation). Check /var/log/libvirt/vfio-hooks.log. Set USE_HUGEPAGES=0; VM still boots on THP.
  • If using boot-time static hugepages, the hugetlbfs mount with matching pagesize= is missing.

Schema error: "Extra element features in interleave".

  • Domain XML has elements out of order or duplicated. libvirt expects: vcpu -> iothreads -> cputune -> sysinfo -> os -> features -> cpu -> clock. Check for duplicate <os> or <vcpu> blocks.

TPM passthrough: VM hangs at "Loading Windows Files".

  • tpm2-abrmd is holding the TPM. Stop it on the host: systemctl stop tpm2-abrmd.service tpm2-abrmd.socket.
  • AppArmor blocks /dev/tpm0. Check dmesg | grep DENIED. Add the device path to libvirt-qemu profile.

Get-Tpm in guest shows "Manufacturer: SWTM" but the host has a real TPM.

  • <backend type='emulator'> is still configured, not type='passthrough'. Edit the domain XML.
  • /dev/tpm0 permissions block libvirt-qemu. Check udev rule for GROUP="kvm" MODE="0660".

VAN9001 / VAN9003 in Valorant.

  • Secure Boot not enabled in OVMF firmware feature. Verify <feature enabled='yes' name='secure-boot'/> and <smm state='on'/>.
  • TPM 2.0 not present or detected as software. Switch to passthrough per section 15.

Stop hook fails to bring SDDM back.

  • VFIO modules still loaded with device bound. Stop script's nodedev-reattach must run before modprobe -r vfio*, then modprobe amdgpu (or nvidia stack), then systemctl start display-manager.service.

Host returns from VM with blank monitors (DisplayPort MST).

  • MST sub-stream re-enumeration failed. Wire monitors directly (no MST hub) or toggle connector enable post-rebind.

virtio-blk reports QEMU HARDDISK in guest, anti-cheat refuses.

  • virtio-blk does not accept vendor/product overrides. Switch to bus='sata' with <vendor>/<product>/<serial> (no <wwn> - see below), or bus='scsi' with an LSI SAS controller for full <wwn> support.

unsupported configuration: Only ide and scsi disk support wwn (or vendor/product).

  • libvirt's <wwn>, <vendor>, and <product> elements are supported only on bus='ide' (i440FX-only legacy) and bus='scsi'. Switch the disk to bus='scsi' with an explicit SCSI controller (<controller type='scsi' index='1' model='lsisas1078'>) to get all three back - see section 11.3 for the LSI SAS recipe. SATA bus disks can only spoof <serial>.

User specified x-oem-table-id value is bigger than 8 bytes in size (or x-oem-id ... bigger than 6 bytes).

  • x-oem-id max 6 bytes, x-oem-table-id max 8 bytes (ACPI spec). QEMU rejects longer strings at machine init. Common bug: A M I with 4 trailing spaces is 9 chars - drop one to land at exactly 8: A M I . Count by echo -n 'value' | wc -c.

Windows BSOD on first boot after AVIC enable.

  • AVIC + x2APIC on Zen 3 or earlier: disable x2apic in Windows boot (bcdedit /set x2apicpolicy disable) or disable AVIC in module options.

Disk performance terrible inside guest.

  • qcow2 with cache='writeback' instead of raw block. Convert: qemu-img convert -O raw win11.qcow2 /dev/disk/by-id/....
  • Missing io='io_uring' or io='native' on the driver.
  • No iothread allocated. Add <iothreads>1</iothreads> and reference it in the disk's <driver>.

Host runs out of RAM when VM starts; browser or other apps get OOM-killed.

  • VM <memory> too large for host RAM. Windows touches all assigned memory at boot. On a 32 GiB host with a browser running, the safe VM size is 12 GiB, comfortable is 16 GiB, anything more is reckless. See sizing table in section 8.2.
  • Hook's USE_HUGEPAGES=1 with VM_MEM_GB set higher than the VM's actual <memory> value: hook reserves a HugeTLB pool the VM never touches because the XML uses <source type='memfd'/> (THP path). Either match VM_MEM_GB to the XML memory, switch the XML memory backing to <hugepages>, or set USE_HUGEPAGES=0. See section 8.2 "two consistent hugepage paths".
  • Add swap to give the kernel a spill path before OOM-killing: 8-16 GiB swap file or zram. The hook's pkill -STOP step (section 6.3) suspends Brave/Chrome/etc. during VM runtime - if you removed those lines, large browser sessions cannot be reclaimed.

Hugepages locked but not used by the VM.

  • The XML's <memoryBacking> uses <source type='memfd'/> (THP) but the hook allocates a HugeTLB pool. The two are separate allocators - the pool sits reserved without being mapped by QEMU. Free immediately with echo 0 | sudo tee /sys/kernel/mm/hugepages/hugepages-2048kB/nr_hugepages (works because nothing maps them). Then fix the config mismatch per section 8.2.

Pafish reports "rdtsc forcing vm exit using cpuid: detected".

  • Irreducible without kernel patches. CPUID is an unconditional VMEXIT under VMX/SVM; the host has to handle it and the ~1000-cycle round-trip is the floor. Mitigations (invtsc, tsc mode='native', +monitor, +waitpkg, -overcommit cpu-pm=on, ple_gap=0) compress timing in other paths but cannot avoid the CPUID exit cost.
  • Tier D (section 14) kernel patches scale RDTSC delta to defeat this specific Pafish line, but introduce HPET/PMTMR/APERF-MPERF cross-check failures that VMAware catches. Net detection often worse for VMAware-tier probes.
  • For EAC/BattlEye/nProtect: this specific check is not actioned alone. Ignore the Pafish line; focus on the non-timing detections.

Pafish reports SMBIOS/registry/PCI strings detected despite the spoofing config.

  • Verify the start XML actually got loaded: virsh dumpxml win11 | grep -A2 sysinfo. If <sysinfo> is missing, the domain definition was saved before the edit took effect (virt-manager sometimes caches).
  • Check that QEMU received the args: ps -ef | grep qemu-system-x86_64 | tr ' ' '\n' | grep -E 'smbios|x-oem|acpitable'.
  • For PCI vendor strings (Red Hat, Bochs, virtio in Device Manager): foundation/Tier B can't fix these without Tier C custom QEMU. See section 13.

21. Sources#

Architecture and core references:

Tooling and hooks:

Anti-cheat internals:

Spoofing projects:

TPM 2.0:

KVM and kernel:

GPU specifics:

Looking Glass:

Performance tuning:

Plasma 6.7 / Wayland:

Misc: